ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型与知识图谱融合:GraphRAG多路召回动态融合实战指南

大模型与知识图谱融合:GraphRAG多路召回动态融合实战指南

1. 为什么我要把大模型和知识图谱组合到一起

这几个月我在做企业内部知识库问答系统的时候,最大的麻烦是:大模型直接回答,业务方一问就问到了"你这话的根据是哪条资料",或者抛出一个要跨好几个部门知识才能回答的问题,单纯靠大模型根本接不住。大模型的幻觉、知识时效性差、推理过程不可解释,这三个软肋在纯RAG架构里很难根治。后来我把思路换了一下,用大模型做知识图谱构建,再结合GraphRAG做多路召回动态融合,整个问答系统的准确率和可解释性才真正拉上了一个台阶。

我自己总结下来,这套方案解决的是三个具体问题:一是知识库里散落的文档之间其实存在大量关系,但普通RAG切开之后关系全断了;二是类似"某个项目依赖的模块又归属哪个团队"这类多跳问题,普通向量检索几乎答不好;三是答案必须能溯源,乙方或者企业内部的合规要求不会允许你给一个说不清来源的结论。这篇文章把我的完整思路和踩坑过程写出来,适合正在做RAG系统、知识库问答、行业知识中台,或者准备引入图结构组织知识的工程团队参考。看完你至少能知道大模型抽取三元组有哪些坑,图路径召回和多路融合的理论基础是什么,以及一套可以直接抄作业的动态融合排序实现长什么样。

1.1 大模型问答的三个软肋

先说说为什么纯RAG不够用。普通RAG的流程很多人熟悉:文档切片、向量化、按相似度召回TopK,最后拼进上下文让大模型生成。这套流程优点是把知识库变成"可检索的段落集合",但问题也出在这一层抽象上。文档切片会把原本连贯的叙事切成一节一节,实体A和实体B之间原来隔了两句话的引用关系,切完之后两个片段各走各的向量空间,语义上的连接就此丢失。业务方问一个需要跨片段推理的问题时,召回回来的TopK文本大概率只有一半有用,另一半是相似但不相干的段落。

另外,大模型生成答案时天然倾向于"编一个合理说法",哪怕检索上下文里没有相关证据,模型也会用参数记忆里那套知识兜底。这在通用场景下问题不大,但在企业内部知识库这种要求"每句话都有出处"的场景,幻觉就是不可接受的。最后是时效性,大模型的参数知识停在训练时刻,企业内部的组织变动、项目进展、人员调整时时都在发生,只靠提示词或者RAG片段,很难保证答案贴合最新状态。

1.2 GraphRAG到底做了件什么事

GraphRAG的全称是Graph-based Retrieval Augmented Generation,它并不是用图数据库替代向量库,而是把检索路径从"平面相似度"升级成"结构路径"。普通RAG假设"语义相似的文本就是相关的",GraphRAG假设"知识库里的实体和关系本身构成了一张网,相关答案应该藏在网上的一段路径里"。

举个例子,用户问"某某项目当前依赖的中间件由哪个团队在维护",这个问题的答案分散在几个文档里:一份文档说项目A依赖组件B,另一份文档说组件B的负责人是团队C。两个片段各自检索都回答不了,因为它们之间隔了一层关系。图结构保存了三元组(项目A, depends_on, 组件B)和(组件B, owner, 团队C),检索时就能从节点A出发,沿着depends_on再走一步到组件B,再从组件B走owner边到团队C,把一条完整推理链当作证据送给大模型。

这里也是GraphRAG和普通RAG在"证据呈现"上的核心差别:普通RAG给的是多个独立片段,模型要自己去拼;GraphRAG给的是带路径的子图,模型能看到实体之间怎么连起来。实验结果也验证了这一点,尤其在多跳问题、跨文档问题上,图结构召回带来的提升非常明显。

1.3 整体架构的大致形状

我在项目中采用的链路分离线构建和在线查询两块。离线构建阶段,文档先经过清洗和分块,然后由大模型做实体抽取和关系抽取,产出一批三元组,接着做实体对齐、属性融合,最后写入图数据库。在线查询阶段,用户query进来之后先做意图识别,判断这是一个事实型问题、关系推导型问题还是开放探讨型问题,然后并行执行多路召回:向量召回走embedding相似度,图召回走实体锚定加子图扩展,关键词召回走BM25或者倒排索引,三层结果再做归一化,根据意图动态分配融合权重,排序后拼上下文给大模型。后面每一节我都会对应展开一个环节的细节和实现要点。

2. 大模型驱动知识图谱构建:从Schema到知识融合

