이 문서는 Synaptic Memory가 어떻게, 왜 그렇게 만들어졌는지를 설명합니다. GUIDE.md가 "무엇이냐"라면 이건 "어떻게·왜"입니다.
GraphRAG는 지금까지 3번 크게 발전했습니다.
문서 → LLM으로 엔티티·관계 추출 → 그래프 → 커뮤니티 요약 → 검색
└ $$$$ ──┘
장점: 의미 있는 관계를 LLM이 직접 추출하니 품질이 높음 단점:
- 인덱싱에 LLM 호출 다량 → 10만 토큰당 수십 달러
- 새 문서가 들어오면 재요약 필요
- 관계 추출이 불안정 (동일 문서를 여러 번 돌리면 다른 결과)
문서 → 간단한 엔티티만 추출 → 검색 시점에 LLM 호출
개선점: 인덱싱 비용을 뒤로 미뤄서 총 비용 감소 한계: 여전히 LLM 기반 그래프 구조. 쿼리 때 비용이 쌓임.
핵심 발상: 관계(edge)를 LLM으로 뽑지 말고, 구조적으로 이미 있는 것을 쓰자. 그러면 LLM 비용 0원.
문서/테이블 → 자동 그래프 구축 (FK, 카테고리, 청크 순서) → 하이브리드 검색
└ LLM 0회 ──┘
구조적 그래프 = LLM 없이 얻을 수 있는 관계:
- 외래 키 (테이블 → 테이블)
- 카테고리 (문서 → 카테고리)
- 청크 순서 (청크 → 다음 청크)
- 문서 포함 (문서 → 청크)
이 4가지 관계만으로도 대부분의 질의를 풀 수 있다는 게 LinearRAG, Practical GraphRAG 같은 최근 연구의 결론입니다. "shallow expansion is enough"라고 불립니다.
부족한 부분은 하이브리드 검색으로 메움:
- BM25 (키워드 매칭)
- 벡터 임베딩 (의미 유사)
- PPR (그래프 전파)
- Cross-encoder (정밀 재정렬)
이 네 가지를 합쳐 점수를 내면 LLM 없이도 1세대 GraphRAG에 필적하는 품질이 나옵니다.
| NodeKind | 용도 | 예시 |
|---|---|---|
CONCEPT |
카테고리 / 분류 | "규정 및 지침", "ESG" |
DOCUMENT |
문서 전체 | "2024년 경영 계획서" |
CHUNK |
문서 조각 | Doc의 512토큰 단위 |
ENTITY |
정형 데이터 행 / 추출 엔티티 | products:12800000 |
RULE / DECISION / OBSERVATION |
지식 타입 (선택) | 초기 설계의 잔재 |
실제로는 문서 그래프 (CONCEPT → DOCUMENT → CHUNK) 와 정형 그래프 (ENTITY) 가 병렬로 존재할 수 있습니다. 한 그래프에 섞어 저장해도 됩니다.
| EdgeKind | 방향 | 의미 | 자동 생성 시점 |
|---|---|---|---|
PART_OF |
Document → Category | 문서가 카테고리에 속함 | DocumentIngester |
CONTAINS |
Document → Chunk | 문서가 청크를 포함 | DocumentIngester |
NEXT_CHUNK |
Chunk → Chunk | 다음 청크 (순서) | DocumentIngester |
RELATED |
Entity → Entity | FK 관계 | TableIngester / DbIngester |
MENTIONS |
Entity → Source | 엔티티가 언급됨 | EntityLinker (선택) |
모두 인제스트 시점에 자동 생성됩니다. LLM이 관여하지 않습니다.
PPR (PersonalizedPageRank)이 엣지 가중치를 활용합니다:
_EDGE_WEIGHTS: dict[EdgeKind, float] = {
EdgeKind.CAUSED: 1.0, # 강한 인과
EdgeKind.RESULTED_IN: 1.0,
EdgeKind.PART_OF: 0.7, # 중간
EdgeKind.CONTAINS: 0.6,
EdgeKind.MENTIONS: 0.5,
EdgeKind.RELATED: 0.4, # 약함 (노이즈 방지)
EdgeKind.NEXT_CHUNK: 0.3, # 최소 (청크→청크 노이즈 방지)
}같은 노드라도 어떤 엣지로 도달했느냐에 따라 점수가 다릅니다.
src/synaptic/extensions/evidence_search.py
쿼리 1회에 대해 실제로 일어나는 일:
query_embedding = await embedder.embed(query) # BYOembed_url이 지정됐을 때만 실행. 벡터 검색의 기반.
anchors = QueryAnchors(
query="말 복지 향상 프로그램",
keywords=["말", "복지", "향상"],
entities=[],
categories=["복지 및 교육"], # ← 카테고리 추정
category_node_ids=["cat_welfare_id"],
)DomainProfile의 ontology_hints를 이용해 쿼리가 어느 카테고리를 건드리는지
추정합니다. 카테고리 매칭이 강력한 단서가 됩니다.
fts_hits = await backend.search_fts(query, limit=30)- BM25 점수
- 한국어면 Kiwi 형태소 분석 적용
- 제목은 3배 가중치
- LIKE fallback (한국어 부분 매칭)
vec_hits = await backend.search_vector(query_embedding, limit=30)- usearch HNSW 인덱스 (수 ms)
- cascade: 임베딩 있는 노드만
# top 3 결과의 임베딩 평균을 새 쿼리로 2차 검색
refined_emb = mean(top3.embedding)
vec_hits_2 = await backend.search_vector(refined_emb, limit=30)쿼리와 데이터의 어휘 차이를 극복하는 기법. 첫 검색이 약해도 두 번째가 보강합니다.
ppr_scores = await personalized_pagerank(
backend,
seeds={nid: fts_score for nid, fts_score in fts_hits},
damping=0.85,
top_k=k * 3,
)시드 노드에서 엣지 타입 가중치로 2-hop 전파. 직접 매칭 안 된 노드도 그래프 거리가 가까우면 후보에 포함됩니다.
expanded = await expander.expand(anchors=anchors, seed_nodes=all_seeds)5가지 경로로 이웃을 추가:
- Category siblings: 같은 카테고리의 다른 문서
- Document chunks: 문서의 자식 청크
- Chunk neighbours: 인접 청크 (NEXT_CHUNK)
- Entity mentions: 엔티티 허브 → 언급 문서
- RELATED (v0.13+): 정형 데이터의 FK 관련 노드
각 노드에 reason 태그가 붙어서 이후 reranker가 활용합니다.
final_score = (
0.45 * lexical_score # BM25 정규화
+ 0.25 * semantic_score # 벡터 유사도
+ 0.20 * graph_score # expansion reason 기반
+ 0.10 * structural_score # 카테고리/종류/authority/temporal
)reason prior 예시:
"seed": 1.00, # FTS 직접 매칭
"document_chunk": 0.70, # 같은 문서 내
"chunk_next": 0.55, # 다음 청크
"related": 0.50, # FK 관련 (v0.13+)
"entity_mention": 0.50,
"ppr_discovery": 0.45,
"category_sibling": 0.40,scores = await reranker.rerank(query, [ev.content for ev in top20])쿼리-문서 쌍을 직접 점수화. bge-reranker-v2-m3를 TEI로 배포한 경우 호출. top 20개만 재정렬해서 비용을 제한합니다.
diverse = mmr(candidates, lambda_=0.7) # 관련성 vs 다양성같은 문서에서 5개 이상 안 뽑고, 카테고리별로 최소 1개는 확보하는 등 다양성 보장. 단일 문서가 결과를 도배하는 걸 방지합니다.
코드가 "판단"하지 않습니다. LLM이 도구를 선택합니다:
Agent System Prompt + Graph Metadata
↓
LLM (GPT-4o-mini / Claude)
↓
도구 호출 (JSON tool_calls)
↓
도구 실행 (synaptic-memory 내부)
↓
결과 → 다시 LLM
↓
(반복)
↓
최종 답변
build_graph_context()가 시스템 프롬프트 앞에 자동으로 붙습니다:
[Graph metadata]
Categories (10): 규정 및 지침(235), 운영계획(315), ESG(198), ...
Total nodes: 19,720
[Structured data — tables and columns for filter/aggregate/join]
Table: pr_goods_base (1008 rows)
goods_no: e.g. G00001, G00002
goods_nm: e.g. iPhone 15 Pro, Shin Ramyun
sales_prc: e.g. 1600000.0, 5000.0
[Foreign key relationships]
pr_goods_sold_hist.goods_no → pr_goods_base
pr_goods_user_feedback.goods_no → pr_goods_base
[Graph composition — match tool to data type]
- Structured rows: 19843 → use filter_nodes/aggregate_nodes/join_related
LLM은 이 메타데이터를 보고:
- 어떤 카테고리가 있는지 (검색에 활용)
- 어떤 테이블이 있는지 (filter/aggregate 대상)
- FK가 어떻게 연결됐는지 (join_related 경로)
- 데이터 유형이 뭔지 (문서 vs 정형 → 도구 선택)
## Tool selection (pick the RIGHT one first time)
- Text question → deep_search
- Price/date/attribute filter → filter_nodes
- "how many per X" / TOP N → aggregate_nodes
- "find related records" → join_related
- Find by name/text → filter_nodes with op="contains"
## Fallback when search returns 0 results
1. Try filter_nodes with op="contains" on text columns
2. Try shorter/individual keywords
3. Try translated terms (Korean ↔ English)
이 프롬프트가 코드를 대체합니다. 새 도메인이 추가돼도 프롬프트만 수정하면 됩니다.
@dataclass
class SearchSession:
session_id: str
budget_tool_calls: int # 호출 예산 (무한 루프 방지)
seen_node_ids: set[str] # 이미 본 노드 (중복 방지)
queries_tried: list[str] # 시도한 쿼리
categories_explored: set # 탐색한 카테고리
expanded_nodes: set # 확장한 노드 (v0.13+)
facts: dict # 스크래치 패드- 같은 청크를 두 번 읽지 않음
- 이미 시도한 쿼리를 재시도하지 않음
- 예산 초과 시 도구가
budget_exceeded힌트 반환 → LLM이 답변 생성 모드 전환
처음 보면 의아합니다. GraphRAG 라이브러리들은 보통 Neo4j, Kuzu, Neptune 같은 그래프 DB를 쓰는데 왜 SQLite?
pip install synaptic-memory[sqlite] 한 줄. 그래프 DB 서버 실행 필요 없음.
SQLite의 FTS5는 BM25 지원, 한국어 토큰화, substring 매칭까지 지원합니다. 별도 검색 엔진(Elasticsearch 등)이 필요 없습니다.
벡터 검색은 usearch (100x 빠른 HNSW 인덱스)를 SQLite와 병합해서 처리.
10만 노드 규모까지 단일 knowledge.db 파일 하나로 해결됩니다.
Synaptic Memory의 쿼리는 모두 "시드 → 1-hop 확장" 패턴입니다. 복잡한
Cypher MATCH 패턴이 필요 없습니다. SELECT * FROM syn_edges WHERE source_id=? AND kind=? 면 충분합니다.
백엔드 교체 가능:
- Kuzu (임베디드 Cypher, ~1천만)
- PostgreSQL + pgvector (~100만)
- Qdrant (벡터 전용, 무제한)
StorageBackend 프로토콜을 구현한 백엔드면 어떤 것도 substituable합니다.
모든 도메인마다 설정을 코드로 짜면 유지보수가 지옥입니다. 대신 TOML로:
# my_profile.toml
name = "fashion_ecommerce"
locale = "ko"
stopwords_extra = ["상품", "제품", "rows"]
[ontology_hints]
"상품" = "ENTITY"
"리뷰" = "OBSERVATION"
"브랜드" = "CONCEPT"
[authority_by_kind]
RULE = 10
DECISION = 7
OBSERVATION = 3stopwords_extra: 해당 도메인에서 노이즈인 단어ontology_hints: 카테고리 → NodeKind 매핑authority_by_kind: 랭킹에 영향 (권위 있는 문서 가산점)
없어도 동작합니다. ProfileGenerator가 자동 생성합니다 (3-tier: rule → classifier → LLM).
오해: "그럼 모든 게 키워드 매칭이야?" 아닙니다. LLM을 "인덱스를 만드는 데"만 안 쓴다는 뜻입니다. 검색할 때는 LLM이 도구를 호출하면서 판단합니다.
- 텍스트 파싱·청킹
- BM25 인덱스 구축
- 임베딩 생성 (BYO, 주입된 모델)
- 엔티티 후보 추출 (빈도 + 길이 + 형태소)
- 카테고리 분류 (rule-based)
- 그래프 엣지 구축 (FK, 순서, 포함)
- 에이전트가 도구 선택
- 최종 답변 생성
사용자가 query() 1회 호출하면 LLM 호출 수는 에이전트 턴 수 + 답변 1회
입니다. 보통 1~5회.
Synaptic Memory는 레고 블록처럼 조합 가능합니다:
class MyBackend:
async def save_node(self, node): ...
async def save_edge(self, edge): ...
# StorageBackend Protocol 구현class MyEmbedder:
async def embed(self, text: str) -> list[float]: ...class MyReranker:
async def rerank(self, query, docs) -> list[float]: ...async def my_tool(backend, session, **kwargs) -> ToolResult:
...MCP 서버에 바로 등록 가능.
TOML로 도메인 특화.
ClassifierChain(rule, ontology, llm)로 3-tier 분류 커스터마이즈.
- torch / transformers 직접 의존: BYO로 해결, 설치 가볍게
- LLM으로 인덱싱: 비용 폭발 방지
- 도메인 하드코딩: DomainProfile로 주입
- 판단 로직을 코드에: LLM 프롬프트로 옮김
- 복잡한 설정:
from_data()한 줄로 충분하게 - Neo4j/벡터 DB 필수: 기본값 SQLite
프로덕션 DB와 연결할 때 매번 전체 재빌드 대신 변경분만 그래프에 반영합니다.
from_database()는 한 줄로 그래프를 만들어 주지만, 호출할 때마다 모든 행을
다시 읽고 다시 인제스트합니다. 19,843행짜리 X2BEE 프로덕션 DB로 35초.
100만 행이면 한 시간 단위가 됩니다. 라이브 DB라면 매 시간 또는 분 단위로
동기화해야 하는데, 이걸 풀로드로 처리할 수 없습니다.
CDC(Change Data Capture)는 이전 동기화 이후 변경된 행만 읽어 그래프에 반영합니다.
기본 동작에서 Node.id는 uuid4().hex[:16] — 매 인제스트마다 새 ID가
생성됩니다. 같은 행을 두 번 읽으면 그래프에 두 개의 노드가 생기고 검색
중복이 발생합니다.
CDC는 이걸 deterministic ID로 해결합니다:
deterministic_row_id(source_url, table, primary_key)
= blake2b("{normalized_url}::{table}::{canonical_pk}", digest_size=8)
→ 16 hex chars (기존 UUID 너비와 동일)같은 (source_url, table, pk) 조합은 항상 같은 노드 ID를 만들어 내고,
SQLite 백엔드의 ON CONFLICT(id) DO UPDATE SET이 즉시 upsert로 동작합니다.
CDC 상태는 그래프 SQLite 파일 안에 저장돼서 그래프 파일 하나로 자체 완결됩니다:
CREATE TABLE syn_cdc_state (
source_url, table_name, strategy, change_col,
last_sync_at, last_watermark, primary_key_col,
row_count, schema_fingerprint,
PRIMARY KEY (source_url, table_name)
);
CREATE TABLE syn_cdc_pk_index (
source_url, table_name, pk,
node_id, row_hash, fk_edges, -- fk_edges는 JSON
PRIMARY KEY (source_url, table_name, pk)
);syn_cdc_state는 테이블별 워터마크와 전략을, syn_cdc_pk_index는 모든
소스 행의 (pk → node_id) 매핑과 FK 스냅샷을 가지고 있습니다.
| 전략 | 조건 | 비용 |
|---|---|---|
| timestamp (선호) | updated_at 같은 단조 증가 컬럼 존재 |
O(변경 행 수) |
| hash (fallback) | 단조 컬럼 없음 | O(전체 행 수) — 매번 풀스캔 + blake2b |
자동 선택입니다. detect_change_column()이 컬럼 이름을 보고
updated_at, modified_at, mtime 등을 찾아내면 timestamp 전략, 못
찾으면 hash 전략으로 떨어집니다. 사용자가 손댈 필요 없습니다.
매 동기화마다 소스의 모든 PK를 읽어와서 transient cdc_current_pks
TEMP TABLE에 bulk insert한 다음 한 번의 LEFT JOIN으로 끝냅니다:
SELECT idx.pk, idx.node_id
FROM syn_cdc_pk_index AS idx
LEFT JOIN cdc_current_pks AS cur ON cur.pk = idx.pk
WHERE idx.source_url = ? AND idx.table_name = ?
AND cur.pk IS NULLPython 쪽에서 set diff를 만들지 않으니 1M행짜리 테이블도 메모리가 평탄.
삭제된 노드는 graph.remove()로 처리되고 ON DELETE CASCADE가 엣지를
자동 정리합니다.
syn_cdc_pk_index.fk_edges에 각 행의 FK 값 스냅샷을 JSON으로 저장합니다:
{"category_id": "C1", "vendor_id": "V42"}행이 업데이트되면 prior 스냅샷과 새 FK 값을 diff해서 바뀐 컬럼만 옛
RELATED 엣지를 삭제하고 새 엣지를 만듭니다. 신규 엣지는
TableIngester.ingest가 이미 idempotent (UNIQUE source/target/kind)로
삽입하므로 별도 처리 없이 동작합니다.
소스 스키마에 진짜 PRIMARY KEY가 없는 테이블 (AWS DMS 검증 테이블, 임시 로그 테이블 등)은 CDC 모드에서 skip합니다. 이유:
columns[0]로 fallback할 경우 그 컬럼이 unique가 아니면 (예: TASK_NAME
1개 distinct value) 모든 행이 동일한 deterministic ID로 collapse돼서
46개 행 중 45개가 사라지고 매 동기화마다 churn이 발생합니다.
TableSchema.has_explicit_pk = False인 테이블은 SyncResult.tables에
명시적 에러 항목으로 들어갑니다 — 이런 테이블은 mode="full"로만
인제스트하라는 신호입니다.
# 첫 번째 호출 — deterministic ID로 풀로드 + sync state 시드
graph = await SynapticGraph.from_database(
"postgresql://user:pass@host/db",
db="knowledge.db",
mode="cdc",
)
# N번째 호출 — 변경분만
result = await graph.sync_from_database(
"postgresql://user:pass@host/db"
)
print(result.added, result.updated, result.deleted)
# 자동 모드 — prior state 있으면 cdc, 없으면 full
graph = await SynapticGraph.from_database(dsn, mode="auto")없습니다. CDC는 노드 ID 생성 방식만 바꿀 뿐 검색 알고리즘 (BM25,
Vector, PRF, PPR, Reranker, MMR)을 전혀 건드리지 않습니다.
tests/test_cdc_search_regression.py가 동일 데이터를 mode="full"과
mode="cdc"로 빌드해서 top-k가 일치하는지 매 PR마다 검증합니다.
X2BEE 프로덕션 검증 결과 (19,843행 PostgreSQL):
| 지표 | 값 |
|---|---|
| Initial CDC load | 51초 |
| Full reload baseline | 35초 |
| Idempotent re-sync | 6초 (~6× 빠름) |
| Search top-1 일치 | 4/4 ✓ |
| DB | CDC 모드 | Notes |
|---|---|---|
| SQLite | ✅ | 1차 타깃 |
| PostgreSQL | ✅ | asyncpg 필요 |
| MySQL/MariaDB | ✅ | aiomysql 필요 |
| Oracle | legacy full-reload만 | Phase 6 follow-up |
| MSSQL | legacy full-reload만 | Phase 6 follow-up |
dialect별 placeholder 차이 (? vs $1 vs %s)는 _translate_placeholders가
한 번에 처리합니다. 신규 dialect 추가는 row_reader / pk_reader 두 함수만
구현하면 됩니다.
v0.14.x 시리즈에서 발견한 "silent failure" 패턴의 회수 경로.
v0.14.x 시리즈는 "기능은 코드에 있는데 wiring이 빠져서 silent하게 죽어 있던" 버그들을 여러 건 고쳤습니다:
- v0.14.0 초기: MCP 서버의
_ensure_graph()가ChunkEntityIndex는 wire했지만PhraseExtractor를 빼먹어서 문서 간 phrase hub 다리가 아예 안 만들어짐. v0.14.3에서 한 줄 fix. - v0.14.0 전체 기간: 사용자가 embedder 없이 인제스트 → 나중에
--embed-url로 다시 띄워도 기존 노드는Node.embedding=[]상태 그대로. HNSW 인덱스는 비어 있고 vector 검색이 부분적으로 죽음.
두 경우 모두 신규 인제스트만 고쳐졌고, 이미 들어 있는 데이터는 재인제스트 말고는 복구할 방법이 없었습니다. 실전에서는 재인제스트가 비싸거나 (수십만 문서) 불가능합니다 (소스 파일이 더 이상 없음). 그래서 in-place 복구 도구가 필요했습니다.
result = await graph.backfill(
embeddings=True, # 빈 embedding 채우기
phrases=True, # phrase hub 누락분 재생성
batch_size=64,
max_nodes=None, # None = 전체, int = 처음 N개만
)
print(result.embeddings_filled, result.phrases_linked)Pass 1 — Embedding backfill (embeddings=True):
모든 노드를 walk하고 node.embedding == []인 것만 모아 embedder.embed_batch()에
batch_size씩 넘김. 성공한 결과를 backend.update_node()로 저장.
이미 임베딩 있는 노드는 건너뜀 (멱등성).
Pass 2 — Phrase hub backfill (phrases=True):
텍스트를 가진 노드 중 outgoing CONTAINS 엣지가 하나도 없는 노드만
선별 → phrase_extractor.extract_and_link() 재실행 → 결과로 나온
phrase hub 노드에 CONTAINS 엣지 생성 → ChunkEntityIndex에 등록.
Phrase hub 노드 자신 (태그 _phrase)는 건너뜀 — hub of hubs 방지.
두 pass 모두:
- Best-effort — 단일 행 실패는
BackfillResult.errors에 append만 되고 나머지는 계속 진행. 한 노드의 임베딩 실패가 전체를 abort하지 않음. - Idempotent — 두 번 돌려도 두 번째는
embeddings_filled=0,phrases_linked=0. 건강한 노드는 스킵. - Bounded —
max_nodes파라미터로 점진 처리 가능. 100만 노드 그래프를 한 번에 처리할 수 없을 때 유용.
@dataclass(slots=True)
class BackfillResult:
scanned: int = 0 # 총 inspect한 노드 수
embeddings_filled: int = 0 # 새로 임베딩 채운 노드 수
phrases_linked: int = 0 # 새로 만든 CONTAINS 엣지 수
skipped_no_text: int = 0 # title/content 없어서 embed 불가
elapsed_ms: float = 0.0 # 벽시계 시간
errors: list[str] = [] # per-node 에러 메시지backfill()은 그래프가 이미 필요한 컴포넌트를 wire하고 있어야 동작:
- Embedding backfill:
SynapticGraph(embedder=...)필요. 없으면 no-op (에러 아님).graph.backfill(embeddings=True)는scanned=0을 반환하고 조용히 넘어감. - Phrase backfill:
SynapticGraph(phrase_extractor=...)필요. 없으면 no-op.
즉 백필 도구는 "없는 의존성을 상상으로 만들어내지" 않습니다. 사용자가
먼저 누락된 wiring을 고치고 (--embed-url 추가, 신규 그래프
생성자에서 PhraseExtractor() 전달) 그 다음 backfill을 호출하는
순서입니다.
// MCP 도구 호출 예시
{
"tool": "knowledge_backfill",
"scope": "all", // "all" | "embeddings" | "phrases"
"batch_size": 64,
"max_nodes": null // 전체 처리
}응답:
{
"success": true,
"scope": "all",
"scanned": 19843,
"embeddings_filled": 19843,
"phrases_linked": 5612,
"skipped_no_text": 0,
"elapsed_ms": 42350.2,
"errors": []
}Phrase hub가 없는 그래프는 GraphExpander와 PersonalizedPageRank의
cross-document 탐색이 사실상 dead path입니다. 문서 간 연결이
없으니 PPR이 walk할 엣지가 없고, 1-hop 확장도 같은 문서 내부만
맴돕니다. 결과는 "FTS over disjoint files" — 키워드 매칭이 안 되는
의미 기반 질의에 0건 응답.
Backfill을 실행하면 CONTAINS 엣지가 대량 생성되면서 ChunkEntityIndex가
채워지고, 다음 검색부터 PPR이 실제 그래프를 돌기 시작합니다. 이 효과는
벤치마크 수치로도 바로 드러납니다 (특히 multi-hop KRRA Hard / assort Hard).
- 재-인제스트의 완전한 대체는 아님. 원본 소스의 스키마가 바뀌었거나
새 컬럼이 추가됐다면 backfill은 그걸 감지하지 못합니다. 그때는 CDC
(
sync_from_database())가 올바른 도구. - Embedder / reranker 모델을 바꾸면 재-embed 필요. Backfill은
"현재 wire된 embedder로 다시 돌려" 동작이기 때문에 임베더 전환 시
embeddings=True로 다시 돌려야 기존 벡터가 새 모델 벡터로 교체됩니다. (단, 현재 구현은node.embedding is []만 체크하므로 강제 재-embed를 원하면 수동으로 비워야 함 — P3에서force=True옵션 예정.) - Phrase hub 품질은 extractor에 의존. Korean vs English vs mixed
locale에서 extractor 선택이 중요 —
create_phrase_extractor(profile)경로 사용 권장.
Backfill은 **"새 기능이 아니라 복구 도구"**입니다. v0.14.x 시리즈에서 발견한 교훈 — "feature가 wiring 누락으로 silent하게 죽어 있으면 안 된다" — 의 후속 조치. 이상적으로는 애초에 wiring이 잘 되어 있어서 backfill을 쓸 일이 없는 게 맞지만, 한번 발생한 과거를 되돌리는 경로는 제공해야 사용자가 재인제스트의 비용/불가능성에 묶이지 않습니다.
향후 발견될 새로운 silent-failure 패턴도 같은 방식으로 backfill 도구의
한 pass로 추가될 수 있습니다. 예: fingerprint_backfill=True (스키마
fingerprint 재계산), category_backfill=True (카테고리 라벨 재추출) 등.
- 멀티홉 질의 불안정: GPT-4o-mini 같은 작은 모델은 2-3홉부터 오판
- 패러프레이즈: 데이터에 없는 개념어는 임베딩으로도 한계
- 평가 방식: ID 매칭 기반 GT는 집계 쿼리에 부정확 (LLM-as-Judge 보완)
- CDC Oracle/MSSQL: 아직 legacy full-reload만 (Phase 6 follow-up)
- CDC PK 없는 테이블: 안전을 위해 skip — 실 데이터 테이블이라면 보통 PK가 있으므로 큰 제약은 아님
- CDC 기반 인크리멘털 인덱싱
- Doc2Query++ 쿼리 확장
- Multi-agent 협업 탐색
- 평가 GT 자동 확장 도구
- GraphRAG (Microsoft, 2024): Edge et al., "From Local to Global"
- LightRAG (2024): Han et al., "Simple and Fast Retrieval-Augmented Generation"
- LazyGraphRAG (Microsoft, 2024): Delayed LLM invocation pattern
- LinearRAG (2025): "Practical Graph Retrieval" — 1-hop is enough
- HippoRAG2: MaxP document aggregation
- usearch: Efficient HNSW for embedded vector search
- BM25: Robertson & Walker, "Okapi BM25"
- PPR: Haveliwala, "Topic-sensitive PageRank"
- 실전 예제를 돌려보고 싶다면 → TUTORIAL.md
- 친절한 전체 소개가 필요하다면 → GUIDE.md
- 다른 라이브러리와 비교는 → COMPARISON.md