Graph RAG
一、先搞清楚普通 RAG 的短板
普通 RAG 的流程:用户问 → 向量库搜 chunk → 把 chunk 塞给 LLM → 回答。
这个流程对付"数据结构期末考试考什么"没问题——答案就在课程大纲那个 chunk 里。
但对付"计算机学院整个课程体系是怎么设计的?各方向的先修关系合理吗?"就废了。答案不在任何一个 chunk 里——分散在几十门课的课程大纲里。向量检索能捞出十几个 chunk,但 LLM 拿着这堆碎片拼不出全局视角。
普通 RAG 是"查一段话回答一个问题"。Graph RAG 是"先把所有文档转成一张图,理解图的结构,再回答"。
区别一目了然:
普通 RAG: 问 → chunk检索 → LLM拼答案
Graph RAG: 问 → 社区检索 → 捞社区的全局摘要 + 细节 → LLM有全局视野再回答
二、Graph RAG 的整体流程
分两大阶段:离线建图(只做一次)、在线检索(每次查询做)。
===== 离线阶段 =====
所有文档
│
├─ Step 1: 切 chunk,每段送 LLM 抽实体和关系
│ 输出: 一堆 (实体, 关系, 实体) 三元组
│
├─ Step 2: 把三元组建成一张大图,顺便合并重复实体
│ 输出: 一张图 G = {节点: 实体, 边: 关系}
│
├─ Step 3: 社区检测(把大图切成小图)
│ 输出: 每个节点属于哪个社区
│
└─ Step 4: 每个社区让 LLM 写一份摘要
输出: {社区ID → 社区摘要}
===== 在线阶段 =====
用户查询
│
├─ Step 5: 在社区摘要里搜,找最相关的社区
│
├─ Step 6: 捞社区的摘要 + 社区里的实体关系 + 原始 chunk
│
└─ Step 7: 汇总给 LLM 生成回答
三、Step 1: 抽实体和关系
把每个文档 chunk 送 LLM,让它把里面的"谁""是什么""跟谁什么关系"抽出来。
输入: "王建国教授负责数据结构与算法课程(CS201,4学分),
先修课为程序设计基础(CS101)。王教授属于计算机科学系。"
LLM 抽取输出:
entities: [
{id:e1, name:"王建国", type:Professor},
{id:e2, name:"数据结构与算法", type:Course, props:{code:"CS201", credits:4}},
{id:e3, name:"程序设计基础", type:Course, props:{code:"CS101"}},
{id:e4, name:"计算机科学系", type:Department},
]
relations: [
{source:e1, target:e2, type:teaches},
{source:e2, target:e3, type:has_prerequisite},
{source:e1, target:e4, type:belongs_to},
]
伪代码:
function 抽取(text):
prompt = """
从文本中抽取实体和关系。不用预设类型,从文本自己判断。
输出格式: {entities:[{id,name,type,props}], relations:[{source,target,type}]}
文本: {text}
"""
result = LLM(prompt)
return result.entities, result.relations
不需要预先定义有哪些实体类型。 今天从新闻里抽出 Scholarship,明天从通知里抽出 Competition——LLM 自己判断,代码不用改。
对所有 chunk 跑一遍,得到几百条原始三元组。
四、Step 2: 建图 + 去重
同一个实体(比如"王建国")可能出现在 5 个不同 chunk 里,每次抽取都产生一个节点。需要把相同的实体合并。
去重策略很简单:名称完全相同 → 合并;名称相似度 > 阈值 → 也合并。
function 建图(所有chunk的抽取结果):
G = 空图
for each chunk的(entities, relations):
for each entity in entities:
标准名 = 归一化(entity.name) // "王建国教授" → "王建国"
已存在 = G中按名称查找(标准名)
if 已存在:
G[已存在].描述 += entity的新描述
G[已存在].来源chunks += chunk.id
else:
G.添加节点(id=新ID, name=标准名, type=entity.type)
for each relation in relations:
src = 映射到G中的节点ID(relation.source)
tgt = 映射到G中的节点ID(relation.target)
G.添加边(src, tgt, type=relation.type)
去重后的图大概长这样:
王建国 ──[teaches]──→ 数据结构与算法 ──[has_prerequisite]──→ 程序设计基础
│ │
└──[belongs_to]──→ 计算机科学系 ←──[offers]───────────────────┘
三元组怎么存——Neo4j 落库
我用的是 Neo4j。为什么不用 NetworkX(内存图)或者直接用字典存?因为 NetworkX 在图上跑多跳查询时会越来越慢,而且数据在内存里一重启就没了。Neo4j 是专门为图设计的数据库——存三元组、沿着边遍历、多跳查询都是它的原生操作。
Schema 设计:
// 实体节点 —— 所有实体一个标签,用 type 区分
CREATE (n:Entity {
id: "e1", // 唯一ID
name: "王建国", // 归一化后的名称
category: "Professor", // 实体类型:Professor / Course / Department
aliases: ["王建国教授"], // 别名列表,用于模糊匹配
source_chunks: ["chunk_001", "chunk_015"], // 来源 chunk ID
created_at: 1710000000
})
// 关系边
CREATE (e1)-[:TEACHES {since: "2020", chunk_ref: "chunk_001"}]->(e2)
两个关键设计:
- 所有实体一个
Entity标签,不按类型拆成Professor、Course多个标签。因为 LLM 抽取的实体类型是动态的——今天可能抽出Scholarship,明天可能抽出Competition。拆标签的话每次都要改 schema。 - 每条边都记
chunk_ref——知道这条关系是从哪个 chunk 抽出来的。更新时靠它做增量,出问题时靠它回溯。
批量写入:
from neo4j import GraphDatabase
def build_graph(extracted_triples: list[dict]):
"""把 LLM 抽出来的三元组写入 Neo4j"""
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
with driver.session() as session:
for triple in extracted_triples:
# 用 MERGE 自动去重:节点名相同就合并,边已存在就跳过
session.run("""
MERGE (src:Entity {name: $src_name})
ON CREATE SET src.id = $src_id, src.category = $src_type
ON MATCH SET src.aliases = coalesce(src.aliases, []) + $src_alias
MERGE (tgt:Entity {name: $tgt_name})
ON CREATE SET tgt.id = $tgt_id, tgt.category = $tgt_type
MERGE (src)-[r:RELATES {type: $rel_type}]->(tgt)
ON CREATE SET r.chunk_ref = $chunk_id
""", {
"src_name": triple["source_name"],
"src_id": triple["source_id"],
"src_type": triple["source_type"],
"src_alias": triple.get("source_alias", ""),
"tgt_name": triple["target_name"],
"tgt_id": triple["target_id"],
"tgt_type": triple["target_type"],
"rel_type": triple["relation"],
"chunk_id": triple["chunk_id"],
})
MERGE 的作用:如果节点/边已存在就跳过,不存在就创建。这是 Neo4j 版的"去重+插入"一次搞定。
三元组怎么维护——增量更新
文档不是一次上传就完了。教务处在学期中会改课程大纲、更新教师信息。每次新文档进来,如果全部重新抽取一遍明显不行。
我的做法是以 chunk 为粒度增量更新:
新文档上传
│
├─ 切 chunk → 每个 chunk 算 MD5
│
├─ MD5 命中了旧 chunk → 内容没变,跳过
│
└─ MD5 没命中 → 新 chunk 或内容变了
│
├─ LLM 抽取这个 chunk 的实体和关系
│
├─ 删掉 Neo4j 中所有引用这个旧 chunk_id 的边
│ MATCH ()-[r {chunk_ref: "chunk_015"}]->() DELETE r
│
└─ 写入新三元组(MERGE 自动合并实体节点)
只更新变化的 chunk,不是全量重建。跟 同一份文件多个版本的管理策略 的思路一致——chunk 级增量,新旧分开。
实体节点本身不删——只删边。即使某门课这学期没人教了,节点还在,只是暂时没有 TEACHES 边指向它。这样历史查询("王建国以前教过什么课?")还能追溯。
图怎么查——查询实战
存好之后,两类查询:
局部查询——沿着边找邻居:
-- "数据结构谁教的?"
MATCH (teacher:Entity {category: "Professor"})
-[:TEACHES]->
(course:Entity {name: "数据结构与算法"})
RETURN teacher.name, teacher.aliases
-- "数据结构的先修课是什么?"
MATCH (course:Entity {name: "数据结构与算法"})
-[:HAS_PREREQUISITE]->
(prereq:Entity {category: "Course"})
RETURN prereq.name, prereq.aliases
这些是单跳查询,图数据库的优势还不明显。真正的价值在多跳:
-- "要学AI方向,需要先学哪些课?"(多跳遍历)
MATCH path = (start:Entity {name: "人工智能"})
-[:HAS_PREREQUISITE*1..4]->(prereq:Entity)
RETURN [node in nodes(path) | node.name] AS 课程链条
ORDER BY length(path)
*1..4 表示沿着 HAS_PREREQUISITE 边走 1 到 4 步。关系数据库写这种递归查询要用 WITH RECURSIVE,Neo4j 就一行。
全局查询——在社区摘要中 Map-Reduce:
def search_by_community(query: str, community_summaries: dict):
"""Map: 在所有社区摘要上向量搜索,找最相关的"""
query_emb = embed(query)
# 算每个社区摘要的相似度
scored = []
for cid, summary in community_summaries.items():
score = cosine_similarity(query_emb, embed(summary))
if score > 0.6:
scored.append((cid, score))
scored.sort(key=lambda x: -x[1])
# Reduce: 捞 top 3 社区的细节
context_parts = []
for cid, _ in scored[:3]:
# 从 Neo4j 捞社区内的实体和关系
entities = neo4j_query(f"""
MATCH (e:Entity)
WHERE e.community_id = '{cid}'
RETURN e.name, e.category, e.aliases
""")
relations = neo4j_query(f"""
MATCH (a:Entity)-[r]->(b:Entity)
WHERE a.community_id = '{cid}' AND b.community_id = '{cid}'
RETURN a.name, type(r) as relation, b.name
""")
context_parts.append({
"summary": community_summaries[cid],
"entities": entities,
"relations": relations,
})
return context_parts
五、Step 3: 社区检测——把大图切成小图
图里有上百个节点,让 LLM 一次看全图是不可能的。社区检测算法自动把图切成"内部连接紧密、之间连接稀疏"的小块——每块就是一个"语义主题"。
function 社区检测(G):
运行 Leiden 算法(G)
// Leiden 快且保证社区内部一定连通(Louvain 不保证)
返回 {
社区1: {节点A, 节点B, 节点C, ...}, // 比如"数据结构课程群"
社区2: {节点D, 节点E, 节点F, ...}, // 比如"人工智能课程群"
社区3: {节点G, 节点H, ...}, // 比如"计算机科学系教师"
...
}
不用纠结算法细节。只需要知道:输入一张图,输出每个节点属于哪个社区。 Python 用 graspologic 库,3 行代码搞定。
六、Step 4: 给每个社区写摘要——"导读卡"
这是 Graph RAG 最关键的一步。每个社区,LLM 写一份摘要。这份摘要不是给用户看的,是给检索系统当索引用的。
function 生成社区摘要(社区ID, 社区内的实体列表, 社区内的关系列表):
prompt = """
下面是一个知识图谱子图的内容。
写一份摘要,包含:
1. 这个子图主要涉及什么主题?
2. 有哪些核心实体?
3. 最重要的关系有哪些?
4. 回答什么问题时应该参考这个子图?(写关键词)
实体: {实体列表}
关系: {关系列表}
"""
return LLM(prompt)
生成出来的摘要示例:
社区 #3:
主题: "计算机科学系教师团队与课程分配"
核心实体: [王建国, 数据结构与算法, 程序设计基础, 计算机科学系]
关键关系: [王建国讲授数据结构, 王建国属于计算机科学系]
查询触发词: ["王建国", "数据结构", "计算机科学系老师",
"谁教", "课程安排", "教师"]
到这一步,离线建图就完成了。建好的产物是:一张图 + 一组社区摘要。
七、Step 5-7: 在线检索——Map-Reduce
用户提问时,Graph RAG 分两步走:
Map 阶段:在所有社区摘要里找相关的
function Map(用户查询):
query_embedding = embedding(用户查询)
相关社区 = []
for each (社区ID, 社区摘要) in 所有摘要:
摘要_embedding = embedding(社区摘要)
相似度 = cosine(query_embedding, 摘要_embedding)
相关社区.append((社区ID, 相似度))
返回 按相似度排序取前3个
Reduce 阶段:把相关社区的内容捞出来,汇总
function Reduce(查询, 相关社区列表):
上下文 = []
for each (社区ID, _) in 相关社区:
摘要 = 取社区摘要(社区ID)
// 捞社区的实体和关系
实体列表 = 取社区内实体(社区ID)
关系列表 = 取社区内关系(社区ID)
// 捞实体对应的原始文档chunk
chunks = []
for each 实体 in 实体列表:
chunks += 实体.来源chunks
上下文.append({
摘要: 摘要,
关键实体: 实体列表.前10个,
关键关系: 关系列表.前10个,
原始文档: chunks.前5个,
})
return LLM(f"根据以下上下文回答: {上下文}\n问题: {查询}")
为什么分两步? Map 是轻量的——只比向量,社区数量通常几十个,毫秒级。Reduce 才真正捞内容,但只捞前 3 个社区的,数据量可控。
八、全局查询 vs 局部查询
不是所有问题都需要 Map-Reduce。查询分两类:
function 路由(查询):
if 查询包含 "整体" "全局" "体系" "关系" "结构":
返回 MapReduce检索(查询) // 全局查询,走社区摘要
else:
返回 直接查图(查询) // 局部查询,在图中找实体邻居
局部查询: "数据结构谁教的?"
→ 图中找到"数据结构"节点 → 沿[teaches]边 → 找到"王建国" → 回答
全局查询: "学院课程体系合理吗?"
→ Map 社区摘要 → 命中"课程群"、"教师团队"等社区
→ Reduce 捞细节 → LLM 看完几个社区的全局结构再回答
九、效果:什么时候 Graph RAG 值得用
| 查询类型 | 普通 RAG | Graph RAG | 原因 |
|---|---|---|---|
| "数据结构考试考什么" | ✓ | ✓ | 答案在单个 chunk 里,不用图 |
| "谁教数据结构" | 凑合 | ✓ | 图里一条边直接给出答案 |
| "AI方向的先修课有哪些" | 碎片 | ✓ | 需要沿多条边遍历 |
| "学院整体课程体系如何" | ✗ | ✓ | 需要跨社区全局视野 |
Graph RAG 不是替代普通 RAG——它是加了一层全局理解。如果 90% 的查询都是"XX 是什么"这种点查询,别折腾 Graph RAG;如果经常有"整体""关系""体系"类的问题,就值得做。
十、成本
建图是一次性的:
- 30 份文档 → 200 个 chunk → LLM 调用 200 次抽取 ≈ ¥2
- 15 个社区 × LLM 写摘要 ≈ ¥0.15
- 合计 ≈ 一顿午饭钱
每次查询:
- Map:几十次向量比较,毫秒级
- Reduce:3 个社区的摘要 + 实体关系 + 原始 chunk,≈ 2000 token
- LLM 生成回答 ≈ ¥0.01
- 跟普通 RAG 几乎一样
十一、总结
Graph RAG 的核心思路就一句话:建图 → 切社区 → 写摘要 → 用摘要做索引检索。
不是让 LLM 直接看整张图(看不完),而是先把图切成小块、每块写个导读、检索时先看导读再进入细节。这让 LLM 从"只能看一段话"变成了"先看目录,再看章节"。