LightRAG를 위키 DB로 쓰지 마라 — 지식·그래프·기억을 분리한 LLM Wiki v2 설계

2026-08-11 · 약 163분
LLM위키LightRAG지식그래프에이전트메모리MCPpgvectorPostgreSQLRAG아키텍처FastAPI
LLM 위키가 무너지는 지점은 검색 품질이 아니라 층 설계다. LightRAG를 위키 DB처럼 쓰면 임베딩 모델 교체가 `DROP TABLE`이 되고, 실제로 정점 34.2만 규모 업그레이드가 17시간 넘게 서버를 죽였다(issue #2255) — 원문이 같은 DB에 있었다면 그 17시간 동안 글을 읽지도 쓰지도 못했다. 이 글은 마크다운+PostgreSQL을 원본, LightRAG를 언제든 재생성 가능한 파생 인덱스, Agent Memory를 별도 경험층으로 갈라놓는 설계를 구현 수준까지 내린다. 문서 1,000건 기준 Fast Path는 $0.12·6분인데 Deep Path는 $90·2.9시간 — 이 750배가 두 경로를 쪼개는 경제적 근거다. 라우터의 1.5초 예산 배분과 RRF의 조용한 실패(전멸한 소스도 rank=1로 1/61을 얹는다), pgvector halfvec·iterative_scan의 무증상 결과 누락, 한국어 FTS 3선택지(pg_search는 긴 키워드에서 tsvector 대비 34배), 세션을 없앤 MCP 2026-07-28 사양과 툴 정의만으로 55k 토큰을 먹는 문제, 승격 점수식과 환각 고착 4겹 방어, 전자책의 결정적 인용 검증, 골든셋 50~100개와 실패 6종 진단, 그리고 1인 50~62시간짜리 4주 V1 계획까지 — 14개 섹션과 70개 Q&A로 정리했다.

세 개의 층으로 쪼개라 — Wiki · Knowledge Graph · Agent Memory

Wiki(원본)·Knowledge Graph(파생 인덱스)·Agent Memory(경험 기억)를 한 저장소에 합치면 무너지는 이유를 LightRAG의 실제 이슈(임베딩 차원 하드코딩, 34만 노드 규모 마이그레이션 17시간 다운타임)와 임베딩 역변환·고스트 벡터 연구로 근거를 대고, 세 층의 수명·신뢰도·재생성 가능성·삭제 정책·소유권을 표로 대비한 뒤 분리 비용(정합성·중복 저장·파이프라인)과 층 배치 판별 규칙 7개를 제시하는 섹션.

핵심 요점

  • 세 층을 합치면 가장 느린 층이 나머지의 가용성을 결정한다 — LightRAG 1.4.9.1→1.4.9.4 업그레이드에서 정점 34.2만/간선 40.7만 규모에 17시간 넘는 다운타임이 실제로 발생했고(issue #2255), 위키 원문이 같은 DB에 있었다면 읽기도 불가능했다.
  • 재인덱싱 자유도가 층 분리의 핵심 이익이다. LightRAG는 재임베딩 도구가 없고 PostgreSQL 백엔드는 vector(dim)이 DDL에 박혀 임베딩 모델 교체가 LIGHTRAG_VDB_CHUNKS/_ENTITY/_RELATION 드롭을 요구한다(issue #2119) — 원본이 밖에 있으면 배치 작업, 안에 있으면 데이터 삭제다.
  • 임베딩은 원문의 요약이 아니라 원문이다. Vec2Text는 32토큰을 BLEU 97.3·완전일치 92%로 복원했고, Ghost Vectors(arXiv 2606.18497)는 HNSW 소프트 삭제 벡터가 raw 파일에서 복원됨을 보였다(의료 나이·성별 100%) — 파생 층의 삭제는 tombstone이 아니라 재빌드여야 하며 이는 원본이 밖에 있을 때만 가능한 선택지다.
  • 모순 처리 정답이 층마다 다르다: 위키는 diff·rollback으로 모순을 제거하고, 그래프는 반대 문서의 엔티티 description을 병합해 자기모순을 만들며(그래프 층에서 고치면 안 됨), 기억은 Graphiti식 이중 시간 무효화로 모순을 보존해야 한다.
  • 분리 비용은 정합성·중복·파이프라인이며, 임베딩(3072d float32 = 12KB)이 한국어 1000자 청크(~3KB)보다 4배 무거워 파생 층을 백업에서 빼는 것만으로 백업이 한 자릿수 배 줄어든다. 가장 흔한 실패는 층별 삭제 확인 없이 삭제를 선언하는 것.
  • 층 배치 규칙 7개의 축: 지우면 영원히 사라지는가 / 재인덱싱하면 같은 결과인가 / 주어가 나인가 세상인가 / 틀렸을 때 남이 비용을 무는가(=승격 게이트) / 수명이 임베딩 모델보다 긴가 / 삭제가 법적 의무인가 / 정확한 문자열로 찾는가.
  • "언제든 통째 재생성 가능"은 재인덱싱 시간·비용을 분기마다 실측해 기록하지 않으면 검증되지 않은 가정이다 — 대전제 ①에 붙여야 할 단서.

무너지는 지점은 "규모"가 아니라 "쓰기 특성"이다

RAG 설명은 세 문장으로 끝낸다. 문서를 잘라 임베딩하고, 질의 시점에 가까운 조각을 꺼내 프롬프트에 붙인다. 그래프 RAG는 여기에 LLM이 추출한 엔티티·관계를 얹어 다중 홉 질의를 가능하게 한다. 중요한 건 그 다음 문장이다 — 이 파이프라인의 산출물(청크·임베딩·엔티티·관계)은 전부 다시 만들 수 있고, 입력(원문)만이 다시 만들 수 없다.

한 저장소에 다 넣으면 무너지는 이유는 데이터가 많아서가 아니라 세 종류의 쓰기가 섞이기 때문이다. 위키 문서 저장은 20ms 트랜잭션이고, 그래프 인덱싱은 문서당 LLM 호출 수십 번짜리 초~분 단위 배치이며, 에이전트 기억은 세션마다 수십 건씩 들어오는 append-only 로그다. 같은 저장소에 두면 가장 느리고 가장 깨지기 쉬운 층이 나머지 둘의 가용성을 결정한다.

이건 이론이 아니다. LightRAG를 PostgreSQL + Apache AGE 백엔드로 운영하던 사용자가 1.4.9.1 → 1.4.9.4 업그레이드를 하다가 17시간 넘게 서버가 죽었다(issue #2255). 규모는 정점 약 34.2만, 방향 간선 약 40.7만 — 개인 위키 기준으로 크지도 않다. 원인은 마이그레이션이 실행한 get_all_edges() 쿼리의 실행 계획이 중간 산출 49억 행짜리 nested loop로 잡힌 것. 여기서 핵심은 "그래프 인덱스가 17시간 죽었다"가 아니라, 만약 위키 원문이 같은 DB 안에 있었다면 그 17시간 동안 글을 읽지도 쓰지도 못했다는 점이다.

세 층의 정의와 속성 대비

Wiki (원본) Knowledge Graph (파생 인덱스) Agent Memory (경험 기억)
정의 사람이 쓰고 읽는 마크다운 문서 + 메타데이터 청크·임베딩·엔티티·관계 (LightRAG 등) 세션에서 관측된 결정·시도·선호·실패
저장소 Git + PostgreSQL documents 테이블 PG(pgvector/AGE), Neo4j, Milvus 등 별도 스키마 또는 별도 DB
쓰기 주체 사람 (또는 승격 파이프라인) 인덱서 (자동, 사람 개입 0) 에이전트 (자동)
수명 영구, 버전 전체 보존 임베딩 모델 세대와 동일 (수개월) TTL 있음 — 승격되거나 감쇠
신뢰도 높음, 사람이 검수 원본 신뢰도의 하한 (추출 오류 누적) 낮음~중간, "이렇게 시도했다"까지만 참
재생성 가능성 ❌ 불가 — 여기가 유일한 진실 ✅ 전량 재생성 (원본 + 인덱서 코드) ⚠️ 부분적 — 세션 로그 원본이 있으면
삭제 정책 소프트 삭제 + tombstone, 히스토리 유지 하드 삭제 후 재빌드 (아래 참조) 하드 삭제 + 감사 로그, 개인정보 즉시
소유권 사람 파이프라인 (아무도 소유하지 않음) 에이전트 인스턴스/사용자
정정 방식 diff·rollback으로 모순 제거 정정 개념 없음 — 재빌드만 시간축 무효화로 모순 보존
백업 대상 ✅ 필수 ❌ 제외 (재생성이 더 싸다) ✅ 필수 (재생성 불가)

마지막 세 행이 이 섹션의 전부다. 나머지는 이 세 행의 결과다.

구분이 실제로 만들어내는 차이

① 재인덱싱 자유도 — 임베딩 모델을 바꿀 수 있느냐. LightRAG README는 못을 박는다. "임베딩 모델은 인덱싱 전에 확정해야 하고 질의 단계에서도 같은 모델을 써야 한다. 바꾸면 모든 청크·엔티티·관계를 재임베딩해야 하며 LightRAG는 재임베딩 도구를 제공하지 않는다." PostgreSQL 백엔드에서는 더 딱딱하다. vector(dim)이 테이블 DDL에 박히므로 차원을 바꾸려면 LIGHTRAG_VDB_CHUNKS / LIGHTRAG_VDB_ENTITY / LIGHTRAG_VDB_RELATION을 드롭해야 한다(issue #2119의 expected 1024 dimensions, not 1536이 정확히 이 증상이다).

원본이 바깥에 있으면 이건 그냥 DROP TABLE + 배치 재인덱싱이다. 원본이 안에 있으면 같은 명령이 데이터 삭제다. 같은 SQL, 전혀 다른 사건.

② 백업 범위 — 스토리지를 지배하는 건 원본이 아니다. 3072차원 float32 임베딩 하나는 12,288바이트다. 한국어 1,000자 청크는 UTF-8로 약 3KB. 임베딩이 원문보다 4배 무겁다(pgvector 0.7+ halfvec으로 반으로 줄여도 2배). 여기에 엔티티/관계 벡터와 LLM 캐시가 더 붙는다. 즉 백업에서 파생 층을 빼는 것만으로 백업 크기가 한 자릿수 배 줄고, RPO 설계가 단순해진다. 파생 층의 RPO는 시간이 아니라 **"재인덱싱에 몇 시간 걸리는가"**로 표현된다.

여기서 이 위키의 대전제 ①에 단서를 하나 붙이고 싶다. "LightRAG는 언제든 통째로 재생성 가능하다"는 측정해두지 않으면 선언에 불과하다. 문서 500건 재인덱싱에 LLM 호출이 몇 번 들어가고 몇 분/얼마가 드는지 분기마다 실측해서 기록하지 않으면, 정작 필요한 날 "재생성 가능"은 검증되지 않은 가정이 된다.

③ 프라이버시 경계 — 임베딩은 원문의 요약이 아니라 원문이다. Morris 등의 Vec2Text는 32토큰 텍스트를 임베딩만으로 복원해 **BLEU 97.3, 완전 일치 92%**를 달성했다. 더 나쁜 건 삭제 쪽이다. 「Ghost Vectors」(arXiv 2606.18497)는 HNSW 벡터 DB의 소프트 삭제된 임베딩이 raw 스토리지 파일에서 복원 가능함을 보였다 — 합성 의료 데이터에서 환자 나이·성별 마커 복원률 100%, 얼굴 임베딩 top-1 신원 복원 99%.

결론: 파생 층의 삭제는 tombstone이 아니라 재빌드여야 한다. 그리고 재빌드는 원본이 밖에 있을 때만 선택지다. 층이 합쳐져 있으면 "지웠다고 표시"밖에 못 한다.

④ 모순 처리 — 세 층이 각각 다른 정답을 쓴다.

  • 위키는 모순을 제거한다. 두 문장이 충돌하면 사람이 한쪽을 지우고, git이 이전 판을 보관한다.
  • 그래프는 모순을 다루지 못한다. 반대되는 두 문서에서 같은 엔티티가 추출되면 description이 병합되어 한 노드 안에서 자기모순을 일으킨다. 그래프 층에서 이걸 고치려 들면 안 된다 — 원본을 고치고 재인덱싱하는 게 유일한 정정 경로다.
  • 기억은 모순을 보존해야 한다. Graphiti(Zep)의 이중 시간 모델이 정답에 가깝다: 사실이 바뀌면 옛 사실을 지우지 않고 무효화 구간을 찍어 "지금 참인 것"과 "그때 참이었던 것"을 따로 질의한다. "우리는 A로 갔다가 실패해서 B로 바꿨다"는 A를 지우는 순간 가치를 잃는다.

LightRAG를 위키 DB처럼 쓰면 생기는 문제

문제 구체적으로 무슨 일이 무너지는 순간
스키마 잠김 저장소가 KV/VECTOR/GRAPH/DOC_STATUS 4종으로 나뉘고 각각 LIGHTRAG_KV_STORAGE 등 env로 고정. EMBEDDING_DIM은 DDL에 박힘 임베딩 모델 교체 = 테이블 드롭
문서 수정 반영 수정 = 삭제 + 재삽입. adelete_by_doc_id는 남은 문서에서 엔티티/관계 description을 LLM으로 재구성한다 오타 하나 고치는 데 LLM 비용, 캐시 miss면 그대로 청구서
통째 재구축 불가 LIGHTRAG_DOC_FULL에 원문이 있긴 하지만 그건 파이프라인 입력이지 편집 가능한 문서가 아니다. frontmatter·내부 링크·히스토리·리뷰 상태가 없다 인덱스를 날리는 순간 문서도 사라짐
벤더 종속 마이그레이션 자체가 장애 이벤트(#2255) 업그레이드마다 다운타임 협상
한국어 어휘 검색 부재 그래프 층에 정확 문자열 검색(파일명·설정키·에러 메시지)이 없다 POSTGRES_VECTOR_INDEX_TYPE 같은 토큰을 못 찾음

원본 층은 이렇게 두면 위 문제가 전부 파생 층 안에 갇힌다.

-- 원본 층: 여기만 백업하면 전부 복구된다
CREATE TABLE documents (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug          text NOT NULL UNIQUE,
  title         text NOT NULL,
  body_md       text NOT NULL,              -- 마크다운 원문 = 진실
  frontmatter   jsonb NOT NULL DEFAULT '{}'::jsonb,
  visibility    text NOT NULL DEFAULT 'private'
                CHECK (visibility IN ('private','team','public')),
  content_hash  text GENERATED ALWAYS AS (md5(body_md)) STORED,
  updated_at    timestamptz NOT NULL DEFAULT now(),
  deleted_at    timestamptz                  -- 소프트 삭제 (원본 층에서만)
);

-- 파생 층과의 계약: "무엇을 언제 색인했는가"만 기록. 인덱스 자체는 저장 안 함
CREATE TABLE index_state (
  doc_id          uuid PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
  indexed_hash    text,                      -- 색인 당시 content_hash
  embedding_model text,                      -- 'text-embedding-3-large'
  embedding_dim   int,
  graph_status    text NOT NULL DEFAULT 'pending'
                  CHECK (graph_status IN ('pending','running','done','failed')),
  indexed_at      timestamptz,
  attempts        int NOT NULL DEFAULT 0
);

-- 어휘 검색은 원본 층에 붙인다 (한국어는 simple + pg_bigm/pgroonga 병행 검토)
ALTER TABLE documents ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title,'') || ' ' || body_md)) STORED;
CREATE INDEX documents_tsv_idx ON documents USING GIN (tsv);

한국어 어휘 검색은 여기서 갈린다. PostgreSQL 기본 tsvector는 한국어 형태소 분석기가 없어 simple 설정으로는 공백 단위 토큰만 잡는다. 선택지는 pg_bigm(2-gram, GIN 기반이라 crash-safe), PGroonga(가장 빠르지만 인덱스가 크고 crash-safe가 아니라 손상 시 REINDEX 필요), pg_search(ParadeDB, Tantivy 기반 BM25, PGXN 최신 0.22.5) — 매니지드 PG에서 확장 설치가 가능한지부터 확인하는 게 순서다. (라우터가 이 중 무엇을 언제 고르는지는 → 검색 라우터 섹션에서 다룸)

분리하면 치르는 비용 — 정직하게

비용 실제 크기 완화책
정합성 관리 원본 수정 → 인덱스 미반영 구간(좀비 청크)이 상시 존재. Deep Path 지연만큼 indexed_hash <> content_hash 드리프트 쿼리를 상시 지표로. 0이 아니어도 정상, 추세가 우상향이면 비정상
중복 저장 원문 1× + 청크 1× + 임베딩 4× (halfvec 2×) 파생 층은 백업 제외 · 별도 볼륨 · 별도 보존 정책
파이프라인 복잡도 상태 머신 + 재시도 + 백프레셔가 새로 생긴다 index_state를 outbox로 쓰고 워커는 상태 전이만. 실패는 attempts로 격리
분산 삭제 삭제 요청 1건이 3층 3회 삭제로 번진다 삭제를 원본 층 이벤트로 만들고 각 층이 구독. "지웠다"는 각 층에서 따로 증명

가장 흔한 실패 모드는 세 번째가 아니라 네 번째다. 원본에서 문서를 지우고 "완료"라고 보고했는데 그래프에 엔티티가 남아 답변에 계속 등장하는 사고. 층별 삭제 확인 없이 삭제를 선언하지 말 것.

-- 드리프트: 이 세 숫자만 대시보드에 띄우면 분리의 비용이 관리 가능해진다
SELECT
  count(*) FILTER (WHERE s.doc_id IS NULL)                          AS never_indexed,
  count(*) FILTER (WHERE s.indexed_hash IS DISTINCT FROM d.content_hash) AS stale,
  count(*) FILTER (WHERE s.embedding_model <> current_setting('app.embed_model', true)) AS wrong_model
FROM documents d
LEFT JOIN index_state s ON s.doc_id = d.id
WHERE d.deleted_at IS NULL;

어느 층으로 보낼지 판별하는 실무 규칙 7개

  1. "사람이 지우면 영원히 사라지는가?" → 그렇다면 원본 층. 파생 층에 사람만 아는 정보를 넣는 순간 그 층은 더 이상 파생이 아니다. 가장 흔한 위반: 인덱싱 파이프라인 안에서 LLM이 생성한 요약을 그래프에만 저장하는 것.
  2. "내일 인덱서를 다시 돌리면 똑같이 나오는가?" → 나온다면 파생 층. 비결정적 산출물(LLM 요약·분류)을 파생 층에 두려면 프롬프트와 모델 버전을 원본 층에 고정해야 재현이 성립한다.
  3. "주어가 나/에이전트인가, 세상인가?" → "이 파라미터의 기본값은 X다"는 위키. "우리는 X를 시도했고 Y 때문에 실패해 Z로 바꿨다"는 기억. 후자를 위키에 쓰면 6개월 뒤 남이 사실로 읽는다.
  4. "틀렸을 때 비용을 누가 무는가?" → 남이 물면(공유·발행) 위키, 나만 물면 기억. 이게 승격 게이트의 실질적 기준이다. 기억 → 위키 승격은 "유용해서"가 아니라 "타인이 읽어도 안전해서" 일어난다.
  5. "수명이 임베딩 모델보다 긴가?" → 길면 원본 층. 모델 교체 주기(대략 6~18개월)보다 짧게 사는 데이터만 파생 층 단독 보관이 정당하다.
  6. "삭제 요청이 법적 의무인가?" → 그렇다면 그 데이터는 파생 층에 들어가면 안 되거나, 들어간다면 tombstone이 아닌 재빌드 삭제 경로가 미리 있어야 한다(Ghost Vectors). 개인 식별 정보를 그래프 엔티티로 승격시키지 말 것 — 엔티티는 문서 삭제 후에도 다른 문서에 살아남는 설계다.
  7. "정확한 문자열로 찾을 것인가?" → 설정키·파일 경로·에러 메시지·버전 번호는 어휘 검색이 필요하므로 반드시 원본 층에 원형 그대로 남는다. 그래프 층은 이런 토큰을 엔티티로 승격시키면서 정규화해 원형을 잃는다.

규칙 3과 4가 서로 충돌할 때(유용한 경험인데 검증이 안 됨) 답은 기억에 남기고 승격을 보류다. 승격했다면 원본 기억을 지우지 말고 출처로 연결해 둔다 — 위키 문장이 틀렸을 때 그 문장이 어느 세션의 어떤 관측에서 나왔는지 되짚을 수 있어야 한다. 이 세 층을 하나의 인터페이스로 노출하는 방법(→ MCP 섹션에서 다룸)은 층이 분리돼 있을 때만 깔끔하게 설계된다. 층별 도구가 각각 다른 삭제·신뢰도 계약을 갖기 때문이다.

LightRAG 실체 파악 — 검색 모드 5종과 인덱싱 비용 구조

LightRAG의 다섯 검색 모드는 "저수준/고수준 키워드 2종 × entities/relationships/chunks 벡터 인덱스 3개"의 조합에 불과하며, 비용의 대부분은 검색이 아니라 인덱싱 단계(청크당 추출 LLM 호출 + 허브 엔티티 재요약)에서 발생한다. 이 섹션은 lightrag-hku 1.5.6 기준의 실제 설정키·기본값·API로 모드별 선택 기준, 인덱싱 비용 구조, gleaning/요약 튜닝 포인트, 증분 삽입·삭제의 실제 난이도를 정리하고 GraphRAG·nano-graphrag와 비교한다.

핵심 요점

  • 다섯 모드의 차이는 '저수준/고수준 키워드 2종'이 entities_vdb / relationships_vdb / chunks_vdb 중 어디를 때리느냐가 전부다 — naive는 청크만, local은 엔티티, global은 관계, hybrid는 둘, mix는 셋 다. 코어 QueryParam 기본값은 'global'인데 서버 문서는 'mix'를 권장하므로 항상 명시적으로 지정해야 한다.
  • 비용은 검색이 아니라 인덱싱에서 터진다. 논문 기준 검색은 <100토큰·API 1회인 반면, 추출은 청크당 LLM 1회 + gleaning(기본 1회)로 유일하게 O(문서 수)로 도는 경로다. 여기에 FORCE_LLM_SUMMARY_ON_MERGE=8이 유발하는 허브 엔티티 재요약이 초선형 비용으로 얹힌다.
  • 역할별 모델 분리가 원가를 결정한다 — EXTRACT/KEYWORD는 non-thinking(README가 KEYWORD는 'must'라고 못박음), QUERY만 강한 모델. 비싼 모델은 O(질의)에, 싼 모델은 O(문서)에 배치하는 원칙.
  • gleaning을 0으로 내리면 추출 호출이 절반이 되지만 실패가 조용하다 — 그래프 밀도가 떨어져 local은 멀쩡한데 global이 먼저 무너진다. 이 값은 env.example에 없고 생성자 인자(entity_extract_max_gleaning)로만 설정한다.
  • 증분 삽입은 합집합 병합이라 싸지만(GraphRAG/nano-graphrag는 커뮤니티 리포트를 재생성) 삭제는 비싸다 — adelete_by_doc_id는 async 전용에 잔존 엔티티 재구축이 필요하고 캐시 잔류 버그(#2442)가 있다. 일상 수정은 문서 단위 delete+insert, 전량 재빌드는 workspace blue/green 스왑으로 이원화하는 게 현실적이다.
  • insert(ids=[...])에 마크다운 슬러그/PG document.id 같은 안정 키를 넘겨야 재발행이 '새 문서'가 아니라 '갱신'이 되고, 원본과 파생 인덱스가 같은 키로 조인된다. 임베딩 모델 교체 시에는 반드시 새 workspace로 재빌드해야 벡터 스토리지가 깨지지 않는다.

다섯 모드는 "키워드 2종 × 인덱스 3개"의 조합일 뿐이다

LightRAG(PyPI lightrag-hku, 현재 1.5.6, Python ≥3.10)는 질의를 받으면 먼저 LLM을 한 번 태워 키워드를 두 벌 뽑는다. 하나는 저수준(low-level) 키워드 — "구체적 엔티티", 다른 하나는 고수준(high-level) 키워드 — "개념·테마·요약". 이 두 벌이 서로 다른 벡터 인덱스를 때리는 것이 모드 차이의 전부다. 인덱스는 세 개뿐이다: entities_vdb(엔티티 설명), relationships_vdb(관계 설명), chunks_vdb(원문 청크).

모드 쓰는 키워드 조회 인덱스 질의당 LLM 호출 잘 맞는 질의 무너지는 지점
naive 없음(원문 임베딩) chunks_vdb 1 (답변만) "이 파일에 X 설정값 뭐였지" — 인용 정확도 개체명이 여러 문서에 흩어지면 리콜 붕괴
local 저수준 entities_vdb → 1-hop 관계 + 근거 청크 2 (키워드+답변) "A가 뭐야", "A의 속성/의존성" 질의에 고유명사가 없으면 키워드가 빈다
global 고수준 relationships_vdb → 연결 엔티티 + 청크 2 "A와 B는 왜 얽혀 있나", "이 결정의 파급" 관계 요약문만 물어오므로 원문 근거가 얇아져 답이 붕 뜬다
hybrid 둘 다 entities + relationships 2 위 둘의 합집합 local/global의 실패를 둘 다 상속
mix 둘 다 entities + relationships + chunks_vdb 2 기본값으로 삼을 만한 유일한 모드 컨텍스트가 가장 길다 = 가장 느리고 비싸다

⚠️ 기본값이 문서마다 다르다. 코어 QueryParam.mode의 dataclass 기본값은 "global"인데, 서버/WebUI 문서는 mix를 기본이자 권장으로 쓴다. 절대 기본값에 기대지 말고 매 호출에 명시하라. bypass 모드(검색 없이 LLM 직행)도 있으니 라우터의 "검색 불필요" 분기에 그대로 쓸 수 있다(→ 라우터 설계는 다른 섹션에서 다룸).

지연·토큰의 실제 구조는 이렇다. LightRAG 논문(Legal 데이터셋, Figure 2)은 검색 단계에서 <100 토큰 / API 호출 1회를 보고하는데, GraphRAG는 같은 자리에서 610개 커뮤니티 × ~1,000 토큰 = 610,000 토큰을 태운다. 즉 LightRAG의 검색은 원래 싸다. 비싼 건 인덱싱이다.

인덱싱 비용은 정확히 세 군데서 터진다

단계 실제로 도는 일 지배 설정키 (기본값) 비용 성격
① 청킹 토큰 단위 분할 CHUNK_SIZE=1200, CHUNK_OVERLAP_SIZE=100 0 (LLM 미사용)
② 엔티티/관계 추출 청크당 LLM 1회 + gleaning 최대 N회 entity_extract_max_gleaning (=1), MAX_EXTRACT_INPUT_TOKENS=20480, MAX_EXTRACTION_RECORDS=100, MAX_EXTRACTION_ENTITIES=40 인덱싱 토큰의 압도적 다수. 문서 수에 정비례
③ 병합/요약 같은 엔티티 설명 조각이 쌓이면 map-reduce 재요약 FORCE_LLM_SUMMARY_ON_MERGE=8, SUMMARY_MAX_TOKENS=1200, SUMMARY_CONTEXT_SIZE=12000, SUMMARY_LENGTH_RECOMMENDED=600 숨은 폭탄. 허브 엔티티가 문서 추가마다 재요약된다
④ 임베딩 청크 + 엔티티 + 관계 전부 EMBEDDING_BATCH_NUM=32, EMBEDDING_FUNC_MAX_ASYNC=8 벡터 건수가 청크 수의 수 배
⑤ 그래프 upsert 노드/엣지 병합 LIGHTRAG_GRAPH_STORAGE, MAX_PARALLEL_INSERT=3 NetworkX는 단일 프로세스 락, PG/Neo4j는 트랜잭션

②가 지배적인 이유는 단순하다. 추출만이 O(청크 수)로 LLM을 부르는 유일한 경로다. 청크 1,200토큰 기준 1MB 마크다운이면 대략 200~250청크, gleaning 1회를 켜면 400~500회 호출이다. ③은 더 고약하다 — 코퍼스가 커질수록 "PostgreSQL", "FastAPI" 같은 허브 엔티티의 설명 조각이 계속 8개를 넘겨 재요약을 유발한다. 체감상 문서 수에 초선형으로 붙는 비용의 정체가 이것이다.

왜 추출에 non-thinking 모델을 쓰라고 하는가

LightRAG는 LLM 역할을 넷으로 쪼개 놓았다(EXTRACT / QUERY / KEYWORD / VLM). README의 권고는 명확하다:

  • EXTRACT: "빠르고 저렴한 주류 모델로 충분하며, non-thinking 모델을 강력 권장" — 느리고 비싼 추출을 피하기 위해.
  • KEYWORD: "질의 지연에 직결되므로 반드시 non-thinking".
  • QUERY: "길고 노이즈 많은 컨텍스트에서 최종 답을 쓰므로 추출 모델보다 강해야 한다" — thinking 모델 허용.

산수로 보면 당연하다. thinking 토큰이 청크당 1~2k 붙으면 ②에서 그대로 청크 수만큼 곱해진다. 반면 QUERY는 사용자 질의당 1회다. 비싼 모델은 O(질의)에, 싼 모델은 O(문서)에. 이 한 줄이 전체 원가를 결정한다.

튜닝 포인트와 실패 모드:

  • gleaning 0으로 내리기 — 추출 호출이 절반이 된다. 대가는 롱테일 엔티티 누락이고, 실패가 조용하다: 그래프가 얇아져도 에러가 없고 local은 멀쩡히 답한다. 먼저 무너지는 건 global(관계 밀도 부족)이다. 참고로 이 값은 env.example에 키가 없다 — 생성자 인자로만 설정한다.
  • FORCE_LLM_SUMMARY_ON_MERGE 올리기 — 요약 호출이 줄지만 엔티티 설명이 중복 문장 누더기가 되고, 그게 local 모드 컨텍스트로 그대로 들어간다.
  • enable_llm_cache_for_entity_extract=True 유지 — 재인덱싱 원가를 좌우한다. 다만 추출 프롬프트나 모델을 바꾸면 캐시가 전부 무효가 되므로, "모델 교체 = 전량 재추출"로 예산을 잡아라.
  • ENTITY_EXTRACTION_USE_JSON=true — env.example 주석 그대로 "지연은 늘지만 신뢰도가 낫다". 야간 배치라면 켜라.

증분 삽입은 쉽고, 삭제는 어렵다

증분 삽입은 실제로 싸다. 새 문서를 같은 파이프라인에 태워 서브그래프를 만든 뒤 노드·엣지 집합의 합집합으로 병합한다. 커뮤니티 재계산이 없다는 게 핵심이다(GraphRAG는 커뮤니티 리포트를 리포트당 ~5,000토큰으로 재생성해야 하고, 논문 데이터셋 기준 커뮤니티가 1,399개였다). 중복은 내용 해시 기반 문서 ID로 막히며, rag.insert(text, ids=[...])로 직접 지정할 수 있다.

위키 대전제와의 접점: 마크다운 파일의 안정 슬러그(또는 PG의 document.id)를 그대로 ids=에 넣어라. 그래야 재발행이 "새 문서"가 아니라 "같은 문서의 갱신"이 되고, PG의 원본과 LightRAG의 파생 인덱스가 같은 키로 조인된다.

삭제는 다르다. adelete_by_doc_id()는 async 전용이며, 청크 → 고아 엔티티/관계 → 벡터 인덱스 → 문서 상태로 캐스케이드하되, 다른 문서에도 등장하는 엔티티는 남은 근거로 재구축해야 한다. 공식 문서 경고 그대로 "모든 삭제는 되돌릴 수 없고", "특히 문서 ID 단위 삭제는 오래 걸린다". 청크와 LLM 캐시가 남아 재인제스트가 깨진다는 버그 리포트(#2442)도 열려 있다.

그래서 대전제 ①(LightRAG는 언제든 통째로 재생성 가능한 파생 인덱스)은 옳다. 다만 한 군데는 반박이 필요하다 — 코퍼스가 수천 문서를 넘으면 전량 재생성은 위 표의 ②를 통째로 다시 무는 일이다. 현실적인 절충은 이중 전략이다: 일상 수정은 adelete_by_doc_id + insert(ids=[동일ID]), 전량 재생성은 workspace를 새로 파서 빌드한 뒤 원자적으로 스왑하고 분기당 1회 회귀 목적으로만 돌린다. workspace 파라미터가 데이터 격리 네임스페이스라 blue/green이 그대로 된다.

이웃 프레임워크와의 차이 (짧게)

LightRAG Microsoft GraphRAG nano-graphrag
그래프 구조 플랫 엔티티-관계 그래프 계층 커뮤니티 + 커뮤니티 리포트 GraphRAG 축소판(커뮤니티 유지)
증분 삽입 합집합 병합, 커뮤니티 재계산 없음 커뮤니티 해체 후 리포트 재생성 삽입마다 커뮤니티 재계산·리포트 재생성
문서 단위 삭제 adelete_by_doc_id 있음(느리고 캐시 이슈 존재) 사실상 재빌드 없음
검색 모드 5종 + bypass local / global(커뮤니티) naive / local / global
코드 규모 서버·WebUI·스토리지 백엔드 포함 대형 대형 약 1,100줄(테스트·프롬프트 제외)
적합 개인/팀 지식 OS, 잦은 갱신 "이 코퍼스 전체가 무슨 얘기인가" 요약 파이프라인을 직접 뜯어고칠 때

공개 비교 글들이 인용하는 500페이지 코퍼스 기준 수치(GraphRAG $50~200·45분 vs LightRAG ~$0.50·3분)는 출처의 자체 측정이고 모델·단가 가정이 공개되지 않았다. 방향성(1~2자릿수 차이)만 취하고, 실제 원가는 자기 코퍼스로 100문서 파일럿을 돌려 측정하라.

최소 설치와 코드

pip install "lightrag-hku[api]"        # 또는: uv tool install "lightrag-hku[api]"
# wiki_index.py — 파생 인덱스 빌더. 원본은 PG/마크다운, 여기는 언제든 버릴 수 있다.
import asyncio, os
from lightrag import LightRAG, QueryParam
from lightrag.kg.shared_storage import initialize_pipeline_status

async def build(workspace: str = "wiki_v2"):
    rag = LightRAG(
        working_dir="./lightrag_cache",
        workspace=workspace,               # blue/green 스왑 단위
        kv_storage="PGKVStorage",          # env: LIGHTRAG_KV_STORAGE
        vector_storage="PGVectorStorage",
        graph_storage="PGGraphStorage",
        doc_status_storage="PGDocStatusStorage",
        chunk_token_size=1200,
        chunk_overlap_token_size=100,
        entity_extract_max_gleaning=1,     # 0 = 추출 호출 반토막, 리콜 하락
        llm_model_func=my_extract_llm,     # non-thinking 모델을 여기 물린다
        embedding_func=my_embedding_func,
        llm_model_max_async=4,             # env: MAX_ASYNC_LLM
        enable_llm_cache_for_entity_extract=True,
    )
    await rag.initialize_storages()
    await initialize_pipeline_status()

    # 원본 문서 ID를 그대로 넘겨야 재발행이 update가 된다
    await rag.ainsert("...본문...", ids=["wiki/architecture/lightrag"],
                      file_paths=["wiki/architecture/lightrag.md"])

    print(await rag.aquery("왜 추출과 질의 모델을 분리했나",
                           param=QueryParam(mode="mix", top_k=40, chunk_top_k=20)))

asyncio.run(build())

initialize_storages()와 initialize_pipeline_status()를 둘 다 부르지 않으면 런타임 에러가 난다. 그리고 임베딩 모델을 중간에 바꾸면 벡터 스토리지가 깨지므로 반드시 workspace를 새로 파라.

도입 전 체크리스트

  • [ ] 모드를 코드에서 명시했는가(기본값이 문서마다 다름)
  • [ ] EXTRACT/KEYWORD에 non-thinking 모델, QUERY에만 강한 모델을 물렸는가
  • [ ] 100문서 파일럿으로 청크 수 × (1+gleaning) × 청크당 토큰을 실측했는가
  • [ ] insert(ids=...)에 원본 시스템의 안정 키를 넘기는가
  • [ ] workspace 스왑으로 전량 재빌드 경로를 확보했는가(삭제에 의존하지 않기)
  • [ ] MAX_TOTAL_TOKENS=30000 / MAX_ENTITY_TOKENS=6000 / MAX_RELATION_TOKENS=8000이 실제 QUERY 모델 컨텍스트에 맞는가

Fast Path / Deep Path — “문서 넣으면 30초 멈춤”을 없애는 법

업로드를 Fast Path(저장·청크·임베딩까지, 수백 ms)와 Deep Path(그래프 추출, 문서당 20~40초)로 쪼개는 설계를 문서 상태 머신·큐 선택·멱등성/경합 처리·1,000건 비용 산수까지 실제 설정키와 수치로 정리한 섹션.

핵심 요점

  • 그래프 추출 비용은 청크 수 × LLM 호출로 결정된다 — CHUNK_SIZE=1200 기준 6,000토큰 문서는 6청크, gleaning 포함 LLM 12~15회, MAX_ASYNC_LLM=4에서 벽시계 20~40초. 같은 문서의 임베딩은 요청 1건 200~500ms로 자릿수가 다르다.
  • Fast/Deep을 가르는 실제 기준은 트랜잭션이다. Fast Path(저장·청크·임베딩·FTS)와 잡 enqueue가 한 트랜잭션이어야 하며, Postgres 기반 큐(pgmq / FOR UPDATE SKIP LOCKED)를 쓰면 outbox 없이 공짜로 원자적이 된다.
  • 상태 머신은 stored → searchable → graph_indexing → graph_ready(+ stale, graph_failed)이고, 정본은 우리 documents 테이블의 graph_synced_sha다. LightRAG의 PENDING/PROCESSING/PROCESSED는 파생 인덱스 내부 상태로만 쓴다. graph_failed에서도 검색은 계속 돼야 한다.
  • 큐 선택: FastAPI BackgroundTasks는 재시도·영속성이 없어 Deep Path에 금지, arq 0.28.0은 코드가 가장 깔끔하나 PyPI에 maintenance-only mode가 명시돼 있고, Celery는 이 규모에 과잉. pgmq(PG 14–18, visibility timeout + read_ct, DLQ는 직접 구현)를 권장.
  • 멱등성은 (doc_id, content_sha256) 키로. LightRAG doc id가 내용 md5라 본문이 바뀌면 새 문서로 들어가므로 반드시 delete-then-insert — 빼먹으면 옛 판과 새 판이 공존하며 에러 없이 틀린 답을 낸다. 동시 수정은 잡 취소가 아니라 stale-drop으로 last-write-wins 수렴시킨다.
  • 1,000건 산수: Fast Path는 임베딩 6M 토큰 ≈ $0.12·6분, Deep Path는 LLM 14,000회·입력 40M/출력 10M ≈ Haiku 4.5로 $90·2.9시간(동시 8). 750배 차이가 두 경로를 쪼개는 경제적 근거이며, 백필 큐는 운영 큐와 반드시 분리한다.

30초는 어디서 나오는가 — 산술로 확인하기

그래프 인덱싱 비용은 거의 전적으로 청크 수 × 청크당 LLM 호출로 결정된다. LightRAG 기본값 CHUNK_SIZE=1200 / CHUNK_OVERLAP_SIZE=100 기준으로 6,000토큰짜리 문서는 약 6청크. 청크마다 엔티티·관계 추출 1회, 그리고 누락분을 다시 묻는 gleaning이 1회 더 붙는다(파이프라인 기본 entity_extract_max_gleaning=1, 즉 청크당 최대 2회. 입력이 DEFAULT_MAX_EXTRACT_INPUT_TOKENS=20,480을 넘으면 자동 생략). 여기에 엔티티 병합 시 설명 요약 호출(FORCE_LLM_SUMMARY_ON_MERGE)이 몇 건 더. 합쳐서 문서당 12~15회다.

기본 동시성은 MAX_ASYNC_LLM=4. 호출당 5~8초(입력 3~4k, 출력 ~800토큰)를 잡으면 문서 1건의 벽시계 시간은 (14 ÷ 4) × 6 ≈ 20~40초. 제목의 "30초 멈춤"은 과장이 아니라 기본 설정의 산술 결과다.

같은 문서의 Fast Path 작업량은 자릿수가 다르다.

단계 작업 외부 호출 문서 1건 실측 감각
저장 마크다운 → Postgres(정본) 0 ~ms
청킹 토큰 분할 0 ~ms
임베딩 6청크, EMBEDDING_BATCH_NUM=32 요청 1건 200~500ms
FTS tsvector 갱신 0 ~ms
그래프 추출 엔티티/관계 + gleaning + 병합 요약 LLM 12~15회 20~40초

경계선은 여기다. 위 네 줄까지가 Fast, 마지막 줄이 Deep.

두 경로를 가르는 진짜 기준은 트랜잭션이다

Fast Path가 커밋해야 하는 것: 원본 마크다운, 청크, 임베딩, FTS 인덱스. 커밋되면 문서는 searchable이고 API는 즉시 202 + doc_id를 돌려준다. Deep Path 잡은 같은 트랜잭션 안에서 큐에 들어가야 한다. Redis/RabbitMQ를 쓰면 "커밋은 됐는데 enqueue가 실패"를 막으려고 outbox 테이블 + 릴레이가 필요하다. Postgres 큐라면 같은 트랜잭션에 pgmq.send() 한 줄이면 끝난다. 이 규모에서 Postgres 큐를 고르는 첫 번째 이유가 이거다.

-- 정본은 여기. LightRAG 인덱스는 이 테이블에서 언제든 재생성된다.
CREATE TYPE doc_state AS ENUM
  ('stored','searchable','graph_indexing','graph_ready','stale','graph_failed');

CREATE TABLE documents (
  id               uuid PRIMARY KEY,
  path             text UNIQUE NOT NULL,
  body             text NOT NULL,
  content_sha256   bytea NOT NULL,
  state            doc_state NOT NULL DEFAULT 'stored',
  graph_synced_sha bytea,          -- 그래프에 실제로 반영된 판본
  graph_error      text,
  updated_at       timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON documents (state) WHERE state <> 'graph_ready';

-- Fast Path 전체가 한 트랜잭션. 별도 브로커였다면 여기에 outbox 가 필요하다.
BEGIN;
  INSERT INTO documents (id, path, body, content_sha256, state)
  VALUES ($1, $2, $3, $4, 'searchable')
  ON CONFLICT (path) DO UPDATE
    SET body = EXCLUDED.body,
        content_sha256 = EXCLUDED.content_sha256,
        state = CASE WHEN documents.graph_synced_sha IS NULL
                     THEN 'searchable'::doc_state ELSE 'stale'::doc_state END,
        updated_at = now();
  -- (청크 upsert + 임베딩 insert 생략)
  SELECT pgmq.send('graph_index',
    jsonb_build_object('doc_id', $1::text, 'sha', encode($4,'hex')));
COMMIT;

상태 머신과 UI 노출

상태 의미 진입 조건 UI
stored 원본만 커밋 트랜잭션 내부 사용자에게 안 보임
searchable FTS+vector 검색 가능 청크·임베딩 커밋 ✅ "업로드 완료" — 여기가 사용자 기준 완료 지점
graph_indexing 워커가 잡 점유 pgmq.read 성공 문서 상세에만 회전 표시
graph_ready graph_synced_sha == content_sha256 워커 완료 파란 점
stale 원본이 바뀌었고 그래프는 옛 판 재저장 시 sha 불일치 배지 아님 — 검색 결과 각주로
graph_failed 재시도 소진 read_ct > N 문서 상세 + 관리자 목록. 검색은 계속 된다

핵심은 graph_failed에서도 문서가 검색된다는 것이다. 그래프는 부가가치이지 전제가 아니다 — 대전제 ①(마크다운+Postgres가 정본, LightRAG는 파생 인덱스)의 실무적 귀결이다.

상태의 정본도 우리 테이블이지 LightRAG가 아니다. LightRAG는 자체적으로 PENDING → PARSING → ANALYZING → PROCESSING → PROCESSED / FAILED와 /documents/track_status/{track_id}, /documents/pipeline_status를 갖고 있고 진행률 표시에 쓰기 좋다. 하지만 "이 문서가 그래프에 반영됐는가"의 답은 우리 DB의 graph_synced_sha여야 한다. 인덱스를 통째로 날리고 재생성하는 순간 저쪽 상태는 전부 리셋되기 때문이다.

무너지는 지점: graph_ready를 전역 진행 배너로 만들어 사용자를 기다리게 하면 Fast Path를 만든 의미가 없다. 업로드 직후 포커스는 검색창으로 가야 한다.

큐/워커 선택

옵션 브로커 asyncio 재시도·가시성 원본 트랜잭션과 원자적 이 규모 판정
FastAPI BackgroundTasks 없음(같은 프로세스) 네이티브 없음 ✗ ❌ Deep Path 금지. 프로세스 재시작 = 조용한 유실
RQ Redis ✗(sync 워커) 있음 ✗(outbox 필요) △ Redis를 이미 굴린다면
Celery Redis/RabbitMQ/SQS 부분적 성숙(acks_late, DLQ) ✗ △ 1인 규모엔 운영비 과잉
arq 0.28.0 Redis 네이티브 max_tries=5, job_timeout=300, _job_id 유니크 ✗ △ 코드는 가장 깔끔하나 PyPI에 "maintenance only mode" 명시(issue #510)
pgmq (PG 14–18) Postgres 클라이언트 나름 visibility timeout + read_ct, DLQ는 직접 ✅ ✅ 권장
순수 FOR UPDATE SKIP LOCKED Postgres 〃 직접 구현 ✅ ✅ 의존성 0을 원하면

권장은 pgmq다. 근거 세 개: ①원본이 이미 Postgres라 enqueue가 공짜로 원자적, ②운영해야 할 인프라가 하나 줄어든다, ③visibility timeout + read_ct가 손으로 짤 재시도 로직을 대신한다. 폴링이 싫으면 LISTEN/NOTIFY를 깨우기 신호로만 쓴다 — 알림은 영속되지 않고 페이로드가 8,000바이트에서 잘리므로 큐 자체로 쓰면 안 된다.

무너지는 지점: 초당 수천 건을 넘기면 Postgres가 병목이 되고 아카이브 테이블(pgmq.a_*)의 vacuum·파티셔닝을 신경 써야 한다. 개인 지식 OS는 초당 1건도 안 되므로 해당 없다 — 그 규모가 되면 그때 Celery로 옮기면 된다.

멱등성·경합·독약 메시지

# vt=300s: 문서당 최장 처리시간(≈40s)의 여유 배수.
# 짧으면 두 워커가 같은 문서를 동시에 추출해 중복 관계가 쌓이고 비용이 2배 나가는데, 에러는 안 난다.
rows = await conn.fetch("SELECT * FROM pgmq.read('graph_index', 300, 1)")
for msg in rows:
    doc_id, sha = msg["message"]["doc_id"], msg["message"]["sha"]
    cur = await conn.fetchval(
        "SELECT encode(content_sha256,'hex') FROM documents WHERE id=$1", doc_id)

    if cur != sha:                    # 동시 수정 경합 → 뒤에 최신 잡이 있다. 그냥 버린다.
        await conn.execute("SELECT pgmq.archive('graph_index',$1)", msg["msg_id"]); continue
    if msg["read_ct"] > 5:            # 독약 메시지. pgmq 에 DLQ 는 없으니 직접 종결.
        await mark_failed(doc_id, "max retries")
        await conn.execute("SELECT pgmq.archive('graph_index',$1)", msg["msg_id"]); continue

    await set_state(doc_id, "graph_indexing")
    try:
        # LightRAG doc id = compute_mdhash_id(content, prefix="doc-") — 본문이 바뀌면
        # '새 문서'로 들어가고 옛 노드가 그대로 남는다. 선행 삭제를 빼먹으면
        # 두 판이 그래프에 공존하며 옛 사실을 근거로 답하기 시작한다.
        # (아래 두 메서드명은 버전 간 변동이 있다 — 설치본 시그니처 확인 필요)
        await rag.adelete_by_doc_id(prev_lightrag_id)
        await rag.ainsert(body)
    except TransientError:
        continue                      # 삭제하지 않음 → vt 만료 후 자동 재가시화
    await conn.execute("SELECT pgmq.archive('graph_index',$1)", msg["msg_id"])
    await set_graph_ready(doc_id, sha)
  • 작업 키는 (doc_id, content_sha256). LightRAG는 내용 해시가 같은 문서를 filter_keys()로 알아서 건너뛰므로 "같은 내용 재삽입"은 이미 멱등하다. 문제는 내용이 바뀐 경우다 — 반드시 delete-then-insert.
  • 동시 수정 경합은 취소가 아니라 stale-drop으로 푼다. 잡을 꺼냈을 때 payload의 sha가 현재 sha와 다르면 조용히 archive. 최신 sha 잡이 큐 뒤에 있으므로 last-write-wins로 수렴한다. 큐에서 잡을 찾아 지우는 것보다 훨씬 단순하고 안전하다.
  • 부분 실패가 가장 비싸다. 20청크 중 12청크까지 추출한 뒤 죽으면 상태가 문서 단위라 처음부터 다시다(= LLM 비용 2배). 완화책은 두 개뿐: 문서를 크게 만들지 말 것(1문서 ≤ 20청크), 그리고 LLM 응답 캐시를 켜서 재실행을 싸게 만들 것.
  • rate limit은 임베딩과 추출이 별도 풀이다. text-embedding-3-small Tier 1 기준 3,000 RPM / 1,000,000 TPM(티어별 상이 — 계정 Limits에서 확인). 임베딩을 아무리 때려도 추출용 LLM 쿼터는 안 줄어든다.
  • 워커 동시성 산정. 워커 프로세스 P × MAX_ASYNC_LLM A ≤ 공급자 동시 허용치, 그리고 P·A·(호출당 입력토큰) ÷ (호출 시간) ≤ TPM. 시작값은 P=2, A=4(동시 8). MAX_PARALLEL_INSERT는 문서 병렬도이고 공식 권장이 MAX_ASYNC_LLM의 약 1/3(기본 3)이다 — 이걸 올리면 문서 하나하나가 굶어 개별 지연이 늘어난다.

1,000건이면 얼마고 몇 시간인가

문서 평균 6,000토큰·6청크·gleaning 1회 가정. 문서당 LLM 14회, 입력 ≈40k, 출력 ≈10k.

Fast Path Deep Path
호출 수 임베딩 요청 ~190건 LLM 14,000건
토큰 6M (임베딩) 입력 40M / 출력 10M
비용 $0.12 (Batch 50% → $0.06) Haiku 4.5 $90 / Sonnet 5 $270 (Batch 50% → $45 / $135)
벽시계 ~6분 (TPM 상한이 병목) 동시 8 → 2.9시간, 동시 24 → ~1시간

검색 가능해지는 데 0.12달러, 그래프까지 가는 데 90달러 — 750배다. 이게 Fast/Deep을 쪼개는 경제적 근거의 전부다. 비용을 깎아야 하면 손잡이 순서는 ①gleaning 0으로(호출 절반, 재현율 하락) ②모델 티어 하향 ③FORCE_LLM_SUMMARY_ON_MERGE 끄기 ④마지막에 청크 크기 — 청크를 키우면 호출은 줄지만 추출 품질이 급격히 나빠진다. 초기 백필은 Batch API(최대 24시간, 50% 할인)가 정답이다.

마지막 함정: 백필과 정상 운영을 같은 큐로 돌리면 안 된다. 1,000건 백필이 큐를 막으면 방금 올린 문서가 3시간 뒤에 그래프에 들어간다. graph_index / graph_backfill 두 큐로 나누고 워커 풀도 분리하라. (검색 시 graph_ready가 아닌 문서를 그래프 경로에서 어떻게 다룰지는 → 라우터 섹션에서 다룸)

Retrieval Router — 모든 질문에 그래프를 부르지 마라

"Retrieval Router — 모든 질문에 그래프를 부르지 마라" 섹션 초안. 질의 6유형 라우팅 표, 라우터 구현 3안(규칙/임베딩/소형 LLM) 비교, RRF 융합 파라미터와 소스별 기권 게이트, 1.5초 타임아웃 예산과 병렬 폴백, query_log 기반 관측·수정 루프, 한국어 FTS 붕괴 지점과 대응을 실제 라이브러리·설정키·수치로 채웠다.

핵심 요점

  • 질의를 6유형(사실 조회/관계·비교/전역 요약/경험·결정/최신성/항해형)으로 나누고 그래프는 관계·비교에만 온라인 호출한다 — 전역 요약(LightRAG global, GraphRAG global search)은 map-reduce 구조라 대화형 예산에 못 들어가므로 Deep Path 에서 미리 굽고 캐시만 읽는다.
  • 라우터는 3단 계단: 규칙(1ms 미만) → 임베딩 근접(5~30ms) → 애매하면 기본 하이브리드. 소형 LLM 분류기는 TTFT만 300~600ms(Haiku 4.5 597ms 보고)라 1.5초 예산의 1/3을 먹으므로 온라인에서 빼고 야간 라벨러로만 쓴다. RAGRouter-Bench 에서 TF-IDF+SVM 93.2%가 MiniLM 임베딩 90.3%보다 높았다 — 좁은 도메인에선 어휘 신호가 의미 신호를 이긴다.
  • 융합은 RRF(k=60) + 유형별 가중치. BM25는 상한이 없고 코사인은 [-1,1]이라 가중합은 정규화 없이 성립하지 않는다. 결정적 실패 모드: RRF는 순위만 보므로 전멸한 소스의 쓰레기도 rank=1로 1/61을 얹는다 → 융합 전에 소스별 원점수 하한(cosine 0.20 이상 등)으로 소스를 통째로 기권시켜야 한다.
  • 1.5초 예산 배분(라우팅 30 / 임베딩 120 / 3소스 병렬 250 / 그래프 700 / 융합·재랭킹 250 / 버퍼 150ms)과 statement_timeout 강제. 폴백은 순차 재시도가 아니라 병렬 발사 후 마감 시각 채택 — 순차 체인은 실패마다 예산을 곱으로 먹는다. LightRAG 기본 LLM_TIMEOUT=240초는 반드시 낮춘다.
  • '못 찾음'은 후보 0건과 저점수 후보 20건을 다르게 다뤄야 한다. 후자가 더 위험(무관한 청크로 그럴듯한 답 생성) — 1위 원점수가 하한 미만이면 컨텍스트를 넘기지 말고 기권(QueryParam(mode="bypass")).
  • 관측은 query_log(route, route_reason, router_ver, sources_hit, cited_ids, shadow_route)로 재현 가능하게. 지표는 route_regret(선택 안 한 소스에서만 인용된 비율, 5% 초과 시 가중치 조정)과 shadow 불일치율. 표본 200건 미만에서 가중치를 만지면 인용 노이즈를 학습한다.
  • 한국어 to_tsvector('simple')은 조사가 붙은 채 색인돼 '라우터'가 '라우터가/를/는' 어느 것과도 매치되지 않는다. 형태소 계열(MeCab/textsearch_ko, Kiwi)은 MIRACL NDCG 0.61~0.64 vs 유니코드 계열 0.36~0.41이지만, 과분해 상태에서 AND 매칭을 걸면 0.13까지 붕괴 — 한국어에서 AND 기본은 금지. 토크나이저를 고치기 전엔 '사실 조회=FTS 우선' 자체가 성립하지 않는다.

검색 계층에서 실제로 비싼 것은 임베딩도 SQL도 아니라 그래프다. LightRAG 의 local/global/hybrid/mix 모드는 검색 전에 LLM 으로 키워드를 뽑는 단계를 거치고, 그 한 번의 호출만으로 이미 수백 ms~수 초가 나간다. 반면 "그 설정키 뭐였지" 같은 질의는 tsvector 한 방이면 끝난다(한국어 PostgreSQL 하이브리드 검색 p50 1.79ms 실측 사례가 있다). 라우터의 임무는 "더 좋은 답을 고르는 것"이 아니라 비싼 경로를 부르지 않을 이유를 먼저 찾는 것이다.

질의 유형 6종과 라우팅 표

유형 판별 신호 1차 소스 2차 그래프 p95 예산
사실 조회 고유명사·설정키·에러코드·백틱/따옴표, 12자 이하 FTS(BM25) vector ✗ 150ms
관계·비교 "차이/비교/vs/대신/영향", 개체 2개 이상 graph local·hybrid FTS ✓ 1,200ms
전역 요약 "전반적/한눈에/정리해줘", 개체 0개 사전계산된 커뮤니티 요약 vector ✓(캐시) 온라인 금지
개인 경험·결정 1인칭·"우리/지난번/왜 ~하기로" Agent Memory wiki vector ✗ 250ms
최신성 "최근/지금/현재/이번 주", 날짜 표현 FTS+vector에 시간 필터·부스트 memory ✗ 250ms
항해형 "어디 있", 파일명·경로·.md·제목형 제목/경로 prefix + trigram FTS ✗ 80ms

두 가지가 대전제와 직결된다. 첫째, 개인 경험·결정은 위키가 아니라 memory 가 1차다(원칙 ②). "왜 A 대신 B 를 골랐지"에 위키 문서를 1위로 올리면 결정의 이유가 현재 상태 서술로 조용히 대체된다 — 틀린 답이 아니라 다른 질문에 대한 답이라 사용자가 알아채기 어렵다. 둘째, 전역 요약은 온라인 예산 안에 넣지 마라. LightRAG global, GraphRAG global search 는 커뮤니티 리포트 전체를 map-reduce 로 훑는 구조라 대화형 응답 예산과 원천적으로 맞지 않는다. Deep Path 에서 미리 굽고(→ 인덱싱 섹션에서 다룸) 라우터는 캐시만 읽는다.

라우터 구현 3안

방식 추가 지연(p50) 공개 벤치 정확도 디버깅 대표 실패 모드
규칙·키워드 1ms 미만 RAGRouter-Bench TF-IDF+SVM 93.2% / macro-F1 0.928 최상(선택 이유를 문자열로 그대로 출력) 어휘 밖 표현·한국어 어미 변화에 침묵 실패
임베딩 근접(semantic-router 류) 5~30ms(로컬 인코더·캐시) MiniLM+SVM 90.3% / 0.897 중(상위 유사 발화 5개 로그) 유형 경계에서 임계값 진동
소형 LLM 분류기 TTFT만 300~600ms(Haiku 4.5 597ms 보고) 최상, 근거 문장까지 하(비결정적, 재현 불가) 예산 1.5초의 1/3을 라우팅이 먹고, 프로바이더 장애 = 검색 전체 정지

권고는 3단 계단이다. ① 정규식이 확신하면 즉시 확정 — 항해형·설정키·1인칭은 실무에서 전체 질의의 상당 비중이고 여기서 끝난다. ② 아니면 임베딩 근접. ③ 소형 LLM 분류기는 온라인 경로에서 빼고 야간 배치 라벨러로만 쓴다. 도메인이 좁으면 어휘 신호가 의미 신호를 이긴다는 게 위 벤치의 요지고, 개인 위키는 정확히 그 "좁은 도메인"이다.

# router.py — 3단 계단. 온라인 경로에 LLM 호출 없음.
import re

ENTITY = re.compile(r"[A-Za-z_][A-Za-z0-9_.]{2,}|`[^`]+`|\"[^\"]+\"")
NAV    = re.compile(r"(어디\s*(있|에)|파일|경로|문서\s*열|\.md\b)")
REL    = re.compile(r"(차이|비교|vs\b|대신|영향|왜.*(안|못)|어떻게.*연결)")
GLOBAL = re.compile(r"(전반|전체적|한눈에|정리해\s*줘|요약해\s*줘|지형)")
MEM    = re.compile(r"(우리|내가|지난번|예전에|삽질|왜.*(하기로|했지|골랐))")
FRESH  = re.compile(r"(최근|요즘|지금|현재|최신|이번\s*주|어제)")

def route(q: str, embed, centroids) -> dict:
    if NAV.search(q):  return {"route": "nav",    "why": "regex:NAV"}
    if MEM.search(q):  return {"route": "memory", "why": "regex:MEM"}
    if GLOBAL.search(q) and not ENTITY.search(q):
        return {"route": "graph_global", "why": "regex:GLOBAL,no-entity"}
    if REL.search(q) and len(set(ENTITY.findall(q))) >= 2:
        return {"route": "graph_local", "why": "regex:REL,entities>=2"}
    v = embed(q)                       # centroids: {route: 정규화된 평균벡터}
    sims = sorted(((float(v @ c), r) for r, c in centroids.items()), reverse=True)
    (top, best), (second, _) = sims[0], sims[1]
    fresh = bool(FRESH.search(q))
    if top >= 0.42 and (top - second) >= 0.05:
        return {"route": best, "why": f"emb:{top:.3f}/d{top-second:.3f}", "fresh": fresh}
    return {"route": "hybrid_default", "why": f"emb-ambiguous:{top:.3f}", "fresh": fresh}

임계값 0.42 / Δ0.05 는 인코더마다 다르다. 라벨 200건으로 직접 캘리브레이션하되, 애매하면 라우팅하지 말고 기본 하이브리드로 떨어뜨려라. 오라우팅 1건의 손해가 하이브리드 1건의 추가 지연보다 항상 크다.

융합: RRF 가 기본, 가중치는 유형별로

BM25 점수는 상한이 없고 코사인은 [-1,1] 이라 가중합은 정규화 없이 성립하지 않는다. min-max 정규화를 쓰면 같은 문서가 배치 구성에 따라 다른 점수를 받아 캐시와 회귀 테스트가 깨진다. 그래서 기본은 순위 기반 RRF: score = Σ wᵢ / (k + rankᵢ), k=60.

파라미터 값 근거 / 무너지는 지점
k 60 원 논문의 무튜닝 기본값. 10 이하로 낮추면 1위가 사실상 독식해 다양성이 죽는다
소스별 후보 FTS 50 / vector 50 / memory 20 / graph chunk 20 pgvector hnsw.ef_search 기본값이 40 이라 LIMIT 50 이면 후보가 모자란다 → SET LOCAL hnsw.ef_search = 100
w — 사실 조회 FTS 0.7 / vector 0.3 설정키·에러코드는 어휘가 이긴다(단, 한국어 토크나이저 전제 — 아래 참조)
w — 관계·비교 graph 0.5 / vector 0.3 / FTS 0.2 LightRAG 기본은 TOP_K=40, CHUNK_TOP_K=20
w — 경험·결정 memory 0.6 / vector 0.4 위키가 memory 를 밀어내면 "결정 이유"가 조용히 사라진다
재랭킹 융합 상위 20 → cross-encoder, 조건부 bge-reranker-v2-m3 는 GPU 50~100ms, 공유/CPU 환경에선 p50 240ms 대 보고. 남은 예산이 250ms 미만이면 스킵
최소 점수 게이트 소스별 원점수 하한(예: cosine 0.20 이상) RRF 는 순위만 본다 — 전멸한 소스의 쓰레기도 rank=1 로 1/61 을 얹는다

마지막 줄이 이 절의 핵심 실패 모드다. 한국어 FTS 가 토큰화 실패로 무의미한 매치 3건만 돌려줘도 RRF 는 그 중 하나를 1위로 대접한다. LightRAG 의 COSINE_THRESHOLD=0.2 처럼 융합 이전에 소스를 통째로 기권시키는 장치가 반드시 필요하다.

-- 단일 라운드트립 하이브리드. :w_fts/:w_vec/:w_mem 은 라우트가 결정.
-- 선행: SET LOCAL hnsw.ef_search = 100; SET LOCAL statement_timeout = '250ms';
WITH fts AS (
  SELECT id, row_number() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
  FROM wiki_chunk, websearch_to_tsquery('simple', :q) q
  WHERE tsv @@ q ORDER BY ts_rank_cd(tsv, q) DESC LIMIT 50
), vec AS (
  SELECT id, row_number() OVER (ORDER BY embedding <=> :qv) AS r
  FROM wiki_chunk
  WHERE 1 - (embedding <=> :qv) >= 0.20          -- 소스별 기권 게이트
  ORDER BY embedding <=> :qv LIMIT 50
), mem AS (
  SELECT doc_id AS id, row_number() OVER (ORDER BY score DESC) AS r
  FROM memory_search(:q, :qv) WHERE score >= 0.25 LIMIT 20
)
SELECT id,
       COALESCE(:w_fts / (60 + f.r), 0)
     + COALESCE(:w_vec / (60 + v.r), 0)
     + COALESCE(:w_mem / (60 + m.r), 0)          AS rrf,
       f.r AS r_fts, v.r AS r_vec, m.r AS r_mem  -- 사후 부검용 원순위 보존
FROM fts f FULL JOIN vec v USING (id) FULL JOIN mem m USING (id)
ORDER BY rrf DESC LIMIT 20;

ts_rank_cd/tsvector 대신 진짜 BM25 를 원하면 Tiger Data 의 pg_textsearch(to_bm25query() 함수와 <@> 연산자)나 ParadeDB pg_search 로 fts CTE 만 갈아끼우면 된다 — RRF 자체는 확장 없이 순수 SQL 이다.

타임아웃 예산 1.5초와 폴백 체인

단계 예산 강제 장치
라우팅 30ms 규칙+임베딩(LLM 없음)
질의 임베딩 120ms 질의 해시 캐시 히트 시 0
FTS/vector/memory 병렬 250ms SET LOCAL statement_timeout = '250ms'
그래프(해당 라우트만) 700ms LightRAG 기본은 LLM_TIMEOUT=240, RERANK_TIMEOUT=30(초) — 반드시 낮춰라
융합 + 조건부 재랭킹 250ms 초과 시 재랭킹 스킵
변동 버퍼 150ms p95 기준으로 잡아라, p50 아님

폴백은 순차 재시도가 아니라 병렬 발사 + 마감 시각 채택이다. asyncio.wait(tasks, timeout=0.25) 로 세 소스를 동시에 던지고, 마감에 살아 돌아온 것만 융합한다. 순차 체인(FTS 실패 → vector 시도 → graph 시도)은 실패마다 예산을 곱으로 먹어 p95 를 폭발시킨다. 그래프가 마감을 넘기면 취소하지 말고 백그라운드로 넘겨 결과를 캐시에 적재한다 — 같은 질의의 두 번째 시도는 공짜가 된다.

아무것도 못 찾았을 때: 후보 0건과 "저점수 후보 20건"을 반드시 다르게 다뤄라. 0건은 기권이 쉽다("위키에 없습니다" + 인접 문서 3개 + 문서 생성 링크). 위험한 쪽은 후자다 — LLM 은 무관한 청크로도 그럴듯한 답을 만든다. 융합 1위의 원점수가 하한 미만이면 컨텍스트를 아예 넘기지 말고 기권시킨다. LightRAG 는 QueryParam(mode="bypass") 로 "검색 없이 통과" 경로를 명시적으로 표현할 수 있어 이 결정을 코드에 남기기 좋다.

MCP 노출 형태(원칙 ⑤)에도 예산 얘기가 걸린다. 소스별 도구를 4~5개 따로 열면 라우팅 결정이 호스트 에이전트로 새고, 도구가 10개를 넘어가면 선택 정확도와 지연이 함께 나빠진다는 관측이 있다. wiki_search(query, hint?) 하나로 열고 라우터는 서버 안에 두되, hint 로 강제 오버라이드만 허용하는 편이 예산·재현성 양쪽에서 낫다.

라우터가 틀렸을 때: 관측·수정 루프

라우팅 결정은 재현 가능해야 한다. 최소 스키마:

CREATE TABLE query_log (
  id            bigserial PRIMARY KEY,
  ts            timestamptz NOT NULL DEFAULT now(),
  q             text        NOT NULL,
  route         text        NOT NULL,   -- nav|memory|graph_local|...
  route_reason  text        NOT NULL,   -- 'regex:MEM' | 'emb:0.51/d0.09'
  router_ver    text        NOT NULL,   -- 규칙+센트로이드 버전 핀
  sources_hit   jsonb       NOT NULL,   -- {"fts":0,"vec":50,"mem":3,"graph":null}
  ms            jsonb       NOT NULL,   -- {"route":4,"fts":31,"vec":58,"fuse":9}
  top_ids       bigint[]    NOT NULL,
  cited_ids     bigint[],               -- 답변이 실제로 인용한 것
  shadow_route  text,                   -- 야간 LLM 라벨러가 매긴 정답
  abstained     boolean     NOT NULL DEFAULT false
);
CREATE INDEX ON query_log (ts DESC) WHERE cited_ids IS NOT NULL;

여기서 지표 두 개만 뽑으면 된다.

  • route_regret: cited_ids 중 선택하지 않은 소스에서만 나온 문서의 비율. 5% 를 넘으면 해당 유형의 가중치를 조정한다.
  • shadow 불일치율: 야간 배치로 소형 LLM 이 같은 질의를 라벨링(온라인 경로 아님)해 route 와 대조. 불일치 건만 모아 규칙 1줄 또는 임베딩 예시 발화 1개를 추가하고 router_ver 를 올린다. 규칙을 고친 뒤 과거 로그를 재생해 회귀를 확인할 수 있는 건 오직 이 버전 핀 덕분이다.

실패 모드: 인용은 노이즈가 크다 — LLM 은 상위 문서를 습관적으로 인용하므로 route_regret 은 체계적으로 과소평가된다. 표본 200건 미만에서 가중치를 만지면 잡음을 학습한다.

한국어에서 어휘 검색이 무너지는 지점

기본 to_tsvector('simple', ...) 는 공백 기준이라 조사가 붙은 채 색인된다. "라우터가", "라우터를", "라우터는" 이 서로 다른 토큰이 되고 질의 "라우터"는 셋 중 무엇과도 매치되지 않는다. 영어의 stemmer 같은 구제책이 없다. sources_hit.fts == 0 의 대부분이 여기서 나온다.

방식 성격 비용 무너지는 지점
simple(기본) 어절 그대로 0 조사 결합으로 사실상 미작동
pg_trgm / pg_bigm 문자 n-gram 낮음(GIN) 스테밍·불용어·구문검색 없음, 짧은 질의 오탐↑, 인덱스 비대
textsearch_ko(MeCab + mecab-ko-dic) 형태소 → tsvector 중(빌드·사전 배포) 미등록 신조어·제품명 과분해 → 사용자 사전 운영 필수
Kiwi(kiwipiepy)로 앱단 토큰화 후 simple 색인 형태소, 순수 Python 호출 중 색인/질의 토크나이저 버전 불일치 = 조용한 리콜 붕괴

공개 벤치마크에서 형태소 분석 계열(PostgreSQL+MeCab, Elasticsearch+nori)은 MIRACL BM25 NDCG 0.61~0.64, 유니코드 기본 토크나이저 계열은 0.36~0.41 로 갈렸다. 다만 반대편 실패도 실측돼 있다 — nori 로 과분해된 상태에서 AND 매칭을 걸면 NDCG 0.13 까지 붕괴한다. 한국어에서 AND 를 기본으로 두지 마라. websearch_to_tsquery 의 암묵적 AND 를 OR + 커버리지 부스트로 바꾸는 편이 안전하다.

라우터 관점의 결론은 불편하지만 명확하다. 토크나이저를 고치기 전까지 "사실 조회 = FTS 우선"은 한국어 위키에서 성립하지 않는다. 고치기 전에는 사실 조회 가중치도 vector 쪽에 두고, sources_hit.fts == 0 비율을 상시 대시보드에 띄워라. 이 값이 30% 를 넘으면 그건 라우팅 문제가 아니라 색인 문제이며, 라우터 가중치를 아무리 만져도 회복되지 않는다.

저장소 진화 — PostgreSQL 하나로 어디까지 가고 언제 Neo4j 로 가나

Personal Knowledge OS 의 저장소를 V1(PostgreSQL+pgvector) → V2(+LightRAG PG 스토리지) → V3(+Neo4j) 3단계로 나누고, 각 단계의 승급 트리거를 실측 수치(홉 깊이별 지연, 엣지 수, 인덱스 빌드 시간)로 고정한 섹션. pgvector 파라미터·halfvec·iterative scan, LightRAG 의 PG 스토리지 환경변수와 Apache AGE 회피 근거, 한국어 FTS 4종 비교, 원본/파생 분리 백업 정책을 코드·표와 함께 다룬다.

핵심 요점

  • 승급 트리거를 정량화: V1→V2 는 '2홉 이상 그래프 질의가 주간 질의의 10% 초과', V2→V3 는 '엣지 100만 초과 / 그래프 질의 p95 > 1s / 전량 재구축 > 6h'. Neo4j 는 그래프 전체가 아니라 '경로 질의'만 가져간다 — 이웃 확장은 Postgres 재귀 CTE 가 오히려 4배 빠르고(1홉 0.4ms vs 2.9ms), 최단경로만 Neo4j 가 81~135배 빠르다.
  • pgvector 실전: 2000차원 초과 임베딩(3072d 등)은 halfvec 캐스팅 표현식 인덱스가 사실상 유일한 경로이고, opclass/연산자 불일치와 캐스팅 표현 불일치는 인덱스를 조용히 무력화한다. maintenance_work_mem 이 부족하면 디스크 빌드로 내려앉아 1M×1536d 빌드가 9.5분(병렬) 대신 1시간 27분 이상으로 튄다.
  • 0.8.0 의 hnsw.iterative_scan 은 오버필터링을 완화하지만 hnsw.max_scan_tuples(기본 20,000) 상한에 걸리면 에러 없이 결과가 부족한 채 끝난다 — 반환 건수를 계측하지 않으면 못 잡는 조용한 실패.
  • LightRAG PG 단일화는 PGKVStorage/PGVectorStorage/PGDocStatusStorage + PGTableGraphStorage 조합이 정답. Apache AGE 기반 PGGraphStorage 는 확장 설치 제약(관리형 PG 불가)에 더해, 342,654정점/815,570엣지 업그레이드에서 17시간 전면 다운타임 사례가 있고 해당 이슈는 not planned 로 닫혔다.
  • PG 기반 그래프의 진짜 한계는 다중 홉 — 재귀 CTE 는 실무상 3~4홉이 상한이고 AGE 도 결국 재귀 SQL 로 번역되므로 같은 지수 폭발을 문법으로 감쌀 뿐이다. PostgreSQL 19 의 SQL/PGQ(GRAPH_TABLE)는 문법 표준화이지 성능 승급으로 계산하면 안 된다.
  • 한국어 FTS 는 기본 tokenizer 가 조사 분리를 못 해 리콜이 0 이 된다. 운영 단순함 우선이면 crash-safe 한 pg_bigm, 랭킹·스니펫이 필요하면 pg_search(pdb.lindera(korean)), PGroonga 는 최고 속도 대신 '크래시 후 REINDEX'와 2.3배 인덱스 크기를 감수할 때만.
  • '파생은 언제든 재생성 가능'에 단서: 엔티티 추출이 입력 1,000단어당 ~1,200토큰을 태우므로 LLM 캐시 테이블은 파생이어도 백업 대상이다. 반대로 인덱스는 덤프에서 빼고 복원 후 병렬 재빌드하며, 재구축은 POSTGRES_WORKSPACE 를 이용한 blue/green 으로만 한다.

결론부터: 승급 트리거를 먼저 못 박는다

저장소 선택은 "무엇이 더 좋은가"가 아니라 "언제 갈아타는가"의 문제다. 대전제 ①(마크다운+PostgreSQL 이 원본, LightRAG 는 파생 인덱스)을 지키면 갈아타기 비용은 재구축 시간으로 환산되므로, 승급 기준은 감이 아니라 숫자로 정할 수 있다.

단계 구성 이 단계로 충분한 조건 다음 단계 승급 트리거(하나라도 걸리면)
V1 PostgreSQL 16+ / pgvector / FTS 문서 < 5k, 청크 < 200k, 질의 = 단일 홉 "X에 대한 문서 찾기" 그래프성 질의(2홉 이상 관계 추적)가 주간 질의의 10% 초과
V2 + LightRAG(KV·vector·graph 전부 PG) 엔티티 < 20만, 엣지 < 50만, 그래프 질의 p95 < 300ms ① 2홉 이상 경로/최단경로 질의 상시화 ② 엣지 100만 초과 ③ 그래프 질의 p95 > 1s ④ 재구축(전량 리인덱싱) > 6h
V3 + Neo4j(그래프만 분리) — 되돌리는 트리거: 월간 그래프 질의 < 수백 건이면 운영비가 이득을 못 넘는다

V3 를 "그래프만 분리"로 한정하는 이유는 벤치마크가 그렇게 말하기 때문이다. 43,234 엔티티/134,741 트리플 기준으로 이웃 확장(fan-out)은 Postgres 재귀 CTE 가 오히려 4배 빠르고(1홉 0.4ms vs 2.9ms, 3홉 43.7ms vs 171.5ms), 최단경로는 Neo4j 가 81~135배 빠르다(125~359ms vs 1.5~2.7ms). LightRAG 계열 검색은 대부분 "엔티티 조회 + 1홉 이웃"이라 Neo4j 로 옮겨도 이득이 없다. Neo4j 는 경로를 물을 때만 산다.

V1 — pgvector 를 실무 파라미터로 고정하기

pgvector(현행 0.8.x) 에서 실제로 만지는 손잡이는 셋뿐이다.

항목 HNSW IVFFlat
빌드 파라미터 m=16, ef_construction=64(기본) lists = rows/1000 (100만 이하), √rows (초과)
질의 파라미터 hnsw.ef_search(기본 40) ivfflat.probes(기본 1, 권장 시작값 √lists)
빌드 시간(1M×1536d, m=16/ef_c=200) 단일 스레드 약 1시간 27분 → 병렬 빌드 시 약 9.5분 훨씬 빠름
데이터 없을 때 빌드 가능 불가(클러스터링 필요)
개인 위키 권장 기본값 재구축이 잦고 빌드시간이 병목일 때만

빌드 시간에서 사람들이 가장 자주 밟는 지뢰는 maintenance_work_mem(기본 64MB)이다. 인덱스가 여기에 안 들어가면 Postgres 가 디스크 기반 빌드로 조용히 내려앉아 수십 배 느려진다. 재구축 전에 SET maintenance_work_mem='4GB'; SET max_parallel_maintenance_workers=7; 을 세션에 박아두고, max_parallel_workers 도 같이 올려야 워커가 실제로 붙는다.

차원 2000 초과는 vector 로 인덱스를 못 만든다. text-embedding-3-large(3072d) 같은 모델을 쓰면 halfvec(최대 4000d, 차원당 4바이트→2바이트)로 캐스팅한 표현식 인덱스가 사실상 유일한 선택지다. 저장 용량은 절반이 되지만 재현율 손실은 반드시 네 데이터로 측정하라(범용 수치는 확인 필요).

-- 원본(SoT) 은 마크다운 파일 + 이 테이블. 파생 인덱스는 여기서 재생성된다.
CREATE TABLE wiki_chunks (
  id           bigserial PRIMARY KEY,
  doc_path     text NOT NULL,            -- 마크다운 원본 경로 = 진짜 키
  content_hash text NOT NULL,            -- 재구축 idempotency + LLM 캐시 키
  lang         text NOT NULL DEFAULT 'ko',
  status       text NOT NULL DEFAULT 'active',
  body         text NOT NULL,
  embedding    vector(3072),             -- 저장은 vector, 인덱스는 halfvec 캐스팅
  tsv          tsvector GENERATED ALWAYS AS (to_tsvector('simple', body)) STORED
);

-- 2000차원 초과 → halfvec 표현식 인덱스. 질의에도 '동일한 캐스팅 표현'을 써야 탄다.
CREATE INDEX ON wiki_chunks
  USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)
  WITH (m = 16, ef_construction = 200)
  WHERE status = 'active';               -- 부분 인덱스로 저선택도 필터 흡수

-- 저카디널리티 필터는 B-tree 로, 고카디널리티는 iterative scan 으로.
CREATE INDEX ON wiki_chunks (lang, status);

필터링 실패 모드 세 가지. (a) halfvec_cosine_ops 로 인덱스를 만들고 질의는 <->(L2)로 쓰면 인덱스를 아예 안 탄다 — opclass 와 연산자는 반드시 짝을 맞춰야 한다. (b) 표현식 인덱스는 질의문에 똑같은 embedding::halfvec(3072) 표현이 있어야 매칭된다. (c) WHERE 로 걸러낸 뒤 결과가 LIMIT 에 못 미치는 오버필터링은 0.8.0 의 hnsw.iterative_scan = strict_order | relaxed_order 로 완화하지만, hnsw.max_scan_tuples(기본 20,000) 에 걸리면 에러 없이 결과가 부족한 채로 끝난다. 조용한 실패라서 반환 건수를 로그로 세지 않으면 못 잡는다.

억 단위로 갈 계획이면 VectorChord(vchordrq) 를 검토할 값어치가 있다(RaBitQ 압축, 16 vCPU 에서 1억 벡터 인덱스 20분 이내 주장 — 벤더 벤치마크임에 유의). 다만 개인 위키 규모에서 pgvector 를 못 견딜 일은 거의 없다.

V2 — PostgreSQL 하나를 KV·vector·graph 로 동시에 쓰기

LightRAG 는 스토리지 4종을 각각 갈아끼운다. PG 단일화 설정은 다음과 같다.

# LightRAG: KV / vector / graph / doc-status 를 전부 PostgreSQL 로
LIGHTRAG_KV_STORAGE=PGKVStorage
LIGHTRAG_VECTOR_STORAGE=PGVectorStorage
LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage
LIGHTRAG_GRAPH_STORAGE=PGTableGraphStorage   # AGE 대신 일반 테이블(권장)

POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DATABASE=wiki
POSTGRES_MAX_CONNECTIONS=25
POSTGRES_WORKSPACE=wiki_v3                   # ★ blue/green 재구축의 핵심

POSTGRES_VECTOR_INDEX_TYPE=HNSW_HALFVEC      # HNSW | IVFFlat | HNSW_HALFVEC | VCHORDRQ
POSTGRES_HNSW_M=16
POSTGRES_HNSW_EF=200
EMBEDDING_DIM=3072
COSINE_THRESHOLD=0.2

POSTGRES_WORKSPACE 가 이 설계의 숨은 주인공이다. 파생 인덱스를 새 워크스페이스에 통째로 재생성한 뒤 라우터의 포인터만 바꾸는 blue/green 이 가능해지고, 그게 대전제 ①을 실제 운영 절차로 만들어준다.

Apache AGE 를 쓸 것인가 — 신규라면 쓰지 마라

백엔드 설치 실측 성능(합성 BA 그래프 8,000노드/39,975엣지, 서드파티 측정) 판단
PGGraphStorage(Apache AGE) 확장 필요. postgres:17-alpine 에 없음, RDS/Cloud SQL 계열 불가 175 RPS, p95 213.3ms 신규 채택 비권장
PGTableGraphStorage 없음(일반 테이블+JSONB+B-tree) — 기본값으로 쓸 것
PgRcteGraphStorage(서드파티) 없음 12,776 RPS, p50 0.7ms 참고용 근거
Neo4j 별도 프로세스 1,684 RPS, p50 4.3ms 경로 질의 전용

AGE 를 피하는 이유는 속도만이 아니다. 실제 장애 사례로, LightRAG 1.4.9.1→1.4.9.4 업그레이드 시 AGE 그래프(342,654 정점 / 815,570 엣지)에 대한 마이그레이션 Cypher 가 사실상 카테시안 곱 플랜을 타면서 17시간 이상 전면 다운타임이 발생했고, 이슈는 "not planned" 로 닫혔다. 파생 인덱스가 원본 서비스를 인질로 잡은 셈이다. 소규모에서도 3,500노드/4,500엣지에 top-k=10 검색이 3~5분 걸린다는 보고가 있다.

PG 기반 그래프의 진짜 한계는 다중 홉이다. 재귀 CTE 는 홉마다 중간 결과가 지수적으로 불어나 실무상 3~4홉이 상한이고 5홉 이상은 초 단위~타임아웃으로 무너진다. AGE 도 결국 재귀 SQL 로 번역되므로 같은 폭발을 문법만 예쁘게 감싼 것이다. 반대로 말하면, 검색이 "엔티티 조회 + 1홉 이웃"에 머무는 한 PG 로 충분하다. PostgreSQL 19 에 SQL/PGQ(GRAPH_TABLE, CREATE PROPERTY GRAPH)가 커밋되어 표준 문법이 생기지만, 옵티마이저가 재귀 확장을 어떻게 푸는지는 여전히 미지수라 "문법 개선"이지 "성능 승급"으로 계산하지 마라.

한국어 FTS — 기본 tokenizer 는 쓰면 안 된다

Postgres 기본 파서는 한국어에 형태소 개념이 없다. to_tsvector('simple', ...) 은 공백으로만 자르므로 "위키에"와 "위키"가 다른 토큰이 되고, 조사가 붙는 순간 리콜이 0 이 된다.

방식 원리 강점 무너지는 지점
simple/english tsvector 공백 분리 설치 0 조사·복합명사 미분리. 긴 키워드에서 96ms(7천 건)까지 악화
pg_bigm 2-gram GIN GIN 이라 크래시 세이프·WAL 지원, 설치 쉬움 후보 과다 → pg_bigm.enable_recheck(기본 on) 리체크 비용. 일본어 위키 90만건에서 PGroonga 대비 검색 40~50배 느림. 1글자 검색은 인덱스 효과 급락
PGroonga Groonga 전문 인덱스 90만건 기준 인덱스 19분/검색 65ms, 갱신이 검색을 막지 않음 크래시 세이프 아님 → 장애 후 REINDEX 필수. 인덱스 크기 약 2.3배(9.8GB vs 4.2GB)
textsearch_ko(mecab-ko) 형태소 분석 진짜 형태소, korean 설정 제공, DB 인코딩 UTF-8 필수 mecab-ko/mecab-ko-dic 이 매우 오래됨(빌드 플래그 수동 조정). 신조어·영문 혼용 취약
pg_search(ParadeDB, pdb.lindera(korean)) Tantivy BM25 + ko-dic BM25 스코어·하이라이트·패싯. 긴 키워드에서 tsvector 대비 34배(2.8ms vs 96.4ms) 별도 확장(관리형 제약), 색인 엔진이 하나 더 늘어남

선택 기준(단일 노드 개인 위키 기준). ① 문서 5만 건 미만 + 운영 단순함이 최우선 → pg_bigm. 크래시 세이프가 백업 정책을 단순하게 만든다. ② 랭킹·스니펫이 검색 품질에 직결(라우터가 FTS 결과를 vector 결과와 섞어 재정렬 — → 라우터 설계는 다른 섹션에서 다룸) → pg_search. ③ PGroonga 는 속도는 최고지만 "장애 후 REINDEX"라는 운영 부채를 받아들일 때만. mecab 계열은 이미 mecab 파이프라인을 운영 중인 경우가 아니면 신규로 들이지 마라.

백업·마이그레이션·재구축 — 원본과 파생을 다르게 다룬다

  • 원본: 마크다운 파일(git) + wiki_chunks 의 doc_path/content_hash/body. pg_dump -Fc 로 논리 백업, 용량이 작아 시간당 스냅샷이 부담 없다.
  • 파생: 임베딩 컬럼, LightRAG 의 lightrag_* 테이블, HNSW 인덱스. 원칙적으로 백업 불필요 — 다만 여기서 대전제 ①에 단서를 달아야 한다.

대전제는 "LightRAG 는 언제든 통째로 재생성 가능"이라 말하지만, 재생성이 무료라는 뜻은 아니다. 엔티티/관계 추출은 입력 1,000단어당 약 1,200토큰의 LLM 호출을 태운다. 문서 5,000건 규모면 전량 재구축은 돈과 시간이 모두 드는 작업이다. 그래서 실무 규칙은 이렇게 갈린다.

  1. LLM 캐시 테이블은 파생이지만 백업한다. 캐시가 살아 있으면 그래프 재구축이 LLM 호출 없이 진행된다. 단 청킹 파라미터나 모델을 바꾸면 캐시는 무효라고 가정하고 일정을 잡아라.
  2. 임베딩은 백업하고 인덱스는 버린다. 임베딩 재계산은 API 비용, 인덱스 재생성은 CPU 시간이다. pg_dump 대상에서 인덱스를 빼고 복원 후 병렬 빌드로 다시 만드는 편이 덤프 크기·복원 시간 모두 유리하다. "데이터 적재 후 인덱스 생성" 순서는 pgvector 공식 권고이기도 하다.
  3. 재구축은 in-place 로 하지 않는다. POSTGRES_WORKSPACE 를 바꿔 새 워크스페이스에 만들고, 건수·샘플 질의 회귀를 확인한 뒤 포인터를 돌린다. 위 17시간 다운타임 사례가 정확히 "in-place 마이그레이션이 원본 서비스를 막는" 실패 모드다.

재구축 예산 체크리스트(각 항목을 실제로 재보고 표에 적어둘 것): 임베딩 재계산 시간·비용 / 그래프 추출 LLM 시간·비용(캐시 히트율 포함) / HNSW 병렬 빌드 시간(1M×1536d ≈ 10분대가 기준선) / FTS 인덱스 빌드 시간 / 전체 합이 6시간을 넘으면 V2 의 승급 트리거가 켜진 것이다. 그때 할 일은 Neo4j 도입이 아니라, 먼저 재구축을 증분화하고 워크스페이스 blue/green 을 자동화하는 것이다.

문서 계약 — frontmatter·ID·링크 규약이 검색 품질을 결정한다

프론트매터를 파서·인덱서·라우터가 공유하는 타입 계약으로 다루고, 불변 ULID 기반 이중 키·직접 구현하는 위키링크 해소·헤딩 기반 청킹(벡터 512 / 그래프 1200)·Git 규약·CI 게이트까지 실제 스키마와 수치로 설계한 섹션.

핵심 요점

  • 프론트매터 필드는 소비처(FTS 가중치·라우터 필터·승격 게이트)가 있는 것만 남긴다. 단, 고카디널리티 필드로 사전 필터를 걸면 pgvector HNSW recall 이 무너진다(선택도 10% + ef_search=40 → 평균 4행) — 필터는 status/tags/type 같은 저카디널리티만.
  • OKF v0.2 처럼 '파일 경로 = ID' 로 하면 파일 이동이 곧 삭제+생성이 되어 청크·백링크·메모리 참조가 끊긴다. 불변 ULID(`id`) + 가변 slug 이중 키로 분리하고, 둘 중 하나만 정본으로 둔다.
  • 위키링크·앨리어스 해소는 뷰어에 위임하지 말고 파이프라인이 직접 6단계로 처리한 뒤 links(src_id, dst_id, resolved) 테이블에 적재한다. 이 테이블은 LightRAG 와 달리 파생이 아니라 원본 층이다.
  • 청크 크기는 인덱스마다 달라야 한다 — 벡터/FTS 는 H2/H3 경계 + 480/512 tok + 오버랩 50~80, 그래프 추출은 LightRAG 기본값 CHUNK_SIZE=1200 / CHUNK_OVERLAP_SIZE=100. 표·코드펜스는 상한을 넘겨도 원자 단위로 둔다(잘린 표는 사실을 왜곡).
  • 청크 프리픽스로 `title > H2 > H3` 를 넣는 것은 가장 값싼 contextual retrieval 이다 — Anthropic 실측으로 top-20 검색 실패율 5.7%→3.7%, BM25 병용 시 49% 감소.
  • 제목 중복·고아 문서·만능 문서는 사람 리뷰로 못 잡는다. id 유일성, title+aliases 유일성, unresolved 링크 5% 임계, 인바운드 0 + 90일 무수정 orphan, 문서 4000 tok 상한을 pre-commit/CI 게이트로 강제한다.
  • Git 은 문서 1개 = 커밋 1개, `*.md merge=union` 금지(프론트매터가 두 번 생겨 파싱 전체 실패), 50MiB 경고/100MiB 하드 블록이라 첨부는 LFS 대신 오브젝트 스토리지 + sources URL 참조.

프론트매터는 메타데이터가 아니라 인덱스 계약이다

프론트매터를 "렌더링에 안 쓰이는 장식"으로 두면 검색 품질은 청킹이나 임베딩 모델이 아니라 필드 누락에서 무너진다. 파서·인덱서·라우터가 공유하는 유일한 타입 시그니처이기 때문이다. 각 필드는 반드시 소비처가 하나 이상 있어야 하고, 소비처가 없는 필드는 지운다.

필드 타입 실제 소비처 없을 때 무너지는 지점
id ULID(26자) Postgres PK, 청크 FK, 링크 그래프 노드 파일 이동 시 청크·백링크·엣지가 전부 고아
title text FTS setweight(A), 청크 프리픽스 동명 문서가 서로를 덮어씀
aliases list[str] 위키링크 해소, FTS 질의 확장 약칭·영문명으로 검색하면 못 찾음
tags list[str] 라우터 사전 필터, 그래프 커뮤니티 시드 필터를 못 걸어 top-k 오염
status draft/stable/deprecated 인덱싱 게이트, 리랭커 감점 초안이 정답으로 인용됨
created/updated date 신선도 정렬, stale 감사 3년 전 결정이 최신으로 보임
sources list[{url, retrieved}] 인용 검증, 재생성 시 원본 추적 사실과 환각을 구분 불가
confidence 0.0~1.0 승격 게이트, 응답 헤지 문구 메모리→위키 승격이 사람 감에 의존

필드명은 되도록 공개 스펙에 맞춘다. Google Cloud의 Open Knowledge Format(OKF) v0.2 는 type 만 필수로 두고 title/description/tags/sources 를 권장하며, status 기본값을 stable 로, 만료를 stale_after 로, 검증 이력을 verified: [{actor, at}] 로 정의한다. Obsidian 계열 도구를 뷰어로 쓴다면 예약어인 tags/aliases/cssclasses 는 반드시 리스트여야 하고, 1.9부터 단수형(tag, alias)은 인식되지 않는다.

트레이드오프: 필드가 늘수록 작성 마찰이 커져 빈 값이 늘고, 더 위험하게는 고카디널리티 필드로 사전 필터를 걸면 ANN recall 이 조용히 무너진다. pgvector HNSW 는 그래프를 걸은 뒤 필터를 적용하므로, 선택도 10% 조건에 hnsw.ef_search=40 이면 평균 4행만 살아남는다. 0.8.0의 iterative scan(hnsw.iterative_scan, hnsw.max_scan_tuples)이 완화해 주지만 CPU와 테일 레이턴시를 판다. 규칙은 하나다 — 필터는 저카디널리티(status, tags, type)만. updated 같은 건 필터가 아니라 정렬·후처리로.

---
id: 01JQ8F3K2M7X9V4B6N0PQRSTUV      # ULID, 불변. 파일이 어디로 가든 유지
type: decision                       # concept | decision | runbook | incident
title: 벡터 인덱스로 pgvector 를 선택한 이유
aliases: [pgvector 결정, why-pgvector, ADR-014]
tags: [postgres, vector-search, adr] # 저카디널리티만
status: stable                       # draft | stable | deprecated
created: 2026-03-11
updated: 2026-08-02
confidence: 0.8                      # <0.6 이면 그래프 인덱싱 제외
sources:
  - url: https://github.com/pgvector/pgvector
    retrieved: 2026-08-02
superseded_by: null                  # status=deprecated 면 필수
---

> **TL;DR** — 별도 벡터 DB 대신 pgvector 를 쓴다. 트랜잭션 하나로
> 원본·청크·임베딩을 함께 커밋할 수 있고, 재색인이 곧 `DELETE`+`INSERT` 다.

ID는 신원, 파일 경로는 주소

OKF v0.2는 concept ID를 "번들 내 파일 경로에서 .md 를 뗀 것"으로 정의한다. 번들 자족성 면에서는 옳지만 개인 위키에는 치명적이다. 리팩터링이 잦은데 파일을 옮기는 순간 ID가 바뀌고, 인덱서 입장에선 삭제+생성으로 보여 청크·엣지·백링크가 통째로 재계산되며 Agent Memory 가 걸어둔 참조가 끊긴다.

처방은 이중 키다. 불변 id(ULID) + 가변 slug(경로 파생). DB FK·그래프 노드·메모리 참조는 id, 사람이 읽는 URL과 [[링크]]는 slug.

UUIDv7 ULID
표준화 RFC 9562 (2024) GitHub 커뮤니티 스펙
Postgres 네이티브 uuid(16B) text/bytea 캐스팅 필요
표기 36자 하이픈 26자 Crockford Base32, 더블클릭 선택 가능
권장 위치 DB PK 프론트매터(사람이 grep 함)

둘을 동시에 유지하면 동기화 버그라는 새 실패 모드가 생긴다. 하나만 정본으로 두고 나머지는 파생으로 계산하라. 그리고 사람이 id 를 복붙하다 중복시키면 PK 충돌이 아니라 upsert 경로에서 조용한 덮어쓰기가 난다 — CI에서 id 유일성 검사는 선택이 아니다.

[[위키링크]] 는 해소 실패가 기본값이다

해소 순서를 코드로 못 박아라: ①[[id:01JQ...]] 직접 참조 → ②정확한 경로 → ③파일명(확장자 제외·대소문자 무시) → ④정규화(공백/-/_ 동일 취급) → ⑤aliases 역인덱스 → ⑥실패 시 unresolved 로 기록.

뷰어에 위임하지 마라. Obsidian 1.12.7 기준으로도 프론트매터 aliases 는 자동완성엔 뜨지만 링크 리졸버(getFirstLinkpathDest)가 참조하지 않는 사례가 보고돼 있다. 즉 앨리어스 해소는 파이프라인이 직접 해야 한다. JS 스택이면 mdast-util-wiki-link / remark-wiki-link 가 permalinks 배열로 기존/신규를 구분하고 pageResolver 로 permalink 를 계산하며 [[Real Page:Page Alias]] 형태를 파싱한다.

해소 결과는 반드시 테이블로 떨어뜨린다 — links(src_id, dst_id, raw, resolved bool). 백링크, 고아 탐지, 그래프 재생성이 전부 이 테이블 하나에서 나온다. LightRAG 인덱스는 언제든 통째로 날려도 되지만, 링크 테이블은 마크다운에서 결정적으로 재생성되므로 파생이 아니라 원본 층에 속한다(라우팅에서의 활용은 → 다른 섹션).

실패 모드: unresolved 링크를 CI 에러로 막으면 위키가 자라지 않는다. OKF 도 "Consumers MUST tolerate broken links" 라고 명시한다. 막지 말고 계측하라 — unresolved 비율이 5%를 넘으면 리포트만.

문서 구조를 그대로 청킹 경계로

from langchain_text_splitters import MarkdownHeaderTextSplitter

HEADERS = [("#", "h1"), ("##", "h2"), ("###", "h3")]
TARGET, HARD_MAX, OVERLAP = 480, 512, 64   # 토큰 기준

def chunk(md: str, doc_title: str, count):          # count = 토크나이저
    # strip_headers=False: 헤딩 텍스트를 청크 안에 남긴다(공짜 문맥)
    for sec in MarkdownHeaderTextSplitter(HEADERS, strip_headers=False).split_text(md):
        crumb = " > ".join([doc_title, *[sec.metadata.get(k, "") for k in
                                         ("h1", "h2", "h3") if sec.metadata.get(k)]])
        for body in soft_split(sec.page_content, count):
            yield f"[{crumb}]\n{body}"              # 프리픽스 = contextual embedding

def soft_split(text: str, count):
    buf, blocks = [], atomic_blocks(text)  # 표/코드펜스는 쪼개지 않고 통째 반환
    for b in blocks:
        if count("\n".join(buf + [b])) > HARD_MAX and buf:
            yield "\n".join(buf)
            buf = tail_tokens(buf, OVERLAP)        # 오버랩은 블록 경계에서만
        buf.append(b)
    if buf:
        yield "\n".join(buf)
항목 값 근거 / 실패 모드
1차 경계 H2/H3 구조가 곧 의미 단위. 구조 인식 분할이 가장 값싼 개선
목표 / 상한 480 / 512 tok 512-토큰 임베딩 창 기준. 초과분만 2차 분할
오버랩 50~80 tok (10~15%) 20%를 넘기면 중복 청크가 top-k 를 잠식
표·코드블록 원자 단위, 상한 초과해도 분할 금지 512에서 잘린 표는 헤더행만 남아 사실을 왜곡
청크 프리픽스 title > H2 > H3 (+1문장) Anthropic 실측: top-20 검색 실패율 5.7%→3.7%, BM25 병용 시 49% 감소
그래프 인덱싱 CHUNK_SIZE=1200, CHUNK_OVERLAP_SIZE=100 LightRAG 기본값. 엔티티 추출엔 더 넓은 문맥이 필요

핵심은 두 인덱스의 청크 크기가 달라야 한다는 것이다. 벡터/FTS는 정밀도(≈512), 그래프 추출은 관계 문맥(≈1200). 하나로 통일하려는 유혹이 실패 모드다.

LLM이 읽기 좋은 본문 형태

  • 요약을 맨 위에. 첫 청크가 사실상 문서 대표 벡터가 된다. 결론을 학술 논문처럼 맨 끝에 두면 결론 청크에 주어가 없다.
  • 정의 문장 한 줄. "X는 Y이다" 형태. 대명사와 "위에서 말한" 금지 — 청크는 문맥 없이 홀로 읽힌다.
  • ## Findings / ## Open Questions. 헤딩 기반 청킹에서 이 둘은 자동으로 독립 청크가 된다. Findings는 confidence 를 올리는 근거 창구, Open Questions는 Agent Memory 승격의 타깃이 된다(→ 다른 섹션).

Git 규약

규약 값 이유 / 실패 모드
커밋 단위 문서 1개 = 커밋 1개 Fast Path 인덱서가 git diff --name-status 로 변경분만 잡는다
메시지 wiki(<slug>): ... 승격·검증 이벤트를 grep 가능
충돌 *.md merge=union 금지 union merge는 --- 프론트매터를 두 번 만들어 전체 파싱 실패
첨부 50MiB 경고 / 100MiB 하드 블록 LFS는 클론 비용을 키운다. 이미지·PDF는 오브젝트 스토리지에 두고 sources 로 URL 참조
에이전트 커밋 별도 브랜치 + 별도 author 사람 편집과 섞이면 되돌릴 수 없다. 커밋당 변경 파일 상한(예: 20)을 pre-commit으로 강제

오염 안티패턴과 CI 체크리스트

제목 중복은 리랭커가 두 문서를 같은 것으로 보게 만들어 둘 다 top-k를 먹고 정답을 밀어낸다. 고아 문서는 프로젝트가 끝난 뒤에도 남아 영구히 검색 결과를 오염시킨다. 만능 문서는 헤딩 30개짜리 God doc이라 어떤 질의에도 애매하게 걸리고 정확히는 안 걸린다. 셋 다 사람 리뷰로는 못 잡으므로 게이트로 만든다.

  1. id ULID 형식 + 전역 유일 (중복은 upsert에서 조용한 덮어쓰기)
  2. title + aliases 정규화 후 유일 (대소문자·공백 제거 비교)
  3. status=deprecated 면 superseded_by 필수, 인덱싱에서 제외
  4. unresolved 링크 비율 < 5% (초과 시 실패가 아니라 리포트)
  5. 인바운드 링크 0 + 90일 무수정 → orphan 리포트
  6. 단일 문서 > 4,000 tok 또는 H2 > 12개 → split 후보
  7. sources 가 비었는데 confidence > 0.7 이면 커밋 거부

python-frontmatter(frontmatter.parse() → (metadata, content))로 읽고 Pydantic 모델 하나에 이 규칙을 담아 pre-commit에서 돌리면 200줄이 안 된다. 이 게이트가 없으면 위 계약은 전부 위키의 주석일 뿐이다.

구현 ① — DB 스키마와 FastAPI 엔드포인트

개인 지식 OS의 저장 계층을 실제 DDL 수준으로 설계한 섹션. pages/page_revisions를 원본으로 두고 chunks/embeddings/links를 content_hash 키의 파생 캐시로 분리한 뒤, GIN·HNSW·부분 인덱스 선택 기준, soft delete와 tombstone, 리비전 전문 저장, If-Match 낙관적 잠금 FastAPI 라우트, "커밋 후 발행" 아웃박스, Alembic 운영 체크리스트를 트레이드오프·실패 모드와 함께 정리했다.

핵심 요점

  • 원본(pages/page_revisions/sources)과 파생(chunks/links)을 테이블 단위로 갈라놓되, embeddings만은 '재생성에 API 비용이 드는 파생 캐시'로 3분류하고 PK를 (content_hash, model)로 잡아 리네임·문단 이동 시 재임베딩을 0건으로 만든다
  • PostgreSQL 18부터 생성 컬럼 기본값이 VIRTUAL이므로 tsvector 컬럼에 STORED를 반드시 명시해야 하고, 한국어는 기본 파서로 어절이 안 쪼개지므로 pg_bigm(2-gram) 또는 textsearch_ko(mecab) 도입이 전제다
  • 인덱스는 GIN(fts) + HNSW(halfvec, m=16/ef_construction=128) + 부분 인덱스 조합. 필터 동반 벡터 검색은 hnsw.ef_search 기본 40 때문에 결과가 말라붙으므로 pgvector 0.8.0+의 hnsw.iterative_scan=relaxed_order가 사실상 필수
  • 재인덱싱은 page.content_hash → 청크별 content_hash → embeddings ON CONFLICT DO NOTHING 3계층 계단식으로 판단해 편집 1회당 재임베딩을 1~2청크로 줄인다
  • 리비전은 diff가 아니라 전문 저장이 기본값 — TOAST가 2KB 초과분을 자동 압축하므로 텍스트 위키에서 diff는 조기 최적화이며, pg_column_size()로 5분 만에 실측 가능하다
  • 쓰기 라우트는 If-Match 부재 시 428 / 불일치 시 412(강한 ETag 필수), 브로커 호출은 금지하고 같은 트랜잭션의 outbox 행 + FOR UPDATE SKIP LOCKED 릴레이로 발행하며 LISTEN/NOTIFY는 웨이크업 신호로만 쓴다
  • soft delete는 deleted_at만으로 부족 — tombstone(purge_after) + 파생 인덱스 삭제 이벤트가 없으면 삭제 문서가 검색에 계속 뜬다

원본과 파생을 코드가 아니라 스키마 레벨에서 갈라놓지 않으면, 두 달 뒤 "이 임베딩은 어느 버전 문서에서 나온 거지?"에 답할 수 없게 된다. 이 섹션은 그 경계를 테이블·키·인덱스로 못 박는 방법을 다룬다.

테이블 지도: 무엇이 원본이고 무엇이 버려도 되는가

테이블 계층 통째 재생성 백업 대상 비고
pages 원본 ❌ ✅ 마크다운 현재 상태
page_revisions 원본 ❌ ✅ append-only
sources / citations 원본 ❌ ✅ 인용은 사람이 넣은 사실
links 파생 ✅ (본문 파싱) ❌ 백링크·red link
chunks 파생 ✅ ❌ 청커 버전 바뀌면 폐기
embeddings 파생이지만 캐시 ✅ (유료) ⚠️ 권장 재생성에 API 비용이 든다
ingest_jobs / outbox 운영 ✅ ❌ 24h 지나면 파티션 드롭
memories 별도 층 ❌ ✅ 에이전트 경험·결정
promotions 별도 층 ❌ ✅ memory → page 승격 심사

여기서 대전제 ①을 한 군데 수정한다. "LightRAG는 언제든 재생성 가능한 파생"은 맞지만, embeddings는 재생성이 무료가 아니다. 그래서 임베딩만은 파생 스키마에 두되 백업은 뜬다. 대신 PK를 chunk_id가 아니라 (content_hash, model)로 잡으면, 문서 리네임·목차 이동·문단 순서 교체에서 재임베딩이 0건이 된다.

코어 DDL

CREATE TABLE pages (
  id           uuid PRIMARY KEY DEFAULT uuidv7(),   -- PG18 내장
  slug         text        NOT NULL,
  title        text        NOT NULL,
  body_md      text        NOT NULL,
  frontmatter  jsonb       NOT NULL DEFAULT '{}',
  content_hash bytea       NOT NULL,                -- sha256(body_md || frontmatter)
  version      bigint      NOT NULL DEFAULT 1,      -- 낙관적 잠금 카운터 = ETag
  updated_at   timestamptz NOT NULL DEFAULT now(),
  deleted_at   timestamptz,                         -- soft delete
  purge_after  timestamptz,                         -- tombstone 만료 시각
  fts tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('simple', coalesce(title,'')),   'A') ||
    setweight(to_tsvector('simple', coalesce(body_md,'')), 'B')
  ) STORED                                          -- PG18: STORED 생략 금지
);

CREATE UNIQUE INDEX pages_slug_live ON pages (slug) WHERE deleted_at IS NULL;
CREATE INDEX pages_fts_gin  ON pages USING gin (fts);
CREATE INDEX pages_dirty    ON pages (updated_at) WHERE deleted_at IS NULL;

CREATE TABLE page_revisions (
  page_id      uuid   NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
  version      bigint NOT NULL,
  body_md      text   NOT NULL,        -- 전문 저장 (TOAST + lz4)
  content_hash bytea  NOT NULL,
  op           text   NOT NULL,        -- create|edit|promote|revert|delete
  memory_id    uuid,                   -- promote 인 경우 출처 메모리
  author       text   NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (page_id, version)
);

PG18 함정: 18부터 GENERATED ALWAYS AS (...)는 키워드를 생략하면 VIRTUAL이 기본이다. 게다가 virtual 컬럼은 사용자 정의 함수·타입을 참조할 수 없어서, 한국어 tokenizer 계열(textsearch_ko의 mecab 설정 등)을 넣는 순간 생성 자체가 실패한다. STORED를 항상 명시하라.

한국어 FTS는 기본 설정으로 안 된다. simple/english 파서는 "지식그래프를"을 한 토큰으로 잡아 "지식그래프" 검색에 안 걸린다. 선택지는 두 개다: pg_bigm(2-gram, 설치 쉬움·인덱스 큼·짧은 질의에 노이즈) 또는 textsearch_ko(mecab-ko-dic 형태소, 품질 우위·서버에 사전 설치 필요). 개인 위키라면 pg_bigm + trigram으로 시작하고, 재현율이 문제가 될 때 mecab으로 올리는 편이 회수가 빠르다.

인덱스 결정표

목적 인덱스 기본값·근거 무너지는 지점
키워드 검색 GIN (fts) 별도 컬럼이라 질의에 regconfig 재명시 불필요 한국어 파서 미설정 시 재현율 급락
벡터 검색 HNSW (vec halfvec_cosine_ops) WITH (m=16, ef_construction=128) halfvec는 2*dim+8B(1536차원 ≈ 3KB, float32의 절반), 인덱스 차원 상한 4000 (vector는 2000) hnsw.ef_search 기본 40 — 필터가 10%만 통과시키면 평균 4행만 남는다
필터 동반 벡터 SET hnsw.iterative_scan = 'relaxed_order' (pgvector 0.8.0+) hnsw.max_scan_tuples 기본 20000, scan_mem_multiplier 기본 1 strict_order는 정렬 보장 대신 느림
백링크 links (resolved_page_id) WHERE resolved_page_id IS NOT NULL
red link 대시보드 links (to_slug) WHERE resolved_page_id IS NULL 부분 인덱스라 아주 작다
잡 큐 ingest_jobs (run_after) WHERE status IN ('queued','retry') 완료 수백만 행이 인덱스에 안 들어감 상태 전이 누락 시 인덱스가 무한 성장
승격 중복 방지 UNIQUE (memory_id) WHERE status='pending' 같은 기억이 두 번 큐에 못 들어감

재인덱싱 판단: 해시 3계층

updated_at만 보면 오탐(포맷터만 돌려도 갱신)이 나고, 문서 해시만 보면 8KB 문서의 한 줄 수정에 전 청크를 다시 임베딩한다. 계단식으로 판단한다.

  1. pages.content_hash 불변 → 전 파이프라인 skip.
  2. 변경 시 재청킹 후 청크별 content_hash 비교 → 바뀐 청크만 통과. 편집 1회당 재임베딩이 보통 1~2청크로 줄어든다.
  3. embeddings PRIMARY KEY (content_hash, model)에 ON CONFLICT DO NOTHING → 이미 있으면 API 호출 0.

모델 교체는 마이그레이션이 아니라 model 값이 다른 행을 추가하는 일이다. 두 모델을 동시에 유지한 채 검색 품질 A/B를 돌리고, 확정된 뒤에 DELETE ... WHERE model = 'old'로 정리한다.

soft delete와 tombstone

deleted_at만으로는 부족하다. 파생 인덱스(LightRAG 그래프, 벡터)에 이미 들어간 내용을 지웠다는 사실 자체를 전파해야 하기 때문이다. 삭제는 ① pages.deleted_at 설정 → ② page_revisions에 op='delete' 한 행 → ③ 아웃박스에 page.deleted 이벤트(소비자가 delete_by_doc_id 계열 호출) → ④ purge_after = now() + 30 days 후 하드 삭제, 4단계다. purge_after가 없으면 tombstone이 영구 누적되고, purge_after만 있고 이벤트가 없으면 삭제한 문서가 검색 결과에 계속 뜬다 — 실제로 가장 흔한 사고다.

리비전: diff 말고 전문 저장

전략 저장량 복원 언제 무너지나
전문 8KB × 10만 리비전 = 800MB(압축 전) O(1) base64 이미지가 본문에 섞이면 폭발
diff 체인 수십 MB O(n) 재생 중간 손상 시 이후 전부 소실
N마다 스냅샷 중간 O(N) 구현·테스트 비용이 개인 규모엔 과함

PostgreSQL은 행이 약 2KB(TOAST_TUPLE_THRESHOLD)를 넘으면 자동으로 압축·아웃오브라인 저장한다. 압축기는 PG14부터 default_toast_compression으로 선택 가능하고 lz4가 쓰인다(배포판 기본값은 직접 확인할 것). 즉 텍스트 위키에서 diff 저장은 대개 조기 최적화다. 실제 비용은 SELECT pg_column_size(body_md) FROM page_revisions로 5분이면 측정된다. diff가 정당해지는 건 리비전/문서 비가 100:1을 넘는 협업 위키뿐이다.

FastAPI 라우트

라우트 시그니처 요점 동시성·전송
POST /pages PageIn → 201 + Location + ETag 커밋 시점에 FTS 즉시 검색 가능(Fast Path)
GET /pages/{slug} ?version= 로 과거 리비전 응답에 강한 ETag "7" (weak 불가)
PUT /pages/{slug} If-Match 필수 없으면 428, 불일치 412
DELETE /pages/{slug} ?mode=soft|purge → 202 tombstone + 이벤트
GET /search q, mode=auto|fts|vector|graph|memory, k 라우팅 규칙은 (→ 검색 섹션)
GET /pages/{slug}/related k, min_score 벡터 + 백링크 병합
GET /jobs/{id} / GET /jobs/stream 잡 상태 SSE
GET /promotions?status=pending / POST /promotions/{id}/decide 승격 큐 승인 시 내부적으로 PUT 경로 재사용

승격을 내부적으로 PUT 경로에 태우는 게 핵심이다. 그래야 승격된 문장도 리비전(op='promote', memory_id)을 남기고, 나중에 "이 문장 왜 여기 있지?"를 기억까지 역추적할 수 있다.

@router.put("/pages/{slug}")
async def update_page(
    slug: str, body: PageIn,
    db: AsyncSession = Depends(get_db),
    principal: Principal = Depends(require_scope("wiki:write")),
    if_match: Annotated[str | None, Header(alias="If-Match")] = None,
) -> PageOut:
    if if_match is None:
        raise HTTPException(428, "If-Match required")      # RFC 6585
    row = (await db.execute(
        update(Page)
        .where(Page.slug == slug,
               Page.deleted_at.is_(None),
               Page.version == parse_etag(if_match))
        .values(body_md=body.md, content_hash=sha256(body.md),
                version=Page.version + 1, updated_at=func.now())
        .returning(Page)
    )).scalar_one_or_none()
    if row is None:
        raise HTTPException(412, "stale version")          # RFC 9110 §13.1.1
    db.add(Revision(page_id=row.id, version=row.version, body_md=body.md))
    db.add(Outbox(topic="page.updated",
                  payload={"page_id": str(row.id), "version": row.version}))
    await db.commit()          # ← 커밋 전에는 누구에게도 알리지 않는다
    return PageOut.model_validate(row)

If-Match는 강한 validator를 요구하므로 W/"..."를 쓰면 안 된다. ORM 매핑을 쓴다면 SQLAlchemy version_id_col이 같은 일을 해주고 불일치 시 StaleDataError를 던지지만, 위처럼 UPDATE ... WHERE version = ? RETURNING이 왕복 1회로 끝나고 412 변환도 명시적이다.

스트리밍: FastAPI 0.135.0부터 from fastapi.sse import EventSourceResponse, ServerSentEvent가 내장이라 sse-starlette 의존성이 필요 없다(현재 0.141.1). 다만 SSE 핸들러가 DB 세션을 잡은 채 수 분간 살아 있으면 커넥션 풀이 마른다 — 잡 상태 스트림은 풀에서 빌린 세션을 폴링 주기마다 반납하도록 짜라.

아웃박스: "DB 커밋 후 큐 발행"

규칙 하나. HTTP 핸들러는 브로커를 직접 호출하지 않는다. 같은 트랜잭션에 outbox 행을 넣고 커밋하면, 이벤트는 비즈니스 행이 존재할 때에만 존재한다. 릴레이는 별도 프로세스:

SELECT ... FROM outbox WHERE published_at IS NULL ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100

SKIP LOCKED는 잠긴 행을 기다리지 않고 건너뛰므로 워커를 수평 확장해도 서로 막지 않는다. 다만 "동시 실행 N개 제한" 같은 전역 제약은 SKIP LOCKED로 못 지킨다 — 그건 pg_advisory_xact_lock이 필요하다.

LISTEN/NOTIFY는 웨이크업 신호로만 써라. 페이로드 상한 8000바이트, 큐 8GB, 리스너가 끊긴 동안의 알림은 그냥 사라지고, transaction-mode PgBouncer 뒤에서는 LISTEN이 아예 깨진다. 알림을 유실해도 릴레이의 주기 폴링이 결국 집어가는 구조여야 한다. 브로커를 따로 두기 싫으면 pgmq 확장이 가시성 타임아웃까지 얹어준다.

Alembic 운영 (1.19.1)

  • HNSW 인덱스는 데이터가 찬 뒤에 만든다. pgvector 문서 권고이자, 빈 테이블에 만든 그래프는 품질이 나쁘다. 마이그레이션에서는 with op.get_context().autocommit_block(): 안에서 postgresql_concurrently=True로 생성한다(CREATE INDEX CONCURRENTLY는 트랜잭션 안에서 불가).
  • ⚠️ asyncpg 드라이버로 마이그레이션을 돌리면 autocommit_block이 멈춰버리는 사례가 보고돼 있다. 런타임은 asyncpg, 마이그레이션 커넥션만 psycopg로 분리하는 게 안전하다.
  • 빌드 전에 maintenance_work_mem 상향 + max_parallel_maintenance_workers(기본 2) 상향. 이걸 안 올리면 수십만 벡터에서 인덱스 빌드가 시간 단위로 늘어진다.
  • env.py에 transaction_per_migration=True.
  • 파생 테이블은 Alembic의 관할이 아니다. 청커 버전이 바뀌면 마이그레이션이 아니라 TRUNCATE chunks + 전량 재큐잉이다. 스키마 버전과 파이프라인 버전을 한 개념으로 묶으면 롤백이 불가능해진다.

체크리스트: ①생성 컬럼에 STORED 명시 ②slug 유일성은 WHERE deleted_at IS NULL 부분 인덱스 ③임베딩 PK는 (content_hash, model) ④쓰기 라우트는 If-Match 없으면 428 ⑤브로커 호출은 커밋 뒤 릴레이에서만 ⑥삭제는 tombstone + 이벤트 + purge_after 3종 세트.

구현 ② — MCP 서버로 지식베이스를 열어라

MCP의 Tools/Resources/Prompts를 위키 3층(원본·그래프·메모리)에 매핑하는 실제 툴 시그니처와 에러 규약, 툴 개수·응답 크기 예산, 쓰기 툴의 서버측 승인 게이트(MRTR)와 감사 로그, 그리고 2026-07-28 사양에서 세션이 사라진 뒤의 전송·인증·멀티에이전트 격리 설계를 다룬다. 모든 필드명·기본값·수치는 MCP 스펙, Anthropic 문서, MCP Python SDK v2 소스에서 직접 확인했다.

핵심 요점

  • MCP 프리미티브는 '누가 트리거하느냐'로 갈린다 — Tools=모델, Resources=호스트 앱, Prompts=사용자. Claude Code에서 리소스는 사람이 @-멘션으로 지목해야 붙으므로, 정본 원문은 Tool과 Resource에 이중 노출하고 툴 결과에 resource_link로 같은 URI를 실어야 에이전트 경로가 죽지 않는다.
  • ToolAnnotations 기본값이 함정: destructiveHint 기본 true, openWorldHint 기본 true, readOnlyHint 기본 false. 읽기 툴에 readOnlyHint를 안 붙이면 클라이언트가 파괴적 툴로 취급한다. 동시에 스펙은 annotations를 신뢰 불가로 다루라 하므로 실제 게이트는 서버에 있어야 한다.
  • 에러는 이분법이다 — 실행 오류(검증 실패·페이지 없음·sha 충돌)는 isError:true로 모델이 self-correct하게, 프로토콜 오류만 JSON-RPC -32602로. 404를 JSON-RPC error로 던지면 모델이 재시도를 포기하고, 인증 실패를 isError로 감싸면 무한 재시도한다.
  • 토큰 예산은 실측치가 있다: 흔한 5개 서버 조합이 55k 토큰을 정의만으로 먹고 툴 30~50개에서 선택 정확도가 꺾인다. Claude Code MCP 출력은 10k 경고·25k 기본 상한이며 _meta["anthropic/maxResultSizeChars"](최대 500k자)로만 툴별 예외가 가능하다.
  • 현행 2026-07-28 사양은 세션을 없앴다 — initialize 핸드셰이크와 Mcp-Session-Id 제거, GET SSE·Last-Event-ID 재개 제거, server/discover 필수. 상태가 필요하면 서버가 명시적 핸들을 반환해 평범한 툴 인자로 받되, 핸들은 권한이 아니므로 매 호출 권한 재검증이 필요하다.
  • 멀티 에이전트 격리의 정본 근거는 스펙 문장 'credentials are per-request input, not connection state' — tools/list는 커넥션별로 달라지면 안 되지만 authorization별로는 달라도 된다. 따라서 agent_id/scope_id는 토큰 클레임에서만 뽑고 툴 인자로 받으면 안 된다.

프리미티브 3종을 위키에 겹치기

MCP 서버가 노출하는 건 세 가지뿐이다. Tools(모델이 스스로 골라 호출), Resources(호스트 앱이 컨텍스트에 넣을지 결정), Prompts(사용자가 명시적으로 고름). 스펙이 못 박은 건 "누가 트리거하느냐"지 "무엇이 들었느냐"가 아니다 — Prompts 페이지는 "This refers to who decides when the prompt is used, not who authors its content" 라고 명시한다.

프리미티브 제어 주체 위키 매핑 무너지는 지점
Tools 모델 wiki_*, knowledge_*, memory_*, book_* 전부 개수가 늘면 라우팅 정확도가 먼저 무너진다
Resources 호스트 앱 wiki://page/{path} 템플릿(RFC 6570) = 정본 원문 클라이언트가 자동으로 안 읽는다. Claude Code는 @server:protocol://resource/path @-멘션으로 사람이 지목해야 붙는다
Prompts 사용자 승격·리뷰 워크플로 (/mcp__wiki__promote_memory) 슬래시 커맨드 네임스페이스 충돌, 서버 여러 개면 목록이 지저분해짐

실무 결론: 정본 원문은 Tool과 Resource 양쪽에 이중 노출한다. 리소스는 사람이 붙이는 첨부, 툴은 에이전트가 쓰는 경로. 둘이 같은 wiki://page/{path} URI를 가리키게 하고, wiki_search 결과에는 resource_link 콘텐츠 블록으로 그 URI를 실어 보낸다(스펙이 툴 결과에 허용하는 타입). 리소스에만 걸면 에이전트 경로가 조용히 죽는다 — 예외도 로그도 없이.

툴 표면: 10개로 접는다

수치부터. Anthropic 문서 기준 GitHub·Slack·Sentry·Grafana·Splunk 정도의 흔한 조합이 작업 시작 전에 약 55k 토큰을 정의만으로 먹고, 툴 30~50개를 넘으면 선택 정확도가 떨어지기 시작한다. 그래서 book_list/book_add/book_update/book_note로 펼치지 말고 book_search + book_get + 쓰기 하나로 접는다. 네임스페이스는 접두어로: wiki_ / knowledge_ / memory_ / book_ — tool search의 정규식·BM25가 이 접두어 하나로 그룹 전체를 잡는다.

툴 핵심 인자 반환 annotations
wiki_search query, k=8, cursor, response_format {route, hits[], total, next_cursor} readOnly
wiki_get_page path 또는 uri, section? frontmatter + 본문 + sha readOnly
wiki_related path, k, kind=link|semantic|graph 이웃 페이지 + 근거 readOnly
wiki_create_page path, title, body_md, tags[] {path, sha} 쓰기
wiki_update_page path, body_md, base_sha, message {sha, diff_stat} 쓰기·비멱등
knowledge_search query, mode, top_k 그래프 근거 + 원본 uri[] readOnly, openWorld=false
knowledge_graph entity, depth=1, limit=50 엔티티·관계 서브그래프 readOnly
memory_search / memory_save query,kind,since / kind,body,evidence_uri 경험·결정 레코드 읽기 / 쓰기

knowledge_search의 mode는 LightRAG QueryParam.mode 값을 그대로 노출한다(naive/local/global/hybrid/mix). 값을 직접 뚫어두면 라우터가 틀렸을 때 에이전트가 스스로 교정할 수 있다.

에러 규약은 스펙에 이분법이 있다. 툴 실행 오류(입력 검증, 페이지 없음, base_sha 충돌)는 isError: true + 사람이 읽는 문장 — 모델이 self-correct 한다. 프로토콜 오류(없는 툴, CallToolRequest 스키마 위반)만 JSON-RPC error(-32602). 2026-07-28에서 리소스 not-found도 -32002 → -32602로 바뀌었다. 실패 모드: 404를 JSON-RPC error로 던지면 모델은 "툴이 고장났다"로 읽고 재시도를 포기한다. 반대로 인증 실패를 isError로 감싸면 모델이 영원히 재시도한다.

annotations 기본값이 함정이다. destructiveHint 기본 true, openWorldHint 기본 true, readOnlyHint 기본 false. 즉 읽기 툴에 readOnlyHint=True를 안 붙이면 클라이언트는 그 툴을 파괴적·개방형으로 간주해 매번 확인을 띄운다. 동시에 스펙은 클라이언트에게 "tool annotations를 신뢰할 수 없는 서버로부터는 믿지 말라" 고 경고한다 — annotations는 UX 힌트지 보안 장치가 아니다.

# pip install "mcp>=2.0.0" — v2 는 타입이 별도 패키지(mcp-types)로 분리됐다
from mcp.server.mcpserver import MCPServer   # v1: mcp.server.fastmcp.FastMCP
from mcp_types import ToolAnnotations        # v1: mcp.types (v2 에는 mcp/types.py 가 없다)
from pydantic import BaseModel, Field

mcp = MCPServer("wiki")
RO = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
                     idempotentHint=True, openWorldHint=False)

class Hit(BaseModel):
    path: str
    uri: str = Field(description="wiki://page/{path} — 전문은 wiki_get_page 로")
    title: str
    updated_at: str
    score: float
    snippet: str = Field(description="≤400자 발췌, 매치 하이라이트 포함")

class SearchOut(BaseModel):
    route: str = Field(description="fts|vector|graph — 라우터가 실제로 고른 백엔드")
    hits: list[Hit]
    total: int
    next_cursor: str | None = None

@mcp.tool(annotations=RO, meta={"anthropic/maxResultSizeChars": 60000})
async def wiki_search(query: str, k: int = 8, cursor: str | None = None,
                      response_format: str = "concise") -> SearchOut:
    """마크다운 위키 원본(Source of Truth)을 검색한다. 파생 인덱스가 아니라 정본이다.
    쓸 때: "우리가 X를 어떻게 하기로 했지", 런북/결정/파일 찾기.
    쓰지 말 것: 개념 간 관계 추론은 knowledge_graph, 지난 세션의 경험은 memory_search.
    질의는 자연어 문장 그대로. 반환은 발췌뿐이므로 전문이 필요하면 uri 로 wiki_get_page.
    """

@mcp.tool()  # 쓰기 툴: 비워두면 destructiveHint/openWorldHint 가 true 로 간주된다
async def wiki_update_page(path: str, body_md: str, base_sha: str, message: str) -> dict:
    """위키 페이지를 갱신한다. base_sha 불일치면 isError 로 현재 sha 를 돌려준다."""

description은 문서가 아니라 라우터 입력이다. tool search의 regex·BM25는 이름·설명·인자명·인자 설명 전부를 훑는다. 그래서 "쓰지 말 것" 줄이 실제로 오라우팅을 줄인다 — 특히 wiki_search와 memory_search처럼 표면이 비슷한 쌍에서.

응답 크기: 상한을 모르면 잘린다

한도 값 조정 방법
Claude Code 경고 10,000 토큰 고정(조정 불가)
Claude Code 기본 상한 25,000 토큰 MAX_MCP_OUTPUT_TOKENS
툴별 예외 _meta["anthropic/maxResultSizeChars"] 최대 500,000자
목록 페이징 cursor → nextCursor tools/list, resources/list
결과 캐싱(2026-07-28) ttlMs, cacheScope 필수 목록 계열 결과에 부착

wiki_search는 발췌 400자 + uri만 반환하고 전문은 별도 호출로 민다. response_format: "concise" | "detailed"는 Anthropic 자체 측정에서 concise가 detailed의 약 1/3 토큰이었다. 실패 모드는 양쪽에 있다. 요약만 반환하면 근거가 소실되므로 hit마다 path + updated_at + uri를 반드시 실어 원본 회귀가 1콜이어야 하고, 반대로 전문을 그냥 반환하면 상한에 걸려 디스크로 밀리고 파일 참조로 대체되어 모델이 내용을 아예 못 본다.

쓰기 툴: 승인은 서버에서 건다

클라이언트 승인 UI에만 기대면 안 되는 이유는 위와 같다 — annotations는 힌트고, 다른 에이전트는 다른 UI를 쓴다. 2026-07-28의 MRTR로 서버가 직접 게이트를 건다: tools/call이 resultType: "input_required" + inputRequests(안에 elicitation/create) + requestState를 반환하고, 클라이언트가 inputResponses를 실어 새 request id로 재시도한다.

-- 승인 게이트와 감사 로그를 서버 쪽 테이블 하나로 합친다
CREATE TABLE mcp_audit (
  id            bigserial PRIMARY KEY,
  ts            timestamptz NOT NULL DEFAULT now(),
  actor         text NOT NULL,        -- 토큰 클레임(sub). 툴 인자로 받지 않는다
  client        text,                 -- _meta 의 io.modelcontextprotocol/clientInfo
  tool          text NOT NULL,
  args_hash     text NOT NULL,        -- sha256(canonical_json(arguments))
  target_path   text,
  base_sha      text,                 -- 낙관적 잠금 근거
  decision      text NOT NULL
                CHECK (decision IN ('auto','approved','denied','expired')),
  request_state text,                 -- MRTR 재시도 상관키(서명된 불투명 문자열)
  result_sha    text,
  latency_ms    int
);
CREATE INDEX ON mcp_audit (target_path, ts DESC);
CREATE INDEX ON mcp_audit (actor, ts DESC);

실패 모드: requestState를 서버 메모리에 두면 라운드로빈 뒤에서 깨진다 — 세션이 없으니 재시도가 다른 인스턴스로 간다. 서명된 불투명 문자열로 만들어 클라이언트가 들고 다니게 하라. 그리고 승인 게이트를 모든 쓰기에 걸면 사람이 지쳐서 전부 승인하게 된다. wiki_update_page는 base_sha 일치 + 변경 라인 수 임계 이하면 auto로 통과시키고, 신규 생성·대량 삭제·memory_save → wiki 승격만 사람에게 올리는 게 실제로 굴러간다.

전송과 인증 — 그리고 세션이 사라진 사양

stdio Streamable HTTP
기동 클라이언트가 서브프로세스로 실행 독립 프로세스, 단일 POST 엔드포인트
인증 스펙 적용 대상 아님 — 환경변수에서 크리덴셜 OAuth 2.1 리소스 서버
다중 에이전트 에이전트마다 프로세스 1개 한 서버 공유, 토큰으로 격리
적합 1인 로컬 위키 기본값 폰·팀·CI에서 같은 위키를 볼 때

2026-07-28 개정(현행 최신)은 프로토콜 레벨 세션을 통째로 없앴다: initialize/notifications/initialized 제거, Mcp-Session-Id 헤더 제거(SEP-2567/2575), GET SSE 스트림 제거(→ subscriptions/listen), Last-Event-ID 재개 제거, server/discover 필수. 모든 POST가 MCP-Protocol-Version·Mcp-Method 헤더를 달아야 하고(Mcp-Name 은 tools/call·resources/read·prompts/get 한정), 헤더가 본문 _meta와 어긋나면 400 + -32020 (HeaderMismatch). 대신 스펙은 "호출 간 상태가 필요하면 서버가 명시적 핸들을 반환하고 그걸 평범한 툴 인자로 받아라" 고 못 박는다. 위키에선 검색 커서와 편집 드래프트 ID가 그 핸들이고, 스펙 표현대로 핸들은 이름이지 권한이 아니므로 매 호출 권한을 재검증해야 한다.

인증은 RFC 9728 Protected Resource Metadata 필수(401의 WWW-Authenticate에 resource_metadata), 클라이언트는 RFC 8707 resource 파라미터 필수, 서버는 audience 검증 필수, 받은 토큰을 업스트림으로 흘리는 token passthrough는 금지. 로컬 HTTP라도 Origin 검증 실패 시 403과 127.0.0.1 바인딩은 스펙 강제다(DNS rebinding).

한 서버, 여러 에이전트

클라이언트 쪽은 각자 다르다. Claude Code는 claude mcp add --transport http wiki --scope project <url> + .mcp.json(${VAR:-default} 확장), 툴은 mcp__wiki__wiki_search로 노출되고 이 이름이 그대로 퍼미션 규칙 키다. Codex는 ~/.codex/config.toml의 [mcp_servers.wiki]에 url + bearer_token_env_var.

서버 쪽 격리 근거는 스펙에 있다: tools/list 결과는 커넥션별로 달라지면 안 되지만, 제시된 authorization에 따라 달라지는 건 허용된다 — "credentials are per-request input, not connection state." 따라서 agent_id/scope_id를 툴 인자로 받지 마라. 토큰 클레임에서 뽑아라. 인자로 받는 순간 모델이 문자열 하나로 남의 메모리 네임스페이스를 읽고, "위키는 공유·메모리는 사적"이라는 전제가 그 한 줄에서 무너진다.

트레이드오프는 정직하게: 토큰별 격리는 "Codex 세션에서 만든 결정을 Claude Code가 못 본다"를 뜻한다. 그래서 층을 나눈다 — 위키는 cacheScope: "public"으로 공용, 메모리는 에이전트별 네임스페이스, 승격(→ 다른 섹션에서 다룸)을 통과한 것만 공용으로 넘어간다.

출항 전 체크리스트

  • 읽기 툴 전부에 readOnlyHint=True — 안 붙이면 destructive로 간주된다
  • 툴 10개 초과 시 접두어 네임스페이스 + 지연 로딩 전제로 설계
  • 모든 목록 반환에 next_cursor, 모든 검색 hit에 원본 uri
  • 25k 토큰 상한을 넘길 툴에는 _meta["anthropic/maxResultSizeChars"]
  • 쓰기 툴은 base_sha 낙관적 잠금 + mcp_audit 행 + 임계 초과 시에만 MRTR 승인
  • agent_id는 토큰 클레임에서만, 인자로는 절대 받지 않기

참고 출처

↗ MCP Specification 2026-07-28 — Tools (annotations, isError, structuredContent, Stateful Tools, x-mcp-header)↗ MCP Specification 2026-07-28 — Key Changes (SEP-2567/2575/2322: 세션·핸드셰이크 제거, server/discover, MRTR, ttlMs/cacheScope)↗ MCP Specification 2026-07-28 — Streamable HTTP transport (Origin 403, localhost 바인딩, MCP-Protocol-Version/Mcp-Method/Mcp-Name, -32020)↗ MCP Specification 2026-07-28 — Prompts (user-controlled, prompts/list·prompts/get)↗ MCP Specification — Authorization (OAuth 2.1, RFC 9728, RFC 8707 resource, token passthrough 금지, stdio는 환경변수)↗ MCP schema.ts — ToolAnnotations 기본값(readOnlyHint false, destructiveHint true, idempotentHint false, openWorldHint true)↗ Anthropic Engineering — Writing effective tools for agents (네임스페이싱, response_format concise≈1/3 토큰, 페이지네이션·truncation)↗ Anthropic — Tool search tool (55k 토큰 정의 부하, 30~50개에서 정확도 저하, defer_loading, 85%+ 절감)↗ Claude Code — Connect to tools via MCP (--scope, .mcp.json, mcp__server__tool, MAX_MCP_OUTPUT_TOKENS, anthropic/maxResultSizeChars, alwaysLoad)↗ MCP Python SDK — Migration Guide v1→v2 (FastMCP→MCPServer, mcp.types→mcp_types, stateless_http/json_response가 run()으로 이동)↗ Anthropic Engineering — Code execution with MCP (툴 정의 컨텍스트 과부하, 150k→2k 토큰 사례)↗ LightRAG — Programming with Core (QueryParam.mode: naive/local/global/hybrid/mix, top_k)

Agent Memory 층 — 무엇을 기억하고 무엇을 버리나

Agent Memory 층을 에피소드/의사결정/선호/절차/실패 5타입으로 나눠 각각의 저장 형태·반감기·무효화 규칙을 정하고, 자동 캡처는 raw 티어에 격리해 검색 인덱스 오염을 막는 설계를 Postgres+pgvector 스키마와 RRF+최신성 쿼리로 제시한다. mem0/Letta/Graphiti/agentmemory 를 스코프·시간축·삭제 가능성 기준으로 비교하고, 소프트 삭제 임베딩 복원(Ghost Vectors)과 예산 의존적 consolidation 역전 현상까지 실패 모드로 다룬다.

핵심 요점

  • 메모리 타입(에피소드/의사결정/선호/절차/실패)마다 반감기와 무효화 규칙을 다르게 두고, 한 테이블에 kind 로 분기한다 — 의사결정·실패·선호는 감쇠 면제, 절차는 참조 해시 변경 시 stale, 에피소드만 30일 반감기
  • 자동 캡처는 공격적으로 하되 인덱싱은 raw 관측의 3~5% 이하로 제한한다. SessionEnd 훅은 전체 예산 1.5초라 LLM 요약을 직접 못 하고 큐잉이 필요하며, PreCompact 가 요약 캡처의 골든 포인트다
  • 검색은 RRF(k=60) 융합 후 최신성을 곱셈 가중으로만 걸고 하한(예: 0.35)을 둔다 — 하한이 없으면 오래된 장애 회고가 영구히 밀려 최신성 가중 자체가 실패 모드가 된다
  • 모순은 삭제가 아니라 valid_to 마감 + supersedes 체인으로 처리하고, decision/preference 타입의 자동 supersede 는 금지한다(LLM 모순 판정 false positive 로 유효한 기억이 조용히 사라진다)
  • 소프트 삭제는 삭제가 아니다 — HNSW 인덱스에서 삭제된 임베딩이 이름 25.5%·얼굴 신원 99%까지 복원된다는 실측이 있어, subject_key 단위 DELETE→VACUUM→REINDEX 또는 subject별 키 파기가 필요하다
  • eager consolidation 은 대부분 손해다: 컨텍스트 예산이 넉넉하면(256토큰 실험) 원문 유지가 압축보다 8pp 앞선다. 통합은 스코프 토큰 예산 초과 시에만, 유휴 시간에, 원본을 남긴 채로 한다
  • 자체 구현 손익분기는 '위키용 Postgres+pgvector 를 이미 굴리는가 / 삭제를 SQL 로 증명해야 하는가 / 타입별 무효화 규칙이 필요한가'로 판정하고, 엔티티 관계 추론이 핵심이면 Graphiti 를 쓰는 게 싸다

Agent Memory 는 위키의 초안 보관소가 아니다. 위키는 "무엇이 참인가"를, 메모리는 "우리가 무엇을 겪었고 무엇을 정했는가"를 담는다. 그리고 여기서 이 글의 대전제 ①이 한 군데 깨진다 — LightRAG 인덱스는 마크다운에서 통째로 재생성되지만, 메모리의 원본(세션 대화)은 이미 휘발됐다. 메모리 저장소는 파생 인덱스가 아니라 두 번째 원본이고, 따라서 위키와 별도의 백업·마이그레이션·삭제 정책을 가져야 한다. 파생 인덱스처럼 취급해 "언제든 재생성"이라고 적어두면, 마이그레이션 실패 시 조용히 전부 잃는다.

다섯 타입, 다섯 수명

타입을 나누는 이유는 분류학 취미가 아니라 무효화 규칙과 반감기가 타입마다 다르기 때문이다. 한 테이블에 다 넣되 kind 로 정책을 분기한다.

타입 저장 형태 기본 반감기 무효화 트리거 위키 승격 대상
에피소드 (무엇을 했나) 세션 요약 1~3문장 + 커밋 sha/PR 링크 30일 없음(사실 기록). 오래되면 랭킹에서만 침몰 ✗ (근거로만 인용)
의사결정 (왜 그렇게 했나) ADR 축소판: 선택/기각안/근거/되돌리는 법 ∞ (decay_exempt) 새 결정이 supersedes 로 대체할 때만 ✓ 1순위
선호 (사람·팀 규칙) 단문 명제 1개 = 1행 ∞ 사용자가 반대 지시 → 즉시 supersede ✓ (프로젝트 규칙이면)
절차 (어떻게 하나) 명령어·순서·검증 커맨드 포함 스니펫 90일 참조한 스크립트/엔드포인트 해시 변경 시 stale ✓ 런북으로
실패 (뭐가 안 됐나) 증상 → 오진 → 진짜 원인 → 판정 방법 ∞ 원인이 코드에서 제거됐음이 증명될 때 ✓ (가장 가치 높음)

실패 메모리에 "오진"을 같이 적는 게 핵심이다. 다음 세션이 같은 잘못된 가설로 3시간 태우는 걸 막는 건 정답이 아니라 기각된 가설이다. 반대로 실패 메모리는 재발 방지 문구가 없으면 그냥 푸념이 되어 검색 노이즈가 된다 — evidence 가 비어 있는 실패 행은 인덱싱하지 않는 게 낫다.

자동 캡처는 raw 티어에만 쓴다

Claude Code 훅으로 무료로 얻을 수 있는 캡처 지점은 SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PreCompact/PostCompact, SubagentStart/SubagentStop, Stop, SessionEnd 등이다. 여기서 실무적으로 중요한 제약이 하나 있다: 훅 타임아웃 기본값이 일반 600초, UserPromptSubmit 30초인데 SessionEnd 훅은 전체 예산이 1.5초다. 즉 "세션 종료 시 LLM 으로 요약해 저장"은 SessionEnd 에서 직접 할 수 없다 — 큐에 넣고 워커가 처리해야 한다. PreCompact 는 컨텍스트가 날아가기 직전이라 요약 캡처의 진짜 골든 포인트이고, SessionStart 는 hookSpecificOutput.additionalContext 로 메모리를 주입하는 반대 방향 지점이다(matcher 로 startup|resume|clear|compact|fork 구분 가능).

문제는 노이즈다. PostToolUse 를 전부 인덱싱하면 하루 수백 건의 "pytest 를 실행했다" 류 관측이 쌓이고, 어휘 검색에서 흔한 토큰(test, deploy, error)의 점수가 전부 동점이 되면서 정작 필요한 한 건이 상위 12개 밖으로 밀린다. 처방은 단순하다: 캡처는 공격적으로, 인덱싱은 보수적으로.

이벤트 티어 검색 인덱스 비고
PostToolUse / PreToolUse raw ✗ (indexed=false) SHA-256 5분 윈도 dedup (agentmemory 방식)
PostToolUseFailure raw → 후보 워커가 실패 타입으로 승격 시 ✓ 반복 3회 이상만
PreCompact / SessionEnd 요약 ✓ 비동기 워커가 LLM 요약
명시적 remember 호출 확정 ✓ + confidence=0.9 사람이 쓴 것만 고신뢰

경험칙: 인덱싱 대상은 raw 관측의 3~5% 이하로 유지한다. 이 비율이 20% 를 넘어가면 recall@5 가 아니라 precision 이 무너진다.

스키마와 융합 검색

-- Postgres 16+ / pgvector 0.8.6 (2026-07-29)
CREATE TYPE mem_kind AS ENUM ('episodic','decision','preference','procedural','failure');

CREATE TABLE memory (
  id            bigserial PRIMARY KEY,
  kind          mem_kind NOT NULL,
  scope_agent   text NOT NULL,             -- 쓴 주체 (write scope)
  scope_project text NOT NULL,             -- 읽기 경계 (read scope)
  subject_key   text,                      -- 삭제요구 단위(사람/고객) — 인덱스 필수
  body          text NOT NULL,
  body_tsv      tsvector GENERATED ALWAYS AS (to_tsvector('simple', body)) STORED,
  embedding     vector(1024),
  observed_at   timestamptz NOT NULL DEFAULT now(),  -- 겪은 시각
  valid_from    timestamptz NOT NULL DEFAULT now(),  -- 믿기 시작한 시각
  valid_to      timestamptz,                          -- NULL = 현재 유효
  supersedes    bigint REFERENCES memory(id),
  decay_exempt  boolean NOT NULL DEFAULT false,
  confidence    real    NOT NULL DEFAULT 0.5,
  evidence      jsonb   NOT NULL DEFAULT '[]',        -- 커밋sha/PR/로그경로
  promoted_doc  text,                                  -- 위키 승격 시 역참조
  indexed       boolean NOT NULL DEFAULT false         -- false = raw 티어
);
CREATE INDEX ON memory USING gin(body_tsv);
CREATE INDEX ON memory USING hnsw (embedding vector_cosine_ops)
  WHERE indexed AND valid_to IS NULL;
CREATE INDEX ON memory (subject_key) WHERE subject_key IS NOT NULL;

observed_at 과 valid_from 을 분리한 게 bi-temporal 의 최소 형태다. Graphiti 가 created_at/expired_at(시스템 시각)과 valid_at/invalid_at(세계 시각)을 따로 두는 것과 같은 이유 — "언제 참이었나"와 "언제 그걸 알았나"는 다르고, 사후 분석에서 필요한 건 후자다.

검색은 RRF(k=60)로 어휘·벡터를 융합한 뒤 최신성은 융합 이후 곱셈 가중으로만 건다. 임베딩이나 랭킹 함수 안에 시간을 녹이면 나중에 못 끈다.

WITH lex AS (
  SELECT id, row_number() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS r
  FROM memory, websearch_to_tsquery('simple', $1) q
  WHERE indexed AND valid_to IS NULL AND scope_project = ANY($2) AND body_tsv @@ q
  LIMIT 50),
vec AS (
  SELECT id, row_number() OVER (ORDER BY embedding <=> $3) AS r
  FROM memory
  WHERE indexed AND valid_to IS NULL AND scope_project = ANY($2)
  LIMIT 50)
SELECT m.id, m.kind, m.body,
       (COALESCE(1.0/(60+lex.r), 0) + COALESCE(1.0/(60+vec.r), 0))
       * CASE WHEN m.decay_exempt THEN 1.0
              ELSE greatest(0.35,                      -- 하한 없으면 오래된 정답이 사라진다
                exp(-ln(2) * extract(epoch FROM now() - m.observed_at)
                           / (86400 * h.half_life_days))) END AS score
FROM memory m
LEFT JOIN lex ON lex.id = m.id
LEFT JOIN vec ON vec.id = m.id
JOIN mem_halflife h ON h.kind = m.kind
WHERE lex.id IS NOT NULL OR vec.id IS NOT NULL
ORDER BY score DESC LIMIT 12;

greatest(0.35, ...) 이 없으면 8개월 된 장애 회고 한 건이 영원히 13위로 밀린다. 실제로 최신성 가중의 가장 흔한 실패 모드가 이거다. 그리고 한 세션이 상위를 독식하는 것도 막아야 한다 — agentmemory 는 세션당 최대 3건으로 다양화한다. 같은 규칙을 DISTINCT ON (session_id) 계열로 흉내낼 수 있다.

모순: supersede 는 삭제가 아니다

새 기억이 옛 기억과 충돌하면 지우지 말고 valid_to 를 닫고 새 행에 supersedes 를 건다(Graphiti 의 edge invalidation 과 동일한 발상). 그래야 "그때 우리는 뭘 믿고 있었나"를 물을 수 있고, 오판으로 supersede 한 걸 되돌릴 수 있다.

  • 실패 모드: LLM 에게 모순 판정을 맡기면 false positive 가 난다. "포트는 8012" 와 "스테이징 포트는 8013" 은 모순이 아닌데 대체돼버린다.
  • 처방: (1) supersede 는 kind 가 같고 스코프가 같을 때만 후보, (2) decision/preference 타입은 자동 supersede 금지 — 사람 승인 게이트, (3) supersede 로그를 감사 테이블에 남겨 되돌리기 가능하게.
  • 위키 승격(대전제 ②)은 여기서 걸린다. promoted_doc 역참조를 남기지 않으면, 같은 결정이 위키에도 있고 메모리에도 살아 있어 검색이 두 판본을 동시에 뱉는다. 승격 시 메모리 행은 삭제가 아니라 promoted_doc 채우고 indexed=false 로 내리는 게 안전하다.

시크릿·개인정보·삭제 요구

쓰기 경로에 fail-closed 필터를 둔다. 정규식+엔트로피로 토큰 패턴(sk-, ghp_, AKIA, JWT, 40자 이상 고엔트로피 문자열)을 잡아 거부하고, 자연어 PII 는 Presidio 같은 분석기로 태깅한다. 다만 차단 문구를 프롬프트에 적는 것으로는 절대 못 막는다 — 훅 자동 캡처는 프롬프트를 읽지 않는다. 필터는 저장 API 안에 있어야 한다.

삭제는 더 까다롭다. 최근 연구(Ghost Vectors, arXiv 2606.18497)는 HNSW 인덱스에서 소프트 삭제된 임베딩이 원시 인덱스 파일 접근만으로 복원 가능함을 보였다 — 위키피디아 인물 데이터에서 정확한 이름 25.5%, 지리 정보 46.4%, 얼굴 임베딩은 top-1 신원 99% 복구. API 가 404 를 준다고 지워진 게 아니다.

  • 실무 처방: subject_key 로 삭제 단위를 미리 쪼개 두고, 삭제 시 행 DELETE → VACUUM → 해당 파티션 인덱스 REINDEX 까지를 한 트랜잭션 절차로 묶는다.
  • 더 강한 보장이 필요하면 subject 별 봉투 암호화 후 키 파기(위 논문의 Epoch Key Rotation 방식) — 인덱스 재구축 없이 복원 불가를 만든다.
  • 검증 항목: 삭제 후 인덱스 파일 레벨에서 잔존 여부를 실제로 확인하는 테스트가 있는가. 없으면 삭제 기능은 "동작한 적 없음" 상태일 수 있다.

여러 에이전트가 한 서버를 쓸 때

읽기와 쓰기 스코프를 비대칭으로 설계한다. 읽기는 넓게(프로젝트 전체 union), 쓰기는 좁게(쓴 에이전트 소유), 교차 프로젝트 이동은 명시적 승격만.

시스템 스코프 키 공유 시 충돌 양상
mem0 user_id / agent_id / run_id run_id 는 단기, user_id 는 영속. 경계 오설정 시 세션 잔여물이 영구화
Letta 블록을 block_ids 로 다중 에이전트에 attach 블록 업데이트가 내용 전체 치환 → 마지막 쓰기 승리. 낙관적 버전/리스 필요
Graphiti group_id 별 서브그래프 그래프가 합쳐지면 엔티티 해소가 서로 오염
agentmemory AGENT_ID + AGENTMEMORY_AGENT_SCOPE (shared/isolated) shared 로 두면 A 의 절차 메모리가 B 의 검색을 지배

가장 흔한 사고는 "공유하면 좋겠지"로 shared 를 켜는 것이다. 에이전트마다 도메인이 다르면 공유 스코프는 서로의 recall 을 갉아먹는다. 기본값은 isolated, 공유는 preference 와 decision 두 타입만 화이트리스트로 여는 걸 권한다.

오픈소스 선택지와 자체 구현 손익분기

모델 저장 시간축 운영 부담 언제
mem0 (Apache-2.0) 추출-저장-검색, add/search/get_all/delete/history 벡터스토어(Qdrant 등) + 옵션 그래프 약함 낮음 (5줄) 빨리 붙이고 싶을 때
Letta 자기편집 메모리 블록(label/value/limit/read_only), sleep-time 에이전트 자체 서버 없음 중 에이전트가 스스로 메모리를 고치게 할 때
Zep / Graphiti 시간 인지 지식그래프, add_episode() Neo4j 5.26+ / FalkorDB 1.1.2+ / Neptune 강함 (valid_at/invalid_at) 높음 (DB + 에피소드당 LLM 호출) 사실이 자주 바뀌고 "그때 뭐였나"를 물어야 할 때
agentmemory (Apache-2.0) 훅 자동 캡처 + 4티어 압축 SQLite (~/.agentmemory, :3111) 중 낮음 코딩 에이전트 세션 캡처가 주목적일 때
자체 (Postgres) 위 스키마 이미 있는 PG + pgvector 원하는 만큼 초기 1주 위키용 PG 를 이미 굴리고 있을 때

벤치마크 숫자는 참고만 하라. mem0 는 LoCoMo 92.5 / LongMemEval 94.4 / p50 0.88s 를 제시하지만 문서 스스로 "managed platform 기준, OSS 는 방향성만 동일"이라 못 박고, Zep 논문은 DMR 94.8%(MemGPT 93.4%) 와 LongMemEval 최대 +18.5%·지연 90% 감소를 보고한다. 전부 벤더 자체 하네스이고 제3자 재현에서 값이 크게 흔들린 사례가 보고돼 있다. 이 숫자로 선택하지 말고, 위 표의 시간축과 삭제 가능성 두 열로 선택하라.

손익분기 판단 기준 — 아래 중 3개 이상이면 자체 구현이 싸다.

  • [ ] 위키용 Postgres + pgvector 를 이미 운영 중이다(스키마 2개 추가로 끝)
  • [ ] 삭제 요구를 SQL 한 줄로 증명해야 한다(벤더 API 의 소프트 삭제로는 부족)
  • [ ] 메모리 타입별로 다른 반감기·무효화 규칙이 필요하다
  • [ ] 검색 라우터(대전제 ④)가 위키·메모리를 한 쿼리로 융합해야 한다 → 같은 DB 가 압도적으로 유리
  • [ ] 그래프 메모리가 정말 필요하지는 않다 (필요하면 Graphiti 를 붙이는 게 자체 구현보다 싸다)

반대로 엔티티 관계 추론이 핵심이면 자체 구현하지 마라. Graphiti 급 엔티티 해소·커뮤니티 탐지를 직접 짜는 건 몇 달짜리다. 대신 비용을 계산하라 — 에피소드당 LLM 호출이 있고 SEMAPHORE_LIMIT 기본값 10 으로 동시성이 눌려 있다. 자동 캡처를 그대로 그래프에 흘리면 토큰 비용이 선형으로 폭발한다.

압축·통합은 늦게, 예산이 넘칠 때만

여기서 직관과 반대되는 실측이 있다. LongMemEval 기반 연구(arXiv 2607.17545)에 따르면 consolidation 의 효과는 컨텍스트 예산에 따라 부호가 뒤집힌다: 예산 32토큰에서 요약(Abstract)은 52.0% vs 원문 유지 4.0%(+48pp), 128토큰에서 +14.2pp, 그러나 256토큰에서는 원문 유지가 8pp 앞서고 모든 압축 연산자가 손해였다. LoCoMo 에서도 같은 교차가 재현됐다.

결론: eager consolidation 은 대부분 손해다. 매 상호작용마다 LLM 으로 병합하는 방식은 토큰 비용을 쓰면서 정확도까지 깎는다. 대신:

  1. 원문은 raw 티어에 그대로 둔다(디스크는 싸다).
  2. 통합은 스코프별 토큰 예산 초과 시에만 트리거한다. TOKEN_BUDGET 2000 같은 상한을 스코프마다 두고, 넘칠 때 오래된 episodic 부터 병합한다.
  3. 병합은 유휴 시간에(Letta 의 sleep-time 에이전트, 또는 그냥 cron 워커). 사용자 대기 경로에 넣지 않는다.
  4. decision/failure/preference 는 압축 금지. 이 타입들은 세부가 곧 가치다.
  5. 감쇠는 Ebbinghaus 류 지수 감쇠를 랭킹에만 적용하고, 행을 물리 삭제하지 않는다(삭제 요구 대응과 감쇠는 다른 메커니즘이다).

실패 모드: 통합을 켜두고 원문을 지우면 되돌릴 수 없다. 통합 결과 행에 supersedes 로 원본 id 배열을 남기고 원본은 indexed=false 로만 내려라. 통합 품질이 나쁘다는 게 3주 뒤에 드러났을 때, 그게 유일한 복구 경로다.

(라우터가 언제 memory 를 고르는지, MCP 로 어떻게 노출하는지는 → 다른 섹션에서 다룸)

Knowledge Promotion — 살아있는 위키의 심장

Agent Memory에서 마크다운 위키로 올리는 승격 파이프라인을 상태 전이로 설계하고, 3축 점수 산식(재사용성·중요도·근거강도)과 임계값, 3게이트 중복·모순 검사(pg_trgm + pgvector + NLI), 환각 고착 방어(외부 검증 가능한 앵커 강제 + TTL 재검증), 배치 리뷰 게이트와 되돌리기·감사 로그를 실제 스키마·설정키·수치로 제시한 섹션.

핵심 요점

  • 승격은 복사가 아니라 상태 전이(candidate→staged→published)이며, 커밋 포인트는 마크다운+PostgreSQL 뿐이고 LightRAG 재인덱싱은 실패해도 승격이 성립하는 파생 작업이다. 단, 스테이징 단계는 Fast Path 인덱싱조차 하면 안 된다(미승인 문장의 검색 노출 = 환각 고착의 입구) — 원칙 ③의 예외.
  • 점수 산식 promotion_score = 0.40·R(재사용성, 5세션에서 포화) + 0.35·I(타입 가중치) + 0.25·E(외부 검증가능 앵커 수). LLM 자기보고 confidence는 배제 — 70B급도 ECE ≈ 0.1이고 소형 모델은 정확도와 거의 무상관이다. 임계값 0.75/0.45는 이론값이 아니라 '하루 리뷰 10건' 큐 길이에서 역산한 값.
  • 중복 검사는 3게이트(제목 pg_trgm ≥0.55 · bge-m3 임베딩 코사인 ≥0.90 · 엔티티 Jaccard ≥0.50) 중 2개 이상 통과 시에만 merge. 단일 도메인 코퍼스에서는 코사인이 0.8대에 몰리므로 절대 임계 대신 무작위 1000쌍의 p99/p95 분위수로 캘리브해야 미탐이 안 터진다. NLI(cross-encoder/nli-deberta-v3-base) contradiction ≥0.60이면 자동 승격 영구 차단.
  • merge를 기본값으로 두는 근거는 Wikipedia 실측 — 기존 문서 diff를 보는 pending changes 큐는 1~2일에 소화되지만 신규 문서 생성(AfC)은 백로그 드라이브가 필요하다. 신규 생성은 '존재할 자격'을, diff는 '이 줄이 맞는가'만 묻기 때문.
  • 환각 고착 방어는 4겹 전부 필요: sources 비면 발행 422 fail-closed, memory:/session: 자기참조 앵커 거부, origin: llm-promoted를 렌더링 배지로 노출, verify_after TTL 재검증. Graphiti/Zep식 valid_at/invalid_at으로 틀린 사실은 삭제가 아니라 무효화한다. 다만 URL 앵커는 스냅샷 해시 없이는 재검증이 장식에 불과하다.
  • 인간 게이트는 후보 생성이 아니라 발행에 두고 하루 1회·10건 상한·diff 뷰·키 3개(a/r/e). 자동 승격은 6개 조건 AND(점수 0.75 · 앵커 2개 · 모순 0.20 미만 · merge · 20줄 이하 · 비보호 문서). 승격 로그의 목적은 감사가 아니라 튜닝이며 자동 승격분 revert율 5%를 넘으면 임계값이 아니라 앵커 정의부터 의심한다.

승격은 "복사"가 아니라 상태 전이다

Agent Memory의 한 줄을 위키 문서로 복사하는 순간 설계가 무너진다. 승격은 candidate → scored → staged → published(+ rejected / reverted) 상태 전이여야 하고, 커밋 포인트는 마크다운 파일과 PostgreSQL 행 두 개뿐이다. LightRAG 재인덱싱은 커밋 뒤에 따라오는 파생 작업이므로 실패해도 승격 자체는 성립한다(원칙 ①③). 순서를 뒤집어 그래프에 먼저 넣으면 되돌리기가 불가능해진다.

원칙 ③에 대한 예외 하나: 승격 경로에서는 Fast Path 인덱싱조차 발행 이후로 미뤄야 한다. 미승인 문장이 검색에 노출되는 순간, 그게 곧 아래에서 다룰 환각 고착의 입구다. 스테이징은 격리 테이블에만 있고 라우터가 절대 읽지 않는다. 그리고 승격된 뒤에도 원본 메모리는 지우지 않는다 — 되돌리기와 재검증의 앵커가 사라지기 때문이다.

1. 후보 탐지 — 요약이 아니라 반복을 본다

"세션 끝날 때마다 LLM에게 승격할 것 요약시키기"는 최악의 트리거다. 비용이 세션 수에 비례하고, 노이즈가 상한 없이 늘어난다. 실제로 쓸 만한 트리거는 세 개뿐이다.

트리거 조건 비용 실패 모드
반복 관찰 동일 개념 태그가 서로 다른 세션 3개 이상에서 출현 SQL만, LLM 0회 한 번뿐인 중대 사건(인시던트)을 영원히 놓침
명시 신호 lesson_save / decision 타입 메모리 0 사람이 태그를 안 붙이면 무력
검색 재사용 메모리 검색에서 30일 내 5회 이상 hit 로그 집계 검색 없이 지나간 지식은 안 보임

세 트리거를 OR로 걸되, 인시던트 타입만 1회 출현에도 후보로 올린다. 나머지는 반복을 기다린다.

2. 점수화 — LLM 자기보고 확신도는 쓰지 마라

promotion_score = 0.40·R + 0.35·I + 0.25·E

R(재사용성) = min(1, log2(1 + distinct_sessions) / log2(6))   # 5회에서 1.0 포화
I(중요도)   = {incident:1.0, decision:0.8, runbook:0.6, howto:0.45, preference:0.25}
E(근거강도) = min(1, 0.25 × 외부검증가능_앵커수)              # 4개에서 1.0

E를 LLM이 말한 confidence로 대체하고 싶은 유혹이 크지만, 이건 측정된 실패다. verbalized confidence는 70B급에서도 ECE가 약 0.1(기대 정확도와 10%p 어긋남)이고, 소형 모델(Gemma 1.1-2B)에서는 정확도와 거의 무상관으로 무너진다. 그래서 E는 모델의 말이 아니라 셀 수 있는 외부 앵커로만 계산한다: 커밋 SHA, 파일:라인, HTTP로 열리는 URL, 재현 명령어 + 그 출력. 앵커 0개면 E=0이고 자동 승격 후보에서 구조적으로 탈락한다.

구간 처리 근거
≥ 0.75 (모순 없음, 앵커 ≥2) 자동 스테이징 → 배치 diff에서만 확인 상위 ~20%만 통과하도록 코퍼스에서 역산
0.45 ~ 0.75 리뷰 큐 (하루 10건 상한) 사람이 정직하게 판단 가능한 건수의 실무 상한
< 0.45 보류, 90일 뒤 R만 재계산 재사용 신호는 뒤늦게 쌓이므로 폐기가 아니라 유예

0.75는 이론값이 아니라 큐 길이에서 역산한 값이다. 하루 후보 40~60건이 들어오는 개인 위키에서 상위 20%가 자동, 다음 밴드가 10건 안쪽으로 떨어지게 맞춘 결과다. 후보 유입량이 바뀌면 임계값도 바뀌어야 한다 — 고정 상수로 박아두면 3개월 뒤 큐가 300건이 된다.

3. 중복·모순 — 3게이트, 2개 이상 통과해야 merge

게이트 구현 중복 판정 오탐(과병합) 대응 미탐(중복 생성) 대응
제목 정규화 unicodedata.normalize("NFKC") + casefold + 조사/공백 제거 → pg_trgm.similarity() ≥ 0.55 (GUC 기본 pg_trgm.similarity_threshold는 0.3, 너무 헐렁) 제목만으로는 절대 merge 안 함 별칭 테이블 수동 등록
본문 임베딩 bge-m3(1024d, 8192토큰, 한국어 포함 100+ 언어), pgvector 1 - (a <=> b) ≥ 0.90 동일 / 0.82~0.90 merge 후보 분위수 캘리브(아래) nightly 전수 재스캔
엔티티 집합 LightRAG 엔티티 이름 Jaccard ≥ 0.50 흔한 엔티티(예: "PostgreSQL") 불용어 처리 —
모순 cross-encoder/nli-deberta-v3-base (contradiction/entailment/neutral) contradiction ≥ 0.60 → 자동 승격 영구 차단 사람에게 두 문장 나란히 제시 재검증 주기에서 재평가

임베딩 임계값의 함정: 단일 도메인 위키에서는 모든 문서쌍이 0.8대에 몰린다. 절대값 0.90을 그대로 쓰면 미탐이 폭발한다. 처방은 자기 코퍼스로 캘리브하는 것 — 무작위 문서쌍 1,000개를 샘플링해 코사인 분포의 p99를 "동일" 기준, p95를 "merge 후보" 기준으로 잡고, 문서가 300건 늘 때마다 재계산한다.

CREATE TABLE promotion_candidate (
  id            bigserial PRIMARY KEY,
  memory_ids    bigint[]    NOT NULL,
  title_norm    text        NOT NULL,
  body_md       text        NOT NULL,
  embedding     vector(1024) NOT NULL,          -- bge-m3
  entities      text[]      NOT NULL DEFAULT '{}',
  anchors       jsonb       NOT NULL DEFAULT '[]',  -- [{kind:'commit',ref:'a1b2c3'}...]
  score_r       real NOT NULL, score_i real NOT NULL, score_e real NOT NULL,
  score_total   real GENERATED ALWAYS AS (0.40*score_r + 0.35*score_i + 0.25*score_e) STORED,
  nli_contra_max real NOT NULL DEFAULT 0,
  target_doc_id text,                            -- NULL이면 신규 생성
  state         text NOT NULL DEFAULT 'candidate'
                CHECK (state IN ('candidate','staged','published','rejected','reverted')),
  created_at    timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON promotion_candidate USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);
CREATE INDEX ON promotion_candidate USING gin (title_norm gin_trgm_ops);
-- 앵커 0개 후보의 자동 승격을 스키마 레벨에서 차단
CREATE INDEX ON promotion_candidate (state, score_total DESC)
  WHERE jsonb_array_length(anchors) >= 2 AND nli_contra_max < 0.2;

4. merge를 기본값으로 — 신규 문서는 비싸다

Wikipedia가 이미 답을 실측해줬다. 기존 문서의 diff를 검토하는 pending changes 큐는 보통 비어 있고 1~2일 안에 소화되는데, 신규 문서 생성을 검토하는 AfC는 주기적으로 백로그 드라이브를 열어야 한다. 같은 사람, 같은 커뮤니티, 다른 리뷰 단위다. 신규 문서는 "이 주제가 존재할 자격이 있는가"라는 판단을 요구하고 diff는 "이 줄이 맞는가"만 묻는다.

그래서 파이프라인 기본값은 merge다. merge 규칙:

  • 문서 전체 재작성 금지. LLM에게 섹션 하나만 주고 append 또는 replace-lines 패치를 내게 한다. Mem0가 후보 사실과 유사 메모리를 함께 주고 ADD / UPDATE / DELETE / NOOP 중 하나를 tool call로 고르게 하는 방식이 이 층에 그대로 이식된다 — if/else 트리보다 유지보수가 싸고, 결정이 로그에 남는다.
  • 게이트를 2개 이상 통과 → target_doc_id 채워서 merge. 1개만 통과 → 신규 생성 + 기존 문서에 related 링크만 추가.
  • DELETE는 절대 파일 삭제로 매핑하지 않는다. 삭제 대신 무효화다(다음 절).

5. 환각 고착 — 진짜로 무서운 실패 모드

LLM이 쓴 문장이 위키에 들어가고 → 검색이 그걸 찾아오고 → 다음 세션이 "위키에 이렇게 적혀 있으므로"라며 근거로 재인용하고 → 그 재인용이 새 메모리가 되어 R 점수를 올리고 → 다시 승격된다. 자기 참조로 확신만 증폭되는 루프다. 실제로 존재하지 않는 인용 자체가 대규모로 관측되는 시대에, 이 루프를 안 막으면 위키는 3개월 만에 신뢰할 수 없는 물건이 된다.

방어는 네 겹이고, 넷 다 있어야 한다.

방어 구현 이게 없으면
출처 필수 (fail-closed) frontmatter sources 비었으면 발행 API가 422로 거절 앵커 없는 문장이 조용히 정본이 됨
자기참조 앵커 금지 sources[].kind ∈ {commit, file, url, repro} 만 허용. memory: / session: 은 거부 "세션 12345에서 그랬음"이 근거로 둔갑
생성 출처 표기 origin: llm-promoted를 렌더링 화면에도 배지로 노출 읽는 사람이 사람이 쓴 문장과 구분 못 함
주기 재검증 verify_after TTL 경과 시 앵커를 실제로 재실행/재접속 코드가 바뀌어도 위키만 과거에 남음
---
id: doc_pg_partition_rotation
origin: llm-promoted          # human-written | llm-promoted | llm-drafted-human-edited
confidence: 0.72              # promotion_score 그대로. 모델 자기보고 아님
sources:
  - {kind: commit, ref: "a1b2c3d", repo: "svc-api"}
  - {kind: file,   ref: "app/db/partition.py:88-140"}
  - {kind: repro,  cmd: "make bench-partition", observed: "p95 240ms → 90ms"}
valid_at: 2026-06-14           # 세상에서 참이 된 시점
invalid_at: null               # 반증되면 여기에 날짜. 파일은 지우지 않는다
verify_after: 2026-12-14       # TTL. incident 90일 / decision 180일 / howto 365일
promoted_from: [mem_84213, mem_84991]
promotion_score: {r: 0.86, i: 0.80, e: 0.75}
---

valid_at / invalid_at을 두는 이유는 Graphiti/Zep의 bi-temporal 모델과 같다 — 틀린 사실을 삭제하지 않고 무효화해야 "왜 예전에 이렇게 알고 있었나"를 추적할 수 있고, 되돌리기가 파괴적이지 않게 된다. 검색 라우터는 기본적으로 invalid_at IS NULL만 반환하고, 명시적 히스토리 질의에서만 무효 문서를 노출한다.

실패 모드도 정직하게: TTL 재검증은 앵커가 기계로 재실행 가능할 때만 작동한다. kind: url 앵커는 200 응답만 확인할 뿐 내용이 바뀐 건 못 잡는다. URL 앵커는 스냅샷 해시를 같이 저장하지 않으면 사실상 장식이다.

6. 인간 게이트 — 생성이 아니라 발행에

게이트를 후보 생성 시점에 두면 하루 50번 물어보게 되고, 그러면 사람은 읽지 않고 승인하기 시작한다. 임상 알림의 49~96%가 무시된다는 수치, 그리고 AI 판독을 검토하던 15년차 방사선과 의사의 정확도가 82%에서 45.5%로 떨어졌다는 관찰이 같은 이야기를 한다. 자동화 편향은 의지력으로 이기는 게 아니라 건수로 이긴다.

  • 하루 1회, 최대 10건. 세션당 100건이 아니라 10건이 "사람이 정직하게 평가할 수 있는 수"라는 게 실무 합의다.
  • 요약이 아니라 diff를 보여준다. 판단 근거를 요약으로 대체하면 그게 곧 러버스탬핑 유도다.
  • 키 3개로 끝나야 한다: a(승인) / r(거절+사유 태그) / e(에디터 열기). 거절 사유 태그는 다음 달 임계값 튜닝의 유일한 라벨 데이터다.
  • 자동 승격 허용 조건(AND, 전부 충족): score_total ≥ 0.75 · 외부 앵커 ≥ 2 · nli_contra_max < 0.20 · 기존 문서 merge(신규 생성 아님) · 변경 라인 ≤ 20 · 대상 문서가 protected: true 아님. 하나라도 빠지면 큐로 간다.

7. 재인덱싱과 되돌리기

발행은 git 커밋 + PG 행 업데이트가 전부이고, 인덱싱은 그 뒤 두 갈래다.

경로 작업 지연 실패 시
Fast tsvector 갱신 + 임베딩 upsert < 1s, 동기 발행 롤백
Deep await rag.adelete_by_doc_id(doc_id) → await rag.ainsert(text, ids=[doc_id], file_paths=[path]) 수십 초~분, 큐 재시도 3회 후 reindex_status='stale' 마킹, 발행은 유지

LightRAG의 문서 단위 삭제는 해당 문서에만 속한 엔티티/관계를 정리하고, 다른 문서와 공유되는 엔티티는 남은 문서 기준으로 설명을 재구성한다. 다만 동기 별칭이 버전마다 없다 — 1.2.3에서 delete_by_doc_id 부재로 AttributeError가 보고됐다(HKUDS/LightRAG #991). 그래서 async 진입점만 쓰고, 부팅 시 assert hasattr(rag, "adelete_by_doc_id")로 버전 회귀를 막는다. 설치 버전은 핀으로 고정할 것(현행 lightrag-hku 1.5.x, 설치 시점 확인 필요).

되돌리기는 git revert <sha> → 동일한 Fast/Deep 경로 재실행이면 끝난다. 이게 성립하는 유일한 이유가 LightRAG가 언제든 통째로 재생성 가능한 파생 인덱스라는 원칙 ①이다. 그래프 상태가 꼬이면 최후 수단은 전량 재빌드고, 그 비용이 계산 가능하다는 것 자체가 이 아키텍처의 배당금이다. 실패 모드: 재빌드는 문서 수 × LLM 추출 비용에 비례하므로, 문서 2,000건을 넘기면 "언제든 재빌드"는 말뿐이 된다. 그 지점부터는 스냅샷 백업이 필수다.

8. 승격 로그 — 나중에 임계값을 고칠 유일한 근거

컬럼 내용 왜 필요한가
candidate_id, memory_ids[] 어느 메모리에서 왔나 오염원 역추적
git_sha_before/after, doc_path 어떤 커밋으로 반영됐나 revert 대상 특정
scores (jsonb) R/I/E 및 총점 스냅샷 임계값을 사후에 바꿔 재현 시뮬레이션
dup_matches (jsonb) 각 게이트 점수와 매칭 문서 오탐/미탐 라벨
decision, reject_reason, decided_by auto / user 자동 승격 정확도 추적
reindex_status, reverted_by Deep Path 결과 정본과 인덱스의 드리프트 감지

이 로그의 목적은 감사가 아니라 튜닝이다. 3개월 뒤 "auto 승인분 중 나중에 revert된 비율"을 뽑을 수 없다면 0.75라는 숫자는 영원히 근거 없는 상수로 남는다. 목표는 자동 승격분의 revert율 5% 미만이고, 이걸 넘으면 임계값이 아니라 E(앵커 정의)부터 의심한다.

그래프를 UX 로 — 백링크·연관 추천·지식 지도

"그래프를 UX 로 — 백링크·연관 추천·지식 지도" 섹션을 작성했다. 관련 문서 점수를 공통 이웃(Adamic-Adar)·개인화 PageRank·임베딩 코사인 3신호로 분리해 RRF(k=60)로 합치는 설계, 자동 링크의 3티어 권한 분리, 허브 폭발 3층 방어, 정본/별칭 표 기반 중복 엔티티 해소, 그래프 시각화 대신 쓸 운영 뷰 4종, 그래프 품질 지표 6종을 실제 SQL·LightRAG API·NetworkX 시그니처와 함께 정리했다.

핵심 요점

  • 관련 문서 점수는 단일 스칼라가 아니라 공통 이웃(Adamic-Adar)·개인화 PageRank·임베딩 코사인 3신호로 분리하고, 스케일이 다르므로 가중합 대신 RRF(Σ 1/(60+rank))로 랭크 융합한다. PPR만 야간 배치로 빼고 나머지는 Fast Path에서 SQL로 계산한다.
  • 점수 표시의 함정 3가지: min-max 정규화 퍼센트는 후보 집합에 따라 같은 문서가 91%→44%로 흔들리므로 UI에 띄우지 말 것, 숫자 대신 '공유 엔티티' 근거 문자열을 보이고 근거를 못 만들면 추천 자체를 빼며, 절대 임계값 대신 '상위 N + 최소 2개 공유' 같은 규모 불변 조건을 쓴다.
  • 백링크는 파생 테이블(doc_link)에서 렌더 타임에 붙이고 원본 마크다운에 절대 쓰지 않는다(diff 오염). 자동 위키링크는 A=자동적용/B=제안큐/C=표시만 3티어로 쓰기 권한을 분리하고, A티어 수락률이 90% 아래로 떨어지면 즉시 B로 강등한다 — 잘못된 링크는 엣지가 되어 자기 오류를 자기가 인용하는 루프를 만든다.
  • 허브 폭발은 3층 방어: ①추출 시 불용 엔티티 목록 ②질의 시 차수 상한(doc_freq ≤ 8%N, LightRAG MAX_GRAPH_NODES=1000, get_knowledge_graph(max_depth=3, max_nodes)) ③주간 엣지 프루닝. 1·2층은 '추천 근거로 안 쓰기'이고 물리 삭제는 3층뿐 — 순서를 반대로 하면 복구 불가.
  • 중복 엔티티(k8s/K8S/쿠버네티스/Kubernetes)는 LightRAG를 포함한 대부분의 그래프 RAG가 문자열 매칭 dedup만 돌아 살아남는다. 정본/별칭 표를 Postgres(entity_canonical + entity_alias, pg_trgm GIN)에 원본으로 두고, 재인덱싱 후 rag.amerge_entities(source_entities, target_entity, merge_strategy={'description':'concatenate','entity_type':'keep_first'})로 재적용한다.
  • 전체 그래프 뷰는 50노드 미만에서만 읽히고 200부터 헤어볼, 500+는 성능 문제 — 대신 최근 갱신·미해결 질문·근거 없는 주장·오래된 문서(참조 많은데 180일 미수정) 4개 리스트 뷰를 만든다. 품질 지표 중 '재생성 재현율(2회 인덱싱 엔티티 Jaccard ≥ 0.9)'이 '파생 인덱스는 재생성 가능' 원칙의 유일한 실측 증거다.

관련 문서 점수: 하나의 숫자가 아니라 신호 3개

"관련 문서"를 스칼라 하나로 압축하려는 설계는 거의 항상 무너진다. 구조적 관련성(같은 엔티티를 공유), 확산적 관련성(2~3홉 건너 이어짐), 의미적 관련성(링크는 없지만 내용이 닮음)은 서로 다른 실패 모드를 가진 별개의 신호다. 이걸 따로 계산하고 랭크 단위로 합치는 게 유일하게 안정적인 방법이었다.

신호 구현 잡아내는 것 무너지는 지점 계산 위치
공통 이웃 (Adamic-Adar) SQL 집계 / nx.adamic_adar_index(G, ebunch) 같은 개념을 실제로 다루는 문서 허브 엔티티 하나로 전부 연결됨 Fast Path, 요청 시
개인화 PageRank nx.pagerank(G, alpha=0.85, personalization={n:1.0}) 직접 이웃이 없는 2~3홉 문서 그래프 전체 순회 → 수천 노드부터 요청당 실행 불가 Deep Path, 야간 배치
임베딩 코사인 pgvector <=>, HNSW 링크가 0개인 신규 문서(콜드 스타트) 같은 어휘를 쓰는 무관한 문서를 끌어옴 Fast Path

세 신호는 스케일이 아예 다르다. AA는 상한 없는 양수, PPR은 합이 1인 확률, 코사인은 [-1,1]. 가중합을 쓰면 코퍼스가 커질 때마다 가중치를 다시 튜닝해야 한다. RRF(Reciprocal Rank Fusion, Σ 1/(60+rank)) 를 쓰면 원점수를 아예 버리고 순위만 보므로 재튜닝이 사라진다. Postgres 안에서 pgvector·tsvector·그래프를 한 쿼리로 융합한 사례에서 전체 30~60ms, 그래프 단계만 5~10ms가 보고돼 있다.

배치 계산은 개인화 PageRank에만 필요하다. nx.pagerank는 요청당 전체 그래프를 도는 파워 이터레이션이라(max_iter=100, tol=1e-6) 문서 수천 개부터 온라인 경로에 못 둔다. 야간에 문서별 상위 20개만 뽑아 doc_related(doc_id, related_id, ppr_rank)에 넣고, 온라인에서는 이 테이블을 세 번째 랭킹 소스로 조인만 한다. 반대로 노드 임베딩(node2vec 류)은 이 규모에서 거의 항상 손해다 — 전이적(transductive)이라 문서를 추가할 때마다 재학습해야 하고, 하이퍼파라미터·시드에 따라 링크 재구성 정확도가 크게 흔들리며, 강한 커뮤니티 구조에서는 단순 구조 특징 + 로지스틱 회귀에 밀린다는 보고가 있다. 수만 문서 규모에서는 AA + PPR + 임베딩 코사인이 상한이라고 보고 시작하는 편이 낫다.

-- 파생 미러 테이블(doc↔entity 이분 그래프). 원본은 마크다운, 이건 언제든 재생성 가능.
WITH src AS (SELECT entity_id FROM doc_entity WHERE doc_id = $1)
SELECT de.doc_id,
       -- Adamic-Adar: 흔한 엔티티일수록 기여를 1/ln(문서빈도) 로 감쇠
       sum(1.0 / ln(greatest(e.doc_freq, 2)))            AS aa_score,
       count(*)                                          AS shared_n,
       (array_agg(e.display_name ORDER BY e.doc_freq))[1:3] AS reason
FROM doc_entity de
JOIN src        ON src.entity_id = de.entity_id
JOIN entity e   ON e.id = de.entity_id
WHERE de.doc_id <> $1
  AND e.is_stopword = false          -- 불용 엔티티 제외
  AND e.doc_freq <= $2               -- 허브 상한: 전체 문서의 8% 넘으면 후보에서 뺀다
GROUP BY de.doc_id
HAVING count(*) >= 2                 -- 엔티티 1개만 공유 = 사실상 노이즈
ORDER BY aa_score DESC
LIMIT 20;

점수 표시의 함정은 세 가지다. ① 정규화 퍼센트를 UI에 띄우지 마라. min-max 정규화는 후보 집합에 의존하므로 같은 문서가 어제 91%, 오늘 44%로 보인다. 사용자는 이걸 시스템 고장으로 읽는다. ② 숫자 대신 근거를 보여라. "관련도 0.83"보다 공유: pgvector, HNSW, RRF가 훨씬 낫고, 근거 문자열을 만들지 못하는 추천은 아예 노출하지 않는 게 정답이다. ③ 절대 임계값 대신 구조적 조건. score > 0.7 컷오프는 코퍼스가 3배가 되면 조용히 무너진다. "상위 8개 + 최소 2개 엔티티 공유"처럼 순위와 구조로 자르면 규모에 불변이다.

자동 백링크는 파생, 자동 위키링크는 제안까지만

백링크는 절대 원본 마크다운에 쓰지 않는다. 인바운드 링크는 doc_link(src_doc, dst_doc, anchor, context, kind) 테이블에서 파생시키고 렌더 타임에 붙인다. 원본에 ## Backlinks 섹션을 자동 생성하면 문서 하나 추가할 때마다 수십 개 파일이 diff에 잡혀 git 히스토리가 죽고, 그 diff 노이즈 때문에 사람이 실제 내용 변경을 못 본다.

여기서 실제로 값을 만드는 필드는 dst_doc이 아니라 context — 링크가 등장한 문장 원문이다. "이 문서를 12개 문서가 참조함"은 정보가 없고, "…pgvector HNSW는 필터 조건에서 결과가 모자랄 수 있어 hnsw.iterative_scan을 켠다"는 문장은 클릭하기 전에 이미 답이다. 백링크 패널은 링크 목록이 아니라 인용문 목록으로 만들어라. 파싱은 Fast Path에서 정규식으로 충분하고, 그래프 인덱싱을 기다릴 이유가 없다.

위키링크 자동 제안은 훨씬 위험하다. Obsidian의 unlinked mentions가 실전에서 겪는 실패가 그대로 재현된다 — 코드블록 안 토큰이 링크 후보로 잡히고, 동명 문서 두 개 중 아무거나 고르고, 흔한 단어가 매 문서에 뜬다. 그래서 쓰기 권한을 주지 않는 3티어로 나눈다.

티어 조건 동작 원본 수정
A. 자동 적용 정확 별칭 일치 + 문서당 1회 + 코드/인용 블록 밖 링크 삽입, 커밋 메시지에 auto-link O (되돌리기 로그 필수)
B. 제안 큐 임베딩 코사인 ≥ 0.80 또는 trgm 유사도 ≥ 0.6 리뷰 인박스에 쌓고 일괄 수락/거절 X
C. 표시만 그 외 사이드바 "언급됨"에만 노출 X

운영 기준은 하나다: A 티어 제안의 사람 수락률이 90% 아래로 떨어지면 A를 끄고 전부 B로 내린다. 정확도 낮은 자동 링크는 단순 노이즈가 아니라 오염이다 — 잘못된 링크가 그래프 엣지가 되고, 그 엣지가 다시 관련 문서 추천의 근거가 되어 자기가 만든 오류를 자기가 인용하는 루프가 생긴다. B 티어 큐의 미처리 항목이 200개를 넘으면 그건 제안기가 아니라 쓰레기 생성기다.

허브 폭발: 3층 방어

LLM 추출은 반드시 "AI", "시스템", "데이터", "사용자" 같은 걸 엔티티로 뽑는다. 이 노드는 금방 모든 문서에 붙어 그래프를 완전그래프로 만든다. GraphRAG 계열에서 고차수 일반 엔티티가 순회를 지배해 검색을 오염시킨다는 지적이 반복되고, KG 디노이징 연구도 저품질 엣지 제거가 정확도와 지연을 동시에 개선한다고 본다.

층 시점 수단 실패 모드
1. 불용 엔티티 추출 시 entity_canonical.is_stopword, 추출 프롬프트의 entity_types 화이트리스트 도메인 핵심어를 실수로 넣으면 그 주제가 그래프에서 통째로 사라짐
2. 차수 상한 질의 시 doc_freq <= 0.08 * N, LightRAG MAX_GRAPH_NODES=1000, get_knowledge_graph(node_label, max_depth=3, max_nodes=...) 진짜 중요한 허브(주력 프로젝트 문서)도 같이 잘림 → degree_cap NULL 화이트리스트 필요
3. 엣지 프루닝 주간 배치 엣지 가중치 하위 20% + 출처 청크 1개뿐인 엣지 제거 신규 문서의 정당한 엣지가 "약해서" 잘림 → 생성 7일 이내는 면제

Adamic-Adar가 이미 1/ln(degree)로 허브를 감쇠시킨다는 점이 중요하다. 즉 1층·2층은 허브를 없애는 게 아니라 "추천 근거로 쓰지 않는" 것이고, 그래프에서 물리적으로 지우는 건 3층뿐이다. 순서를 반대로 하면 복구가 안 된다.

고아 문서와 중복 엔티티

k8s / K8S / 쿠버네티스 / Kubernetes는 LightRAG를 포함한 대부분의 그래프 RAG에서 문자열 매칭 기반 dedup만 돌기 때문에 서로 다른 4개 노드가 된다. 대소문자·약어·동의어·다국어 표기는 그대로 통과한다. 그래서 정본/별칭 표는 Postgres에 두고, LightRAG 쪽 머지는 그 표를 재생하는 절차여야 한다(원칙 ①). 그래프에서 직접 머지만 하면 재인덱싱 한 번에 전부 날아간다.

CREATE TABLE entity_canonical (
  canonical_key text PRIMARY KEY,      -- 'Kubernetes'
  display_name  text NOT NULL,
  entity_type   text,
  is_stopword   boolean NOT NULL DEFAULT false,
  degree_cap    int,                   -- NULL = 허브 상한 면제(화이트리스트)
  decided_by    text NOT NULL          -- 'human' | 'auto:v3'
);
CREATE TABLE entity_alias (
  alias         text PRIMARY KEY,      -- 'k8s', 'K8S', '쿠버네티스'
  canonical_key text NOT NULL REFERENCES entity_canonical(canonical_key),
  source        text NOT NULL,         -- 'human' | 'trgm' | 'embed'
  confidence    real
);
CREATE INDEX entity_alias_trgm ON entity_alias USING gin (alias gin_trgm_ops);
# 재인덱싱 후 별칭 재적용 (lightrag-hku 1.5.6)
await rag.amerge_entities(
    source_entities=["k8s", "K8S", "쿠버네티스"],
    target_entity="Kubernetes",
    merge_strategy={"description": "concatenate", "entity_type": "keep_first"},
)

후보 생성은 2단계 블로킹으로 충분하다: pg_trgm similarity ≥ 0.45(표기 변형) ∪ 엔티티 설명 임베딩 kNN top-10 & 코사인 ≥ 0.85(동의어·다국어). 그다음 사람이 확정한다. 임계값을 낮춰 자동 머지를 늘리면 역방향 실패가 온다 — Claude Code와 Claude, v2.6과 v2.6.5가 합쳐지고, 머지는 되돌릴 수 없다. 자동 머지는 confidence ≥ 0.95 + 같은 entity_type일 때만, 나머지는 전부 큐로.

고아 문서는 인바운드 링크 0 + 공유 엔티티 0인 문서다. 이건 "정리 대상"이 아니라 추출 실패의 지표다. 고아 비율이 갑자기 튀면 대개 새 문서가 아니라 추출 프롬프트나 청킹이 바뀐 것이다.

시각화는 대부분 장식이다 — 대신 만들 뷰 4개

전체 그래프 뷰는 50노드 미만에서만 읽히고, 200을 넘으면 "헤어볼"이 되며, 500+에서는 렌더 성능까지 문제가 된다는 게 PKM 커뮤니티의 반복된 결론이다. 밀집한 그래프는 이해처럼 보이지만 실제로는 우연한 링크 밀도를 기록할 뿐이다("graph theatre"). 노트 상태·우선순위·날짜 같은 실제로 일할 때 필요한 정보가 하나도 없다는 게 결정적이다.

살아남는 시각화는 딱 둘이다. 로컬 그래프(현재 문서 1~2홉) — 이건 규모와 무관하게 유용하다. 그리고 머지 전 후보 클러스터 그래프 — 중복 엔티티 6개가 한 덩어리로 보이면 사람이 3초 만에 판단한다. 공통점은 둘 다 화면에 뜬 순간 사용자가 내려야 할 결정이 정해져 있다는 것이다("여기로 갈까", "합칠까"). 전체 그래프에는 그 결정이 없어서 아무리 예뻐도 닫게 된다. 예외는 딱 하나, 분기에 한 번 연결요소를 색으로 칠한 스냅샷을 보는 것 — 위키가 서로 안 닿는 섬 세 개로 쪼개졌다는 사실은 리스트로는 안 보인다. 그건 탐색 도구가 아니라 감사 도구다. 나머지 예산은 전부 리스트 뷰로 보내라.

뷰 정의 왜 그래프보다 나은가
최근 갱신 ORDER BY updated_at DESC, 변경 요약 diff 포함 유일하게 매일 열게 되는 뷰. 그래프는 시간축이 없다
미해결 질문 본문 > TODO: / ? 프론트매터 태그 수집 위키의 실제 작업 큐. 노드 색으로는 표현 불가
근거 없는 주장 인용/출처 링크가 0인 단정 문단 그래프 연결도와 무관 — 잘 연결된 문서도 근거는 없을 수 있다
오래된 문서 updated_at < now() - 180일 AND 인바운드 링크 ≥ 3 참조는 많은데 안 고쳐진 문서 = 최우선 부채. 그래프에선 "건강한 허브"로 보인다

마지막 행이 핵심이다. 그래프가 건강해 보이는 지점과 위키가 썩는 지점이 정확히 겹친다.

그래프 품질 지표

지표 계산 출발 목표 넘어가면
고아 비율 인바운드 0 문서 / 전체 < 10% 추출·청킹 회귀 의심
최대 연결요소 비율 giant component / 전체 노드 0.7~0.9 1.0에 붙으면 허브 폭발, 0.5 미만이면 그래프 무의미
허브 집중도 상위 1% 노드가 가진 엣지 비중 < 30% 불용 엔티티 목록 갱신
별칭 충돌률 자동 머지 중 사람이 되돌린 비율 < 5% 임계값 즉시 상향
A티어 링크 수락률 사람 승인 / 자동 삽입 ≥ 90% A 티어 끄고 B로 강등
재생성 재현율 같은 코퍼스 2회 인덱싱 시 엔티티 집합 Jaccard ≥ 0.9 원칙 ①이 거짓 — 재빌드가 안전하지 않다

마지막 지표는 다른 지표들의 전제조건이다. LLM 추출은 비결정적이라 재인덱싱마다 엔티티가 달라진다. Jaccard가 0.7까지 떨어지면 "LightRAG는 언제든 재생성 가능한 파생 인덱스"라는 전제 자체가 깨진 것이고, 그때는 추출 temperature를 0으로 고정하거나 엔티티 타입을 좁히는 것부터 해야 한다. 재현율을 재지 않으면 재생성 가능하다고 믿는 것과 실제로 재생성 가능한 것의 차이를 영원히 모른다. (검색 라우터가 이 그래프 신호를 언제 고르는지는 → 라우터 섹션에서 다룸)

전자책 에이전트 — 위키를 산출물로 바꾸는 층

"X에 대한 전자책 만들어줘"를 8단계 파이프라인(범위 질의 → 클러스터링 → 목차 → 챕터 컨텍스트 → 초안 → 일관성 → 인용 검증 → 변환)으로 구현하는 설계를, 긴 산출물이 무너지는 4가지 실패 모드와 방어책, 문장 단위 인용 유지(Anthropic Citations API의 char_location·구조화출력 비호환), pandoc/WeasyPrint 한글 변환 함정, 20챕터 기준 비용·병렬화 수치와 함께 정리했다.

핵심 요점

  • 책은 위키의 복사본이 아니라 핀 고정된 뷰 — 챕터마다 (doc_id, revision) 쌍을 박아야 3개월 뒤 각주가 거짓말하지 않는다.
  • Citations API는 구조화 출력(output_config.format)과 병용 시 400 — 목차 생성(스키마 필요)과 본문 생성(인용 필요)을 반드시 다른 호출로 분리해야 하는 구조적 제약.
  • 인용 검증은 LLM이 아니라 (doc_id, revision, start_char, end_char)로 원본을 다시 잘라 cited_text와 바이트 대조하는 결정적 검사여야 한다. 단 '인용이 유효함'과 '인용이 문장을 뒷받침함'은 별개 문제다.
  • 긴 산출물의 4대 붕괴(용어 흔들림·챕터 중복·앞뒤 참조 깨짐·분량 불균형)는 각각 용어집 고정+어간 검사, MinHash→임베딩 2단 탐지, {{ref:chNN}} 토큰 렌더, target_words 비율 게이트로 막는다.
  • Opus 5는 thinking이 기본 ON이라 max_tokens가 thinking+본문 합산 상한 — 본문 6k 목표에 max_tokens=6000을 주면 잘린다. 1h 프롬프트 캐시로 20챕터 빌드가 $8.8→$6.4, 배치까지 쓰면 $3.2.
  • 한글 EPUB/PDF 함정: OFL 폰트 서브셋 임베드, :lang(ko){word-break:keep-all}, WeasyPrint 각주는 v54+ float:footnote, typst는 CJK 줄바꿈 미흡(typst#276)이라 한국어 책엔 WeasyPrint/xelatex.

전자책 에이전트는 위키의 "읽기" 층이 아니라 산출물 층이다. 위키가 원본이라는 대전제(①)를 지키려면 책은 위키의 복사본이 아니라 핀 고정된 뷰여야 한다 — 책 매니페스트는 문서 ID가 아니라 (doc_id, revision) 쌍을 잡아둔다. 그래야 3개월 뒤 각주를 눌렀을 때 "그 문장이 있던 그 버전"으로 갈 수 있다.

-- 책은 파생물이지만, 파생 시점의 원본 상태는 불변으로 박아둔다
CREATE TABLE book_projects (
  id BIGSERIAL PRIMARY KEY, slug TEXT UNIQUE NOT NULL, topic TEXT NOT NULL,
  glossary JSONB NOT NULL DEFAULT '{}',       -- {"파생 인덱스":{"canonical":..,"banned":[..]}}
  status TEXT NOT NULL DEFAULT 'scoping', created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE book_chapters (
  book_id BIGINT REFERENCES book_projects(id), idx INT, anchor TEXT NOT NULL,  -- 'ch03'
  title TEXT NOT NULL, thesis TEXT NOT NULL,
  target_words INT NOT NULL, actual_words INT,
  source_pins JSONB NOT NULL,                 -- [{"doc_id":123,"revision":"a1b2c3"}, ...]
  draft_md TEXT, summary_md TEXT,             -- summary_md = 다음 챕터로 넘길 300자 요약
  PRIMARY KEY (book_id, idx)
);
CREATE TABLE book_citations (
  book_id BIGINT, chapter_idx INT, sentence_hash TEXT,
  doc_id BIGINT, revision TEXT, start_char INT, end_char INT, cited_text TEXT,
  verified BOOL NOT NULL DEFAULT false
);

8단계 파이프라인과 각 단계의 예산

단계 입력 산출 모델/설정 실패 시 증상
1 범위 질의 토픽 후보 문서 200~400 라우터(→ 다른 섹션), recall 우선으로 임계 하향 목차에 구멍
2 클러스터링 후보 임베딩 8~15개 주제 덩어리 HDBSCAN/Leiden, 그래프 커뮤니티는 힌트로만 자료 많은 주제가 장을 독식
3 목차 생성 클러스터+요약 챕터 스펙 JSON output_config.format(json_schema) 강제 챕터 경계가 겹침
4 컨텍스트 수집 챕터 스펙 document 블록 세트 청크 단위 document + citations.enabled 근거 없는 챕터
5 초안 위 전부 챕터 마크다운 Opus급, effort:"high", 스트리밍 길이·용어 편차
6 일관성 패스 전체 초안 패치 diff 저가 모델 + 결정적 검사 중복·참조 깨짐
7 인용 검증 문장↔인용 매핑 통과/차단 리포트 LLM 아님, 오프셋 대조 그럴듯한 가짜 각주
8 변환 최종 md EPUB/PDF/HTML pandoc / WeasyPrint 한글 깨짐·TOC 누락

3단계와 5단계를 분리하는 건 취향이 아니라 API 제약이다. Anthropic Citations는 output_config.format(구조화 출력)과 같이 못 쓴다 — 400을 뱉는다. 목차처럼 스키마가 필요한 산출은 구조화 출력으로, 본문처럼 인용이 필요한 산출은 citations로. 한 요청에서 둘 다 하려는 설계는 첫 호출에서 죽는다.

챕터 요청: 무엇을 캐시하고 무엇을 흘려보내는가

컨텍스트는 네 덩어리다. 고정 프리앰블(용어집 + 전체 목차 + 스타일 가이드) → 앞 챕터 요약 체인 → 이 챕터의 근거 문서 → 챕터 스펙. 이 순서가 곧 캐시 설계다. 프리앰블을 맨 앞에 두고 cache_control을 걸면 캐시 읽기는 입력가의 0.1배, 쓰기는 1.25배(5분 TTL) 또는 2배(1시간 TTL)다. 책 한 권 빌드가 20~40분 걸린다면 ttl:"1h"가 정답이다.

resp = client.messages.create(
    model="claude-opus-5",
    max_tokens=24000,       # ⚠️ Opus 5는 thinking이 기본 ON이고 max_tokens는
                            # thinking+본문 합산 상한 — 본문 6k 목표에 6000을 주면 잘린다
    output_config={"effort": "high"},
    system=[{"type": "text", "text": PREAMBLE,        # 용어집+TOC+스타일 (≈30k tok)
             "cache_control": {"type": "ephemeral", "ttl": "1h"}}],
    messages=[{"role": "user", "content": [
        *[{"type": "document",
           "source": {"type": "text", "media_type": "text/plain", "data": c["text"]},
           "title": c["title"],
           "context": json.dumps({"doc_id": c["doc_id"], "revision": c["revision"]}),
           "citations": {"enabled": True}}   # all-or-none: 한 요청 안에서 전부 켜거나 전부 끄거나
          for c in chunks],
        {"type": "text", "text": PREV_SUMMARIES},  # 앞 챕터 요약 체인 (챕터당 300자)
        {"type": "text", "text": CHAPTER_SPEC},    # thesis / target_words / 금지 주제
    ]}],
)
# 응답은 text 블록 여러 개로 쪼개지고, 근거 있는 블록만 .citations 배열을 갖는다
for b in resp.content:
    if b.type == "text":
        for cit in (b.citations or []):
            store(cit.document_index, cit.start_char_index, cit.end_char_index, cit.cited_text)

title은 길이 제한이 있으므로 doc_id/revision 같은 메타데이터는 context 필드에 JSON 문자열로 넣는다. context와 title은 모델에 전달되지만 인용 대상 본문으로는 쓰이지 않는다.

긴 산출물이 실제로 무너지는 지점

실패 모드 왜 생기나 방어 하드 게이트 지표
용어 흔들림 ("파생 인덱스"↔"세컨더리 인덱스") 챕터마다 독립 호출 → 매번 새로 작명 용어집을 프리앰블에 고정 + 후처리 정규식. 한국어는 조사 때문에 완전일치가 안 되니 어간 기준 검사 금지 변형어 출현 = 0
챕터 간 중복 각 챕터가 같은 근거 문서를 다시 설명 문장 3-gram 셰이글 MinHash(datasketch, num_perm=128~256)로 표면 중복 + 임베딩 코사인으로 의미 중복 문단쌍 Jaccard>0.8 = 0
앞뒤 참조 깨짐 ("앞서 4장에서 본") 모델이 최종 챕터 번호를 모름 참조는 {{ref:ch03}} 토큰으로만 쓰게 하고 렌더 단계에서 해석 미해결 토큰 = 0 → 빌드 실패
분량 불균형 자료가 많은 챕터가 3배로 부풀음 목차 단계에서 target_words 배정, 실제/목표 비가 0.7~1.4 밖이면 재생성 범위 이탈 챕터 = 0

중복 탐지에는 흔한 오판이 하나 있다. MinHash는 표면 중복만 잡는다 — 같은 내용을 다른 문장으로 쓴 두 챕터는 Jaccard가 거의 0으로 나온다. 반대로 임베딩만 쓰면 "같은 주제를 다른 각도로 다룬 정당한 반복"까지 잘라낸다. 실무 조합은 MinHash로 후보를 좁히고(싸다) 임베딩으로 확정(비싸다), 그리고 잘라내는 게 아니라 리뷰 큐로 보내는 것이다. 자동 삭제는 논지 전개를 망가뜨린다.

인용 검증: LLM에게 시키지 말 것

Citations API는 문서 타입별로 인용 단위가 다르다. plain text는 문장 단위 char_location(0-indexed, end 배타적), PDF는 page_location(1-indexed), custom content는 내가 준 블록 인덱스. 위키처럼 마크다운 원본이 있으면 plain text로 넣어 문장 단위를 얻고, 불릿·표처럼 문장 분할이 망가지는 구조는 custom content로 넣어 내가 정한 블록이 곧 인용 단위가 되게 한다.

검증 자체는 모델을 부르지 않는다. (doc_id, revision, start_char, end_char)로 원본을 다시 잘라 cited_text와 바이트 단위로 대조한다. 불일치 = 하드 실패. cited_text는 출력 토큰에 포함되지 않고(다음 턴에 되돌려 보내도 입력 토큰에 안 잡힌다) API가 직접 추출하므로 "출처를 지어내지 마"라는 프롬프트보다 구조적으로 안전하다. 다만 인용이 유효하다는 것과 그 인용이 문장을 뒷받침한다는 것은 다른 문제다. 후자는 별도 NLI/판정 패스가 필요하고, 그건 비용이 든다.

위키에 근거가 없을 때는 3분기로 처리한다. ①사실 주장인데 인용 0개 → 문장 차단, [근거 없음] 마커 + 리뷰 큐. ②연결·요약 문장(사실 주장 아님) → 통과. ③반복적으로 비는 주제 → 위키에 gap 이슈로 등록한다(Agent Memory가 아니라 위키 TODO다 — 검증 없이 메모리가 위키로 승격되면 안 된다는 원칙 ②를 여기서도 지킨다). 실패 모드: 임계를 세게 잡으면 책이 각주로 도배되어 읽을 수 없어진다. 챕터당 인용 밀도 상한(예: 문장의 40%)도 게이트에 넣어야 한다.

변환 실무: pandoc / WeasyPrint와 한글 함정

# EPUB — --epub-chapter-level 은 --split-level 의 deprecated 동의어 (pandoc 3.x)
pandoc book.md -o book.epub \
  --toc --toc-depth=2 --split-level=1 --number-sections \
  --css=epub.css --epub-cover-image=cover.png --epub-metadata=meta.xml \
  --epub-embed-font=PretendardVariable.subset.woff2 -M lang=ko-KR
java -jar epubcheck.jar book.epub      # 5.x. errors 0 아니면 배포 금지

# PDF — 한국어는 WeasyPrint(69.x, Pango>=1.44) 또는 xelatex+xeCJK 권장
pandoc book.md -o book.pdf --pdf-engine=weasyprint --css=print.css --toc
# xelatex 경로: --pdf-engine=xelatex -V CJKmainfont="Noto Serif KR"
함정 증상 대응
한글 폰트 미임베드 리더기에서 두부(□) 또는 시스템 폰트 대체 OFL 폰트(Pretendard·Noto Sans/Serif KR)만 사용, 서브셋 필수 — 안 하면 폰트 하나가 수 MB
한국어 줄바꿈 어절 중간에서 잘림 :lang(ko){ word-break: keep-all; } — 단 긴 복합명사·URL은 오버플로우하니 overflow-wrap: anywhere 병행
각주 WeasyPrint는 v54부터 float: footnote, ::footnote-call, ::footnote-marker, @page @footnote, footnote-policy 지원. 페이지보다 큰 각주는 정의된 동작 없이 넘침 각주가 길면 미주로 강등
PDF 목차 페이지번호 target-counter() 역방향 참조가 0으로 렌더된 이슈 보고(Kozea/WeasyPrint#786) 핀 고정한 버전에서 직접 확인. PDF 북마크는 h1~h6에 기본 생성
typst 엔진 빠르지만 CJK 줄바꿈·약물 처리가 미흡(typst#276) 한국어 책은 WeasyPrint/xelatex

비용과 병렬화

챕터당 입력 58k(프리앰블 30k + 근거 25k + 요약 3k), 출력 6k, 20챕터, Opus 5($5/$25 per MTok) 가정:

구성 챕터당 20챕터
캐시 없음 $0.44 $8.8
프리앰블 1h 캐시(읽기 0.1×) $0.305 $6.4 (쓰기 $0.30 포함)
초안만 Batch API(50%) $0.15 $3.2 (대부분 1시간 내, 최대 24h)

즉 초안 1회 빌드는 $3~9다. 실제 비용은 일관성 패스·검증·재빌드에서 나온다 — 10번 돌리면 $60~90. 최적화 지점은 여기지 초안이 아니다. 배치는 50% 싸지만 프롬프트 캐시 히트를 잃기 쉬워 실이익이 계산보다 작을 수 있다(측정 필요).

병렬화의 함정: 챕터는 독립이 아니다. 앞 챕터 요약을 넘기는 순간 순차가 강제된다. 절충은 2-pass다 — Pass A는 목차+용어집만 보고 전 챕터를 병렬 초안, Pass B는 요약 체인을 태워 순차로 이음새만 수정. 20개를 동시에 던지면 ITPM/OTPM 한도에 걸려 429가 쏟아지고, max_tokens가 큰 요청을 비스트리밍으로 보내면 SDK가 막거나 HTTP 타임아웃에 걸린다. 스트리밍은 선택이 아니다.

품질 게이트 체크리스트

하드(빌드 실패): 미해결 {{ref:*}} 토큰 0 · 인용 오프셋 대조 불일치 0 · 금지 용어 변형 0 · 챕터 분량비 0.7~1.4 이탈 0 · 문단쌍 Jaccard>0.8 0 · epubcheck error 0 · 모든 챕터가 최소 1개 (doc_id, revision) 핀 보유.
소프트(리뷰 큐): 인용 밀도 상한 초과 · 임베딩 유사 문단 · [근거 없음] 마커 · 톤 일관성.

마지막으로 게이트 자체를 검증해야 한다. 용어를 일부러 흔든 초안, 오프셋을 1칸 민 인용, 존재하지 않는 {{ref:ch99}}를 넣은 뮤테이션 픽스처를 CI에 두고 게이트가 실제로 잡는지 확인한다. 통과율 100%인 게이트는 대개 잘 만든 게이트가 아니라 아무것도 검사하지 않는 게이트다.

평가·관측·비용 — 이게 실제로 작동하는지 어떻게 아는가

LLM Wiki v2 아티클의 "평가·관측·비용" 섹션을 집필했습니다 — 골든셋 스키마(PostgreSQL DDL), 검색/생성 2층 게이트 CI 하네스(검증된 Ragas API), 인터리빙 기반 라우터 판정, 지연 분해 query_log와 알람 기준(OTel gen_ai 규약의 Development 등급 경고 포함), 실측 단가 기반 비용 모델, 실패 6종 진단 순서를 담았습니다. 본문 약 1,196단어 + 표 7개 + 40줄 이하 코드블록 3개.

핵심 요점

  • 골든셋은 50~100개면 통계적으로 충분하며(200개 넘으면 라벨링 비용만 선형 증가, 검정력은 포화), 구성비는 실제 트래픽 40% / 확인된 프로덕션 실패 30% / 부정·경계 케이스 20% / 라우터 라벨 10%. 정답 라벨은 chunk_id가 아니라 슬러그+앵커로 잡아야 청킹 전략 변경 시 라벨이 죽지 않는다.
  • 검색 지표(recall@k·MRR·라우터 정확도·인용 적중률)와 생성 지표(Faithfulness·FactualCorrectness)를 **독립적으로 게이트**해야 한다 — 답변은 좋아지고 recall은 떨어지는 변경이 흔한데 합산 점수 하나로는 못 잡는다. 순위 지표는 Ragas가 아니라 직접 계산(LLM 호출 0회, CI에서 무료·결정적).
  • LLM-as-judge는 확률적 불안정성·위치 편향·척도 세분화에 따른 임의성이 반복 보고되므로, 게이트는 이진/3점 척도 + 3회 판정 중앙값 + 판정자 모델·프롬프트 버전 핀 고정으로 설계한다. 판정자 교체는 '지표 정의 변경'이라 baseline도 함께 갱신해야 한다.
  • 라우터/모드 변경은 효과 크기가 작아 일반 A/B로는 표본이 안 모인다 — 검색 랭킹에서 인터리빙은 최대 100배 민감도(한 사례: 90% 일치에 ~400 vs ~40,000 표본). Team-draft가 가장 단순하지만 상대 선호만 주므로 골든셋(절대값)과 병행해야 하고, 클릭이 없는 개인 위키에서는 대리 신호(답변 채택·재질의율)부터 만들어야 한다.
  • 비용의 지배 변수는 임베딩이 아니라 추출 모델이다(200만 토큰 임베딩 ≈ $0.04 vs 추출은 모델 선택에 따라 몇 달러~두 자릿수 달러). 재임베딩의 진짜 비용은 토큰이 아니라 인덱스 재구축 — pgvector HNSW는 maintenance_work_mem 기본 64MB에서 디스크 빌드로 10~50배 느려지고, LightRAG는 임베딩 모델 교체 시 자동 재임베딩을 제공하지 않는다.
  • 원칙 ①('LightRAG는 언제든 재생성 가능한 파생 인덱스')에 대한 반박: 재생성 비용을 상시 계측하지 않으면 반쯤 거짓말이 된다. 분기 1회 전체 재생성 드릴을 실제로 돌려라. 또한 추출을 로컬 모델로 내리는 손익분기는 소형 API 모델 대비 월 2억 토큰 이상이라, 로컬화의 정당한 이유는 비용이 아니라 프라이버시·레이트리밋이다.
  • 실패 6종(빈 결과·엉뚱한 문서·오래된 답·그래프 미갱신·중복 폭증·비용 폭증) 중 5종은 query_log에 chosen_mode를 남겼는지 여부만으로 진단 시간이 한 자릿수 배 갈린다. 지연은 t_route/t_retrieve/t_rerank/t_generate로 분해 저장하고, OTel gen_ai.* 는 2026-07 기준 전 항목 Development 등급이므로 스팬 모양만 채택하고 속성 이름 고정에 의존하는 대시보드는 만들지 말 것.

골든셋: 50~100개, 그리고 그걸 죽지 않게 만드는 스키마

골든셋은 "잘 만든 질문 모음"이 아니라 회귀 테스트 픽스처다. 50~100개면 충분하다 — 이 구간에서 recall@5의 1~2%p 변화는 노이즈 위로 올라오고, 200개를 넘기면 라벨링 비용은 선형으로 늘지만 검정력은 포화된다.

출처 비율 왜
실제 질의 로그 상위 40% 트래픽 분포 그대로. 안 물어보는 질문에서 이긴들 의미 없다
확인된 프로덕션 실패 30% 인시던트마다 1건 추가. 회귀 테스트의 본체는 여기다
부정·경계 케이스 20% 정답이 "위키에 없음"인 질문. 없어야 할 답을 지어내는 걸 잡는 유일한 수단
라우터 라벨 10% 질의 → 기대 모드(FTS/vector/graph/memory)

마지막 항목이 이 설계에서 특히 중요하다. 원칙 ④(라우터가 모드를 고른다)는 검색 품질 골든셋만으로는 검증되지 않는다. 라우터가 틀린 모드를 골라도 최종 답이 맞으면 지표는 전부 초록이고, 비용만 조용히 3배가 된다. 라우터 정확도는 별도 라벨로 따로 재라.

-- 골든셋은 코드와 함께 버전 관리되는 픽스처 (원본 YAML → PG 시드)
CREATE TABLE golden_query (
  id              text PRIMARY KEY,            -- gq-0042, 영구 불변
  question        text NOT NULL,
  intent          text NOT NULL,               -- fact | howto | synthesis | negative
  expected_mode   text NOT NULL,               -- fts | vector | graph | memory
  expected_docs   text[] NOT NULL,             -- 슬러그+앵커: 'infra/pg-tuning#wal'
  expected_answer text,                        -- NULL 허용: negative 는 '모른다'가 정답
  must_not_cite   text[] DEFAULT '{}',         -- 인용되면 실패 (오염 감지)
  source          text NOT NULL,               -- traffic | incident | edge | router
  incident_ref    text,                        -- 실패에서 태어난 케이스의 출처
  added_at        date NOT NULL DEFAULT now(),
  last_verified   date NOT NULL DEFAULT now()  -- 90일 경과 시 CI가 재검토 플래그
);
CREATE INDEX ON golden_query (source, expected_mode);

정답을 chunk_id로 잡으면 청킹 전략을 바꾸는 순간 라벨이 전부 죽는다. 슬러그+앵커로 잡고 채점 시 chunk → (slug, anchor)로 매핑해 비교하라. 이게 원칙 ①(위키가 원본)을 골든셋 층까지 관철하는 방법이다.

지표: 검색과 생성을 따로 게이트한다

층 지표 계산 게이트 무너지는 지점
검색 recall@5 / @20 결정적, LLM 0회 baseline −2%p 라벨이 낡으면 조용히 거짓 초록
검색 MRR 결정적 baseline −0.03 정답 청크가 여러 개면 의미 희석
검색 라우터 정확도 결정적 ≥0.9 모드가 4개뿐이면 랜덤도 0.25
근거 인용 적중률(인용 ∩ 정답) 결정적 ≥0.85 recall은 정상인데 인용만 틀림 = 리랭킹 문제
생성 Faithfulness LLM 판정 baseline −3%p 판정자 분산
생성 FactualCorrectness LLM 판정 baseline −3%p reference 품질에 종속

두 층은 반드시 독립적으로 게이트한다. 답변 품질은 오르고 recall은 떨어지는 변경(예: 컨텍스트를 잘라 생성 프롬프트를 정리)은 실제로 흔하며, 합산 점수 하나로는 절대 안 잡힌다.

Ragas의 쓸모는 명확하다 — 사람이 매기던 충실도·사실성을 자동화한다. 한계도 명확하다. (1) 순위 지표는 Ragas가 주지 않는다. recall@k·MRR·nDCG는 문서 ID 라벨만 있으면 직접 계산이 가능하고, LLM 호출 0회라 CI에서 무료·결정적이다. (2) 판정자 분산. LLM 판정자는 재실행마다 결론이 바뀌고(확률적 불안정성), 위치 편향이 있으며, 점수 척도를 잘게 쪼갤수록 임의성이 커진다는 게 반복 보고된다. 대응은 셋: 이진/3점 척도, 3회 판정의 중앙값, 판정자 모델·프롬프트 버전 핀 고정. 판정자를 바꾸는 건 "지표 정의 변경"이므로 baseline도 같이 갱신해야 한다.

# ci/eval_gate.py — 검색(결정적)과 생성(LLM 판정)을 따로 게이트
from statistics import mean
from ragas import EvaluationDataset, evaluate
from ragas.llms import LangchainLLMWrapper
from ragas.metrics import LLMContextRecall, Faithfulness, FactualCorrectness

def hit_at_k(cited, expected, k):            # 케이스당 0/1 → 전체 평균이 recall@k
    return float(bool(set(cited[:k]) & set(expected)))

def rr(cited, expected):
    return next((1.0 / i for i, c in enumerate(cited, 1) if c in expected), 0.0)

# 1층: LLM 호출 0회. PR마다 돌려도 비용이 0이다.
retrieval = {
    "recall@5":   mean(hit_at_k(r.cited, r.expected, 5) for r in runs),
    "recall@20":  mean(hit_at_k(r.cited, r.expected, 20) for r in runs),
    "mrr":        mean(rr(r.cited, r.expected) for r in runs),
    "router_acc": mean(r.chosen_mode == r.expected_mode for r in runs),
}
assert retrieval["recall@5"] >= BASELINE["recall@5"] - 0.02   # 절대값 아닌 회귀 폭으로 게이트

# 2층: 판정자는 핀 고정. 바뀌면 baseline 도 같이 갱신한다.
judge = LangchainLLMWrapper(ChatOpenAI(model=JUDGE_MODEL_PIN, temperature=0))
gen = evaluate(
    dataset=EvaluationDataset.from_list(samples),   # user_input / retrieved_contexts / response / reference
    metrics=[LLMContextRecall(), Faithfulness(), FactualCorrectness()],
    llm=judge,
)
assert gen["faithfulness"] >= BASELINE["faithfulness"] - 0.03

라우터·모드 변경은 A/B 말고 인터리빙으로

라우터 변경(hybrid → mix, 벡터 임계값 조정, 리랭커 교체)은 효과 크기가 작아서 일반 A/B로는 표본이 안 모인다. 검색 랭킹에서 인터리빙은 A/B 대비 최대 100배 민감도로 보고된다 — 한 사례에서 90% 일치에 인터리빙 ~400 표본, A/B ~40,000 표본. Team-draft interleaving은 인터리빙 계열 중 민감도가 가장 낮지만 구현이 가장 단순하니, 1인 규모에서는 이걸로 시작하면 된다.

무너지는 지점 둘. 인터리빙은 상대 선호만 준다 — "새 라우터가 낫다"는 알려주지만 "절대 recall이 얼마냐"는 못 준다(그래서 골든셋과 대체재가 아니라 보완재다). 그리고 클릭이 거의 없는 개인 위키에서는 인터리빙 자체가 성립하지 않는다. 대리 신호를 먼저 만들어라: 답변 채택, 재질의율, 인용 클릭.

관측: 지연 분해와 알람 기준

지연은 반드시 분해해서 저장한다. 합계만 있으면 "느려졌다" 알람이 어디를 가리키는지 알 수 없다.

CREATE TABLE query_log (
  id            bigserial PRIMARY KEY,
  ts            timestamptz NOT NULL DEFAULT now(),
  question      text NOT NULL,
  chosen_mode   text NOT NULL,       -- 이 컬럼 하나가 실패 6종 중 5종을 진단한다
  candidates    int  NOT NULL,       -- 0 이면 '빈 결과'
  cited_chunks  text[] NOT NULL,
  t_route_ms int, t_retrieve_ms int, t_rerank_ms int, t_generate_ms int,
  in_tokens  int, out_tokens int, error_code text
);
CREATE INDEX ON query_log (chosen_mode, ts DESC);

-- 비용 폭증은 합계가 아니라 모드별 분해로만 진범이 보인다
SELECT chosen_mode, count(*) AS n, round(avg(in_tokens)) AS avg_in,
       sum(in_tokens) FILTER (WHERE ts > now() - interval '1 day') AS in_1d
FROM query_log WHERE ts > now() - interval '7 days'
GROUP BY 1 ORDER BY in_1d DESC NULLS LAST;

OpenTelemetry를 쓴다면 GenAI 규약이 gen_ai.operation.name="retrieval" 스팬을 정의하고, gen_ai.provider.name(필수)·gen_ai.request.model·gen_ai.usage.input_tokens(권장)를 붙인다. 다만 2026년 7월 기준 gen_ai.* 전 항목이 Development(구 experimental) 등급이고, core semconv v1.42.0(2026-06)에서 deprecate되어 별도 저장소 semantic-conventions-genai로 이관됐다. 스팬 트리 모양(invoke_agent → chat/execute_tool/retrieval)은 지금 채택하되, 속성 이름 고정에 의존하는 대시보드는 만들지 마라.

신호 알람 기준 뜻
Deep Path 큐 적체 대기 > 50건 또는 최고령 > 30분 그래프가 조용히 낡는 중 (원칙 ③의 대가)
빈 결과율 7일 이동평균 > 3% 라우터/임계값/인덱스
인덱싱 실패율 > 1% 추출 파싱 실패
p95 t_retrieve_ms baseline 1.5배 인덱스 팽창·플랜 변경
일 질의당 입력 토큰 7일 중앙값 2배 모드 폭주

Fast/Deep 분리(원칙 ③)를 채택한 순간 Deep Path 큐 지연은 관측의 1급 시민이 된다. "PG에는 있는데 그래프에는 없는" 창은 버그가 아니라 설계된 상태이므로, 답변에 그래프 신선도(indexed_at)를 노출해 사용자가 그 창을 볼 수 있게 하라.

비용 모델: 인덱싱 1회성 vs 질의당

1,000 문서 × 평균 1,500 단어 ≈ 200만 토큰을 가정하자.

항목 성격 대략 비용 지배 변수
임베딩 (초기) 1회성 200만 토큰 × $0.02/M ≈ $0.04 사실상 무료
그래프 추출 (초기) 1회성 Haiku 급($1/$5 per MTok)이면 몇 달러, Opus 급($5/$25)이면 두 자릿수 달러 추출 모델 선택이 전부
질의당 반복 라우터 호출 + 생성 입력 토큰 모드. graph/mix는 FTS 대비 입력 토큰 몇 배
재임베딩 이벤트 토큰은 위와 동일(쌈) + 인덱스 재구축 시간 maintenance_work_mem
저장 상시 1536차원 float32 = 6KB/청크. 10만 청크 ≈ 600MB + HNSW 그래프 오버헤드 차원·양자화

(임베딩 단가는 2026-08 공개 요금표 기준 text-embedding-3-small $0.02/M — 3-large $0.13/M, Cohere embed-v4 및 voyage-3-lite 각 $0.01/M. 배포 전 재확인. LightRAG 계열 추출이 1,000단어당 ~1,200토큰을 쓴다는 수치는 2차 출처 추정치라 자기 코퍼스로 실측해야 한다.)

재임베딩의 진짜 비용은 토큰이 아니라 인덱스다. pgvector HNSW는 maintenance_work_mem 기본값 64MB에서 디스크 기반 빌드로 떨어져 10~50배 느려진다. 100만 행 규모면 튜닝 여부에 따라 수 분 vs 수십 분이고, REINDEX INDEX CONCURRENTLY도 원래 빌드와 비슷한 메모리를 요구한다. 게다가 LightRAG는 임베딩 모델 교체 시 자동 재임베딩을 제공하지 않는다 — 재생성 스크립트는 네가 갖고 있어야 한다.

여기서 원칙 ①에 한 가지 반박을 걸어둔다. "LightRAG는 언제든 통째로 재생성 가능한 파생 인덱스"는 기술적으로 참이지만, 재생성 비용을 계측하지 않으면 반쯤 거짓말이 된다. 재생성 비용(추출 토큰 + 인덱스 빌드 시간)을 대시보드에 상시 표기하고, 그 값이 "하루 안에 못 돌리는" 수준이 되는 순간 그 인덱스는 파생물이 아니라 자산이다. 분기 1회 전체 재생성 드릴을 스케줄에 넣어 실제로 돌려봐라 — 안 돌려본 재생성 경로는 없는 경로다.

로컬/소형 모델로 추출을 내리는 손익분기는 공개 벤치마크상 하루 ~5,000 요청 부근이다. 프런티어 모델 대비로는 월 800만 토큰 정도에서 넘어가지만, 소형 API 모델과 비교하면 월 2억 토큰 이상이어야 자체 호스팅이 이긴다. 개인 지식 OS의 Deep Path는 여기 한참 못 미친다. 결론: 추출을 로컬로 내리는 정당한 이유는 프라이버시와 레이트리밋이지 비용이 아니다. 비용으로 정당화하려 들면 숫자가 반박한다.

자주 나오는 실패 6종과 진단 순서

증상 1순위 확인 2순위 흔한 진범
빈 결과 query_log.chosen_mode 해당 백엔드에 같은 질의를 직접 라우터가 graph를 골랐는데 문서는 Fast Path만 통과한 상태
엉뚱한 문서 인용 청크 ∩ 정답 집합 recall@20은 정상인가? 정상이면 리랭킹/프롬프트 문제. 아니면 청킹 경계
오래된 답 파일 mtime vs 인덱스 indexed_at 삭제 경로가 실제로 도는가 "없는 게" 아니라 옛 버전이 상위에 걸린 경우가 더 흔하다
그래프 미갱신 Deep Path 큐 깊이·최고령 워커 실패 로그 → 추출 파싱 실패율 원칙 ③의 정상 부작용. 버그로 오진하기 쉽다
중복 폭증 엔티티 수 / 문서 수 추이 재인덱싱이 삭제 없이 append만 했는가 문서 ID가 경로 기반인데 경로가 바뀜 (→ 그래프 품질은 다른 섹션에서 다룸)
비용 폭증 모드별 질의 수 × 모드별 평균 입력 토큰 LLM 캐시 히트율(ENABLE_LLM_CACHE) 라우터 프롬프트 변경으로 graph/mix 쏠림

여섯 개를 관통하는 원칙 하나: 로그에 없는 수치는 추측하지 마라. grep으로 "새 내용이 인덱스에 있나"만 확인하고 정상 판정하는 게 가장 흔한 오진이다 — 있는데 순위에 안 걸리거나 두 버전이 공존하는 경우가 더 많다. 그리고 위 6종 중 5종은 chosen_mode를 로그에 남겼는가만으로 진단 시간이 한 자릿수 배 갈린다.

V1 실행 계획 — 4주 스프린트와 “하지 말 것” 목록

V1 을 4주 스프린트로 쪼갠 실행 계획 — 1주차에 Fast Path(문서→FTS/vector→MCP)를 반드시 관통시키고, 그래프·메모리·승격·전자책 순으로 붙이는 이유와 주차별 DoD·검증법을 정리했다. 스택은 Next.js 16 / FastAPI / PostgreSQL 18+pgvector 0.8.6 / LightRAG 1.5.6 / pgqueuer / FastMCP 3 으로 확정하고 각 선택의 교체 비용과 "하지 말 것" 5종(조기 Neo4j·조기 마이크로서비스·전부 자동 승격·그래프 시각화 선행·자체 임베딩 학습)의 해금 조건을 수치로 못박았다.

핵심 요점

  • 1주차 DoD 는 '문서 100건 적재 + FTS/vector 검색 p95 300ms + MCP 도구 3종(search/get/append)' 이라는 최소 관통이고, 그래프·메모리·라우터는 전부 뒤로 뺀다 — LightRAG 인덱싱이 문서당 1만 토큰 규모라 동기 경로에 넣는 순간 문서를 안 넣게 되기 때문이다.
  • 스택 확정: Next.js 16.3 / FastAPI / PostgreSQL 18 + pgvector 0.8.6(halfvec + HNSW, 0.8.3~0.8.4 의 HNSW vacuum 손상 수정 이후 버전으로 핀) / lightrag-hku 1.5.6 / pgqueuer 1.3.2(Redis 없이 Postgres LISTEN-NOTIFY) / FastMCP 3.4.7(spec 2026-07-28 stateless).
  • 한국어 FTS 는 기본 tsvector 로 안 된다 — PGroonga(PG18 지원) 또는 pg_bigm 을 처음부터 넣고, 필터가 붙는 벡터 검색은 pgvector 0.8.0 의 hnsw.iterative_scan(strict_order/relaxed_order) 을 켜지 않으면 결과가 조용히 빈다.
  • 리포는 둘로: 코드는 단일 모노레포(apps/web, services/api|worker|mcp, packages/schema), 마크다운 원본 vault 는 별도 git 리포 — 원본은 에디터가 직접 열고 되돌리기 단위가 커밋이어야 하므로 코드 배포 수명과 분리한다.
  • 하지 말 것 5종과 해금 조건: 조기 Neo4j(노드 10만/다중홉 Cypher 전까지 NetworkXStorage), 조기 마이크로서비스(워커가 API 를 굶길 때까지 한 컨테이너), 전부 자동 승격(수동 100건 거절률 10% 미만까지), 그래프 시각화 선행(LightRAG 내장 Sigma.js WebUI 로 대체), 자체 임베딩 학습(모델 교체 시 LightRAG 재임베딩 도구가 없어 전량 재인덱싱).
  • 1인 기준 총 공수 50~62시간이고, 매일 쓰게 되는 임계점은 '에이전트가 MCP 로 읽고 캡처가 3초 안에 끝나는 순간' — 즉 1주차 산출물 + wiki_append 하나. 실패 모드는 캡처 경로 부재로 문서가 안 늘거나, Deep Path 큐가 밀려 그래프만 낡은 채 정상처럼 보이는 것.

4주를 쪼개는 원칙: 매주 "혼자서도 쓸 수 있는 것"이 하나씩 나온다

이 계획의 유일한 규칙은 매 주말마다 그 주 산출물만으로도 실제로 쓸 수 있어야 한다는 것이다. 4주차 끝에야 처음 동작하는 계획은 3주차에 죽는다. 그래서 순서를 기능 난이도가 아니라 일일 사용 습관이 붙는 순서로 잡는다.

주차 산출물 DoD (완료 정의) 검증 방법
W1 Fast Path 관통: md 파일 → PostgreSQL → FTS+vector 검색 → MCP 도구 3종 문서 100건 적재, search/get/append MCP 도구가 에이전트에서 호출됨, 검색 p95 < 300ms EXPLAIN (ANALYZE, BUFFERS) 로 인덱스 사용 확인 + 실제 에이전트 세션에서 5회 이상 인용
W2 Deep Path: 큐 워커 + LightRAG 인덱싱, 재생성 스크립트 큐 지연 p95 < 10분, rebuild --all 이 무중단으로 완주(원본만으로 전체 재생성) 인덱스 전체 삭제 후 재생성 → 검색 결과 diff 0건
W3 Agent Memory 층 + 승격 게이트(수동 승인) 세션 메모리 저장/회수 동작, 승격 후보 큐에 사람이 승인 버튼을 누르는 경로 존재 실제 승격 5건 수행, 그중 롤백 1건을 되돌려 원본 md diff 확인
W4 발행 파이프라인(전자책/정적 사이트) + 운영 계측 태그 셀렉션 → md 번들 → PDF/EPUB 1건 산출, 실패 대시보드 실제 문서 30편으로 전자책 1권 빌드, 큐 실패율/인덱싱 지연 그래프 확인

1주차: 이것만 되면 나머지는 붙는다

1주차 범위는 잔인하게 좁혀야 한다. 그래프 없음, 메모리 없음, 라우터 없음. documents + chunks 두 테이블, FTS 하나, vector 하나, MCP 도구 세 개다.

-- 확장: pgvector 0.8.6 권장(0.8.2 CVE-2026-3172, 0.8.3~0.8.4 HNSW vacuum 손상 수정 이후)
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgroonga;   -- 한국어 형태소/2-gram. 대안: pg_bigm

CREATE TABLE documents (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  path         text UNIQUE NOT NULL,          -- vault 상대경로 = 진짜 원본 주소
  title        text NOT NULL,
  body         text NOT NULL,                 -- 마크다운 원문 그대로
  frontmatter  jsonb NOT NULL DEFAULT '{}',
  content_hash text NOT NULL,                 -- sha256(body). 재인덱싱 판정 키
  updated_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE chunks (
  id          bigserial PRIMARY KEY,
  document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  ord         int  NOT NULL,
  body        text NOT NULL,
  embedding   halfvec(3072),                  -- 2000차원 초과는 halfvec 로만 인덱싱 가능
  UNIQUE (document_id, ord)
);

CREATE INDEX ON documents USING pgroonga (body);
CREATE INDEX ON chunks USING hnsw (embedding halfvec_cosine_ops)
  WITH (m = 16, ef_construction = 64);

주의할 함정 둘. ① PostgreSQL 기본 tsvector 는 한국어 형태소 분석을 하지 않는다 — 영문 전용 파서로 인덱싱해 놓고 "FTS 가 왜 안 잡히지"로 이틀을 태우는 게 이 단계 1순위 실패 모드다. PGroonga(4.0.4에서 PG18 지원, PG18 이후엔 정렬된 인덱스로 인식됨) 또는 pg_bigm 을 처음부터 넣어라. ② 메타데이터 필터를 붙인 벡터 검색은 HNSW 가 조기 종료해 결과가 모자란다. pgvector 0.8.0 이 추가한 hnsw.iterative_scan = strict_order(또는 relaxed_order), hnsw.max_scan_tuples(기본 20000), hnsw.scan_mem_multiplier 를 세션 단위로 켜 두고 recall 을 재라. 안 켜면 "필터만 붙이면 검색이 비는" 증상이 조용히 남는다.

MCP 도구는 세 개로 충분하다: wiki_search(query, k), wiki_get(path), wiki_append(path, text). 서버는 FastMCP 3.4.x 로 짜고 stdio + HTTP 두 전송을 모두 노출한다. 2026-07-28 스펙이 initialize 핸드셰이크와 Mcp-Session-Id 를 없애고 stateless 로 갔기 때문에, HTTP 로 노출해도 sticky session 없이 라운드로빈 뒤에 둘 수 있다 — 1주차엔 그냥 로컬 stdio 로 쓰다가 나중에 옮겨도 코드가 안 바뀐다는 뜻이다.

왜 그래프 → 메모리 → 승격 → 전자책 순서인가

  • 그래프가 2주차인 이유: LightRAG 인덱싱은 문서당 대략 1만 토큰 규모의 LLM 호출을 쓴다(공개 벤치마크에선 데이터셋 전체 인덱싱에 수천 초 단위). 즉 느리고 돈이 든다. 이걸 1주차 동기 경로에 넣으면 "문서 하나 넣는 데 40초"가 되고, 그 순간 위키에 글을 안 넣게 된다. Fast Path 가 먼저 서야 Deep Path 를 비동기로 미룰 명분이 생긴다.
  • 메모리가 3주차인 이유: 메모리는 에이전트가 실제로 돌아간 세션이 쌓여야 저장할 게 생긴다. 1~2주차에 MCP 를 붙여 실사용을 만들어 놓지 않으면 3주차에 저장할 경험이 없다.
  • 승격이 메모리 뒤인 이유: 승격 기준은 책상에서 못 정한다. 메모리 30~50건이 쌓인 뒤 손으로 분류해 봐야 "무엇이 위키로 올라갈 자격이 있는가"가 나온다(기준 자체는 → 다른 섹션).
  • 전자책이 마지막인 이유: 출력물은 원본이 마크다운이면 언제든 만들 수 있다. 먼저 만들면 스키마가 출력 포맷에 끌려간다.

스택 확정안과 교체 비용

레이어 확정 (버전) 대안 교체 비용
프런트 Next.js 16.3 (App Router) Astro, SvelteKit 낮음. API 가 FastAPI 라 프런트는 갈아끼움
API FastAPI + psycopg/asyncpg Litestar, Django 낮음~중간. 라우터 시그니처만 이식
원본 저장 PostgreSQL 18 + documents.body + 파일 vault(git) SQLite+FTS5 중간. 단일 사용자면 SQLite 도 정답
벡터 pgvector 0.8.6 (halfvec, HNSW) Qdrant, LanceDB 높음. 트랜잭션 경계를 잃음 — 문서 저장과 임베딩이 따로 실패하기 시작한다
그래프 RAG lightrag-hku 1.5.6 (py≥3.10) GraphRAG, 직접 구현 낮음(설계상). 파생 인덱스라 통째 재생성 가능. 단 임베딩 모델을 바꾸면 재임베딩 도구가 없어 전량 재인덱싱이다
LightRAG 백엔드 기본값(JsonKVStorage/NanoVectorDBStorage/NetworkXStorage) → 필요 시 PG*Storage Neo4j 기본→PG 전환은 env 4줄. PG 그래프는 Apache AGE 필요(PG11~18 지원) — 표준 postgres:18-alpine 이미지엔 없다
큐 pgqueuer 1.3.2 (Postgres LISTEN/NOTIFY) arq 0.28(Redis), Celery 5.6, procrastinate 3.9 낮음. 인프라 1개(Redis) 안 늘리는 값이 크다. 초당 수천 작업이 되면 arq 로
MCP FastMCP 3.4.7 (spec 2026-07-28) mcp 2.0.0 SDK 직접 낮음. 장기 인덱싱 진행상황은 Tasks 확장(io.modelcontextprotocol/tasks, tasks/get)으로 노출

LightRAG 를 붙일 때 실제로 건드리는 건 이 정도다.

# .env — 1주차엔 이 블록 전체를 주석 처리해도 위키는 돌아간다
LIGHTRAG_KV_STORAGE=PGKVStorage
LIGHTRAG_VECTOR_STORAGE=PGVectorStorage
LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage
LIGHTRAG_GRAPH_STORAGE=NetworkXStorage   # AGE 준비되면 PGGraphStorage
POSTGRES_HOST=localhost
POSTGRES_DATABASE=wiki
POSTGRES_MAX_CONNECTIONS=25
WORKSPACE=personal_wiki                  # 인덱스 격리 키. 재생성 실험용 두 번째 값을 미리 확보해 둘 것
EMBEDDING_MODEL=text-embedding-3-large
EMBEDDING_DIM=3072
EMBEDDING_BATCH_NUM=32
MAX_ASYNC_LLM=4
# worker: Deep Path 한 건 처리
rag = LightRAG(working_dir="./.lightrag", embedding_func=emb, llm_model_func=llm)
await rag.initialize_storages()          # 빼먹으면 조용히 빈 결과가 나온다
await rag.initialize_pipeline_status()
await rag.ainsert(doc.body, ids=[str(doc.id)])

디렉터리와 리포지토리 배치

knowledge-os/                  # 리포 A: 코드 (단일 리포, 모노레포)
├── apps/web/                  # Next.js 16
├── services/api/              # FastAPI: /documents /search /promotions
├── services/worker/           # pgqueuer 워커 (Deep Path)
├── services/mcp/              # FastMCP 서버 (api 를 HTTP 로 호출만)
├── packages/schema/           # SQL 마이그레이션 + pydantic 모델 (단일 진실)
└── scripts/rebuild_index.py   # 원본 → 파생 전체 재생성
vault/                         # 리포 B: 마크다운 원본 (Obsidian 이 직접 연다)
└── notes/ , daily/ , refs/

리포를 둘로 쪼개는 이유는 하나다. 원본 vault 는 코드 배포와 수명이 다르고, 에디터가 직접 열어야 하며, 되돌리기 단위가 git commit 이어야 한다. 코드 리포에 넣으면 "노트 오타 수정"이 CI 를 돌린다. 반대로 코드는 서비스 4개를 굳이 리포로 쪼개지 마라(아래 참조).

하지 말 것

하지 말 것 왜 지금은 안 되나 대신 지금 해금 조건
조기 Neo4j 운영 DB 가 둘이 되고 백업·마이그레이션·트랜잭션 경계가 두 배. LightRAG 기본 NetworkXStorage 로 수천 노드까지 충분 파일 기반 그래프 → 필요 시 AGE 노드 10만 이상 또는 다중 홉 Cypher 가 주 질의
조기 마이크로서비스 서비스 4개를 리포·배포·인증까지 쪼개면 배포 단위가 4배. 1인 개발에서 이건 순수 손실 한 리포·한 컨테이너, 프로세스만 분리 워커 CPU 가 API 를 굶기기 시작할 때
전부 자동 승격 검증 없는 메모리가 위키로 올라가면 틀린 문서가 검색 상위에 고정된다. 되돌리려면 어떤 승격이 무엇을 덮었는지 추적해야 하는데 그 로그부터 없다 승격은 100% 수동 승인 + 승격 diff 를 vault 커밋으로 수동 승인 100건에서 거절률 < 10%
그래프 시각화 먼저 예쁘고 아무도 안 쓴다. LightRAG 서버가 이미 Sigma.js 기반 그래프 뷰어를 내장하고 있어 직접 만들 이유가 더 없다 내장 WebUI 로 눈검사만 시각화로만 잡히는 버그를 실제로 만났을 때
자체 임베딩 학습/파인튜닝 문서 수천 건 규모에서 검색 품질을 결정하는 건 청킹·FTS 결합·리랭킹이지 임베딩 가중치가 아니다. 게다가 모델을 바꾸면 LightRAG 는 전량 재인덱싱 상용 임베딩 + 리랭커(RERANK_BINDING) 평가셋 200쿼리에서 리랭커 튜닝이 한계에 닿았을 때
1주차에 라우터 만들기 라우팅 규칙은 실제 질의 로그가 있어야 정해진다 FTS+vector 를 항상 둘 다 돌려 합치기 질의 300건 로그 확보 후 (→ 라우터 섹션)

공수와 "매일 쓰게 되는" 임계점

혼자, 본업 옆에서 만든다는 가정(주 12~15시간)으로 실측형 추정: W1 약 18~22h(스키마·적재·검색·MCP), W2 약 12~16h(큐·워커·재생성 스크립트, LightRAG 첫 인덱싱 디버깅이 변수), W3 약 10~14h, W4 약 10h. 합계 50~62시간. 여기서 가장 크게 초과하는 항목은 항상 두 개다 — 한국어 FTS 세팅과 임베딩 차원/halfvec 인덱스 재빌드.

임계점은 명확하다. 에이전트가 MCP 로 위키를 읽고, 캡처가 3초 안에 끝나는 순간부터 매일 쓴다. 즉 1주차 산출물 + wiki_append 하나가 임계점이고, 그래프·메모리·전자책은 그 위의 복리다. 반대 실패 모드도 같이 적어 둔다: Fast Path 만 있고 캡처 경로가 없으면 3주 뒤 문서 수가 안 늘고, Deep Path 큐가 조용히 밀리면 그래프만 몇 주째 낡은 채로 "잘 되는 것처럼" 보인다. 그래서 W2 의 DoD 에 큐 지연 p95 와 전체 재생성 완주를 넣었다. 파생 인덱스라는 대전제는 재생성을 실제로 한 번 돌려 보기 전까지는 주장일 뿐이다.

이 글은 AI 리서치 파이프라인으로 작성되고 사람이 검수했습니다. 섹션마다 1차 출처를 표기합니다.