2.1 本体设计先行:这是最容易返工的地方

很多人觉得用大模型抽取知识图谱,把文本丢给模型让它输出实体关系就行。实际上第一步应该先设计Schema,而且Schema设计的好坏直接决定后面所有环节的天花板。我先踩过一次坑:一开始没有严格定义实体类型,导致模型把"人员介绍"抽成一种实体,"团队成员"又抽成一种实体,后续所有对齐和查询都变得混乱。后来我把实体类型收敛到五类左右,规则才变得可控。

Schema设计有几个经验性原则。第一,实体类型不要贪多,控制在8到15类比较合适,超过这个数量大模型在单次抽取时就开始蒙,实体混淆率明显上升。第二,关系命名尽量用明确的谓词短语,比如"depends_on"、"owns"、"mentions",不要用太抽象的词,否则图查询和路径筛选时很难判断这条边到底意味着什么。第三,每条关系必须带source字段,记录它来自哪篇文档的哪个位置,这是后面做溯源和质检的基础。我做过一个某企业内部知识库的Schema,实体类型包括产品、技术模块、团队、人员、文档、项目阶段,关系类型包括depends_on、owns、mentions、has_stage,每个关系三元组都附带了原文引用。这个结构在后面做多路召回时非常顺手。

2.2 实体抽取与关系抽取的工程实现

实体和关系抽取这一步,我采用的方式是提示词驱动的大模型JSON抽取,而不是微调一个专用模型。原因很简单:业务在快速变化,Schema也跟着变,微调模型的迭代周期太长。提示词方案改一下prompt就能适配新需求。我的抽取prompt大致长这样:

你是一个知识抽取引擎。从给定文本中抽取以下类型的实体和关系。 实体类型: [Product, Team, Person, Document, Stage] 关系类型: [depends_on, owns, mentions, has_stage] 要求: 1. 只抽取文本中明确出现的信息,不要推断。 2. 实体数量控制在15个以内。 3. 关系必须关联两个已抽取实体。 4. 输出JSON格式: {"entities": [{"id": "e1", "type": "Product", "name": "..."}], "relations": [{"subject": "e1", "predicate": "depends_on", "object": "e2", "source": "..."}]} 文本: {chunk}

这里有几个工程细节值得展开。第一,文本块大小控制在800字到1200字之间,太大模型容易丢失部分实体,太小上下文关联又不够。第二,一次prompt不要塞太多实体类型,我自己实践下来,超过六种类型的时候抽取准确率就明显下滑。第三,要做两阶段校验:第一阶段宽松抽取,先把候选实体和关系捞出来,第二阶段用另一个prompt让模型对照Schema自查一遍,剔除掉不符合类型定义的三元组。两阶段会多花一点API调用成本,但能把错误三元组的比例压到很低。

批量抽取时的并发控制也不可忽略。我曾经一次性把几千个文本块全部并发丢给API,结果被限流,整个任务跑了一晚上才跑完。后来总结出一个节奏:把QPS控制在接口上限的70%左右,每跑完一批做一个进度记录,中间断了可以从断点续跑。抽取结果落盘时用JSON Lines格式,每条一行,方便后续做去重和清洗。

2.3 知识融合与质量清洗

大模型抽出来的三元组不会直接进图库,必须先过融合和质量清洗。现实中遇到最多的问题就是同一实体的不同叫法:"GraphRAG"和"图检索增强生成"其实是同一个概念,"某某项目组"和"某某项目团队"指向同一个团队。如果不去重,图数据库里就会出现大量冗余节点,图路径检索的时候噪音成倍放大。

我的做法分两层。第一层是规则加别名的候选生成,手工维护一个同义词表,把常见的缩写和全称映射起来。第二层是向量相似度辅助的实体对齐,把每个实体的名称和描述embedding化,两两计算相似度,高于0.85的送去做合并候选。注意不要设定太低的阈值,否则会把两个不同实体错误合并,那比不去重更糟糕。合并时我按来源可信度和时间戳决定属性保留:内部系统数据优先于公开文档,后写入的优先于先写入的。

质量清洗方面,我设置了一个置信度门槛。每条三元组在抽取时让模型输出一个0到1的置信度,低于0.6的就不进图库,而是进入待复核队列。实际跑下来,低置信度三元组的比例一般在5%到10%之间,人工复核成本是可以接受的。如果这个比例超过15%,我会回头检查是不是Schema定义过于模糊或者文本分块质量太差。

2.4 图存储选型

