图检索增强生成(Graph RAG)这两年在实际项目里出现的频率越来越高,但很多人第一次接触时容易把它和普通向量检索混为一谈,以为只是换了个存储后端。真正上手之后才会发现,图检索解决的是向量检索天生不擅长的东西——多跳关系、实体间的路径推理、以及"这个问题涉及的三四个概念之间到底怎么连起来"这类查询。LangChain 作为编排层,把图数据库的查询能力和大模型的生成能力串起来,让这套流程可以落地成可复用的链。这篇内容面向的是已经跑通过基础 RAG、想进一步处理关系型问题的开发者,也适合正在评估要不要引入图结构的技术负责人。我会围绕 LangChain 里图检索查询的编写方式,把选型逻辑、查询构造、结果融合、踩坑经验这些讲透,代码和配置都能直接拿去改。
1. 先搞清楚图检索到底补的是哪块短板
1.1 向量检索在关系型问题上的失效场景
普通 RAG 的流程大家都熟:文档切块、向量化、存进向量库、查询时按语义相似度召回 top-k 片段、拼进 prompt 让模型回答。这套东西在"某段文字里提到了什么"这类问题上表现很好,因为语义相似度能有效匹配表述接近的内容。但一旦问题变成"甲和乙之间通过哪些中间实体产生关联"或者"满足条件 A 的实体,它所属的上级分类又关联了哪些其他实体",向量检索就开始露怯了。
原因不复杂。向量检索本质上是把每个文本块压缩成一个稠密向量,块与块之间的关系在存储时就被丢掉了。你召回的是若干个孤立的片段,模型拿到这些片段后只能靠自身参数里的先验知识去"猜"它们之间的关系,猜对了是运气,猜错了就是幻觉。我见过一个典型例子:知识库里分别有"某组件依赖某中间件"和"某中间件部署在某环境"两条记录,问"某组件最终跑在什么环境上",向量检索把两条都召回了,但模型不一定能把这条两跳链路正确串起来,尤其是当两条记录分散在不同文档、表述差异较大时。
图结构恰好补的就是这个缺口。实体是节点,关系是边,多跳查询就是沿着边走。LangChain 在这里的角色不是图数据库本身,而是把"自然语言问题 → 图查询语句 → 查询结果 → 自然语言答案"这条链路编排起来。理解这一点很关键,否则容易误以为 LangChain 自带图推理能力。
1.2 图检索与向量检索的分工边界
这里要说清楚一个常见误解:图检索不是向量检索的替代品,两者是互补的。我在项目里的实际做法是混合检索——先用向量检索拿到语义相关的种子实体或文档块,再用图检索沿着这些种子做邻域扩展,最后把两路结果合并排序。纯图检索的问题在于它对自然语言的理解能力弱,用户问一句话,你得先把这句话里的实体和关系意图解析出来才能构造查询,这个解析环节本身就容易出错;纯向量检索的问题前面说了,关系推理弱。
所以判断要不要上图,我的经验标准是看问题里"关系密度"。如果用户的问题里出现"之间""通过""关联""属于""影响"这类词的比例高,且答案需要跨多个文档块拼接,那图检索的收益就明显。反过来,如果问题基本是"某概念的定义是什么""某操作怎么做",向量检索足够,上图反而是过度设计,维护成本还不低。
| 问题类型 | 向量检索表现 | 图检索表现 | 建议方案 |
|---|---|---|---|
| 概念定义、操作步骤 | 好 | 一般 | 纯向量 |
| 单实体属性查询 | 好 | 好 | 任选,向量更省事 |
| 两跳以内关系 | 一般 | 好 | 混合 |
| 多跳路径推理 | 差 | 好 | 图为主,向量做种子 |
| 聚合统计类 | 差 | 好 | 图查询直接算 |
这张表是我在几个项目里反复验证后总结的,不是理论推演。实际选型时还要考虑数据本身的图化程度——如果你的原始数据本来就是结构化的、实体关系清晰的,图化成本低;如果全是长文本,得先做实体关系抽取,这一步的投入要提前算进去。
1.3 LangChain 在图检索链路中的真实定位
很多人搜"LangChain 图检索"时期待的是一个开箱即用的图 RAG 组件,实际拿到手会发现 LangChain 提供的是若干可组合的抽象:图数据库的 Graph 封装、用于生成查询语句的链、用于把查询结果转成自然语言的链。它不替你决定图 schema 怎么设计,也不替你写 Cypher 或 Gremlin 查询,这些还是得自己来。
我在项目里的定位是这样的:LangChain 负责"编排"和"胶水",具体包括把用户问题喂给 LLM 生成图查询、执行查询、把结构化结果序列化成文本、再喂给 LLM 生成最终答案。图数据库负责"存储"和"执行",schema 设计和查询优化是图数据库那边的事。这个分工想清楚了,后面写代码就不会拧巴——你不会指望 LangChain 帮你优化查询性能,也不会在图数据库里硬塞编排逻辑。
2. 图 schema 与查询语句的构造逻辑
2.1 为什么 schema 设计决定了查询的上限
图检索的效果,七成取决于 schema 设计,这个比例不夸张。schema 就是节点类型和关系类型的定义,它决定了你能问出什么问题。我见过一个失败案例:有人把所有实体都建成一种Entity节点,关系全叫RELATED,结果查询时根本没法区分"依赖"和"属于",生成的 Cypher 全是MATCH (a)-[:RELATED]-(b),查出来的东西又杂又乱,模型拿到一堆无差别邻居,回答质量还不如向量检索。
正确的做法是按业务语义细分节点类型和关系类型。比如做技术文档知识图谱,节点至少分Component、Middleware、Environment、Team,关系分DEPENDS_ON、DEPLOYED_IN、OWNED_BY。这样查询语句才能精确表达意图。schema 设计时我遵循两个原则:一是关系类型要能回答"为什么连"这个问题,二是节点属性要包含查询时可能用到的过滤字段。前者保证语义清晰,后者保证查询效率。
LangChain 这边不强制 schema,但你在构造生成查询的 prompt 时,必须把 schema 描述清楚喂给 LLM,否则它生成的查询语句会瞎猜关系名。我通常把 schema 写成一段结构化的文本描述,连同几个示例查询一起放进 prompt,效果比只给 schema 好很多。
2.2 用 LLM 生成图查询语句的 prompt 工程
让 LLM 生成 Cypher 是图检索里最容易翻车的环节。翻车点主要有三个:关系名拼错、方向搞反、过滤条件漏掉。我调了很久的 prompt,最后稳定下来的结构是这样的——先给 schema 描述,再给三到五个"问题-Cypher"示例对,最后明确约束"只输出查询语句,不要解释,关系名必须从给定列表中选择"。
from langchain.prompts import PromptTemplate CYPHER_GENERATION_TEMPLATE = """你是一个图数据库查询专家。根据下面的 schema 和示例,把用户问题转换成 Cypher 查询语句。 Schema: 节点类型: Component(name, version), Middleware(name, category), Environment(name, tier) 关系类型: (Component)-[:DEPENDS_ON]->(Middleware), (Middleware)-[:DEPLOYED_IN]->(Environment) 示例1: 问题: 某组件依赖了哪些中间件? Cypher: MATCH (c:Component {{name: '某组件'}})-[:DEPENDS_ON]->(m:Middleware) RETURN m.name 示例2: 问题: 某中间件部署在哪些环境? Cypher: MATCH (m:Middleware {{name: '某中间件'}})-[:DEPLOYED_IN]->(e:Environment) RETURN e.name 约束: 1. 只输出 Cypher 语句,不要任何解释文字 2. 关系名必须从上面的关系类型列表中选择 3. 注意关系方向,箭头方向必须与 schema 一致 问题: {question} Cypher:""" cypher_prompt = PromptTemplate( input_variables=["question"], template=CYPHER_GENERATION_TEMPLATE )这段 prompt 的关键在于示例对和约束。示例对的作用不是让模型学语法,而是给它"问题到查询"的映射范式,尤其是实体名怎么从问题里提取出来填进{{name: '...'}}。约束里"关系名必须从列表选"这条能显著降低拼写错误率,我实测下来能把关系名错误从三成降到一成以内。另外注意示例里的实体名用了占位符风格,实际使用时模型会把用户问题里的实体名替换进去。
还有一个细节:prompt 里不要放太多示例,三到五个足够,放多了反而让模型倾向于照抄示例结构,遇到稍复杂的问题就套不进去。我试过放十个示例,结果模型生成复杂查询时错误率反而上升,因为它总想往示例的简单模式上靠。
2.3 查询语句的校验与兜底
LLM 生成的 Cypher 不能直接拿去执行,必须先校验。校验分两层:语法层和语义层。语法层可以用图数据库自带的解析器做 dry-run,比如 Neo4j 的EXPLAIN命令,不实际执行但能发现语法错误。语义层要检查关系名是否在 schema 里、节点标签是否存在、属性名是否拼对。
def validate_cypher(cypher: str, allowed_relations: list, allowed_labels: list) -> tuple: # 语义层校验:检查关系名和标签 import re relations_used = re.findall(r'\[:(\w+)\]', cypher) labels_used = re.findall(r'\((\w+):', cypher) for rel in relations_used: if rel not in allowed_relations: return False, f"未知关系类型: {rel}" for label in labels_used: if label not in allowed_labels: return False, f"未知节点标签: {label}" return True, "校验通过"校验不通过时的兜底策略很重要。我的做法是重试一次,把校验错误信息拼进 prompt 让模型修正;如果第二次还失败,就降级到向量检索,保证链路不断。这个降级逻辑看起来简单,但在生产环境里救过好几次场——LLM 偶尔会生成一些奇怪的查询,没有兜底的话整个请求就挂了。
提示:校验函数里用正则提取关系名和标签是权宜之计,更稳的做法是用图数据库的查询解析器拿到 AST 再检查。正则对复杂查询(比如带 UNION 或子查询的)容易漏检,生产环境建议上解析器。
3. 把查询结果喂回模型的序列化处理
3.1 结构化结果转文本的取舍
图查询返回的是结构化数据,通常是若干行记录,每行是若干字段的键值对。直接把这些记录str()一下塞进 prompt 是最省事的做法,但效果往往不好,因为模型对原始的结构化数据格式不敏感,容易漏读字段或者把不同行的数据串起来。我试过几种序列化方式,最后稳定用的是"自然语言模板 + 关键字段显式标注"的混合方式。
具体来说,对每一行结果,用一句话模板把它转成自然语言,同时把关键字段用方括号标出来。比如查询返回{component: 'A', middleware: 'B', environment: 'C'},序列化成"组件 [A] 依赖中间件 [B],该中间件部署在环境 [C]"。这样模型读起来顺畅,方括号又帮它快速定位实体。实测下来比纯 JSON 序列化的回答准确率高不少,尤其是多行结果需要模型做聚合时。
def serialize_graph_results(records: list, template: str) -> str: lines = [] for rec in records: try: lines.append(template.format(**rec)) except KeyError as e: lines.append(f"记录字段缺失: {e}, 原始数据: {rec}") return "\n".join(lines) # 使用示例 template = "组件 [{component}] 依赖中间件 [{middleware}],该中间件部署在环境 [{environment}]" text = serialize_graph_results(records, template)模板里的字段名必须和查询返回的字段名严格对应,这个对应关系在写查询时就要定好,用AS别名固定下来。我踩过的坑是查询里用了RETURN m.name没起别名,返回字段名是m.name,模板里写{middleware}就 KeyError 了。后来养成习惯,所有RETURN都显式AS一个干净的字段名。
3.2 多跳结果的路径展开与去重
多跳查询返回的往往是路径,比如(a)-[:DEPENDS_ON]->(b)-[:DEPLOYED_IN]->(c),返回的是整条路径上的节点和关系。这种结果序列化时要展开成"a 依赖 b,b 部署在 c"这样的链式描述,而不是把三个节点平铺。平铺的话模型容易丢失顺序信息,把依赖方向搞反。
去重也是个实际问题。多跳查询经常返回重复的中间节点,比如多个组件都依赖同一个中间件,路径展开后这个中间件会出现多次。序列化前要去重,但去重不能简单按节点去,得按"路径片段"去,否则会把不同路径上的相同节点错误合并。我的做法是先把每条路径展开成边列表,对边列表做集合去重,再按边重新组织成描述文本。
def expand_paths(paths: list) -> str: edges = set() for path in paths: nodes = path["nodes"] rels = path["rels"] for i, rel in enumerate(rels): edge = (nodes[i], rel, nodes[i+1]) edges.add(edge) # 按起点分组组织描述 from collections import defaultdict grouped = defaultdict(list) for src, rel, dst in edges: grouped[src].append((rel, dst)) lines = [] for src, targets in grouped.items(): desc = ",".join([f"通过 {rel} 关联到 {dst}" for rel, dst in targets]) lines.append(f"{src} {desc}") return "\n".join(lines)这段代码里用集合对边去重是关键,边是三元组,天然可哈希。按起点分组是为了让描述有组织,模型读起来能看出"一个实体关联了哪些东西"的结构,比一堆散乱的边描述清晰得多。
3.3 结果规模控制与截断策略
图查询的结果规模不好控,一个热门节点可能有几百个邻居,全塞进 prompt 直接超上下文。截断策略要分情况:如果是"列出所有 X"类查询,截断会丢信息,得在 prompt 里告诉模型"结果已截断,仅展示前 N 条";如果是"找路径"类查询,优先保留最短路径,长路径截掉。
我的默认策略是结果行数超过 50 行就截断,同时按相关性排序——相关性用查询里的过滤条件命中度来算,命中过滤条件多的排前面。截断后在序列化文本开头加一句"以下为部分结果(共 N 条,展示前 50 条)",让模型知道信息不完整,回答时会加"根据现有信息"这类限定语,避免它把不完整的结果当成全集来下结论。
注意:截断阈值不要拍脑袋定,要结合你的模型上下文窗口和单条结果的 token 数算。我一般先估算单条序列化结果的平均 token 数,再用上下文窗口减去 prompt 其他部分的 token 预算,倒推出能放多少条。这个计算在项目初期做一次,后面数据量变化时再校准。
4. 混合检索链的组装与实测调优
4.1 向量种子加图扩展的链式组装
混合检索的组装思路是:向量检索先跑,拿到 top-k 文档块,从这些块里抽实体名作为种子,再用种子去图里做邻域查询。LangChain 里可以用RunnableParallel把两路并行跑,也可以用顺序链先向量后图。我倾向顺序链,因为图查询依赖向量结果里的实体,有先后依赖。
from langchain.schema.runnable import RunnablePassthrough, RunnableLambda def extract_entities_from_docs(docs: list) -> list: # 从文档块里抽实体名,可以用 LLM 也可以用规则 # 这里用简化规则示例 entities = [] for doc in docs: # 实际项目里这里接实体识别 entities.extend(doc.metadata.get("entities", [])) return list(set(entities)) hybrid_chain = ( {"question": RunnablePassthrough(), "docs": vector_retriever} | RunnableLambda(lambda x: { "question": x["question"], "docs": x["docs"], "entities": extract_entities_from_docs(x["docs"]) }) | RunnableLambda(lambda x: { **x, "graph_results": graph_query(x["entities"], x["question"]) }) | RunnableLambda(format_final_prompt) | llm )这个链的结构清晰,但有个隐藏问题:extract_entities_from_docs的质量直接决定图查询的召回。如果实体抽取漏了关键实体,图查询就查不到东西。我的改进是在实体抽取后加一步"实体消歧",把抽取到的实体名和图里实际存在的节点名做模糊匹配,匹配不上的丢弃,匹配上的用图里的标准名。这一步能把图查询的命中率提上去不少,因为文档里的实体表述和图里的节点名经常有细微差异。
4.2 两路结果的融合排序
向量结果和图结果怎么融合,直接影响最终回答质量。简单拼接是最差的做法,因为两路结果的相关性度量不在一个尺度上。我的做法是给两路结果分别打分再合并:向量结果用相似度分数,图结果用"路径长度 + 过滤条件命中数"算分,路径越短、命中越多分越高,然后归一化到同一区间合并排序。
| 结果来源 | 打分依据 | 归一化方式 |
|---|---|---|
| 向量检索 | 余弦相似度 | 直接映射到 0-1 |
| 图检索(单跳) | 过滤命中数 / 总过滤数 | 映射到 0-1 |
| 图检索(多跳) | 1 / 路径长度 × 命中率 | 映射到 0-1 |
融合后取 top-N 进 prompt。这里有个经验:图结果里路径短的优先,因为短路径的置信度高,长路径往往是噪声。我设过一个规则,路径长度超过 3 的图结果直接降权一半,实测下来回答的准确率有提升,因为长路径里混入了太多弱关联。
4.3 实测中暴露的三个典型问题
第一个问题是图查询超时。多跳查询在数据量大时容易慢,尤其是没建索引的属性过滤。解决办法是给常用过滤字段建索引,同时给图查询设超时,超时后降级到向量结果。LangChain 这边用RunnableLambda包一层超时控制就行,但要注意超时后的降级逻辑要能拿到向量结果,所以链的结构上向量结果要一直透传。
第二个问题是实体名歧义。同一个名字在图里可能对应多个节点,比如"某中间件"在不同分类下各有一个节点。查询时如果不加分类过滤,会查出多个节点的邻居混在一起。解决办法是在实体抽取后带上上下文分类信息,查询时用分类做过滤。这个问题的根因是 schema 设计时没考虑同名实体,属于设计阶段就该规避的。
第三个问题是 LLM 生成查询时的"过度简化"。模型倾向于生成单跳查询,哪怕问题明显需要多跳。比如问"某组件最终部署在什么环境",模型可能只生成MATCH (c)-[:DEPENDS_ON]->(m) RETURN m,漏了第二跳。解决办法是在 prompt 的示例里放一个多跳的例子,并在约束里明确"如果问题涉及'最终''经过'等词,考虑多跳查询"。这个调整很有效,多跳查询的生成率明显上升。
5. 生产化之前必须处理的几件事
5.1 查询缓存与幂等性
图查询在生产环境里会被反复调用,相同或相似的查询很多。加一层查询缓存能显著降低图数据库压力。缓存的 key 用"规范化后的查询语句 + 参数",规范化是指把实体名参数化,这样"查组件 A 的依赖"和"查组件 B 的依赖"能命中同一个缓存模板,只是参数不同。LangChain 这边可以用RunnableLambda包一层缓存逻辑,或者用 LangChain 自带的 cache 机制。
幂等性方面,图查询本身是只读的,幂等没问题,但要注意 LLM 生成查询这一步不幂等——同样的输入可能生成不同的查询语句。所以缓存要缓存在"生成查询之后、执行之前"这一层,而不是缓存在最外层,否则缓存命中率会很低。
5.2 查询失败的可观测性
图检索链路的失败点比普通 RAG 多,至少有四个:实体抽取失败、查询生成失败、查询执行失败、结果序列化失败。每个失败点都要有日志和指标,否则线上出问题很难定位。我的做法是在每个RunnableLambda里包 try-except,失败时记录"失败阶段 + 输入 + 错误信息",同时打点计数。指标上重点看"查询生成成功率"和"查询执行成功率"两个,前者低说明 prompt 要调,后者低说明 schema 或数据有问题。
import logging from functools import wraps def with_observability(stage_name): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): try: result = func(*args, **kwargs) metrics.increment(f"graph_rag.{stage_name}.success") return result except Exception as e: logging.error(f"阶段 {stage_name} 失败: {e}, 输入: {args}, {kwargs}") metrics.increment(f"graph_rag.{stage_name}.failure") raise return wrapper return decorator这个装饰器模式用起来顺手,每个阶段套一下就有日志和指标了。注意失败时要raise而不是吞掉,让上层决定降级策略,吞掉的话降级逻辑就不知道发生了失败。
5.3 schema 演进时的兼容处理
图 schema 不是一成不变的,业务发展会加新节点类型、新关系类型。schema 变了之后,之前缓存的查询、prompt 里的示例、校验函数里的白名单都要同步更新,否则会出现"新关系查不到"或"旧查询校验失败"的问题。我的做法是把 schema 定义抽成一个独立的配置文件,prompt 生成、校验、缓存 key 生成都从这个配置读,schema 变更只改一处。
另外,schema 演进时要注意向后兼容。加新关系类型是安全的,删旧关系类型要谨慎,因为可能有存量查询还在用。我的策略是旧关系类型标记为 deprecated 但保留一段时间,新查询不再生成它,等存量查询自然淘汰后再删。这个过渡期一般设一到两个月,具体看查询日志里旧关系的出现频率。
6. 几个容易被忽略的实操细节
6.1 实体抽取的粒度控制
实体抽取粒度太粗会漏实体,太细会抽出大量无意义片段。我的经验是按"能在图里找到对应节点"这个标准来定粒度——抽出来的实体必须能匹配到图里的节点,匹配不上的要么是抽取错了,要么是图里缺这个节点。前者调抽取规则,后者考虑补数据。这个标准听起来简单,但实际执行时能过滤掉大量噪声,比单纯追求"抽全"实用得多。
抽取方式上,规则抽取和 LLM 抽取各有适用场景。规则抽取快、稳定、成本低,适合实体名有固定模式的场景(比如组件名都是特定前缀);LLM 抽取灵活,能处理表述多样的场景,但慢且成本高。我一般先用规则抽,规则抽不到的再用 LLM 补,这样兼顾成本和覆盖率。
6.2 图查询的深度限制
多跳查询一定要设深度上限,否则在稠密图上会爆炸。深度上限设多少要看业务,一般两到三跳够用,超过三跳的查询结果相关性下降很快,而且执行时间指数增长。设了上限之后,查询语句里用*1..3这样的变长路径语法,同时在 prompt 里告诉模型"最多三跳",避免它生成过深的查询。
深度限制和结果规模控制要配合用。深度大、结果多的时候,截断策略要更激进,优先保留短路径。我见过没设深度限制的查询在测试环境把数据库拖垮的情况,生产环境一定要设。
6.3 prompt 里 schema 描述的详略平衡
schema 描述写太详细,prompt 太长,成本和延迟都上去了;写太简略,模型生成查询时容易猜错。我的平衡点是:节点类型和关系类型全列,属性只列查询时可能用到的过滤字段,示例查询给三到五个覆盖主要查询模式。这个详略程度实测下来生成准确率和成本比较均衡。如果 schema 特别大(几十种节点类型),考虑按问题类型动态裁剪 schema 描述,只把相关的部分喂给模型。
动态裁剪的实现方式是在生成查询前先做一次意图分类,判断问题涉及哪几类节点,只把这几类的 schema 描述放进 prompt。这个分类可以用轻量模型做,成本可控。我做过对比,动态裁剪后 prompt 长度降了六成,生成准确率基本持平,延迟明显下降。
6.4 结果为空时的处理
图查询返回空结果很常见,原因可能是实体没匹配上、关系不存在、过滤条件太严。空结果直接喂给模型,模型容易编造答案。我的处理是空结果时明确告诉模型"图查询未返回结果",同时把向量检索的结果作为补充喂进去,让模型基于向量结果回答,并在回答里说明"未找到直接关系,以下基于相关文档"。这样既避免了幻觉,又保证了有答案可给。
空结果的日志要重点看,因为空结果率高往往说明实体抽取或 schema 匹配有问题。我一般把空结果率作为一个核心监控指标,超过阈值就排查。排查顺序是:先看实体抽取是否漏了,再看图里是否缺数据,最后看查询条件是否过严。这个顺序能覆盖大部分空结果的原因。
7. 写在最后的几点个人体会
图检索这套东西,我最大的体会是"schema 先行"。很多人上来就写代码、调 prompt,结果 schema 没设计好,后面怎么调效果都上不去。花时间把节点类型、关系类型、属性字段想清楚,比调十版 prompt 都值。schema 设计时多问自己"用户会怎么问",从问题反推 schema,比从数据正推更贴近实际需求。
另一个体会是"降级要早做"。图检索链路的失败点比普通 RAG 多,没有降级逻辑的话线上稳定性很难保证。我在项目初期就把降级做进去了,向量检索作为兜底,图检索失败时链路不断,只是回答质量降一档。这个设计让上线过程平滑很多,不用等图检索调到完美才敢上。
最后说个具体的:图查询的 prompt 里,示例对的质量比数量重要。我调 prompt 时大部分时间花在打磨那三五个示例上,每个示例都对应一种典型查询模式,覆盖单跳、多跳、带过滤、聚合这几类。示例打磨好了,模型生成查询的准确率自然就上去了,比堆一堆示例管用。这个经验在别的 LLM 应用场景里也通用——示例的质量决定输出的下限。