RAG 系列(十六):Graph RAG——用知识图谱解决多跳关系问题

向量检索的关系盲点

前面几篇从各个角度优化了检索质量:更好的分块、Rerank 重排序、查询改写、CRAG 纠偏。但有一类问题,这些方法都帮不上多少忙:

需要横跨多个实体推理的问题。

举个例子:

"bge-large-zh-v1.5 和 bge-reranker-v2-m3 都来自哪个机构?各自在 RAG 中扮演什么角色?"

向量检索会找到提到 BAAI 或 bge 的文档片段------这没问题,两个模型都会出现。但检索的语义是"找相似",不是"找关系"。LLM 拿到的是两段分散的文本,需要自己去理解"它们都来自 BAAI"这个关联。

再复杂一点:

"从 RAG 到 CRAG,检索质量评估经历了哪些演进步骤?"

这个问题要求按技术演进顺序把 RAG → Rerank → Self-RAG → CRAG 串联起来。向量检索找到的是语义最相似的 top-4 文档,不保证是这条演进链上的节点。

Graph RAG 的切入点:把文档中的实体和关系显式提取出来,构建成知识图谱。检索时不是"找最像的文档",而是"从问题实体出发,沿关系边遍历图"------这天然适合多跳推理。


知识图谱的核心概念

知识图谱的基本单位是三元组

复制代码
(头实体,关系,尾实体)

例如:

sql 复制代码
BAAI         --[开发]--> bge-large-zh-v1.5
BAAI         --[开发]--> bge-reranker-v2-m3
bge-large-zh-v1.5  --[用于]--> 向量检索
bge-reranker-v2-m3 --[用于]--> 重排序

把这些三元组组织成有向图(NetworkX DiGraph),"BAAI 开发了哪些模型"这个问题就变成了:找到 BAAI 节点,列出所有出边。


Graph RAG 的完整流程

css 复制代码
构建阶段(离线):
  文档 → LLM 提取三元组 → NetworkX 有向图
  文档 → Embedding → ChromaDB 向量索引

查询阶段(在线):
  问题
    ↓
  LLM 提取问题实体(如 BAAI、bge-large-zh-v1.5)
    ↓
  模糊匹配图节点(子串匹配)
    ↓
  BFS 2跳遍历:seed_nodes → 邻居 → 邻居的邻居
    ↓
  组装三元组上下文(图遍历结果 + top-2 向量文档补充)
    ↓
  LLM 生成答案

关键设计:Graph RAG 不是纯图检索,而是图遍历上下文 + 向量检索的混合。纯图遍历有"实体边界"------如果问题中的实体没被提取进图,就会漏检;向量检索兜底覆盖这类盲区。


实现:三元组提取

先踩了一个坑

LangChain 提供了 LLMGraphTransformer,专门用于从文本提取图结构。我最初使用它:

python 复制代码
from langchain_experimental.graph_transformers import LLMGraphTransformer
graph_transformer = LLMGraphTransformer(llm=llm)
graph_docs = graph_transformer.convert_to_graph_documents([doc])

结果 12 篇文档全部报错:

typescript 复制代码
Invalid JSON: invalid number at line 1 column 2
[type=json_invalid, input_value='- Node: RAG (Retrieval-A...']

原因:LLMGraphTransformer 要求 LLM 返回严格的 JSON 格式,但 GLM-4-flash 返回的是文本列表(- Node: xxx),Pydantic 解析失败。

改用自定义 prompt

放弃 JSON,改用更宽松的分隔符格式------实体A | 关系 | 实体B,每行一个三元组,split("|") 解析,不依赖 JSON,容错性强:

python 复制代码
TRIPLE_EXTRACT_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "从以下文本中提取实体和关系,输出三元组列表。\n"
     "格式要求:每行一个三元组,格式严格为:实体A | 关系 | 实体B\n"
     "规则:\n"
     "- 实体用名词短语,不加括号或引号\n"
     "- 关系用动词短语,如:使用、包含、由...提出、适用于、优于\n"
     "- 每行只输出三元组,不要编号,不要解释,不要其他内容\n"
     "- 每篇文档提取8-15个三元组\n\n"
     "示例输出(格式参考):\n"
     "RAG | 使用 | 向量检索\n"
     "RAGAS | 由...提出 | Es等人\n"
     "Chroma | 适用于 | 本地开发"),
    ("human", "文本:\n{text}"),
])

