前几篇聊了属性图的底层数据模型、Data-Processor 的抽取管线和 schema 校验的基础写法,这次换个角度,用一个完整的案例把“基于预定义模式的属性图构建”从头到尾串一遍。想解决什么问题呢?就是很多团队在做 RAG 时都会撞上的一堵墙:文档切块之后,实体之间的依赖关系全断了,模型只能靠零散的文本片段猜答案。与其让模型瞎猜,不如在 Data-Processor 阶段就把关系显式建模成属性图,再让图去辅助 RAG 检索。
这篇文章会围绕某企业研发文档库的真实场景展开,讲清楚 schema 怎么从业务问题反推出来、抽取怎么被预定义模式约束、图数据怎么入库存量、以及接入 RAG 之后的效果对比。不管你是刚接触 GraphRAG 的新手,还是已经被 LLM 抽取结果搞到头大的老手,这篇应该都能给出一些能直接“抄作业”的思路。
1. 为什么普通向量 RAG 会漏掉关系信息:属性图模式的价值定位
1.1 一次让我彻底转向图增强检索的线上事故
大概在半年多前,我在某企业的知识库项目里搭了一套基于向量库的 RAG 问答服务,喂进去的是产品手册和故障记录。日常问题像“XX 功能怎么配置”“错误码 E1003 是什么意思”,向量检索配合提示词完全够用,准确率看着还挺漂亮。直到有一天,运维同事问了一句:“支付接口最近三天频繁超时,按依赖关系看,可能影响哪几个下游服务?”
我第一反应是,这问题得先知道“支付接口”被哪些模块调用,它又依赖哪些模块,然后顺着依赖关系一层层往下找。我第二反应是,向量 RAG 根本干不了这活儿。它会把相关的文档块全部召回,一股脑塞给大模型,结果模型把“支付服务”和“支付回调服务”当成同一个东西,给出了一个完全错误的结论。
这次翻车其实不怪模型,怪的是文本表示方式。纯向量 RAG 的切块策略把文档切成互相独立的片段,片段里“服务 A 调用服务 B”这类关系在语义上是存在的,但在存储结构上是断裂的。模型读到的只有几段文字,没有一张能表达依赖关系的网。所以我动了给 RAG 补一张“关系网”的念头,也就是这篇文章要聊的基于预定义模式的属性图。
1.2 属性图到底是什么:节点、边和属性三件套
属性图(Property Graph)是图数据模型的一种,核心由三个部分组成:节点(Node)、关系(Relation/Edge)和属性(Property)。节点代表实体,关系代表实体之间的语义联系,属性和标签用来描述节点或关系的特征。它在落地时没那么重的本体论包袱,不需要复杂的类继承和推理规则,强调的是“能清楚表达业务事实就行”,这也是它在工程上容易落地的原因。
用生活里的例子解释,你可以把属性图理解成城市地铁图:地铁站是节点,站与站之间的线路是关系,“站名”“线路编号”“首末班时间”是属性。换到 RAG 场景,节点可以是模块、接口、故障工单,关系可以是“被调用”“由某模块实现”“影响”,属性就是版本号、方法名、故障等级这类短字段。
那为什么不用一张大表或者 JSON 文档存关系?因为属性图天然适合多跳查询。关系型数据库用 JOIN 查两层还行,查到第三第四层时 SQL 会复杂到让人想砸键盘;而图数据库的遍历查询写起来像说话一样简单。更重要的是,属性图既能给人直观查看,也能序列化成文本喂给大模型,和 RAG 检索流程无缝衔接。
1.3 “预定义模式”到底是什么:让抽取从不可控变成可对齐
做过知识抽取的朋友应该都有体会:拿一个大模型对着文档做开放抽取,让它自己找实体、找关系,输出看着挺丰富,但落库的时候就崩溃了。同一类关系可能有八种叫法,同一个实体出现各种变体,抽取结果的字段命名乱七八糟,清洗代码要写几百行。这就是没有“预定义模式”(schema)的后果。
预定义模式就是:在建图之前,先明确这张图里只允许出现哪些节点类型、哪些关系类型、每个类型必须携带哪些属性。比如我们规定节点类型只有“文档”“模块”“接口”“故障记录”四种,关系类型只有“文档描述模块”“模块暴露接口”“接口调用接口”“故障记录关联模块”四种,那 LLM 抽取时的自由度就被限制死了,输出 JSON 的字段也变得完全可预期。
这一步对 Data-Processor 管线非常关键。有 schema 约束,后面的校验、清洗、入库才能自动化执行;没有 schema,你可能得人工盯着每一批抽取结果,那就不叫处理器了,叫人工标注系统。所以,预定义模式不是给模型套枷锁,而是给整条数据链路建立可操作性的前提。
2. 预定义模式的设计方法论:从业务提问反推 schema
2.1 先收集高频问题,再决定建什么图
很多人一上来就想“把所有能抽的知识都抽出来”,我建议反过来。先想清楚这个知识库会被问哪些问题,再决定建什么图。我在案例里用的方法是:把历史工单、客服记录、群聊提问全部拉出来,按问题类型做聚类。该企业研发文档库里,高频问题基本集中在三类:
- 模块与接口的归属关系:“XX 功能是哪个模块提供的?”
- 故障影响范围:“某个接口挂了会影响哪些下游?”
- 负责人定位:“这个模块现在归谁管?”
这三类高频问题直接决定了 schema 里必须有什么。模块和接口节点必须有,“模块暴露接口”和“接口调用接口”关系必须有,负责人节点和“归属”关系也必须预留。至于“文档里描述了哪些专业术语”之类的问题,频率实在太低,第一版 schema 干脆不碰。
这样做的好处是,图的范围被控制在可维护的规模内,抽取准确率更容易达标。以后想扩展,随时可以在现有 schema 上增加新类型,而不是一开始就把自己埋进一个巨型图里。
2.2 节点类型与关系类型:数量要克制
schema 设计最容易犯的错是贪多。我见过有人一口气定义了二十种节点、三十种关系,结果抽取阶段 LLM 的准确率惨不忍睹。道理很简单:预定义模式最大的价值是“约束”,但约束过多,模型在判断边界时就会频繁犹豫,比如“这到底是‘文档’还是‘术语’?”
我的经验是,第一版节点类型控制在 8 个左右,关系类型控制在 10 个左右,并且每个类型要有明确的判定标准,最好用一两个正反例子写进提示词。比如“接口”的判定标准是:出现在 API 文档中、有请求方法和 URL 定义的内容才算接口;正文里随便提到一句“支持 HTTP 调用”不算。这个判定标准不是拍脑袋定的,是从问题反推出来的,每个元素都要能回答某个高频问题。
关系类型的取舍也有口诀:只保留“能独立支撑一次查询跳转”的关系。如果一个关系建了但查询时永远用不到,它就是噪音,只会增加抽取出错率。比如“文档更新频率”这种信息,放在文档节点的属性里更方便,没必要单独建一条关系。
2.3 属性字段的边界:哪些进图,哪些放引用
属性图的属性是键值对,字段设计有一条原则:只放短字段,长文本一律不进图。短字段包括模块代码、接口方法、URL 路径、故障等级、版本号、时间戳等,这些字段适合检索、过滤和展示。
而故障的详细描述、解决方案全文、文档正文这些长文本,不该塞进节点属性里。塞进去会让图数据库变得臃肿,查询性能下降,而且这些内容存文档库更合适。我的做法是:在节点上保留一个“来源引用”字段,存文档的唯一标识、页码或块 ID,需要读全文时再回原库取。这个引用字段在建图时是必填项,否则这张图就变成了一座孤岛,出问题都没法回溯验证。
| 适合进属性 | 不适合进属性 |
|---|---|
| 模块编码、接口 URL、故障等级 | 故障详细描述 |
| 版本号、时间戳、负责人 ID | 解决方案全文 |
| 状态标记、语言类型 | 文档正文、评论内容 |
| 来源文档 ID + 块 ID | 大段代码示例 |
3. 案例分析:某企业研发文档库的属性图构建链路
3.1 数据源摸底与格式处理
案例背景是这样:某企业的研发文档库大约有 3000 多份文档,格式上 Markdown 占一半,PDF 和 Word 各占四分之一。内容涵盖产品需求、接口文档、故障处理 SOP、模块负责人说明。第一步不是写抽取代码,而是做数据源摸底,因为我们很快发现三个问题:
- PDF 解析出来的表格经常错位,接口参数列全混在一起;
- Word 文档里残留大量修订批注,会被当成正文抽出来;
- Markdown 文档之间互相复制粘贴,同一段描述在好几个文件里重复出现。
针对这三个问题,要写不同的预处理逻辑。PDF 用按页解析、再单独合并表格区域的方式;Word 先剥掉批注和修订标记再转纯文本;Markdown 按标题层级做结构保留,并把代码块和正文分开处理。预处理阶段的目标只有一个:给抽取阶段提供干净的纯文本。这一步做不好,后面 LLM 抽取的准确率一定崩。
3.2 Data-Processor 的解析与分块管线
整个 Data-Processor 的流程,我拆成四步:解析、清洗、分块、抽取。解析负责把不同格式转成统一的内部文本结构;清洗负责去批注、去重复段落、修正乱码;分块是决定抽取质量的关键一步。
分块不是简单按字数切。对于 Markdown,按标题层级切块,让每个块保持相对完整的语义;对于 PDF 和 Word,按页码范围切块,再把识别出的表格单独作为“表格块”处理。块的大小控制在 800 到 2000 字之间。太小了缺少上下文,LLM 判断不了关系;太大了抽取结果容易混杂多个主题,导致实体归属混乱。
清洗阶段有一个细节容易被忽视:文档里经常出现“相关链接”“参考”之类的引用段落,这些内容往往不是主体,但不处理又容易让实体误连。我的方案是把它们单独保留为“引用块”,抽取时只用它做关系校验,不直接参与主体抽取。同时,每个块都要保留来源元数据:源文件路径、一级标题、页码、块序号,这些元数据最终会成为节点属性里的“来源引用”。
3.3 用预定义 schema 约束 LLM 抽取
抽取环节是整个链条的核心。我用 LLM 做实体和关系抽取,但提示词里必须把预定义 schema 完整写进去,并要求输出严格 JSON。下面是一段可复用的提示词结构:
你是知识抽取引擎。只允许抽取以下节点类型中的实体: - Document: 文档 - Module: 软件模块 - Api: 接口(必须出现在 API 文档中,且有方法/URL 定义) - Incident: 故障记录 - Owner: 负责人 只允许抽取以下关系类型: - DESCRIBES: Document -> Module - EXPOSES: Module -> Api - CALLS: Api -> Api - REPORTS: Incident -> Module - OWNS: Owner -> Module 输出格式: { "nodes": [{"type": "...", "id": "...", "properties": {...}}], "edges": [{"source": "...", "target": "...", "type": "...", "properties": {...}}] } 要求: 1. 每个节点必须有唯一 id,格式建议 entityType:实体名 2. 每个关系中 source 与 target 的实体名必须出现在 nodes 中 3. 无法确定的实体不要输出 4. 属性只输出短字段这段提示词看着简单,但作用很大。“只允许抽取”这几个字,其实就是把数据对齐到预定义 schema 的核心机制。实际抽取出来的 JSON 长这样:
{ "nodes": [ {"type": "Module", "id": "Module:支付服务", "properties": {"code": "pay-svc", "language": "Java"}}, {"type": "Api", "id": "Api:POST /v1/pay", "properties": {"method": "POST", "url": "/v1/pay"}} ], "edges": [ {"source": "Module:支付服务", "target": "Api:POST /v1/pay", "type": "EXPOSES", "properties": {"version": "v1"}} ] }实测中,LLM 大部分时候能严格遵守约束,但偶尔会发明新关系类型、生成 JSON 语法错误,输出结果还需要再过一层 JSON 修复和 schema 校验。
3.4 实体对齐与冲突消解
LLM 抽取完一批块之后,紧接着要做的是实体对齐。最典型的问题是“PayService”“支付服务”“payment-service”其实是同一个实体。如果不做对齐,图里会有一堆重复节点,关系查询会串得乱七八糟。
第一版我用的方案是:每个实体名称先做归一化,转小写、去掉符号和空格;再维护一份别名映射表,比如“支付服务”和“PayService”预先人工标定为同一实体。每一批抽取结果进入图库之前,先用一个轻量的向量相似度做候选匹配,分数高于阈值的合并到已有节点,低于阈值的单独标记为待人工审核。这个办法工程实现不复杂,能解决 80% 的重复问题。
另外一类冲突是属性值冲突。同一个实体在不同文档里出现了不同负责人、不同版本号,我的处理规则是“最新时间戳优先”,因为版本类属性通常以最新为准。比较难判断的关系冲突就生成待确认清单,由业务负责人批量审核,不能指望全自动。
3.5 图数据库写入与一致性校验
实体对齐完成之后就可以入库了。图数据库我用的是 Neo4j 社区版,查询语言是 Cypher。写入时不建议一条一条 CREATE,性能太差,应该用批量提交的方式:
UNWIND $batch AS row MERGE (n:Module {id: row.id}) SET n += row.properties这里用 MERGE 而不是 CREATE,是为了配合之前建立的唯一性约束,防止同 id 重复插入。批量的大小建议控制在 500 行左右一批,太大内存会爆,太小写入效率又上不去。
写入完成之后必须做一致性校验。我用三个查询快速判断数据质量:
// 检查悬挂关系:关系指向的节点不存在 MATCH (a)-[r]->(b) WHERE b IS NULL RETURN r LIMIT 50 // 检查孤儿节点:没有任何关联关系的节点 MATCH (n) WHERE NOT (n)--() RETURN n LIMIT 50 // 统计图中各类节点/关系的数量,和抽取统计对比 MATCH (n) RETURN labels(n)[0] AS type, count(n)悬挂关系比例超过 1%,说明实体对齐有问题,要倒回去重查。孤儿节点数量很多,说明某些关系没有被正确抽取,可能是提示词约束太紧。统计数量用来做前后批次的对账,防止增量更新时节点被重复创建。
4. 图数据如何接入 RAG 检索与效果实测
4.1 两种接入方式:子图序列化与 Text-to-Cypher
图建好之后,怎么让它参与 RAG 检索?主流做法有两类,我实际都试过,各有各的适用场景。
第一类是子图序列化。把图中的子图,比如某个模块直接相关的出边入边,序列化成文本块,存入向量库。用户提问时,先用向量检索找到相关子图文本,再拼进上下文。这种方式改造最轻,能直接复用现有向量 RAG 链路,缺点是子图序列化之后,多跳关系还是容易被截断,相当于把图又拍扁成了文本。
第二类是 Text-to-Cypher。让大模型把自然语言转换成 Cypher 查询,直接在图上执行遍历,再把查询结果组织成上下文。这种方式保留了图的多跳能力,但依赖模型对图结构的理解,以及 Cypher 生成的准确率。
实际项目里我用的是组合方案:先用向量检索召回候选实体 ID,再用这些 ID 去图上做 2 到 3 跳的邻域扩展,把扩展出来的子图序列化作为上下文。这个方案既稳住了召回率,又保住了多跳能力,是目前我觉得最平衡的做法。
4.2 多跳问答的实测对比
为了验证效果,我构造了一组测试集,包含 60 个问题,平均跳数约 2.3。对比了两套方案:纯向量 RAG,和“向量召回 + 图邻域扩展”的图增强 RAG。测试结果如下:
| 问题类型 | 纯向量 RAG 准确率 | 图增强 RAG 准确率 |
|---|---|---|
| 单跳:功能配置 | 78% | 80% |
| 双跳:接口归属影响 | 43% | 71% |
| 多跳:故障传播链 | 28% | 69% |
| 属性查询:版本/负责人 | 25% | 82% |
| 跨模块反事实问题 | 20% | 64% |
单跳问题上两者差距不大,纯向量甚至更省事。但一旦问题需要连续跨两个以上节点,图增强 RAG 的优势就很明显。属性查询这类问题,纯向量 RAG 经常把不同实体的属性混在一起,而图查询能命中确定的节点属性,准确率大幅提升。
4.3 检索失败的边界场景分析
图增强 RAG 也不是万能。实测中我们总结了四类典型失败场景,全部和预定义模式的覆盖范围有关。
第一类,实体类型不在 schema 中。比如用户问某个人的离职影响,而“人员”不是已知节点类型,图查询自然返回空。这时系统应该能识别出“图里没有该类型”,降级回文档 RAG,而不是硬生成错误答案。
第二类,关系方向匹配失败。用户问“谁影响了支付服务”,但图里存的是“故障记录 -> 模块”的关系,没有反向索引。解决方案是在 schema 设计阶段明确关系方向语义,入库时同时生成反向关系视图,或者查询时做双向匹配。
第三类,属性值不一致。比如文档里写“v2.1.0”,图里存成“Version: 2.1.0”。这个只能靠写入时的标准化规则统一,不能指望抽取模型完全解决。
第四类,多实体歧义。用户问“支付服务”,图中存在“支付网关”和“支付核心”两个节点,需要靠上下文消歧。我的做法是,对每个候选节点都做邻域扩展,把两个子图都作为候选取回,让大模型自己判断哪个更契合,而不是只取一个。
4.4 评估闭环:用图谱自身的统计指标做日常监控
接入 RAG 之后,不能光看问答效果,还要定期检查图本身的质量,因为图质量的退化会直接影响提问效果。
我们每个迭代周期都跑四个指标:实体数增量与异常节点占比、悬挂关系比例、孤立节点比例、热点实体度数分布。如果某个模块节点的度数突然暴增,大概率是有重复实体没合并;如果某类关系数量持续为零,说明抽取提示词可能约束过严,需要检查。
这套监控可以写进 Data-Processor 日志,每天自动输出一张质量报表。图数据不是建一次就完事的,它跟代码一样需要持续维护,否则精度就会在不知不觉中劣化。
5. 构建过程中踩过的坑与可复用的工程经验
5.1 LLM 抽取不稳:加了两次校验还是出错
第一个坑比较典型。刚开始上线,LLM 抽取结果经过 JSON 修复和类型过滤,准确率已经到 95% 左右,但入库之后做一致性校验,发现有一部分实体 ID 是“Automatically generated entity 12”这种没有语义的字符串。排查后发现,模型在抽取时如果拿不准,会自动生成占位 ID,而且这种 ID 还能通过重复性校验,因为同一个占位 ID 被反复使用,后写的数据覆盖了先写的数据。
解决方案是加了一条硬规则:实体 ID 必须匹配“类型名 + 冒号 + 实际名称”的格式,否则直接丢弃该节点和所有相关边。同时增加一个“置信度”属性,低于阈值的关系不落库。宁可少一条边,不能错一条边,因为图中一旦出现错误边,多跳查询时错误会沿着路径被放大,比少边的破坏力大得多。
5.2 关系方向抽反:要写清楚“指向谁”
关系方向是所有抽取类项目最容易翻车的点之一。同一句话“支付模块调用账务服务”,模型可能抽成 (Payment)-CALLS->(Accounting),也可能抽成 (Accounting)-CALLS->(Payment),方向不同,语义完全相反。
我在 schema 设计文档里,为每个关系类型写清楚了方向定义和防混淆示例。比如 CALLS 定义成“调用方 -> 被调用方”,并强调“实体 A 调用实体 B,方向是 A 指向 B”。但光写文字不够,还要在抽取提示词的输出示例里给出正确样例和错误样例。实测发现,给了错误样例之后,方向错误率能下降一半。
另外还有一个土办法:入库时同时生成一条等价的反向关系(比如 INVOKED_BY),查询阶段就不用纠结方向了。代价是边数量膨胀,但对几万节点的小规模图完全可接受。
5.3 预定义模式的两难:固化还是演进
预定义模式的优点是稳定,缺点是面对新需求时太死板。案例里的企业后来新加了一批采购合同文档,涉及“供应商”“合同金额”这些新实体,原有 schema 完全没有覆盖。一开始我们硬塞进“文档”节点类型的属性里,结果抽取质量很差,查询也绕。
后面我调整了方案:给 schema 设计一个“通用灰区”。把未匹配到预定义类型的实体,先抽成 GenericNode 节点,关系存成 RELATED_TO,保留原始文本引用。等这类实体在灰区积累到一定量,或者高频问题明确指向它们时,再升级为正式类型。这样做既保住了主线数据的强结构,又给模式演进留了缓冲,思路类似给数据库加一列“其他”字段。
5.4 正式动工前最该做的三件事
最后整理几条对后来者最实用的建议。
第一,先用一个小子集做端到端验证。选 50 份文档、3 个模块,把 schema、抽取、入库、查询、RAG 问答整条链路走通,再扩大数据量。一上来就处理 3000 份文档,很容易被数据质量问题淹没。
第二,测试集和评估问题先写好。动工之前准备 20 到 30 个测试问题,明确预期答案。后面每次调整 schema、调整提示词,都用同一套测试集回归,看准确率变化。否则今天调一组参数,模块类问题变好了,故障类问题又变差,你根本不知道是变好了还是变坏了。
第三,图数据必须有“人审 - 缓存 - 自动”三层机制。全自动抽取不可能 100% 正确,我建议低置信度的实体进入待审队列,审过的结果缓存下来,后续批次优先沿用缓存结果,只有新增内容才重新抽取,能省掉大量重复的模型调用成本。
最后再分享一个小经验,属性图的构建不是一锤子买卖。每轮文档更新,都需要做增量抽取和增量合并。我把 Data-Processor 设计成可以重放的管道,重放时只处理“变更过的块”,其余直接跳过。这样即使抽取模型换了版本,也能在几小时内完成全库重建,而不用担心历史数据被污染。对于想在 RAG 系统里加入关系建模的朋友,建议从最困扰你的一类多跳问题入手,先画一个最小 schema,跑通再扩展。图不是越大越好,能回答好问题的图才是好图。