(2/3) GraphRAG 제대로 얹기 — 벡터 검색과 그래프 탐색을 한 파이프라인에서 섞기
- 문서에서 지식 그래프 만들기 — 한 번 뽑고 끝이 아니라 계속 관리되게
- GraphRAG 제대로 얹기 — 벡터 검색과 그래프 탐색을 한 파이프라인에서 섞기 ← 지금 글
- GraphRAG, 진짜 나아졌나 — 평가 세트로 일반 RAG 와 수치 비교하기
Summary
지난 글에서 문서 더미로부터 지식 그래프를 뽑고, 문서가 바뀌어도 그래프가 따라오게 만드는 파이프라인을 짰어요. 글 끝에서 “정산 시스템이 죽으면 어떤 시스템이 영향을 받아?” 같은 질문이 그래프 두 홉 탐색으로 풀리는 걸 봤죠. 오늘은 예고했던 후속편입니다. 그 그래프를 ChromaDB RAG 파이프라인 옆에 나란히 세워서, 벡터 검색과 그래프 탐색을 한 번의 질문 처리에서 같이 쓰는 구성을 만들어요.
핵심 아이디어는 간단합니다. 벡터 검색은 “비슷한 내용의 문서 조각”을 잘 찾고, 그래프는 “떨어져 있는 사실 사이의 관계”를 잘 잇습니다. 실제 질문은 대부분 둘 다 필요해요. “정산 배치 실패하면 누구한테 연락하고, 뭐부터 확인해?” 라는 질문의 답은 담당자(그래프의 관계)와 확인 절차(문서의 서술) 를 합쳐야 나오거든요.
💡 이 글에서 다루는 것
- 벡터만 / 그래프만 쓸 때 각각 어떤 질문에서 무너지는지
- 엔티티 링킹 — 질문 문장에서 그래프 노드 찾아내기
- k-hop 서브그래프 추출 — 링크된 노드 주변 관계를 근거 문장과 함께 텍스트화
- 이중 검색 — 그래프에서 나온 엔티티로 벡터 쿼리를 확장하는 요령
- 컨텍스트 합성 + 답변 생성 — 그래프 사실과 문서 조각에 출처를 달아 LLM 에 넘기기
- 라우팅 — 모든 질문에 그래프를 태울 필요는 없다는 이야기
지난 글의 kg.db(SQLite 트리플 스토어)와 스키마 코드(normalize_key, ALIASES)를 그대로 이어서 씁니다. 예시의 시스템/팀 이름은 전부 가상의 값이에요.
1. 벡터와 그래프는 서로 다른 질문에 강하다
먼저 두 검색이 각각 어디서 무너지는지 분명히 해둘게요. 이걸 알아야 “왜 섞는지”가 아니라 “어떻게 섞는지”를 설계할 수 있습니다.
| 질문 유형 | 벡터 검색 | 그래프 탐색 |
|---|---|---|
| “장애 대응 절차 알려줘” (서술 조회) | ✅ 강함 | ❌ 절차 본문이 없음 |
| “A 죽으면 뭐가 영향 받아?” (다단계 관계) | ❌ 한 문서에 답이 없음 | ✅ 강함 |
| “A 담당자가 누구야?” (단일 사실) | △ 문서에 있으면 | ✅ 강함 |
| “A 담당자한테 뭘 물어봐야 해?” (관계+서술 복합) | △ 반쪽 | △ 반쪽 |
마지막 줄의 복합 질문이 실전에서 제일 흔한데, 어느 한쪽만으로는 반쪽짜리 답이 나옵니다. 그래서 파이프라인을 이렇게 구성해요.
질문
│
├─▶ [엔티티 링킹] 질문 속 표기 → 그래프 노드 키
│ │
│ ▼
│ [그래프 탐색] k-hop 서브그래프 → 사실 + 근거 문장
│ │
│ ▼ (찾은 엔티티 라벨로 쿼리 확장)
└─▶ [벡터 검색] 질문 + 확장 쿼리 → 문서 조각 top-k
│
▼
[컨텍스트 합성] 출처 붙여서 한 프롬프트로
│
▼
[LLM 답변] 근거 인용 강제
그래프 탐색 결과가 벡터 검색의 입력으로도 들어가는 게 포인트예요. 질문에 없던 엔티티(예: 두 홉 건너의 “주문 시스템”)가 그래프에서 나오면, 그 엔티티로 벡터 검색을 한 번 더 확장합니다. 벡터 검색 혼자서는 절대 못 찾았을 문서 조각이 이 확장에서 걸려 나와요.
2. 엔티티 링킹 — 질문에서 그래프 노드 찾기
GraphRAG 의 출발점은 “이 질문이 그래프의 어느 노드 이야기인가”를 알아내는 것입니다. 거창한 모델 없이, 지난 글에서 만든 정규화 함수와 별칭 사전을 재활용하면 첫 버전으로 충분해요. 질문을 정규화한 뒤, 노드 라벨(과 별칭)의 정규화 슬러그가 질문 안에 들어있는지 보는 방식입니다.
import re
def norm(text: str) -> str:
return re.sub(r"[\s\-_]+", "", text).lower()
def link_entities(conn, question: str) -> list[str]:
q = norm(question)
hits = set()
for node_key, label in conn.execute("SELECT node_key, label FROM nodes"):
if norm(label) in q:
hits.add(node_key)
for alias, node_key in conn.execute("SELECT alias, node_key FROM aliases"):
if norm(alias) in q:
hits.add(node_key)
return sorted(hits)
question = "정산 배치 실패하면 누구한테 연락하고 뭐부터 확인해야 해?"
linked = link_entities(conn, question)
print(linked)
['system:정산시스템']
“정산 배치”라는 표기는 노드 라벨(“정산 시스템”)과 다르지만, 지난 글에서 별칭 사전에 "정산배치": "정산시스템" 을 넣어뒀기 때문에 잡혔어요. 엔티티 링킹의 품질은 결국 별칭 사전의 품질 입니다. 링킹에 실패한 질문을 로그로 모아뒀다가 별칭을 보강하는 루틴이, 지난 글의 “노드 분열 리뷰”와 같은 주간 루틴으로 자연스럽게 합쳐져요.
⚠️ 라벨이 너무 짧은 노드(“DB”, “API” 같은 한 단어)는 아무 질문에나 걸리는 오탐이 생깁니다. 링킹 대상에서 최소 길이(예: 정규화 후 3자 이상)를 거는 걸 추천드려요.
3. 서브그래프 추출 — 관계를 근거와 함께 텍스트로
링크된 노드를 중심으로 k-hop 이웃을 긁어옵니다. 지난 글에서 엣지마다 evidence(근거 문장)를 저장해뒀기 때문에, 여기서 뽑히는 건 그냥 트리플이 아니라 인용 가능한 사실 이에요.
from collections import deque
def subgraph_facts(conn, start_keys: list[str], hops: int = 2, limit: int = 30) -> list[dict]:
seen_nodes = set(start_keys)
facts, frontier = [], deque([(k, 0) for k in start_keys])
while frontier and len(facts) < limit:
node, depth = frontier.popleft()
if depth >= hops:
continue
rows = conn.execute("""
SELECT src_key, relation, dst_key, doc_id, evidence FROM edges
WHERE src_key = ? OR dst_key = ?
""", (node, node)).fetchall()
for src, rel, dst, doc_id, ev in rows:
fact = {"src": src, "rel": rel, "dst": dst, "doc_id": doc_id, "evidence": ev}
if fact not in facts:
facts.append(fact)
for nxt in (src, dst):
if nxt not in seen_nodes:
seen_nodes.add(nxt)
frontier.append((nxt, depth + 1))
return facts
facts = subgraph_facts(conn, linked, hops=2)
for f in facts[:4]:
print(f"{f['src']} --{f['rel']}--> {f['dst']} [{f['doc_id']}]")
team:결제플랫폼팀 --owns--> system:정산시스템 [wiki/정산가이드]
person:박신입 --operates--> system:정산시스템 [wiki/정산가이드]
system:정산시스템 --depends_on--> system:주문시스템 [wiki/정산가이드]
system:재무리포트 --depends_on--> system:정산시스템 [wiki/재무월마감]
limit 을 꼭 두세요. 허브 노드(모든 시스템이 의존하는 공용 DB 같은 것)에 링크가 걸리면 서브그래프가 수백 개 사실로 폭발하는데, 컨텍스트에 다 넣으면 오히려 답이 흐려집니다. 홉 수는 2가 실용적인 기본값이에요. 1홉은 복합 질문에서 부족하고, 3홉부터는 관련 없는 사실이 섞이는 비율이 빠르게 올라갑니다.
4. 이중 검색 — 그래프가 벡터 쿼리를 확장한다
이제 벡터 쪽입니다. ChromaDB 컬렉션은 RAG 파이프라인 글에서 만들던 것과 같은 구조라고 가정할게요. 여기서 다른 점은 하나, 질문만 던지는 게 아니라 서브그래프에서 나온 엔티티 라벨을 쿼리로 추가 한다는 것입니다.
import chromadb
chroma = chromadb.HttpClient(host="localhost", port=8000)
col = chroma.get_collection("wiki_docs")
def vector_context(question: str, facts: list[dict], conn, k: int = 3) -> list[dict]:
# 서브그래프에 등장한 노드들의 사람이 읽는 라벨 수집
keys = {f["src"] for f in facts} | {f["dst"] for f in facts}
labels = [row[0] for key in keys
for row in conn.execute("SELECT label FROM nodes WHERE node_key = ?", (key,))]
queries = [question] + labels # 질문 + 엔티티 라벨로 확장
res = col.query(query_texts=queries, n_results=k)
chunks, seen = [], set()
for ids, docs, metas in zip(res["ids"], res["documents"], res["metadatas"]):
for cid, doc, meta in zip(ids, docs, metas):
if cid not in seen:
seen.add(cid)
chunks.append({"id": cid, "text": doc, "doc_id": meta["doc_id"]})
return chunks[:8] # 쿼리별 top-k 를 합치고 중복 제거
chunks = vector_context(question, facts, conn)
for c in chunks[:3]:
print(f"[{c['doc_id']}] {c['text'][:40]}...")
[wiki/정산가이드] 정산 배치가 실패하면 우선 주문 시스템의 일마감 완료 여부를 확인...
[wiki/장애대응절차] 배치 장애는 운영 담당자에게 1차 연락 후 소유 팀 채널에 공유...
[wiki/주문시스템운영] 주문 시스템의 일마감은 매일 04:00 에 종료되며 지연 시...
세 번째 조각을 보세요. 질문에는 “주문 시스템”이라는 단어가 없었는데, 그래프가 정산시스템 --depends_on--> 주문시스템 을 알려준 덕에 주문 시스템 운영 문서가 검색 범위에 들어왔어요. 복합 질문에서 GraphRAG 가 일반 RAG 를 이기는 지점이 정확히 여기입니다.
5. 컨텍스트 합성과 답변 생성 — 출처를 강제한다
이제 두 재료를 한 프롬프트로 합칩니다. 규칙은 하나예요. 모든 재료에 출처를 달고, 답변에도 출처 인용을 강제한다. 그래프 사실에는 지난 글에서 저장한 doc_id 와 evidence 가 있고, 문서 조각에도 doc_id 가 있으니 재료는 이미 준비되어 있습니다.
import anthropic
client = anthropic.Anthropic()
def build_context(facts: list[dict], chunks: list[dict]) -> str:
lines = ["## 그래프 사실 (관계)"]
for f in facts:
lines.append(f"- {f['src']} --{f['rel']}--> {f['dst']}"
f" (출처: {f['doc_id']}, 근거: \"{f['evidence']}\")")
lines.append("\n## 문서 조각 (서술)")
for c in chunks:
lines.append(f"- [{c['doc_id']}] {c['text']}")
return "\n".join(lines)
def answer(question: str, facts: list[dict], chunks: list[dict]) -> str:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
system=(
"너는 사내 운영 지식 어시스턴트다. 아래 컨텍스트 안의 정보로만 답한다. "
"관계형 사실은 '그래프 사실'에서, 절차/세부 내용은 '문서 조각'에서 가져오고, "
"모든 주장 뒤에 (출처: doc_id) 를 붙인다. 컨텍스트에 없는 내용은 모른다고 답한다."
),
messages=[{"role": "user",
"content": f"{build_context(facts, chunks)}\n\n질문: {question}"}],
)
return response.content[0].text
print(answer(question, facts, chunks))
정산 배치가 실패하면 다음 순서로 대응하세요.
1. 연락: 정산 시스템 운영 담당자인 박신입 매니저에게 1차 연락하고
(출처: wiki/정산가이드), 소유 팀인 결제플랫폼팀 채널에 공유합니다
(출처: wiki/장애대응절차).
2. 확인: 정산 시스템은 주문 시스템에 의존하므로 (출처: wiki/정산가이드)
주문 시스템의 일마감 완료 여부를 먼저 확인합니다. 일마감은 매일
04:00 에 종료되며, 지연 시 정산 배치도 함께 지연됩니다
(출처: wiki/주문시스템운영).
3. 영향 공유: 정산이 지연되면 재무리포트가 영향을 받으므로
(출처: wiki/재무월마감) 관련 팀에 미리 알립니다.
답변을 뜯어보면 “누구한테”(그래프의 operates/owns 관계), “뭐부터”(문서의 절차 서술), “영향 범위”(그래프의 depends_on 역방향)가 각각 다른 재료에서 왔어요. 어느 한쪽만 썼다면 이 셋 중 하나는 반드시 빠졌을 답입니다.
💡 출처 인용을 시스템 프롬프트로 강제하는 건 품질 장치이면서 동시에 디버깅 장치 예요. 답이 이상할 때 인용된 doc_id 를 따라가면, 문서가 낡은 건지 그래프 추출이 틀린 건지 검색이 엉뚱한 조각을 물어온 건지 바로 갈라집니다.
6. 라우팅 — 모든 질문에 그래프를 태울 필요는 없다
마지막으로 운영 요령입니다. GraphRAG 경로는 일반 RAG 보다 단계가 많아서 느리고, 엔티티 링킹이 빗나가면 오히려 잡음이 들어가요. 그래서 저는 전 질문을 GraphRAG 로 보내지 않고, 간단한 규칙으로 경로를 가릅니다.
def route(conn, question: str) -> str:
linked = link_entities(conn, question)
if not linked:
return "vector-only" # 그래프에 아는 엔티티가 없음 → 일반 RAG
return "graph-rag"
print(route(conn, "정산 배치 실패하면 누구한테 연락해?"))
print(route(conn, "파이썬에서 datetime 파싱 어떻게 해?"))
graph-rag
vector-only
엔티티 링킹 성공 여부 자체가 훌륭한 라우터예요. 그래프가 모르는 주제면 그래프가 보탤 것도 없으니까요. 여기에 운영하면서 지표 두 개만 챙기면 됩니다.
- 링킹 실패율 — vector-only 로 빠진 질문 중 실제로는 사내 시스템 질문이었던 비율. 높으면 별칭 사전을 보강할 차례예요.
- 그래프 기여율 — 최종 답변이 인용한 출처 중 그래프 사실의 비율. 0에 가까우면 서브그래프가 잡음만 넣고 있다는 뜻이라 홉 수나 limit 을 줄입니다.
지난 글의 재색인 통계와 이 두 지표를 같은 리포트에 실어두면, 그래프 파이프라인 전체가 건강한지 숫자 몇 개로 확인할 수 있어요.
일단 오늘은 여기까지….. 다음 글에서는 이 파이프라인에 평가 세트를 붙여서, GraphRAG 가 일반 RAG 대비 실제로 얼마나 나아졌는지 수치로 재는 방법을 정리해볼게요.
← 이전 글: (1/3) 문서에서 지식 그래프 만들기 — 한 번 뽑고 끝이 아니라 계속 관리되게 | 다음 글 →: (3/3) GraphRAG, 진짜 나아졌나 — 평가 세트로 일반 RAG 와 수치 비교하기