图数据库的选型,我从迁移成本和查询灵活性角度做过对比。Neo4j是起步阶段比较稳妥的选择:属性图模型清晰,Cypher查询上手快,生态和文档都很丰富。数据量到了亿级节点以上再考虑分布式图库会更合适。我还见过一些团队用关系型数据库加上递归CTE来存图,小规模场景也能跑,但查询一旦涉及多跳路径,SQL的复杂度和性能都不理想。

建图时我给节点做了标签和索引设计,比如实体节点统一Label为Entity,再按细类型建二级标签。实体名称字段必须建索引,这是实体锚定时最频繁的查询路径。关系类型也尽量统一,不要同一个意思一会儿用"belongs_to"一会儿用"part_of"。图模型设计得干净,后面的路径召回才写得顺手。

3. GraphRAG的多路召回机制

3.1 三条召回路径的设计出发点

很多人听到GraphRAG以为只有图路径召回这一路,其实效果最好的方案是同时保留多条召回路径,让它们各显神通。我用了三条路径:

召回路径输入输出最适合的查询类型
向量召回query embedding与query语义相似的节点、片段事实型问题、概念解释
图路径召回query实体锚定子图路径、邻接三元组关系推导、多跳问题
关键词召回query分词BM25/倒排索引命中的节点、原文精确术语、带专名的查询

设计三条路径的出发点是覆盖不同信息需求。向量召回擅长处理"语义相似但用词不同"的查询,图路径召回擅长处理"需要跨节点拼接"的查询,关键词召回擅长处理"术语精确匹配"的查询。三条路径的结果有交叉,也有各自独有的信息,动态融合的意义就是让模型在回答不同问题时,自动选择最信任的路径组合。

3.2 图结构召回的关键:实体锚定与子图扩展

图路径召回是整个GraphRAG的核心,也是工程上坑最多的地方。第一步是实体锚定,也就是从用户query里识别出图库中存在的实体。我采用的是轻量级大模型提示词识别加别名库匹配的组合方式:先用一个很短的prompt让大模型从query中抽取实体名,再用别名库做一次模糊匹配,把query文本里的词映射到图库中的标准实体ID。别小看这一步,锚定错了,后面所有路径都是错的。

第二步是子图扩展。拿到种子实体节点后,需要决定扩展几跳、扩展哪些边。我的默认策略是做两跳扩展,第一跳保留所有与种子节点直接相连的邻居,第二跳只沿白名单关系类型继续扩展。比如查询"某项目依赖什么组件",第一跳沿depends_on扩展到组件节点,第二跳再沿owner扩展到团队节点。扩展时要做剪枝,否则图数据库会给你返回一个巨大的邻居集合。我有一次在中心节点上做两跳扩展,返回了上千个节点,直接把上下文撑爆了。后来加了两条剪枝规则:只保留筛选条件下实体类型属于白名单的邻居;度数超过一定阈值的节点不做第二跳扩展,这类节点往往是"文档"或者"通用标签",扩展下去全是噪音。

Cypher查询我一般这样写:

MATCH (e:Entity {name: $seed}) OPTIONAL MATCH (e)-[r]-(neighbor) WHERE neighbor:Product OR neighbor:Team OR neighbor:Person WITH e, collect({relType: type(r), neighbor: neighbor.name}) AS neighbors RETURN e.name, neighbors LIMIT 50

这段查询会返回种子实体的一跳邻居和关系类型。二跳扩展的时候再对返回的邻居节点做一次同样的查询,然后在前端代码里合并结果。不要在Cypher里写太深的递归,性能往往不好。

3.3 召回结果的归一化与信息组织

三条路径返回的结果格式不同:向量路径返回的是浮点相似度,图路径返回的是图元组和路径,关键词路径返回的是非整数得分。直接把它们加起来排名是不科学的。我做的第一步是各自召回TopK,K一般设置在20左右。第二步是把每条路径的得分归一化到0到1区间。向量得分本身已经在0到1之间,但分布往往集中在0.7以上,直接使用会有偏差,我用的是Min-Max归一化,把该路径TopK结果内的最小值压到0、最大值压到1。图路径得分我定义了路径相关性的计算方式:种子实体直接命中得1分,一跳命中得0.7,二跳命中得0.4,再考虑关系类型匹配因素加成。关键词路径就用BM25得分的Min-Max归一化。

这一步做完,每条候选都带上了三个来源的归一化分数,然后才进入动态融合层。归一化阶段有一个容易被忽略的细节:如果某条路径返回的结果为空,它的权重自动分摊到其他路径上,不要因为一路为空就把整条候选都扔掉,其他路径可能还有好的结果。

