知识图谱增强大模型三条路线:检索、训练与校验
知识图谱增强大模型这件事,我前后折腾了差不多一年半,中间推翻过两套方案。最开始我也犯过一个特别典型的错误:把子图里的三元组拼成一段文本,直接怼进 prompt,然后跑评测,发现效果比纯向量检索还差一截,当时第一反应是"知识图谱这条路走不通"。后来复盘才发现,问题根本不在图谱本身,而在于我把结构化的东西喂成了模型最难消化的形式,还顺带挤占了本该留给推理的上下文预算。这篇文章就把我这段时间对知识图谱增强LLM的几条主流路线做一次横向拆解——检索式、训练式、约束与验证式各自解决什么问题、在什么条件下会失效、参数该怎么定、评测怎么做才不至于自己骗自己。适合已经跑通过基础 RAG、想把结构化知识真正用起来的中高级开发者,也适合刚接手图谱类项目、正在纠结技术选型的同学。
1. 先厘清图谱在这个系统里到底扮演什么角色
很多人一上来就问"用哪个图数据库""要不要上GNN",这些问题其实都排得太后。真正决定后面所有设计的是一个前置判断:你希望图谱提供的是事实、是结构、还是判断依据。这三种定位对应的架构差异极大,混着做基本都会烂尾。
1.1 三种定位的分野:外部记忆、结构先验、事实校验器
第一种定位最直观,把图谱当成外部记忆。模型参数里没记住的长尾事实,通过子图检索临时补进上下文。典型场景是问"某个机构的某位负责人现在是谁",答案就在图谱的一条边上,检索到就赢了,检索不到就输了。这条路线本质上是 RAG 的一个变种,只不过检索单元从文本块换成了实体和关系,工程重心全在召回率和序列化格式上。
第二种定位更深一层,把图谱当成结构先验。它不追求补全某条具体事实,而是希望通过图上的邻接关系,把"哪些实体属于同一个话题簇""两个概念之间隔着几跳"这类拓扑信息注入模型的表示空间。这条路线的代表做法是用图神经网络编码子图,再把节点表示投影到模型的输入空间,或者干脆把结构信息混进指令微调数据里。它的收益不在单点事实准确率,而在多跳推理和关系判断类任务上的稳定性。
第三种定位最少人提但价值很高,把图谱当成事实校验器。模型先自由生成,生成完把结果里的实体关系抽出来,回图谱里比对,冲突就触发重写。这条路线的妙处在于它不改动模型任何参数、也不需要图谱覆盖得特别全,只要有足够高的精确率就能拦下一批幻觉。
我个人的经验是:三种定位不要在同一条链路里都做。见过一个团队同时上了子图检索、GNN 编码器和事后校验,结果出了 badcase 根本定位不到是哪一层的问题,调试成本直接翻倍。先选一条主线做到能出数,再考虑叠加。
1.2 一个反直觉的观察:收益主要来自缩小搜索空间
这里说一个我踩了很久才想明白的点。图谱增强带来的提升,很多时候并不是因为多给了模型几条它不知道的事实,而是因为把一个开放域的生成问题,收窄成了一个在有限候选集上的选择问题。
举个具体例子。问"某部作品的改编影视版本的导演,还执导过哪些同类型作品"。纯文本检索会召回一堆影评和简介,模型得自己在里面绕。但如果图谱能给出"作品—改编—影视版本—导演—执导—其他作品"这样一条两跳路径,候选集就从"全网文本"收缩到了十几个实体。模型要做的只是排序和表述,出错概率自然下降。
这个观察有个很实际的推论:当你的评测集里大部分问题的答案本来就在模型参数里时,图谱增强几乎不会有提升。我早期在一个偏常识的问答集上做实验,加了图谱反而掉了两个点,原因是子图里的干扰实体把模型带偏了。后来把评测集换成以长尾实体和时效性事实为主的数据,提升才显现出来。所以做这套东西之前,先确认你的业务问题里"模型不知道但图谱知道"的比例有多高,这个比例基本决定了投入产出比的上限。
2. 检索式增强:把子图塞进上下文之前必须做的四件事
检索式是落地成本最低、也最容易做出效果的一条路线。但"低门槛"不等于"随便做就行",我见过的大部分失败案例,问题都集中在这一层的四个环节上。
2.1 实体链接的召回率是整个链路的天花板
实体链接这一步如果漏了,后面所有环节做得再精细都是零。而且这个环节的失败特别隐蔽——它不会报错,只是默默地检出一堆不相关的子图,然后模型基于错误的上下文给出了一个语法通顺、逻辑自洽、事实完全错误的答案。
我的做法是三级召回加一层消歧。第一级用别名表做精确匹配,别名表必须手工维护,覆盖简称、旧称、外文译名、常见错别字,这部分工作量不小但没有捷径;第二级用稀疏检索(BM25 或字符级 n-gram)在实体名称和描述字段上召回 Top-50 候选;第三级用稠密向量做语义召回,把问题里提到的实体描述和候选实体的摘要做相似度。三路结果做并集去重后进入消歧。
消歧这一步建议不要只靠向量相似度。我的经验是加两个特征特别管用:一是上下文共现,候选实体在问题所在段落的局部窗口里是否出现过;二是类型约束,如果问题里出现了"哪家公司""哪位导演"这类类型线索,直接用图谱里的类型标签做硬过滤,能砍掉一大半错例。这两条加起来,在我手上的业务数据里把链接准确率从 71% 拉到了 89%。
注意:别忽略"未链接"这个选项。有些问题里压根没有图谱能覆盖的实体,这时候强行链接一个最相似的上去,比不链接更糟。我的处理是设一个相似度阈值,低于阈值就走纯文本 RAG 兜底,这条兜底路径必须存在。
2.2 多跳扩展不是跳得越多越好:扩张预算的经验算法
确定种子实体之后,下一步是从图谱里把相关子图挖出来。这里最容易犯的错是无脑做三跳、四跳的 BFS,结果挖出来几千个节点,序列化之后几万 token,模型根本读不完。
我的预算控制思路是这样的:先给一个节点上限和边上限,再用相关性排序决定谁进谁出。具体参数上,两跳以内、节点控制在 30 到 80 之间、边控制在 60 到 200 之间,在我测过的多数场景里是比较稳的区间。三跳以上不是不能做,但必须配合强剪枝,否则上下文里的噪声占比会迅速超过信号。
排序函数我一般用加权的个性化随机游走而不是纯 BFS。直觉是这样:从问题里的种子实体出发,沿着与问题意图相关的关系边游走,游走停留概率高的节点,就是与问题最相关的节点。关系边的权重可以用"该关系在问题类型下的历史命中率"来估,也可以简单点,按关系类型给一个手工权重表——比如"属于""位于"这类泛化关系权重低,"执导""收购""毕业于"这类具体关系权重高。泛化关系权重低的原因很直白:一个实体通过"属于"能连出去几百个兄弟节点,这些节点对回答问题几乎没信息量。
另外一个很实用的技巧是按问题类型预设谓词集合。先做一个轻量的问题分类(单跳事实、多跳关系、比较类、时序类),每类预绑定一组谓词白名单,扩展时只走白名单里的边。这个改动实现成本很低,但能把子图规模压下来一个数量级,同时明显减少干扰实体。
2.3 子图序列化格式对最终答案的影响
这是我一开始栽跟头的地方。同样一张子图,不同的文本化方式,最终答案质量能差出十个点以上。我把试过的几种格式和实测表现列在下面:
| 序列化方式 | 示例形态 | 优点 | 主要问题 |
|---|---|---|---|
| 裸三元组列表 | (A, 执导, B) | 信息密度最高 | 与预训练语料分布差异大,模型常忽略 |
| 自然语言短句 | A 执导了 B,该作品上映于某年 | 贴近预训练分布,模型利用率高 | token 消耗约增加 1.5 到 2 倍 |
| 实体为中心分组 | 以 A 为标题,下面列它的各条关系 | 结构清晰,便于多跳追踪 | 跨实体关系表达不直观 |
| 路径式 | A —执导→ B —上映于→ 某年 | 多跳推理任务表现最好 | 单跳任务上略冗余 |
实测下来,实体为中心分组加自然语言短句的组合在大多数问答场景里最稳。把每个中心实体写成一个小节,下面用短句列出它的出边关系,再附上时间戳和来源标记。多跳类问题可以额外把关键路径单独拎出来放在最前面,让模型先看到"主干道"。
还有一个细节值得说:关系名的自然语言化要提前做。图谱里的关系名往往是directed_by、located_in这种机器友好的形式,直接拼进 prompt 会让模型多花注意力去做语义映射。我一般在建图阶段就维护一张关系名到自然语言短语的映射表,序列化时直接查表替换,这个改动几乎零成本。
2.4 一个可跑的参考实现
下面这段是我常用的骨架,去掉了业务相关的部分,保留了核心逻辑。依赖只有标准库和一个图数据库客户端,替换掉查询部分就能跑。
from collections import defaultdict import math # 关系类型权重:具体关系高,泛化关系低 REL_WEIGHT = { "执导": 1.0, "编剧": 0.9, "主演": 0.8, "隶属于": 0.3, "位于": 0.2, "相关": 0.1, } def expand_subgraph(seed_ids, neighbor_fn, max_nodes=60, max_edges=150, hops=2): """带预算的加权扩展。neighbor_fn(entity_id) -> [(rel, tail_id, tail_name)]""" visited = set(seed_ids) frontier = list(seed_ids) edges = [] for _ in range(hops): next_frontier = [] # 按关系权重降序处理,保证高价值边优先入队 candidates = [] for node in frontier: for rel, tail, tail_name in neighbor_fn(node): w = REL_WEIGHT.get(rel, 0.5) candidates.append((w, node, rel, tail, tail_name)) candidates.sort(key=lambda x: -x[0]) for w, head, rel, tail, tail_name in candidates: if len(edges) >= max_edges or len(visited) >= max_nodes: break edges.append((head, rel, tail, tail_name, w)) if tail not in visited: visited.add(tail) next_frontier.append(tail) frontier = next_frontier if not frontier: break return visited, edges def serialize(visited, edges, name_of, question_paths=None): """实体为中心分组 + 自然语言短句 + 关键路径前置""" out = [] if question_paths: out.append("【关键路径】") out.extend(question_paths) out.append("") by_head = defaultdict(list) for head, rel, tail, tail_name, _ in edges: by_head[head].append((rel, tail_name)) # 出边多的实体优先,通常是信息枢纽 for head in sorted(by_head, key=lambda h: -len(by_head[h])): out.append(f"【{name_of(head)}】") for rel, tail_name in by_head[head][:12]: out.append(f" - {rel}:{tail_name}") out.append("") return "\n".join(out)这段代码有两个地方值得留意。一是candidates.sort按权重降序处理,配合break实现的是"高价值边优先占预算",而不是按遍历顺序随便截断——这个顺序差异在子图稠密的时候对结果影响很大。二是by_head排序用出边数量做代理指标,出边多的实体通常是信息枢纽,放前面符合模型"先看重点"的注意力习惯。至于关系名直接用了中文,实际项目里应该走前面说的映射表。
3. 训练式增强:结构编码器和语言模型怎么对齐
检索式解决的是"模型不知道"的问题,训练式想解决的是"模型不会用结构"的问题。这条路线的门槛明显高一档,但如果你手上有一个稳定的、规模不小的图谱,并且业务里多跳推理占比很高,值得投入。
3.1 结构注入的三个位置及其代价
按注入位置分,主流做法有三类。输入层注入是把子图编码成若干软 token 或结构 token,拼在文本 token 前面一起送进模型,本质上是把图当成一种"结构化前缀";注意力层注入是在模型的部分层里插入额外的交叉注意力模块,让文本 token 直接对子图节点做注意力;输出层注入不改模型内部,只在解码时用图谱约束候选词或对输出做重排。
从我自己的实测和看过的公开结果来看,性价比排序大概是:输入层注入 > 输出层约束 > 注意力层注入。输入层注入改动最小、最容易回滚,效果也最稳;注意力层注入理论上限最高,但训练不稳定、显存开销大,我在两个项目里试过都没能稳定跑赢输入层方案,最后都退回去了。
3.2 投影层冷启动为什么会崩
输入层注入的核心是一个投影层:把图神经网络输出的节点向量,映射到语言模型的词嵌入空间。这一步的坑特别集中。
投影层初始是随机的,如果一上来就和模型一起全参数微调,梯度会非常大,直接把模型原本的语言能力冲垮。表现出来就是训练 loss 掉得很快,但验证集上的生成质量断崖式下跌,模型开始输出重复片段或者乱码。
我后来固定用三步走。第一步完全冻结语言模型,只训投影层,用对比学习的目标——让"子图表示"和"对应答案文本的表示"在嵌入空间里靠近,负样本用同一批次里的其他答案。这一步大概跑几万步,目的是让投影层的输出分布先落到模型能理解的范围里。第二步解冻最上面两三层,学习率设成预训练阶段的十分之一左右,继续训。第三步才考虑全参数,但学习率还要再降一个量级,而且要监控验证集上的通用能力有没有退化。
判断投影层有没有训好的一个土办法:拿一批子图编码后直接送进模型,让它"复述"子图里的事实。如果它能用通顺的自然语言把子图内容说出来,说明对齐基本成了;如果输出的是无关内容或者一直重复,就还得接着训。
3.3 指令微调里混入图结构数据的配比
另一个成本更低的训练式思路,是把图结构转成指令数据混进微调集,不引入任何新模块。这条路线的关键参数是配比。
我试过的比例是图结构数据占 15% 到 25%。低于 10% 模型基本学不会用;高于 30% 会出现明显的过拟合,模型在纯文本任务上的表现开始下滑,生成风格也变得僵硬,动不动就想列三元组。
数据构造上有个细节:样本里要包含"图谱里没有答案"的情况。这一点很多人会漏。如果训练集里每条样本的答案都能在子图里找到,模型会学成"无论如何都要从上下文里抠一个答案出来",遇到图谱覆盖不到的问题就开始编。我的做法是掺入 15% 左右的负样本,子图里故意不含答案,目标输出是"根据现有信息无法确定"。加了这批数据之后,幻觉率下降得比预期明显。
4. 约束式与验证式:让图谱参与解码和纠错
前两条路线都是在"生成之前"下功夫,这一条是在"生成之中"和"生成之后"下手。它的优势是对模型零侵入,可以套在任何底座模型上,缺点是多了一次或多次推理,延迟和成本都会上升。
4.1 受控解码与实体白名单
最直接的用法是实体白名单约束。当问题明确指向某一类实体(比如问某部作品有哪些改编版本),可以先把候选实体集合查出来,在解码阶段用前缀树限制生成只能落在这些实体名上。
实现上要注意两点。一是白名单不能太小,我一般至少保留 20 个候选,太小的白名单会导致模型在被迫选择时硬凑一个错误答案,反而不如放开让它说"不确定"。二是要在白名单里显式加入"以上都不是"这个选项,不然模型没有退路。
这条路线的适用面其实比想象中窄,只在答案空间封闭且可枚举的场景下好用。开放式问答基本用不上,强行用会显著降低答案的流畅度。
4.2 图谱当裁判:把生成结果回灌做一致性检查
我更常用的是事后校验。流程是:模型正常生成,然后用一个抽取器把答案里的实体和关系抽成三元组,逐条回图谱查询,看是否存在冲突。冲突判定分三种情况——图谱明确记录为假、图谱无记录、图谱记录一致。只有第一种情况触发重写,第二种情况只是降低置信度提示,不动答案。
这个设计的考量是图谱召回率永远不可能到 100%。如果把"图谱无记录"也判成错误,会大量误伤正确答案,特别是在长尾实体上。这条经验是我在一个项目里用几百条 badcase 换来的,早期版本误伤率超过三成,用户体感极差。
重写的时候要把冲突信息明确告诉模型,格式大概是"你之前的回答中提到 A 执导了 B,但已知信息显示 B 的执导者是 C,请据此修正"。直接说"你错了"没有用,模型不知道错在哪,重写大概率还是错的。
4.3 多轮自修复的收敛条件
多轮校验必须设上限,我一般设两轮,最多三轮。超过两轮还在反复改的样本,基本都是图谱本身有问题或者问题本身有歧义,继续迭代只是在放大错误。
还有一个必须设的条件是振荡检测。如果第 N 轮和第 N-2 轮的结果完全一致,说明模型在两个答案之间来回跳,立刻停止并取置信度高的那个,或者直接降级返回"信息存在冲突"。不加这个检测,线上偶尔会出现请求超时,排查起来很费劲。
5. 评测:怎么避免自己骗自己
这一段是我最想强调的。知识图谱增强这个方向,实验设计做不好,很容易得出完全相反的结论。
5.1 必须做 token 等价的对照组
最常见的错误是拿"纯文本 RAG 检索 3 个块"和"子图检索 + 序列化"做对比,然后发现图谱方案赢了,就认为图谱有用。但图谱方案的上下文可能长了两三倍,这个提升到底来自结构还是来自更多上下文,根本说不清。
正确的做法是设一组token 等价对照:给纯文本 RAG 也塞进同样 token 数的内容,哪怕是无脑多召回几个块。如果图谱方案还能赢,那才是结构带来的增量。我在一个项目里做过这个对照,原本 6 个点的提升在 token 对齐后只剩 2 个点出头,剩下那部分是实打实的,但前面那 4 个点是自欺欺人。
5.2 反事实与干扰项构造
光看准确率不够,还得看抗干扰能力。构造方法是:从子图里把关键的尾实体替换成同类型的错误实体,然后观察模型是否被带偏。
如果换掉一个关键实体模型就答错,说明它是在抄上下文而不是在推理,这种系统在真实场景里很脆弱,因为真实图谱里一定混着过时和错误的数据。如果换掉之后模型还能答对,说明它有自己的判断,图谱只是辅助。两种情况的优化方向完全不同,前者要去修图谱质量,后者可以去扩图谱覆盖。
5.3 分桶统计:按跳数、热度、时效性拆开看
整体指标几乎没有指导意义。我一般至少按三个维度分桶:
| 分桶维度 | 分桶方式 | 观察重点 |
|---|---|---|
| 推理跳数 | 1 跳 / 2 跳 / 3 跳及以上 | 图谱增强应该在多跳上拉开差距,1 跳上优势不明显甚至落后 |
| 实体热度 | 按图谱内度数分高、中、低三档 | 低热度实体上提升应最显著,若无提升说明召回有问题 |
| 时效敏感度 | 答案是否会随时间变化 | 时效类问题必须单独看,这类最容易被图谱的陈旧数据拖累 |
按这个表拆开看,经常能发现一些反直觉的现象。比如我遇到过一次整体指标涨了 3 个点,拆开看发现 1 跳任务掉了 5 个点、3 跳任务涨了 12 个点。这种结构信息非常有价值——它直接告诉你应该在路由层加一个跳数预判,1 跳的问题走纯文本,多跳的问题才走图谱。
6. 落地阶段绕不开的几个麻烦
前面讲的都是方法层面的取舍,真到工程落地还有几个麻烦是绕不过去的,早点想清楚能省很多返工。
6.1 图谱时效性和 schema 漂移
图谱不是静态的。实体合并、关系类型调整、属性废弃,这些变更在上游看起来是正常的维护动作,到了下游就是查询报错或者召回为空。我吃过一次亏:上游把两个关系类型合并了,我的谓词白名单里还是旧名字,那条链路的召回直接归零。更坑的是它不报错,只是查不到东西,监控上完全看不出来,是靠人工抽查才发现的。
后来我加了两道保险。一是谓词白名单启动时做一次存在性校验,缺失的直接告警;二是召回数量做环比监控,某个问题类型的平均子图节点数突然掉了一半,立刻触发告警。这两条实现成本很低,但能拦住大部分静默故障。
6.2 成本结构:子图检索贵在哪
很多人以为图数据库查询很快,成本不是问题。单次查询确实快,但整条链路的开销主要在三个地方:实体链接要做三路召回加消歧,通常占掉一半以上的延迟;多跳扩展在大图上可能触发几十次邻居查询;序列化之后 prompt 变长,输入 token 成本上升。
我的优化顺序是先做缓存,再做裁剪。缓存分两层,实体链接结果按问题文本的规范化形式缓存,子图结果按"种子实体集合加问题类型"缓存,命中率在真实业务里通常不低,因为热门实体的问题会反复出现。裁剪则是前面说的谓词白名单和节点预算,把子图规模压到够用就行,不要追求"多召回点总没坏处"——在上下文这件事上,多召回经常是有坏处的。
6.3 我的选型建议表
最后把我判断路线时用的对照表放出来,纯属个人经验,不同业务差异会很大,仅供参考。
| 你的处境 | 建议起点 | 理由 |
|---|---|---|
| 已有图谱,想快速验证价值 | 检索式,先只做 1 到 2 跳 | 一周内能出数,验证"模型不知道但图谱知道"的比例 |
| 多跳推理占比高,图谱质量稳定 | 检索式打底,叠加训练式输入层注入 | 结构信息在多跳任务上收益最明显 |
| 图谱覆盖不全,但精确率很高 | 验证式,事后校验 | 不依赖全覆盖,只要能拦下一部分幻觉就有收益 |
| 答案空间封闭可枚举 | 约束式,实体白名单 | 实现最简单,几乎不会引入新的错误 |
| 图谱本身还在建设中 | 先别动模型,把精力放在实体链接和 schema 上 | 上游不牢,下游所有优化都会被浪费 |
我在实际项目里的体会是,这四条路线不是替代关系,而是有明确的先后顺序:先把检索式的召回率和序列化格式打磨到极限,再考虑校验式兜底,训练式放到最后。因为前两步的收益占比通常能到七八成,而训练式的投入产出比在数据量不够大的时候非常难看。踩过几次坑之后我现在的习惯是,任何一个图谱类需求进来,先花两天把评测集和 token 等价对照组搭起来,再动手写第一行检索代码——这个前置动作看起来慢,实际上省下的返工时间远超这两天。