def extract_triples(text: str) -> list[tuple[str, str, str]]:
    raw = triple_chain.invoke({"text": text})
    triples = []
    for line in raw.strip().splitlines():
        parts = [p.strip() for p in line.split("|")]
        if len(parts) == 3 and all(parts):
            triples.append((parts[0], parts[1], parts[2]))
    return triples

构建 NetworkX 图

python 复制代码
KG = nx.DiGraph()

for doc in DOCUMENTS:
    triples = extract_triples(doc.page_content)
    for head, rel, tail in triples:
        KG.add_node(head, source=doc.metadata["source"])
        KG.add_node(tail, source=doc.metadata["source"])
        KG.add_edge(head, tail, relation=rel)

12 篇文档最终提取出:176 个节点,139 条边


实现:BFS 图遍历检索

python 复制代码
def graph_retrieve(question: str, graph: nx.DiGraph, hops: int = 2):
    # Step 1: 从问题提取实体
    entities = extract_entities(question)   # LLM 输出,每行一个实体

    # Step 2: 模糊匹配图节点(子串匹配)
    seed_nodes = []
    for entity in entities:
        entity_lower = entity.lower()
        for node in graph.nodes:
            if entity_lower in node.lower() or node.lower() in entity_lower:
                seed_nodes.append(node)

    if not seed_nodes:
        return []   # 无匹配,退回向量检索兜底

    # Step 3: BFS k 跳遍历
    visited = set(seed_nodes)
    frontier = set(seed_nodes)
    for _ in range(hops):
        next_frontier = set()
        for node in frontier:
            neighbors = set(graph.successors(node)) | set(graph.predecessors(node))
            next_frontier |= neighbors - visited
        visited |= next_frontier
        frontier = next_frontier

    # Step 4: 组装三元组文本作为上下文
    triples = [
        f"{u} --[{data['relation']}]--> {v}"
        for u, v, data in graph.edges(data=True)
        if u in visited or v in visited
    ]

    return [Document(page_content=
        f"[图谱实体]: {', '.join(list(visited)[:20])}\n\n"
        f"[图谱关系]:\n" + "\n".join(triples[:40])
    )]

BFS 2跳的含义:从种子实体出发,收集直接邻居(1跳)和邻居的邻居(2跳)。对"BAAI 开发的模型"这类问题,1跳就够;对"bge-large-zh-v1.5 和 bge-reranker-v2-m3 都来自哪里、各自用于什么",需要 2 跳才能连通 BAAI → 两个模型 → 各自的用途。


实验结果

测试集设计

8 条问题分两类:

类型 数量 代表问题
单跳事实题 2 "RAGAS 包含哪四个指标?"
多跳关系题 6 "bge-large-zh-v1.5 和 bge-reranker-v2-m3 分别来自哪里、用于什么?"

多跳题是 Graph RAG 的主场,也是向量检索的弱项。

RAGAS 指标对比

diff 复制代码
======================================================================
  RAGAS 指标对比(向量 RAG vs Graph RAG)
======================================================================

  指标                    向量 RAG     Graph RAG    变化
  ──────────────────────────────────────────────────────
  context_recall          0.812        0.750     ↓-0.062
  context_precision       0.729        0.948     ↑+0.219  ◀
  faithfulness            0.865        0.883     ↑+0.018
  answer_relevancy        0.536        0.465     ↓-0.071
======================================================================

context_precision +0.219,这是本系列中向量检索和非向量方法之间最明显的精度差距之一。


结果解读

为什么 context_precision 大幅提升?

context_precision 衡量的是:送给 LLM 的文档中,有多少是真正相关的。

向量检索返回的是语义最相似的 top-4 文档片段------相似不等于精确。问"BAAI 开发了哪两个模型",向量检索会拉回所有提到 BAAI 或 BGE 的段落,里面有大量无关内容(模型维度、榜单排名、使用场景......)。

图遍历不同。它从 BAAI 节点出发,只走到直接相关的子节点,输出:

sql 复制代码
BAAI --[开发]--> bge-large-zh-v1.5
BAAI --[开发]--> bge-reranker-v2-m3
bge-large-zh-v1.5 --[用于]--> Embedding 检索
bge-reranker-v2-m3 --[用于]--> 重排序

这 4 条三元组直接命中问题,没有噪声,context_precision 自然高。

为什么 context_recall 轻微下降?