4. 动态融合排序的实现

4.1 写死权重为什么不行

三条路径的归一化分数都有了,接下来就是合出最终得分。一开始我图省事,直接给三路权重写死成各占三分之一,跑出来效果很一般。我拿一个关系推导问题和一个小术语问题分别测试,发现固定权重既让术语问题里图路径的噪音大量混入,又让关系推导问题里向量路径的结果干扰了排序。道理其实很简单:不同query类型对路径的依赖程度完全不同。事实型问题靠向量和关键词就够了,图路径的加入不会带来额外信息,反而因为归一化分数偏高把真正相关的段落挤下去;关系推导问题必须靠图路径,向量召回只能找到片段,找不到跨节点的连接;开放探讨型问题则更复杂,需要把多路结果混合起来给模型更丰富的视角。

既然query类型不同,融合权重就不应该是一个固定值,而是跟着query动态变化。这就是动态融合的核心动机。

4.2 用意图识别来驱动权重预测

动态权重我采用了两阶段方案,第一阶段先落规则,第二阶段再演进到数据驱动。规则阶段做法是给query打一个意图标签,用的是大模型零样本分类,prompt就一行:让模型判断query属于factual、relational还是exploratory,并输出置信度。然后通过一个映射表把意图标签映射到三路权重:

意图标签向量权重图路径权重关键词权重
factual0.500.150.35
relational0.200.700.10
exploratory0.400.400.20

置信度也很重要。当模型对意图判断不那么有把握时,我会把权重向均匀方向拉。比如预测是relational但置信度只有0.55,最终权重就变成0.3乘以均匀值加0.7乘以映射表值。这个平滑处理显著减少了意图误判带来的负面影响。

等规则阶段跑稳定了,并且积累了一定量带人工标注的query意图数据之后,再升级成数据驱动方案:用query的embedding作为特征,训练一个三分类线性模型输出意图概率,把这个概率当成权重直接使用。这样既避开了大模型在线分类的延迟和成本,又能捕捉到query表达上的细微差异。我在实际项目中最后用的就是这条路线,意图分类的延迟从大模型调用的几百毫秒降到了几毫秒。

4.3 融合打分与排序细节

动态融合的打分公式我用的是加权和:

final_score = w_vector * S_vector + w_graph * S_graph + w_keyword * S_keyword

其中S_vector、S_graph、S_keyword分别是归一化后的三路得分。这个公式简单直接,缺点是对各路径内部的分数分布仍然敏感。所以我在实际落地时又叠加了一层Rank-based融合,也就是RRF(Reciprocal Rank Fusion)的思路:

RRF_score(d) = sum( 1 / (k + rank_i(d)) )

这里rank_i(d)是候选d在第i条路径中的名次,k是平滑常数,一般取60。RRF的核心思想是:我们不需要关心各路径分数的绝对大小,只关心候选在每条路径中的排名,排名越靠前,融合得分越高。这天然规避了量纲和分布问题。我的最终排序是RRF分数与加权和分数做一次加权混合,一般RRF占0.6、加权和占0.4。纯粹用RRF的问题是它忽略了分数差距,第一名和第二名的差距可能在原始分数里非常悬殊,RRF只看名次会损失这部分信息。

最后还有一个细节:融合排序之后,我不会把所有TopK结果都塞进上下文,而是做一次动态截断。如果query意图是factual,TopK取8个就够了;如果是exploratory,可以放宽到15个。上下文长度决定生成质量,也决定推理成本,这个trade-off需要根据你用的模型窗口来调。

4.4 一份可以直接跑的简化实现

下面是一份简化版的动态融合排序代码,我用numpy和简单的邻接表模拟了向量召回、图召回和关键词召回,并实现了意图驱动的权重融合。真实项目里把召回函数替换成向量库和图数据库调用即可。

