2026/10/29 10:44:10

GraphRAG实战:知识图谱如何补齐RAG多跳推理短板

GraphRAG实战:知识图谱如何补齐RAG多跳推理短板 简介《Building AI Agents with LLMs, RAG, and Knowledge Graphs》是一本面向具备机器学习与自然语言处理基础、熟悉Python并希望将AI系统落地于实际业务场景的开发者和研究人员的英文专著。书中从Transformer架构与注意力机制讲起梳理LLM的演化脉络与主要局限随后深入剖析RAG技术的发展路径既说明朴素RAG的不足也系统介绍高级RAG的分层索引、查询转换、重排序等优化策略以及模块化RAG的设计思路。更进一步作者引导读者使用LLM自动构建知识图谱并将其与AI Agent集成以增强语义理解与复杂推理能力适合医疗、法律、金融等需要可解释、可追溯问答的场景。资源为单个PDF文件压缩包大小约46.39MB便于移动阅读与检索目前已有163人学习浏览对探索HybridRAG架构、多跳查询与关系挖掘的实践者有较高参考价值。1. LLM、RAG、知识图谱组合的智能体先从一次多跳翻车说起把 LLM、RAG 和 Knowledge Graphs 组合成 AI Agents第一步要承认RAG 不是万能药。同一个多跳问题——「某科技公司的创始人后来投资的另一家公司核心业务是什么」——纯向量 RAG 会从知识库里捞出好几段相似度 0.8 以上的碎片却拼不出横跨两个实体、三跳关系的完整链路。原因不难理解向量检索擅长找语义相似的段落但段落与段落之间的关系没有被显式建出来。再加上知识图谱之后事情就变了实体之间的关系是一条条可遍历的边多跳问题变成路径查询答案可以溯源。LLM 负责理解意图和生成回答RAG 负责把开放文档里的背景信息喂进来图谱负责把关系链钉死Agent 负责判断每一步该用哪个工具。这篇笔记按「建模 → 检索 → 编排 → 排错」的顺序讲一套可复现的链路适合正在做知识库问答、智能客服和企业搜索的工程师。2. 知识图谱建模先行schema 设计、三元组抽取与「先粗后细」原则很多团队在搭 Agent 时第一步就急着写 ReAct 循环结果知识没组织好工具再多也答不对。常见做法是先问一句我要回答的问题底层靠的是文本语义还是实体之间的关系如果是后者那就值得在向量库旁边补一个知识图谱。这一步的产出不是一个宏大本体而是一张能跑起来的最小 schema。2.1 纯向量 RAG 的三个瓶颈图谱补在哪一环第一个瓶颈是多跳。向量相似度只能告诉你「这段文本和问题像不像」它不会主动把「张一鸣创立了字节跳动」和「张一鸣投资了某公司」这两条信息串成一条路径。模型需要把所有相关段落一起喂进去靠注意力机制隐式拼接一旦中间某段没被召回到答案就断了。第二个瓶颈是知识更新。文档改了一个事实向量库需要重新切块、重新计算 embedding成本很高图谱只需要找到对应的那一条关系做一次局部 MERGE。第三个瓶颈是证据链不透明。纯 RAG 给出来的依据是一整段原文读者很难定位到「到底是哪个实体、哪条关系支撑了这个结论」。图谱把证据压缩成三元组任何答案都可以反查到一条路径。这也解释了 RAG 知识库和结构知识库的典型分界开放域总结、语义相近的问答靠 RAG 文本块就够了实体密集、逻辑链固定、需要精确回答「谁和谁什么关系」的场景图谱更合适。两者不是替代关系而是同一套 Agent 里的两条腿。维度向量 RAGGraphRAG检索单元文本块实体、关系、路径多跳推理靠 LLM 隐式拼接图遍历显式完成可解释性返回原文块返回三元组和路径更新成本重算 embedding局部 MERGE 关系适合场景开放问答、概述实体密集、逻辑链固定2.2 最小可行图谱实体类型、关系类型和属性怎么定先粗后细是图谱建模里最值得遵守的原则。很多人一上来就设计二十种实体、三十种关系结果 LLM 抽三元组时错误率飙升图变得稀疏查询经常断链。我一般会控制在 10 种关系以内用属性保存细节而不是拆出新的关系类型。用一个科技企业投资图谱举例。实体类型只需要三个Company、Person、Product。关系类型先定三种FOUNDED_BY公司由谁创立、INVESTED_IN公司投资了哪家公司、DEVELOPED公司研发了什么产品。至于「A 轮投资还是 B 轮投资」「从哪一年开始任职」全部放进关系属性里不单独建关系。这样的粒度足够支撑大多数多跳问答又不会让抽取任务难到模型胡编。在 Neo4j 里先建好约束和索引保证后续写入不会产生重复实体CREATE CONSTRAINT company_name IF NOT EXISTS FOR (c:Company) REQUIRE c.name IS UNIQUE; CREATE CONSTRAINT person_name IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; CREATE INDEX company_business IF NOT EXISTS FOR (c:Company) ON (c.business);这段 Cypher 的意图很直接Company 和 Person 的 name 是唯一键同名实体在 MERGE 时只会被复用不会被重复创建。第三个索引给 Company 的 business 属性建普通索引因为后续会有大量「按业务类型找公司」的过滤查询没有索引会全库扫描。注意这是 Neo4j 5.x 的语法旧版本需要用CREATE CONSTRAINT ON (c:Company) ASSERT c.name IS UNIQUE的写法。2.3 用 LLM 抽三元组提示词模板、JSON 校验与去重写入本地文本拆解这一步不一定要买商业抽取服务。用本地模型跑一个高约束提示词配合严格的 JSON 校验效果完全够用。提示词里要把实体类型、关系类型、输出格式全部锁死禁止模型发挥。triple_extract_prompt 你是知识图谱构建助手从文本中抽取实体关系三元组。 只允许使用以下实体类型Company, Person, Product。 只允许使用以下关系类型FOUNDED_BY, INVESTED_IN, DEVELOPED。 规则 1. 只抽取文本明确表达的事实禁止推断 2. 同名实体必须使用相同名称公司全称统一为简称 3. 输出 JSON 数组格式 [{source: 字节跳动, relation: FOUNDED_BY, target: 张一鸣, evidence: 字节跳动由张一鸣创立}] 文本 {text} 调用时把 temperature 设成 0优先使用支持 JSON 输出格式的模型接口解析失败就重试一次再失败就把这段文本跳过并记入日志。很多人忽略了 evidence 字段它在后面排错时非常有用——当图谱关系和原文对不上时翻 evidence 就能定位是哪次抽取写错了。写入 Neo4j 时关系类型来自白名单不要直接拼接用户输入。实体标签也需要按关系方向映射否则所有节点都变成同一种标签查询性能会退化。LABEL_MAP { FOUNDED_BY: (Company, Person), INVESTED_IN: (Company, Company), DEVELOPED: (Company, Product), } def write_triples(triples, driver): for t in triples: rel_type t[relation] s_label, t_label LABEL_MAP.get(rel_type, (Entity, Entity)) cypher f MERGE (s:{s_label} {{name: $source}}) MERGE (t:{t_label} {{name: $target}}) MERGE (s)-[r:{rel_type}]-(t) SET r.evidence coalesce(r.evidence, []) [$evidence] with driver.session() as session: session.run( cypher, sourcet[source], targett[target], evidencet[evidence], )这里的 MERGE 是关键操作实体不存在就创建存在就复用关系不存在就建立存在就追加 evidence 到数组。coalesce 的作用是兼容首次写入时 r.evidence 为 null 的情况。整个函数是幂等的同一个文档重复跑不会产生重复实体只会追加证据来源。3. 把 RAG 从「查段落」升级成「查关系」GraphRAG 检索管线与 Cypher 生成图谱建好之后下一步是把检索管线搭起来。GraphRAG 不是简单地把向量检索换成 Cypher而是三路召回同时跑向量负责语义相似Cypher 负责关系路径全文索引负责兜底实体名匹配。三路结果按来源标签拼在一起交给 LLM 做最终判断。3.1 混合检索向量召回、Cypher 查询与全文索引并行先说为什么不能只靠 Cypher。用户的问题往往是自然语言实体名可能带别名、简称比如「字节」「ByteDance」都能指同一家公司。Cypher 的等值匹配遇到这种情况直接返回空。所以图谱查询之前先建一个全文索引让它做模糊匹配。CREATE FULLTEXT INDEX entity_index IF NOT EXISTS FOR (n:Person | Company) ON EACH [n.name];混合检索的 Python 实现我一般把三路结果放进一个列表每一路都带上来源标签def hybrid_retrieve(question, entities, top_k5): results [] # 路1向量召回找语义相关的文本块 hits vector_index.query(question, top_ktop_k) results.append((vector, hits)) # 路2图查询按实体名找一跳关系 if entities: with driver.session() as session: rows session.run( MATCH (n)-[r]-(m) WHERE n.name IN $entities RETURN n.name AS s, type(r) AS rel, m.name AS t, properties(r) AS props LIMIT 50 , entitiesentities, ).data() results.append((graph, rows)) # 路3全文索引处理别名和不完整名称 fulltext_hits fulltext_search(question, top_k3) results.append((text, fulltext_hits)) return results三个参数需要根据场景调top_k 在 5 到 10 之间太小召回不足太大上下文会爆炸图查询的 LIMIT 设成 50防止一个热门实体带出几百条关系把提示词冲垮全文索引只取前 3 条因为它是兜底命中一个实体就够了。三路结果不需要合并去重保持独立LLM 看到「graph:」和「vector:」前缀时自然知道怎么对齐。3.2 自然语言转 Cypher意图分类和 few-shot 模板查询生成是 GraphRAG 里最容易翻车的一环。我的做法是先用一个轻量分类把问题分成三类实体属性型、关系路径型、开放总结型。前两类走图谱第三类直接走向量 RAG不要硬生成 Cypher。Cypher 生成的提示词里关键约束是「拿不准就返回 None」。这一步宁可少查不能乱查一条错误 Cypher 会把整个上下文污染。cypher_gen_prompt 你是 Neo4j 查询生成器。把问题转成 Cypher。 约束 1. 实体名不做精确匹配优先用 CONTAINS 或全文索引 2. 只读查询禁止写操作 3. 拿不准时返回 None不要编造查询 示例 问题字节跳动的创始人是谁 查询MATCH (c:Company)-[:FOUNDED_BY]-(p:Person) WHERE c.name CONTAINS 字节 RETURN p.name 问题{question} 查询 执行生成的 Cypher 时要捕获所有异常。语法错误、字段不存在、约束冲突任何一种都不该让整个流程中断。正确的做法是 try-except 包住执行任何异常都降级为纯向量 RAG同时把错误记录到日志里后续用它来改进提示词。3.3 端到端最小链路Ollama 本地模型 Neo4j 跑通 GraphRAG整套链路在 Mac 或者 Linux 上都能跑通。Neo4j 用 Docker 起一个社区版模型用 Ollama 拉一个 qwen2.5:7bembedding 用 nomic-embed-text。资源占用可控零基础也能跟着复现。from ollama import chat, embed def graph_rag_answer(question: str) - str: entities extract_entities(question) # 用 LLM 抽取问题里的实体名 cypher generate_cypher(question) # 3.2 的 few-shot 模板 ctx_blocks [] if cypher and cypher.strip() not in (None, ): with driver.session() as session: rows session.run(cypher).data() if rows: ctx_blocks.append(图谱证据:\n format_graph_rows(rows)) vec_rows vector_store.query(question, top_k5) ctx_blocks.append(文档证据:\n format_vec_rows(vec_rows)) final chat(qwen2.5:7b, [ {role: system, content: 结合图谱和文档证据作答。优先采用图谱证据文档证据只作背景补充。}, {role: user, content: f问题{question}\n\n证据\n \n.join(ctx_blocks)}, ]) return final[message][content]这一段把前面所有东西串起来了先抽实体、再生成 Cypher、执行图查询同时跑向量召回最后把两路证据拼进提示词让 LLM 生成。整套链路的成败不单看代码更看格式函数怎么处理空结果——graph_rag_answer 里如果 rows 为空图谱证据块干脆不出现LLM 就不会被空数据干扰。4. 让 Agent 自己决定查什么工具注册、ReAct 循环与状态管理检索链路稳定之后Agent 的作用是把选择权交给模型这个问题该查图还是该查文档还是两路都要。这一章不推荐直接用 LangChain 的 AgentExecutor黑匣子问题太严重工具返回格式、循环终止条件、错误恢复都不容易控制。手写一个轻量 ReAct 循环整套逻辑都摊在自己眼前。4.1 Agent 的四层结构内核、工具集、记忆与策略常见做法是把 Agent 拆成四个层次。内核是 LLM负责推理和生成参数不多但 temperature 和 max_tokens 直接决定行为稳定性。工具集是一个注册表每个工具包含描述、执行函数、最大调用次数三个字段。记忆分短期和长期短期是对话历史长期是图谱里已经确认的实体状态。策略层是 ReAct 循环加上路由规则决定「什么时候调用哪个工具什么时候必须停」。工具注册表用一个 dict 就能搞定TOOLS { graph_query: { desc: 查询知识图谱中的实体关系, run: graph_query, max_calls: 3, }, vector_rag: { desc: 检索文档片段补充背景, run: vector_rag, max_calls: 3, }, final: { desc: 给出最终答案, run: None, max_calls: 1, }, }每个工具都带 max_calls是为了防止同一个操作反复执行。手写的好处是这些计数逻辑完全可见不像框架那样把循环行为藏在抽象层里。4.2 手写一个轻量 ReAct 循环行动解析与终止条件ReAct 循环的本质是让 LLM 输出「Thought Action Action Input」代码负责解析并执行把结果作为 Observation 喂回去。循环一定要有硬终止条件否则模型陷入死循环时没有任何保护。def agent_loop(question, max_steps5): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: question}, ] for step in range(max_steps): reply llm_chat(messages, temperature0.2) action parse_action(reply) # 解析 LLM 输出的 JSON 或 XML if action[type] final: return action[answer] if action[type] not in TOOLS: messages.append({ role: user, content: 无效工具请从注册表中选择, }) continue try: obs TOOLS[action[type]][run](action.get(args, {})) except Exception as e: obs f工具执行失败{e} messages.append({ role: user, content: fObservation: {obs}, }) return 未能完成需要拆分成更具体的问题两个参数很关键。max_steps 设成 5超过直接结束而不是返回兜底答案——这么做是为了暴露出流程问题。temperature 设成 0.2太低模型不会探索太高行动格式会开始漂移。parse_action 失败时要把原始回复追加到上下文里让模型看到自己刚才输出了什么。4.3 多轮对话的状态管理实体状态池与上下文压缩多轮对话里最常见的坑是代词解析。「那家公司后来怎么样了」——「那家」指代上一轮提到的公司如果不做状态管理LLM 只能靠对话历史猜猜错的概率很高。我一般会维护一个实体状态池每轮把确认过的实体名和类型放进去下一轮优先用它补全指代。state_pool {} def resolve_entities(question, state_pool): if any(w in question for w in (它, 那家, 这个公司)): return state_pool.get(last_company) entities extract_entities(question) if entities: state_pool[last_company] entities[0] return entities上下文超过六轮之后直接把全部历史塞给 LLM 会稀释注意力。我一般会用另一个 LLM 调用把历史压成两段式摘要一段保存事实性结论另一段保存待办意图。实体状态池不参与压缩它始终保持结构化这样图谱查询永远有干净的输入。5. 避坑检索空、答案冲突、死循环与图片入库的 5 个排查记录这套链路跑起来之后真正耗时间的不是写代码是排错。这里整理五条血泪经验都是实际跑链路时反复踩过的问题按「现象 → 原因 → 解决」的方式来写。5.1 图谱里明明有关系查询却返回空现象在 Neo4j Browser 里手动查关系清清楚楚Agent 一跑返回空。原因九成是实体名不匹配。「字节跳动科技有限公司」和「字节跳动」在数据库里是两个节点「ByteDance」又是第三个节点。Cypher 的等值匹配完全失效。还有一种是关系方向写反了FOUNDED_BY 的箭头指向反了查询结果自然为空。解决不要依赖精确匹配查询前先做一次实体归一化维护别名表拿不准方向时先跑一个无方向探针确认关系确实存在MATCH (a)-[r]-(b) WHERE a.name CONTAINS 字节 OR b.name CONTAINS 字节 RETURN type(r) AS rel, labels(a) AS a_label, labels(b) AS b_label LIMIT 20这条查询不关心方向能告诉你「字节跳动」这个实体到底参与了哪些关系、方向是什么是排空结果的第一把钥匙。5.2 向量 RAG 的答案与图谱证据冲突现象图谱里明确写着张一鸣创立了字节跳动生成的答案却说创始人是别人。原因向量检索到的文档片段更长、更详细LLM 的注意力被长文本带走了图谱证据虽然也拼在提示词里但排位靠后权重被稀释。解决在系统提示词里显式声明证据优先级并说明冲突时的处理规则提示图谱证据优先于文档证据文档证据只用于补充背景细节不能推翻图谱中已存在的关系。如果业务上确实需要文档更新图谱那应该走图谱更新流程而不是在生成时让模型自由选择。5.3 Agent 反复调用同一个工具进入死循环现象日志里 graph_query 连续被调用五次参数一模一样。原因工具返回了空结果LLM 以为执行没成功于是再试一次。本质是提示词里缺了一句「空结果也是有效结果」。解决工具返回值统一加 status 字段同时在 ReAct 循环里记录工具调用历史发现连续两次相同参数调用同一工具时直接注入一条观察结果「该查询已执行过且结果为空请换思路」。5.4 知识库里存了图片检索却命不中现象图片路径存进了知识库向量检索怎么搜都搜不到。原因文本 embedding 模型不处理像素它只对「图片路径字符串」做向量化路径本身不含任何语义信息。解决先用多模态模型生成图片的文字描述把描述 embedding 进向量库图片路径作为元数据保存。命中描述后再把图片路径返回给应用层。desc vlm_describe(image_path) # 描述样例办公楼外观玻璃幕墙楼顶有公司标志 vector_store.add(desc, meta{image_path: image_path})5.5 关系类型拆太细图谱稀疏到没法查现象边界案例能查到常规问题经常断链。原因schema 设计阶段把关系拆得过细「曾任 CEO」「现任 CEO」「曾担任董事」各建一种关系数据一分散每种关系都只剩几条图遍历基本走不通。解决先合并成粗粒度关系细节放属性。比如统一建 WORKS_AT 关系用 from_year、to_year 和 role 属性区分等数据量上来再考虑迁移拆分。这是第 2 章「先粗后细」原则的代价体现。6. 验证到进阶评测集、增量更新与一条长期习惯链路能跑通只是起点能不能持续演进取决于两件事有量化指标有低成本的更新路径。最后这一章讲一套我用了很久的验证方法外加一个值得长期坚持的工程习惯。6.1 用 30 条问题把链路钉死评测集与三个指标不要凭感觉判断「效果还不错」。建一个至少三十条评测题的集子覆盖三类问题问题类型示例成功标准实体属性型字节跳动是哪一年成立的答案与图谱一致两跳关系型张一鸣投资的其他公司主要做什么路径完整、答案具体开放总结型概括该公司的发展历程关键实体全部出现跑评测时重点看三个指标图谱命中率Cypher 查到了非空结果的比例、答案准确率人工或 LLM 打分、平均工具调用次数。调用次数超过 4 次说明 Agent 在绕路需要查路由策略。6.2 增量更新新文档进来只补关系不重建图谱新文档到达后向量库直接 add 新块图谱不能直接跑抽取脚本因为同一份文档重复抽取会造成关系残留。我的做法是给每个文档一个 source_id入库前先清理该 source 创建的关系MATCH ()-[r]-() WHERE r.source_id $source_id DELETE r;注意这里只删关系不删实体节点。实体可能被其他文档共享删节点会误伤别的数据源。删完关系后重新跑三元组抽取整个更新过程就是幂等的。6.3 一条习惯把每次失败沉淀成评测用例我的经验是每次线上或测试出现检索为空、答错、死循环当天就把问题、预期答案、失败原因写成一个 JSON 用例丢进评测集。三个月之后评测集就是最真实的需求清单每次改动提示词或 schema先跑一遍评测集分数下降就回滚分数上升再发布。这个习惯帮我避免了至少十次「改回去又改回来」的返工。效果从玄学变成了可对比的数字希望帮到你。本文还有配套的精品资源点击获取