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)

两个关键设计:

  1. 所有实体一个 Entity 标签,不按类型拆成 ProfessorCourse 多个标签。因为 LLM 抽取的实体类型是动态的——今天可能抽出 Scholarship,明天可能抽出 Competition。拆标签的话每次都要改 schema。
  2. 每条边都记 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 从"只能看一段话"变成了"先看目录,再看章节"。