목차
- 문제와 접근
- corpus chunking
- chunk별 엔티티/관계 추출
- extract_all_chunks
- merge_chunk_results
- 엔티티 정규화
- edge/node weight
- 그래프 시각화
- Leiden 커뮤니티 탐지
- 커뮤니티 요약
- index 저장/로드
- local_search
- global_search
- 핵심 개념 총정리
- 자주 나오는 실수/주의사항
- 인덱스의 생성 과정까지 추적하기
1. 문제와 접근
Mini GraphRAG를 직접 구성하면 추출·병합·커뮤니티·검색 사이의 데이터 흐름이 드러난다. 각 단계가 어떤 정보를 추가하고 어떤 정보를 줄이는지 확인하면서 결과를 저장하고 다시 읽는 구조까지 연결한다. 그래프 요약과 검색 답변의 근거가 원본 청크로 이어지는지를 기준으로 본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| corpus
↓ chunking
chunk별 entity/relation 추출
↓
graph merge
↓
entity resolve + weight
↓
community detection
↓
community summary
↓
local/global search
|
2. corpus chunking
1
2
3
4
5
6
7
8
9
10
11
12
13
| import json
import re
import pickle
from pathlib import Path
from collections import defaultdict
import networkx as nx
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_text_splitters import RecursiveCharacterTextSplitter
llm = ChatOpenAI(model="gpt-4o-mini")
corpus = Path("./data/council.txt").read_text(encoding="utf-8")
|
1
2
3
4
5
6
7
| splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(corpus)
|
3. chunk별 엔티티/관계 추출
1
2
3
4
5
6
7
8
9
| EXTRACTION_PROMPT = """You are an information extraction system.
Extract entities and relations from the text as JSON.
Schema:
{
"entities": [{"name": "...", "type": "Person|Organisation|Product|Concept"}],
"relations": [{"source": "...", "target": "...", "label": "..."}]
}
Output ONLY valid JSON. No commentary."""
|
1
2
3
4
5
6
| def strip_fence(t):
t = t.strip()
if t.startswith("```"):
t = re.sub(r"^```(?:json)?\s*", "", t)
t = re.sub(r"\s*```$", "", t)
return t
|
1
2
3
4
5
6
7
8
9
10
| def extract_one_chunk(chunk) -> dict:
msgs = [
SystemMessage(content=EXTRACTION_PROMPT),
HumanMessage(content=f'Text:\n"""{chunk}"""'),
]
raw = llm.invoke(msgs).content
try:
return json.loads(strip_fence(raw))
except json.JSONDecodeError:
return {"entities": [], "relations": []}
|
모든 chunk를 순회하면서 추출 결과에 chunk index를 붙인다.
1
2
3
4
5
6
7
8
| def extract_all_chunks(chunks) -> list[dict]:
results = []
for i, c in enumerate(chunks):
r = extract_one_chunk(c)
r["chunk_index"] = i
results.append(r)
print(f"chunk {i}: {len(r['entities'])} entities, {len(r['relations'])} relations")
return results
|
5. merge_chunk_results
여러 청크에서 같은 관계를 얻었을 때 단순히 하나로 합치면 출처를 잃을 수 있다. 관계 설명과 등장 횟수를 어떻게 합칠지 정하고, 원문 청크 ID를 유지해 병합 결과를 다시 확인할 수 있는 형태를 검토한다.
chunk별 추출 결과를 하나의 방향 그래프로 합친다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| def merge_chunk_results(results: list[dict]) -> nx.DiGraph:
G = nx.DiGraph()
for r in results:
ci = r["chunk_index"]
for e in r["entities"]:
name = e["name"]
if name in G:
G.nodes[name]["source_chunks"].append(ci)
else:
G.add_node(name, type=e.get("type", "unknown"), source_chunks=[ci])
for rel in r["relations"]:
s, t = rel["source"], rel["target"]
label = rel.get("label", "")
if s not in G:
G.add_node(s, type="unknown", source_chunks=[ci])
if t not in G:
G.add_node(t, type="unknown", source_chunks=[ci])
if G.has_edge(s, t):
if label and label not in G[s][t]["relations"]:
G[s][t]["relations"].append(label)
G[s][t]["source_chunks"].append(ci)
else:
G.add_edge(s, t, relations=[label] if label else [], source_chunks=[ci])
return G
|
6. 엔티티 정규화
짧은 alias를 긴 이름으로 합친다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| def resolve_entities(G: nx.DiGraph) -> nx.DiGraph:
nodes = list(G.nodes)
aliases = {}
for short in nodes:
for long in nodes:
if short != long and len(short) < len(long) and short in long:
aliases[short] = long
break
def resolve(n):
seen = set()
while n in aliases and n not in seen:
seen.add(n)
n = aliases[n]
return n
G2 = nx.DiGraph()
for n in nodes:
c = resolve(n)
if c not in G2:
G2.add_node(c, **G.nodes[n])
for u, v, d in G.edges(data=True):
cu, cv = resolve(u), resolve(v)
if cu == cv:
continue
if G2.has_edge(cu, cv):
continue
G2.add_edge(cu, cv, **d)
return G2
|
7. edge/node weight
같은 엔티티나 관계가 여러 chunk에서 나오면 weight를 높인다.
1
2
3
4
5
6
| def add_edge_weights(G: nx.DiGraph) -> nx.DiGraph:
for u, v, d in G.edges(data=True):
d["weight"] = len(d.get("source_chunks", []))
for n, d in G.nodes(data=True):
d["weight"] = len(d.get("source_chunks", []))
return G
|
8. 그래프 시각화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def draw(G, node_color, title="", figsize=(12, 8)):
pos = nx.spring_layout(G, seed=42, k=1.2)
plt.figure(figsize=figsize)
nx.draw(
G,
pos,
node_color=node_color,
with_labels=True,
node_size=1600,
font_size=8,
arrowsize=10,
edge_color="#999",
alpha=0.85,
)
plt.title(title)
plt.axis("off")
plt.show()
|
9. Leiden 커뮤니티 탐지
그래프를 커뮤니티 단위로 나눈다.
1
2
3
4
5
6
7
8
9
10
11
12
| from graspologic.partition import leiden
def detect_comunities(G):
partitions = leiden(G.to_undirected())
communities = defaultdict(list)
for node, cid in partitions.items():
communities[cid].append(node)
return dict(communities)
comms = detect_comunities(G)
|
10. 커뮤니티 요약
커뮤니티 요약은 검색에 사용할 또 하나의 가공된 문서다. 요약에 포함된 주장과 원본 관계의 연결을 확인하고, 요약에서 빠진 세부 사항이 필요한 질문에는 어떤 근거를 추가로 전달할지 생각해야 한다.
커뮤니티 내부 엔티티와 관계를 LLM에게 요약시킨다.
1
2
3
4
5
6
7
8
| SUMMARY_PROMPT = """아래는 지식 그래프의 한 커뮤니티에 속한 엔티티와 관계 목록입니다.
이 커뮤니티의 주제와 주요 사실을 4~6문장의 한국어 단락으로 요약해 주세요.
엔티티:
{entities}
관계:
{relations}"""
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| def summarize_community(G: nx.DiGraph, nodes: list[str]) -> str:
nodes_set = set(nodes)
ent_lines = [f"- {n} ({G.nodes[n].get('type', '?')})" for n in nodes]
rel_lines = []
for u, v, d in G.edges(data=True):
if u in nodes_set and v in nodes_set:
for rel in (d.get("relations") or [""]):
rel_lines.append(f"- {u} --[{rel}]--> {v}")
prompt = SUMMARY_PROMPT.format(
entities="\n".join(ent_lines) or "(없음)",
relations="\n".join(rel_lines) or "(없음)",
)
return llm.invoke([HumanMessage(content=prompt)]).content
|
11. index 저장/로드
1
2
3
4
5
6
7
8
| INDEX_PATH = Path("mini_graphrag_index_0611.pkl")
index = {
"graph": G,
"communities": comms,
"summaries": summaries,
"chunks": chunks,
}
|
1
2
3
4
5
6
7
8
| def save_index(index, path=INDEX_PATH):
with open(path, "wb") as f:
pickle.dump(index, f)
def load_index(path=INDEX_PATH):
with open(path, "rb") as f:
return pickle.load(f)
|
12. local_search
질문에 들어 있는 엔티티를 찾아 주변 hop과 원문 chunk를 모아 답변한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| def local_search(question, index, hops):
G, chunks = index["graph"], index["chunks"]
matched = [n for n in G.nodes if n in question]
if not matched:
return "질문에 해당되는 entity 없음. Global search를 시도해 보세요"
nodes = set(matched)
for _ in range(hops):
new = set()
for n in nodes:
new |= set(G.successors(n)) | set(G.predecessors(n))
nodes |= new
chunk_ids = set()
for n in nodes:
chunk_ids |= set(G.nodes[n].get("source_chunks", []))
chunk_text = "\n\n".join(f"[chunk {i}] {chunks[i]}" for i in sorted(chunk_ids))
edge_lines = []
for u, v, d in G.edges(data=True):
if u in nodes and v in nodes:
for rel in (d.get("relations") or [""]):
edge_lines.append(f"({u}) --[{rel}]--> ({v})")
sys = SystemMessage(content="주어진 컨텍스트만 사용해서 한국어로 답하세요. 관계 정보로 다중 홉 추론도 가능합니다.")
user = HumanMessage(content=f"질문: {question}\n\n관계:\n{chr(10).join(edge_lines)}\n\n청크:\n{chunk_text}")
return llm.invoke([sys, user]).content
|
13. global_search
전체 주제 질문은 커뮤니티 요약을 모두 종합해서 답한다.
1
2
3
4
5
6
7
8
9
10
| def global_search(question, index) -> str:
all_summary_text = "\n\n".join(
f"[C{cid}]\n{s}" for cid, s in sorted(index["summaries"].items())
)
sys = SystemMessage(content=(
"당신은 지식 그래프의 커뮤니티 요약을 종합해 답하는 전문가입니다. "
"모든 요약을 비교/종합해 한국어로 답하세요."
))
user = HumanMessage(content=f"질문 : {question}\n\n== 커뮤니티 요약 ==\n{all_summary_text}")
return llm.invoke([sys, user]).content
|
14. 핵심 개념 총정리
| 단계 | 역할 |
|---|
| chunking | 긴 코퍼스를 추출 단위로 분할 |
| extract_one_chunk | chunk에서 엔티티/관계 추출 |
| merge_chunk_results | chunk별 KG를 하나로 병합 |
| source_chunks | 엔티티/관계의 원문 근거 위치 |
| resolve_entities | alias 정규화 |
| weight | 여러 chunk에서 반복된 정도 |
| Leiden | 그래프 커뮤니티 탐지 |
| community summary | global search용 요약 단위 |
| local_search | 특정 엔티티 주변 검색 |
| global_search | 전체 코퍼스 주제 종합 |
15. 자주 나오는 실수/주의사항
실수 1: source chunk를 저장하지 않음
1
| G.add_node(name, type=..., source_chunks=[ci])
|
GraphRAG 답변에는 원문 근거가 필요하다.
실수 2: alias 정규화 후 edge 정보 손실
1
| 같은 edge가 합쳐질 때 relations/source_chunks를 병합해야 한다.
|
실수 3: local_search에서 엔티티 매칭 실패
1
| matched = [n for n in G.nodes if n in question]
|
표현이 다르면 매칭이 안 되므로 실제 시스템에서는 entity linker가 필요하다.
1
| 세부 질문은 local search, 전체 경향 질문은 global search가 적합하다.
|
16. 인덱스의 생성 과정까지 추적하기
저장한 인덱스에는 입력 코퍼스와 추출·정규화 설정을 연결해 둔다. 같은 코드로 다시 실행해도 원문이나 프롬프트가 달라졌다면 결과를 같은 버전으로 취급하기 어렵다.
Local과 Global의 결과를 비교할 때는 답변 문장뿐 아니라 사용한 관계와 요약을 함께 확인한다. 누락이 검색 단계에서 발생했는지 앞선 커뮤니티 요약에서 생겼는지 구분하면 재인덱싱이 필요한 범위도 좁힐 수 있다.