import numpy as np # 模拟三条召回路径,返回候选集 {候选id: (路径名次, 路径归一化得分)} def recall_vector(query_vec, doc_vecs): scores = doc_vecs @ query_vec ranked = np.argsort(-scores)[:20] return {i: (rank + 1, float(scores[i])) for rank, i in enumerate(ranked)} def recall_graph(seed_entities, adjacency, depth=2): result = {} for seed in seed_entities: # 第一跳 for nb in adjacency.get(seed, []): result.setdefault(nb, 0.7) # 第二跳 if depth >= 2: for nb in adjacency.get(seed, []): for nb2 in adjacency.get(nb, []): result.setdefault(nb2, max(result.get(nb2, 0), 0.4)) ranked = sorted(result, key=result.get, reverse=True)[:20] return {cand: (rank + 1, result[cand]) for rank, cand in enumerate(ranked)} def recall_keyword(query_terms, inverted_index): scores = {} for term in query_terms: for doc_id, freq in inverted_index.get(term, []): scores[doc_id] = scores.get(doc_id, 0) + freq ranked = sorted(scores, key=scores.get, reverse=True)[:20] return {cand: (rank + 1, 1.0 / (1.0 + rank)) for rank, cand in enumerate(ranked)} def intent_weights(intent_label): if intent_label == "factual": return np.array([0.50, 0.15, 0.35]) elif intent_label == "relational": return np.array([0.20, 0.70, 0.10]) else: return np.array([0.40, 0.40, 0.20]) def merge_rrf(path_results, weights, k=60): final_score = {} for path_id, cands in enumerate(path_results): for cand, (rank, score) in cands.items(): rrf = 1.0 / (k + rank) weighted = weights[path_id] * score final_score[cand] = final_score.get(cand, 0.0) + rrf * 0.6 + weighted * 0.4 return sorted(final_score.items(), key=lambda x: x[1], reverse=True) # 主流程 query_vec = np.random.rand(128) doc_vecs = np.random.rand(100, 128) query_terms = ["知识图谱", "GraphRAG"] inverted_index = {"知识图谱": [(3, 2), (7, 1)], "GraphRAG": [(3, 1), (19, 3)]} adjacency = {3: [7, 19], 7: [3], 19: [3, 42]} path_result_vec = recall_vector(query_vec, doc_vecs) path_result_graph = recall_graph([3], adjacency, depth=2) path_result_kw = recall_keyword(query_terms, inverted_index) w = intent_weights("relational") merged = merge_rrf([path_result_vec, path_result_graph, path_result_kw], w) print(merged[:5])

这份代码跑通没问题,但它不是最优先化性能的写法。真实场景中,向量召回我一般用HNSW索引的向量库,图召回用图数据库的Cypher查询,关键词召回用ES的BM25。多路召回本身是并行的,注意不要一个接一个串行执行,否则延迟叠加起来用户等不起。

5. 实战中的常见问题与排查技巧实录

5.1 高频问题速查表

问题现象可能原因解决办法
知识抽取结果出现原文没有的实体prompt中实体类型太多,模型开始"发挥"了拆分抽取任务、每块最多处理6种类型、限制实体数量上限
图路径返回的邻居节点太多,上下文被撑爆未做剪枝,中心节点度数过高限制扩展深度,高密度节点不做二跳扩展,按实体类型白名单过滤
向量召回的得分明显偏高但与问题无关embedding分布过于集中,相似度没有区分度做Min-Max归一化,或改用RRF只看排名
动态权重总是偏袒某一路径意图分类不准或权重映射表太粗收集badcase重新调整映射表,对意图置信度做平滑处理
多路召回结果拼进上下文超出模型窗口TopK设得太大,不同路径重复内容多按意图动态截断TopK,去重时保留得分最高的候选

5.2 几条关键实操心得

第一点,实体抽取宁可保守也不要激进。漏掉一些三元组造成的损失是"答案找不到",但抽错实体造成的损失是"答案找到了但张冠李戴",后者在知识库场景里更容易被业务方抓到。我在清洗阶段把置信度门槛提得偏高,宁可多给人审两道,也不让错误三元组进图库。第二点,图数据库不要用来做向量检索,embedding相似度这种活儿交给向量库更合适,图库只负责结构和路径。把两类职责混在一起,维护成本和查询性能都不会好看。第三点,动态融合别一上来就上数据驱动模型。先用规则权重跑一段时间,把badcase收集得足够多了再训练模型,拳路会稳很多。第四点,评估指标一定要拆开看,单独记录向量召回命中率、图召回命中率、关键词召回命中率和融合后的最终命中率。不然你根本不知道融合到底是加了分还是拖了后腿。

最后分享一个小技巧:动态融合排序之后,我习惯在送入大模型之前加一个轻量级的上下文压缩器,对召回的段落按关键实体覆盖率做一次粗排序,只保留与实体路径强相关的句子。这个方法实测能让生成阶段的输入长度下降一半以上,而答案质量基本不受影响。整个项目跑下来,我最大的感受是知识图谱构建的质量直接决定了后面所有环节的天花板,多路召回和动态融合只是修正一部分噪音,底层的Schema设计、实体抽取准确率和知识融合质量才是真正的胜负手。如果你也正在做类似的事情,建议先花大力气把知识抽取这关过好,后面的路会顺很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进