GraphRCA — 지식 그래프 기반 근본 원인 분석: entity/relation 추출 + recursive CTE
아침에 일어나서 cron이 안 돌고 있으면 무슨 생각이 드나?
“아, 또 launchd가 죽었나?””아니면 스크립트에 에러가 났나?””아니면 디스코드 웹훅이 막혔나?”
이 질문들을 던지다 보면 하나를 깨닫는다. 내가 진짜 알고 싶은 건 “왜 멈췄는가”지, “뭐가 멈췄는가”가 아니다. 그런데 기존 로그와 모니터링 도구는 항상 후자만 알려준다.
이 문제의 해결을 위해 지식 그래프 기반 근본 원인 분석 시스템을 만들었다. 이름은 GraphRCA.
What This Builds
Entity backbone — 375개 entity(도구/스크립트/패턴/결정/인시던트)를 knowledge.db에 자동 추출 Relation layer — 1,063개 relation으로 entity 간 인과/참조/연관 관계를 표현 3-mode query engine — explore(탐색), trace(추적), rca(원인 분석) 세 가지 그래프 추론 모드 Recursive CTE traversal — SQLite 재귀 CTE로 2-hop, 3-hop 그래프 탐색, 10-50ms 응답 Auto-linking pipeline — knowledge entry가 저장될 때마다 entity 자동 매칭 → relation 생성
Why Knowledge Graph at the Center
내가 수집하는 모든 결정과 패턴과 실패는 knowledge.db라는 단일 SQLite 파일에 저장된다. 17,614개 항목의 hybrid search 시스템 위에 그래프 레이어를 얹은 이유는 하나다. “크론이 멈췄다”는 건 증상이지 원인이 아니다.
관계형 쿼리(JOIN)로는 “parse_schedule crash가 t variable collision을 유발했고, 그게 cron-stalled 인시던트로 이어졌다”는 인과 체인을 표현할 수 없다. 인과를 모델링하려면 그래프가 필요하다.
여기서 재미있는 결정이 하나 있었다: Neo4j나 별도 그래프 DB를 쓰지 않았다. SQLite의 recursive CTE로 해결했다. 이유는 ponytail 원칙 — 새 의존성을 추가하는 건 마지막 수단이다. SQLite CTE는 그냥 SQL 문법 하나일 뿐, 설치도 설정도 없다. 그래프가 375개 entity 수준이면 충분하다. 10만 개가 넘어가면 다시 생각하겠지만, 지금은 아니었다.
Slice 1: Entity Backbone — 결정과 패턴을 이름 붙인 저장소
첫 번째 작업은 AGENTS.md와 artifact들에서 entity를 추출하는 거였다. seed_entities.py가 하는 일은 단순하다:
tool (55개): PortOne, Cloudflare Workers, opencode, launchd, Discord… script (19개): drewgent_cron.py, recall.py, graph_query.py… pattern (10개): parse_schedule crash, session ingest stalling… decision (9개): flash-for-writing, cron-over-n8n, sqlite-over-postgres… incident (2개): cron-stalled, deprecated-스크립트 concept (19개): GraphRCA, hybrid search, RRF fusion… persona (15개): writer-engineer, content-manager, implementer… project (6개): M-LOG, humanerd.kr, Drewgent… paper (27개): 수집된 논문들 _task (206개): kanban DB에서 자동 생성된 linking node
entity에 이름을 붙이는 행위 자체가 설명 비용을 줄인다. “아, 그거 parse_schedule crash?” — 이 한 마디로 과거의 모든 실패 맥락을 복원할 수 있다면, 그건 거의 초능력에 가깝다.
Slice 2: Relation Layer — entity 사이를 연결하는 4가지 방법
entity만 있으면 의미가 없다. 관계가 의미를 만든다. 네 가지 relation type을 정의했다:
references (833개) — knowledge entry가 특정 entity를 언급. graph_linker.py가 on-demand로 생성한다. 모든 entry를 스캔하는 게 아니라 recall()로 조회된 entry만 링크한다. relates_to (192개) — 같은 entry에서 함께 언급된 entity끼리 연결. co_mention_link.py가 품질 게이트(허용된 type pair만)로 필터링한다. cites (11개) — 논문 간 인용 관계. 수집된 30편의 논문에서 추출. belongs_to (27개) — 논문→카테고리. 논문이 어떤 분야에 속하는지.
여기서 핵심 결정: relation을 자동화하되, 품질 게이트를 걸었다. co_mention_link.py는 tool과 concept의 연결만 허용하고 _task는 어떤 relation도 만들지 못하게 막았다. 무작위 동시언급이 그래프를 노이즈로 채우는 걸 방지하기 위해서다.
Slice 3: 3-Mode Query Engine — recursive CTE로 그래프 질의하기
진짜 재미는 graph_query.py부터 시작된다. 세 가지 모드로 동작한다:
explore: “PortOne에 연결된 게 뭐지?”
fuzzy entity 매칭 → 2-hop neighbor traverse. 결과는 이렇게 나온다:
$ python3 scripts/graph_query.py –mode explore “PortOne” [graph-query] mode=explore depth=3 ┌─ PortOne (tool) id=2 ├── PortOne –references–> PortOne V2 SDK ├── PortOne –relates_to–> M-LOG ├── PortOne –relates_to–> payment-integration └── PortOne –relates_to–> KG이니시스
이건 내가 AGENTS.md를 다시 읽지 않아도 “PortOne이 뭐랑 연결됐더라”를 50ms 안에 알려준다. 문서 검색이 아니라 구조 검색이다.
trace: “Drewgent가 어떤 경로로 여기까지 왔지?”
bidirectional depth=4 traversal. entity에서 시작해서 양방향으로 걸어가며 전체 맥락을 그린다. 세션 시작 시 자동 실행되어 현재 도메인 관련 entity를 system context에 주입하는 데 쓴다.
rca: “왜 cron이 멈췄지?” — 근본 원인 분석
이게 핵심이다. incident나 pattern entity에서 시작해서 caused_by relation을 따라 역방향 depth=3 탐색 → fixes를 순방향으로 추적:
$ python3 scripts/rca_report.py “cron stuck”
============================================================ RCA Report ============================================================
Target: cron-stalled (incident)
Timeline: ● [cause] t variable name collision (pattern) Relation: caused_by ● [cause] parse_schedule crash (pattern) Relation: caused_by ◆ [fix] launchd KeepAlive patch (decision) Relation: fixed_by
── Analysis ── Potential root causes (deepest nodes): t variable name collision, parse_schedule crash
이 리포트 하나면 더 이상 “아까 cron 안 돌았는데 왜 그랬더라”를 기억하려고 로그를 뒤질 필요가 없다. 그래프가 기억한다. 내가 까먹어도.
Architecture: 전체 파이프라인
remember(“Switched from n8n to launchd cron”) │ ├─ knowledge.db: +1 entry (text + embedding) │ └─ extract_entities_llm.py (fallback $0) └─ fuzzy match → “n8n”, “launchd” entity 발견 └─ relates_to relation 생성
seed_entities.py (1회 실행, $0) └─ AGENTS.md 기반 90개 seed entities 생성
graph_linker.py (on-demand by recall(), $0) └─ session/knowledge content → entity mention scan └─ references relation 생성
co_mention_link.py (batch, $0) └─ 같은 entry에서 언급된 entity들 → relates_to └─ 품질 게이트: 허용된 type pair만
graph_query.py (CLI / MCP tool, $0) ├─ mode=explore: fuzzy match + 2-hop ├─ mode=trace: bidirectional depth=4 └─ mode=rca: causal chain depth=3
rca_report.py (CLI / MCP tool, $0) └─ graph path → NL formatted RCA report
각 스크립트는 단일 책임을 가진다. 어느 하나가 고장나도 나머지는 정상 작동한다. seed_entities가 이미 심어놓은 백본이 있으니 graph_linker가 실패해도 explore는 동작한다. 이건 의존성을 느슨하게 유지하는 ponytail 원칙과도 통한다.
Key Design Decisions
DecisionAlternativeWhy This Won SQLite recursive CTENeo4j / Dgraph설치 0, 설정 0, 새 dep 0. 375 entities면 CTE로 충분. 10ms-50ms latency. On-demand graph linkingBatch full scan17K 전체 스캔은 느리다. recall() 호출된 entry만 링크. hot data 우선. 3-mode query (not 1 generic)Single “graph” queryexplore/trace/rca는 질문 유형이 다르다. 하나로 통합하면 모든 유형에 최적이 안 됨. CLI + MCP plugin dualMCP onlyCLI는 확실하게 동작한다(MCP는 opencode 버그: tools/ .ts 안 됨). 파이프라인에서도 CLI 호출. Depth limit (default 3)Unboundeddepth 3이면 대부분의 실용적 RCA를 커버. 무제한은 무한 루프 위험. Fuzzy match (substring)Exact match only”cron stuck”이 “cron-stalled”를 찾아야 한다. 완전 일치는 너무 빡빡함.
Known Failure Modes
Mode증상대응 Entity explosioningest_kanban_entities가 206개 _task 생성, 그래프 노이즈_task는 linking node 전용, relation 차단. 필요시 삭제. Recursive CTE timeoutdepth 5+에서 SQLite CTE가 느려짐depth 기본값 3. traverse_simple fallback이 CTE 실패 시 자동 활성화. No entity match새로운 개념은 그래프에 없음”no entities matched” 메시지 명확히 출력. fuzzy match가 대부분 커버. Stale relationsentity는 있는데 relation이 없음graph_linker on-demand. co_mention_link batch 주기적 실행으로 보완.
MCP Plugin으로 통합
이 모든 기능은 opencode MCP plugin으로도 노출된다. graph-explore(), graph-trace(), graph-rca() 세 개의 MCP tool. 세션 시작 시 도메인 키워드로 자동 graph-explore() 호출 → 관련 entity를 system context에 주입.
사용자가 “왜 cron이 안 돌아가지?” 같은 인과 질문을 하면, 답변 전 자동으로 graph-rca(“cron”)이 실행된다. 그래프에 기록이 있으면 RCA 리포트를 답변에 포함하고, 없으면 “그래프에 관련 기록 없음”을 명시한다. 이게 Tier 1 autonomous rule이다.
Performance Numbers
Graph traversal: 10-50ms (SQLite recursive CTE, depth 3) Entity extraction: subsecond ($0 fallback) / 2-3s (LLM, ~$0.01/mo) Full pipeline cost: $0 (SQLite, Ollama local embedding, numpy) Storage: entities + relations ~5MB 추가
50ms 안에 3-hop causal chain을 그릴 수 있는 시스템. 설치 0, 설정 0, 의존성 0.
Closing
이 시스템이 재미있는 점은 기술적 난제를 해결한 게 아니라, 구조적 문제를 해결했다는 데 있다. 인과 추론 자체는 복잡하지 않다. recursive CTE는 표준 SQL이고, entity 추출은 fuzzy string matching이다. 진짜 문제는 “이 entity들을 언제, 어떻게 수집하고, 어떻게 최신 상태로 유지할 것인가”였다.
on-demand linking + seed backbone + batch co-mention — 이 세 가지 전략이 각각 다른 타이밍과 비용으로 그래프를 채운다. seed는 한 번, on-demand는 조회될 때, batch는 주기적으로. 각각의 실패 지점이 다르기 때문에 전체가 동시에 깨지지 않는다.
그리고 이 모든 게 SQLite 파일 하나 안에 들어 있다. 지식과 관계가 같은 파일에 있으니, 백업 한 번이면 모든 그래프가 통째로 저장된다. Neo4j였으면 백업은 별도 전략이 필요했을 거다. Ponytail 원칙은 이렇게까지 내려온다.
다음은 뭘 할까. entity resolution(중복 entity 통합)과 typed lineage(자동 인과 체인 추론)을 고민 중이다. 지금은 manual relation에 의존하는데, “이 commit이 이 incident를 유발했다”를 자동으로 추론할 수 있다면… 그건 다음 기회에.
Thanks for reading. 그래프가 기억하는 한, 나는 잊어버려도 괜찮다.