문제: 지식은 쌓였는데, 인과는 보이지 않았다
recursive CTE 하나면 그래프 DB가 필요 없다는 걸, 직접 부딪혀서 알게 됐다.
417KB. SQLite 파일 하나의 크기다. 여기에 375개 entity와 1,063개 relation이 들어 있고, 근본 원인 분석(RCA)을 50ms 안에 해낸다. Neo4j도 없고, Dgraph도 없고, 심지어 별도 프로세스도 없다. SQLite의 recursive CTE 하나로 끝냈다.
이 글은 지식 그래프 기반 RCA 시스템을 recursive CTE로 구현한 빌드 로그다. 왜 그래프 DB를 안 썼는지, entity를 어떻게 추출하는지, 그리고 3가지 질문 모드(explore/trace/rca)를 하나의 SQL 구문으로 처리한 방법을 단계별로 풀어본다.
문제: 지식은 쌓였는데, 인과는 보이지 않았다
Drewgent의 knowledge.db에는 17,000개가 넘는 항목이 저장되어 있다. 세션 로그, 결정 기록, 패턴 분석, SEO 기사 — FTS5와 Ollama 임베딩 덕분에 검색은 빠르다. 하지만 “왜 cron이 멈췄는지”를 묻는 순간, 검색은 무용지물이 됐다.
검색은 “cron”이 포함된 문서를 찾아줄 뿐, parse_schedule crash가 cron-stalled로 이어졌고, 그걸 t variable rename으로 고쳤다는 인과 체인은 표현하지 못한다. 17,000개 문서를 각각 읽어야 알 수 있는 정보였다. 검색은 O(1)이지만, 인과 추론은 O(n)이었다.
그래프가 필요하다는 건 분명했다. 문제는 그래프 DB를 도입할 만큼 문제가 컸는가였다.
Why Recursive CTE — Neo4j를 안 고른 이유
Neo4j를 깔면 해결되는 문제다. 하지만 ponytail 원칙이 말했다: “이미 설치된 디펜던시로 해결할 수 있는가?”
SQLite는 이미 knowledge.db에 들어 있었다. recursive CTE는 SQLite 3.8.3(2014)부터 지원한다. 내가 가진 데이터는 375개 entity, 1,063개 relation. 그래프 DB가 필요한 규모가 아니었다. CTE 하나로 3-hop traversal이 50ms 안에 끝난다.
Neo4j를 선택했다면, 설치·설정·인덱싱·백업 전략까지 4개의 결정이 추가로 필요했다. SQLite CTE는 결정이 하나였다: “CTE 쿼리를 짠다.” Ponytail 원칙이 가장 빛나는 순간이다.
What This Builds
- Entity backbone — tool(55)/script(19)/pattern(10)/decision(9)/incident(2)/concept(19)/persona(15) — 375개 entity를 AGENTS.md + kanban + cron state에서 자동 추출
- Relation layer — references(833)/relates_to(192)/cites(11)/belongs_to(27) — 4가지 relation type으로 entity 간 연결을 표현
- On-demand graph linker — recall() 조회 시에만 linking. 17K 전체 스캔 $0, hot data 우선 처리
- 3-mode query engine — explore(탐색)/trace(추적)/rca(원인 분석) — 하나의 recursive CTE로 세 가지 질문 모드
- RCA report generator — causal chain → NL 포맷 리포트, 2초 응답
Slice 1: Entity Backbone — 구조화된 시드 데이터
첫 번째 결정은 “LLM으로 entity를 뽑지 않는다”였다. AGENTS.md에는 이미 구조화된 entity 목록이 있다. tool 목록, script 목록, pattern 목록 — 사람이 정리한 데이터를 그대로 entities 테이블에 시드했다.
# seed_entities.py — 90개 entity를 $0으로 생성
TOOLS = ["PortOne", "Cloudflare Workers", "opencode", "launchd", ...]
SCRIPTS = ["drewgent_cron.py", "recall.py", "graph_query.py", ...]
PATTERNS = ["parse_schedule crash", "t variable name collision", ...]
DECISIONS = ["flash-for-writing", "cron-over-n8n", "sqlite-over-postgres"]
1회 실행, $0, 90개 seed. kanban completed tasks에서 entity-like token을 heuristic으로 추출하는 ingest_kanban_entities.py가 206개 _task entity를 추가했다. cron state anomaly를 ingest_cron_entities.py가 incident entity로 자동 등록한다. 모든 entity 추출이 $0다.
entity type은 11가지로 분류했다: tool, script, project, pattern, decision, incident, concept, persona, paper, category, _task. 각 type은 relation 생성 규칙이 다르다. 예를 들어 _task는 linking node 전용 — 어떤 relation도 만들지 못하게 차단했다. 무작위 동시언급이 그래프를 노이즈로 채우는 걸 막기 위해서다.
Slice 2: On-Demand Graph Linking — bulk linking은 하지 않는다
entity 백본이 준비됐다. 다음 문제: 17,000개 knowledge entry를 어떻게 entity와 연결할 것인가?
답: 연결하지 않는다. 연결이 필요할 때만 연결한다.
graph_linker.py는 recall()로 조회된 entry만 스캔한다. entity label이 text에 언급되었는지 fuzzy match로 확인하고, 발견되면 knowledge_id → entity_id의 references relation을 생성한다. 한 번 링크된 entry는 graph_linked_at 타임스탬프가 기록되어 다시 검사하지 않는다.
이 전략의 결과: 17K entry 중 linking이 필요한 entry만 자연스럽게 연결된다. 아무도 찾지 않는 1년 전 세션은 영원히 linking되지 않지만, 아무 문제없다. 사람이 접근하는 데이터만 linking된다. hot data 우선 처리, cold data는 영원히 cold로 남는다.
co_mention_link.py가 이걸 보완한다. 같은 knowledge entry에서 동시에 언급된 entity들 사이에 relates_to relation을 배치 생성한다. 여기에 entity type pair whitelist를 적용했다 — tool↔pattern, decision↔project 같은 의미 있는 페어만 통과. tool↔_task처럼 무의미한 연결은 차단된다.
Slice 3: Recursive CTE — 그래프 탐색의 실제 코드
이게 핵심이다. 하나의 recursive CTE로 세 가지 모드를 구현했다:
WITH RECURSIVE path AS (
SELECT r.id, r.type, r.source_id, r.target_id,
CASE WHEN r.source_id = ? THEN r.target_id ELSE r.source_id END AS next_id,
1 AS lvl
FROM relations r
WHERE (r.source_id = ? OR r.target_id = ?)
UNION ALL
SELECT r.id, r.type, r.source_id, r.target_id,
CASE WHEN r.source_id = p.next_id THEN r.target_id ELSE r.source_id END,
p.lvl + 1
FROM path p
JOIN relations r ON (r.source_id = p.next_id OR r.target_id = p.next_id)
WHERE p.lvl < ?
)
SELECT DISTINCT p.*, e.label, e.type
FROM path p
JOIN entities e ON e.id = p.next_id
ORDER BY p.lvl
이 CTE가 하는 일은 단순하다: entity ID 하나를 받아서, 그 entity와 연결된 relation을 따라가며 depth만큼 반복한다. UNION ALL로 재귀 호출, depth 제한으로 루프 방지. 전형적인 SQL 재귀 CTE 패턴 — 특별할 게 전혀 없다. 그게 바로 포인트다.
이 CTE 위에 세 가지 질의 모드를 얹었다:
| Mode | 입력 | 동작 | depth |
|---|---|---|---|
explore |
"PortOne payment" | fuzzy entity match → 양방향 2-hop | 2 (기본) |
trace |
"content-pipeline-v3" | bidirectional depth=4 타임라인 | 4 (기본) |
rca |
"cron stuck" | incident 탐색 → caused_by 추적 → fixed_by 수집 |
3 (기본) |
depth 제한을 기본값으로 설정한 이유는 실패 안전장치 때문이다. CTE 재귀가 무한에 빠지거나 SQLite 스택을 터뜨리는 걸 방지한다. 만약 CTE가 실패하면(드문 경우지만 SQLite 버전 차이로 발생 가능), traverse_simple() fallback이 자동 활성화된다 — 반복문으로 1-hop, 2-hop, 3-hop을 순차 탐색한다.
Slice 4: RCA Report — causal chain을 사람이 읽는 언어로
RCA 모드가 가장 재미있다. graph_query.py --mode rca는 incident type entity를 찾으면, caused_by relation을 따라 역방향 depth=3 탐색 + fixed_by를 순방향으로 추적한다. rca_report.py가 이 raw data를 받아서 NL 포맷 리포트로 변환한다.
$ 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
이 리포트가 중요한 이유: 세션 로그 하나 열지 않고 2초 만에 나온다. 이전에는 같은 문제가 발생하면 "아, 이거 전에 본 건데..." 하고 기억을 더듬어야 했다. 지금은 그래프가 기억한다. 나는 까먹어도 된다.
사용자가 "왜"라는 질문을 하면, MCP plugin(graph-rca())이 자동으로 호출된다. 결과가 있으면 RCA 리포트를 답변에 포함하고, 없으면 "그래프에 관련 기록 없음"을 명시한다. 이게 Tier 1 autonomous rule이다.
Architecture: 전체 파이프라인
저장 시점 (remember()):
ingest_fact.py → embedding + entity extraction (LLM fallback $0)
→ entities + relations 생성
배치 (cron):
seed_entities.py (1회) → 90개 seed
ingest_kanban_entities.py → _task entity (206개)
ingest_cron_entities.py → incident entity
co_mention_link.py → entity↔entity relates_to
온디맨드 (recall()):
graph_linker.py → knowledge↔entity references
→ graph_linked_at 타임스탬프 기록 (idempotent)
질의 (CLI / MCP):
graph_query.py --mode explore/trace/rca
→ recursive CTE traversal (50ms)
rca_report.py → NL formatted report (2s)
각 스크립트는 단일 책임, 독립적 실패. seed_entities가 동작하지 않아도 graph_linker는 동작한다. 의존성이 느슨할수록 전체 시스템이 탄력적이다. ponytail 원칙이 인프라 레벨에서 적용된 예다.
Key Design Decisions
| Decision | Alternative | Why This Won |
|---|---|---|
| SQLite recursive CTE | Neo4j / Dgraph | 설치 0, 설정 0, 새 dep 0. 375 entities면 CTE로 충분. 50ms latency. |
| On-demand linking | Batch full scan | 17K 전체 linking은 느리다. recall() 호출된 entry만. hot data 우선. |
| $0 structured first | LLM extraction first | AGENTS.md에 이미 구조화된 데이터가 있다. 그걸 안 쓰는 게 비효율. |
| 3-mode query engine | Single generic query | explore/trace/rca는 질문 유형이 다르다. 최적화 방향도 다르다. |
| Entity type pair whitelist | All co-mentions allowed | 무작위 동시언급은 노이즈다. 의미 있는 relation만 그래프에 추가. |
| Depth 기본값 3 | Unbounded | 실용적 RCA는 3-hop이면 충분. depth 제한이 CTE 스택 오버플로 방지. |
| CLI + MCP plugin dual | MCP only | CLI는 모델에 독립적. MCP는 opencode 버그에 취약(tools/ .ts 실행 불가). |
Known Failure Modes
| Mode | 증상 | 대응 |
|---|---|---|
| Entity explosion | ingest_kanban이 206개 _task 생성 → 그래프 노이즈 | _task는 relation 차단. 무의미한 가장자리 제거. |
| CTE 실패 | depth 5+에서 SQLite CTE 타임아웃 | traverse_simple() fallback. depth 기본값 3. |
| No entity match | 신규 개념이 그래프에 없음 | "no entities matched" 명확히 출력. fuzzy match로 완화. |
| Stale relations | entity는 있는데 relation이 없음 | on-demand linking이 점진적으로 채움. batch cron으로 보완. |
Closing
이 시스템에서 가장 마음에 드는 점은 일반적이지 않은 기술적 선택이다. 대부분의 RCA 시스템은 Neo4j나 별도 그래프 DB를 쓴다. 나는 SQLite recursive CTE를 골랐다. 이유는 간단하다 — 문제의 규모가 그래프 DB를 정당화하지 않았기 때문이다.
375개 entity, 1,063개 relation. 50ms 응답. $0/월. "나중에 커지면 그때 바꾸자"는 결정이 지금은 옳았다. 나중에 10만 entity가 넘어가면 다시 생각하겠지만, 그날이 오기 전까지는 CTE 하나로 충분하다.
그리고 이 모든 게 knowledge.db라는 SQLite 파일 하나에 들어 있다. 지식, 검색, 그래프, RCA — 모두 같은 파일. 백업 한 번이면 모든 게 저장된다. 이것이 ponytail 원칙이 "파일 수준"까지 내려온 결과다.
그래프가 기억하는 한, 나는 잊어버려도 괜찮다. 다음은 entity resolution(중복 통합)과 자동 인과 체인 추론을 고민 중이다.
Thanks for reading. 질문 있으면 언제든.