图遍历有"实体边界":只能展开已被提取进图的实体的邻居。如果某个相关事实存在于文本中,但三元组提取步骤没有把对应实体提取进图,那条信息就永远找不到了。

向量检索依赖语义相似度,覆盖面更广,不要求事先结构化------没有显式的"边界"。这就是为什么 context_recall 向量检索更高:它的触达范围更宽,哪怕有点模糊。

这是精度-召回率的经典权衡。 Graph RAG 主动选择了精度侧:宁可少返回一些,但返回的都要精准。

为什么 answer_relevancy 下降?

图遍历上下文是结构化的三元组列表:

css 复制代码
RAG --[使用]--> 向量检索
向量检索 --[实现于]--> ChromaDB
...

向量检索返回的是完整语义段落,语言更自然。LLM 从三元组推导自然语言答案,表达上不如直接从段落中提炼流畅------这导致 answer_relevancy 的评分略低。

这是图谱上下文格式的固有局限,可以通过在生成 prompt 里加一步"把三元组转化为连贯段落"来缓解。


适用场景与局限性

Graph RAG 最适合:

  • 文档有复杂关系网络:技术文档、知识库、产品手册------实体之间有大量显式关系
  • 问题需要跨实体推理:"X 和 Y 的关系是什么"、"Z 是哪个系列的一部分"
  • 答案不在单一文档内:需要把多个文档的知识"拼"起来

需要权衡的地方:

  • 三元组提取成本:每篇文档都要调用一次 LLM 提取三元组,构建阶段比向量索引贵
  • 实体提取质量决定上限:如果 LLM 没能提取出关键实体,图遍历就会漏检
  • 对 QA 场景收益有限:如果问题大多是单跳事实查询("X 是什么"),向量检索的 recall 优势更明显
  • 答案流畅度:三元组格式的上下文会让 LLM 的答案略显生硬,需要 prompt 工程补偿

完整代码

代码已开源:

github.com/chendongqi/...

核心文件:

  • graph_rag.py --- 完整实现,含图谱构建、BFS检索、RAGAS评估

运行方式:

bash 复制代码
git clone https://github.com/chendongqi/llm-in-action
cd 16-graph-rag
cp .env.example .env
pip install -r requirements.txt
python graph_rag.py

小结

本文实现了 Graph RAG,核心发现:

  1. context_precision +0.219,是图遍历"按需取边"的直接结果------从种子实体出发,只带回路径上的关系,不带无关内容
  2. context_recall 略降 -0.062,是图谱边界的代价------未提取进图的实体和关系是盲区,向量检索没有这个限制
  3. 实现中最重要的决策 :放弃 LLMGraphTransformer,改用自定义 实体 | 关系 | 实体 格式,解决了 GLM-4-flash 不稳定输出 JSON 的问题
  4. 混合策略是正确的:纯图遍历 + 向量检索补充,精度和召回率可以兼顾

从这个系列走到这里,一个规律越来越清晰:没有万能的 RAG 优化方法,每种方案都在某个维度得分,同时在另一个维度付出代价。向量检索召回全但精度不足,Rerank 提升精度但成本增加,Graph RAG 精度极高但召回有边界......选择哪种,取决于你的知识库结构和用户提问模式。


参考资料

相关推荐
冬奇Lab1 小时前
一天一个开源项目(第101篇):OpenHuman - 真正懂你的本地优先个人 AI 超级助手
人工智能·开源·资讯
云上码厂1 小时前
专业的学术会议 / 讲座视频与幻灯片托管、回放平台(可以使用SlidesLive 学英语入门清单)
人工智能
无心水1 小时前
【Hermes:安全、权限与生产环境】40、运行 Hermes 前的生命线:安全审计清单与 11 个必须检查的配置项
人工智能·安全·mcp协议·openclaw·养龙虾·hermes·honcho
温九味闻醉1 小时前
关于腾讯广告算法大赛2025项目分析3-重读
人工智能·机器学习
十铭忘2 小时前
AI画架构图的方法
人工智能
chatexcel2 小时前
AI知识库教程:基于ChatExcel实现规则文档、Excel数据与业务分析联动
人工智能·excel
Hali_Botebie2 小时前
【图卷积网络】GCN是AXΘ 和CNN是AX
网络·人工智能·cnn
还在忙碌的吴小二2 小时前
今日AI行业热点新闻
人工智能
Bode_20022 小时前
AIoT 技术难点
人工智能·制造