中文知识图谱关系抽取从0到1:CAS-KG完整工程实践

📅 发布时间:2026/10/14 22:42:38
中文知识图谱关系抽取从0到1:CAS-KG完整工程实践
关系抽取这件事做过知识图谱的都知道它是整条流水线上最磨人的一环。CAS-KG 是我们内部迭代的一个中文知识图谱项目代号目标是让机器自动从大批量非结构化文本里抽出结构化的三元组实体1关系实体2。刚开始做实体识别那步还算顺利公开工具和预训练模型都很成熟但一到关系抽取问题就变了味模型不仅要认出“谁是谁”还要理解“谁和谁在上下文里到底有什么联系”这种联系往往是一句自然语言里最微妙的成分。这篇文章把 CAS-KG 关系抽取模块从 0 到 1 的完整过程拆开讲包括方案选型、关系 schema 设计、数据标注、模型训练、参数调优、部署落地以及我在项目里一个个踩过来又填回去的坑。内容尽量保持“拿过来就能参考”的颗粒度不堆术语先把逻辑讲明白再给可执行的细节。这里先交代一下背景CAS-KG 在项目初期面临的是典型的领域知识图谱构建场景语料以中文技术文档、新闻资讯和部分结构化报表为主关系体系里既有“创始人”“任职”“位于”“收购”这类高频通用关系也有不少带行业属性的长尾关系。下面所有方案都是在这个背景下逐步磨出来的。1. 项目整体设计与方案选型1.1 关系抽取在整个知识图谱流水线里的位置知识图谱的标准构建流程基本可以概括为“非结构化文本 → 实体识别NER→ 关系抽取 → 属性抽取 → 实体对齐/融合 → 入库”。NER 解决的是边界和类型关系抽取解决的是两个实体之间语义连线的问题。关系抽取最大的特点是它必须依赖上游 NER 的输出。一旦上游把实体边界切错了比如“某某科技大学”被切成“科技”和“大学”下游做关系分类的时候模型看到的根本不是完整实体后面的判断基本是“垃圾进、垃圾出”。所以 CAS-KG 在前期专门做了一个约束把关系抽取建模成“给定句子 头实体 span 尾实体 span预测实体对之间存在哪种关系”的分类任务。这样设计的好处是关系模块可以暂时不用管实体边界问题专心学句子语义哪个环节出问题也能单独定位不至于一堆 bug 缠在一起。后面我们发现这个决定挺关键。如果一开始就把端到端的“从句子直接抽三元组”作为主线以当时的数据标注条件和工程排期很难在短时间内跑通。先把流水线各环节稳定住再考虑整体优化是知识图谱工程里很务实的一条路径。1.2 技术路线怎么选从流水线到联合抽取关系抽取大致有三条主流路线我逐个说下取舍思路。第一种是流水线式先做实体识别再把句子和实体对拼起来送进关系分类器。好处是简单、可控、可维护每个环节都能独立评测坏处是错误会层层传播上游 NER 一旦漏了实体后面关系再强也抽不到。第二种是联合抽取用一个模型同时输出实体和关系常见做法有标注序列、生成式、阅读理解式等。好处是能在一定程度上缓解错误传播模型能利用实体和关系之间的相互约束坏处是标注格式复杂、调参难度大、训练不稳定而且出错后很难定位到底是实体的问题还是关系的问题。第三种是近年来的大模型方案直接构造 prompt 让大模型输出三元组列表zero-shot 或 few-shot 效果都不错。但它的问题是推理成本高、输出格式不稳定在 CAS-KG 这种需要高速批处理和稳定入库的场景里暂时没有列为生产主力更多是作为辅助标注工具。CAS-KG 第一版选择的是“流水线 关系分类器”主线是先跑通、再优化。联合抽取方案放在二期用来做实体和关系的联合纠正。这个顺序反过来会很痛苦——我见过一些团队一上来就直接上联合模型结果数据格式反复改模型训了几个月还在原地打转。先做简单但可用的东西把数据链路理顺才是知识图谱项目能持续迭代的底气。1.3 为什么把数据工程放在最前面关系抽取模型尤其是微调预训练语言模型的方案本质上是在学“句子里的关系表达模式”。你喂给它的样本里如果都是“A 是 B 的创始人”这种典型句式模型一上线就会被各种变体打趴比如“B 由 A 一手创建”“B 是在 A 的推动下成立的”语义一样字面差得很远。CAS-KG 在数据工程上花的时间比调模型多得多。核心训练集大约 2 万条人工标注样本验证集 3 千条测试集 2 千条另外专门留了一批 5 千条的“线上镜像”语料用于模拟真实部署时的分布。这里要专门强调一句标注样本的分布要比样本总量更重要。如果你只在文档里抽关系测试集也都是文档模型当然表现好一推到新闻、对话、表格描述文本上F1 掉 10 个点都是轻的。所以我们在数据阶段就做了两件事一是把所有关系类型的定义做成可供多人判定的操作型定义避免标注员凭感觉瞎标二是主动收集各种非典型句式确保训练集里同一关系有尽量多的表达形态。数据工程这件事表面看不产生代码实际上它决定了模型效果的上限。2. 核心方案细节与参数设计2.1 关系模式规范化先定 schema 再谈模型关系 schema 是关系抽取的天花板模型只是在拟合 schema 定义结果永远不会超出 schema。CAS-KG 的 schema 设计定了三条原则这三条原则后来也被证明是避坑的关键。第一关系类型粒度要适中。类型太少大量本应区分的关系会混成一团模型学不出区分度类型太细每种关系能分到的标注样本就少长尾问题会非常严重。CAS-KG 第一批关系类型控制在 12 个左右优先保证覆盖高频场景。第二关系方向必须统一。比如“A 是 B 的创始人”和“B 由 A 创办”如果不做方向归并同一个事实会被标成“创始”和“被创始”两个关系模型会被搞晕。我们规定所有关系都采用“主语 → 宾语”的统一视角入库前再做一次方向归一化。第三关系定义要可判定。最怕出现“关联”“相关”这种什么都能往里塞的关系标注员看到两个实体同时出现就标一个“关联”最终模型学到的全是噪声。CAS-KG 的做法是给每个关系写一句判定规则比如“只有当句子中出现创办、创建、发起等明确动作且该动作指向被标注对象时才标为创始关系”。下面是一个简化版的关系类型定义表供参考关系类型判定标准正向示例反向示例创始句子中出现明确的创办/创建动作A 创办了 BB 由 A 创办任职句子中出现担任/任职/就职等表述A 担任 B 的 CEOB 的 CEO 是 A位于句子中出现地理方位或地址表述A 位于某地 BB 是 A 的所在地成立于句子中出现成立/创建时间A 成立于 2010 年2010 年是 A 的成立时间收购句子中出现收购/并购/入股等动作A 收购了 BB 被 A 收购发表句子中出现发布/发表/推出等动作A 发表了论文 BB 是 A 发表的论文参与句子中出现参与/加入/出席等动作A 参与了项目 BB 有 A 参与这里有个很实际的体会schema 设计阶段最好让标注负责人、算法负责人和业务方一起评审不要算法一个人闷头定。业务方最知道哪些关系是实际关心的标注负责人最知道哪些关系在真实语料里经常混淆。2.2 模型结构、损失函数与阈值策略CAS-KG 的关系分类模型基于中文预训练语言模型加一层线性分类头。输入序列处理时在头实体前后插入 [HEAD] 特殊标记在尾实体前后插入 [TAIL] 特殊标记然后把 [CLS] 位置输出的向量接一个 sigmoid 多标签分类器。这里有一个新手容易踩的坑为什么用 sigmoid 而不是 softmax因为一个实体对在同一个句子里可能同时具备多种关系比如说“某公司 A 收购了某公司 B”可能同时满足“收购”和“投资”两类标签。多标签场景用 sigmoid 更自然每个类别独立输出一个 0 到 1 的概率也方便后面用阈值单独控制每一类的召回和精确率。损失函数最开始用的是二值交叉熵但很快就发现类别不平衡问题非常严重。负样本没有关系的实体对数量远远超过正样本模型很快就学会了“全输出 0 也能拿到很高的准确率”。所以后来切成了 focal lossgamma 设 2alpha 设 0.25目的就是让模型把注意力放到那些难分的小类别样本上。阈值策略这块很多人习惯性把阈值固定在 0.5这在关系抽取里是大忌。CAS-KG 的做法是先在验证集上对所有类别统一扫描阈值扫描范围 0.2 到 0.7、步长 0.05选 F1 最高点然后再对高频类和长尾类分别扫描。为什么分别扫因为不同关系的置信度分布差异很大统一阈值会让低频类要么全漏掉、要么带上大量噪声。2.3 一组可复用的基线训练配置下面是我们训练关系分类模型时采用的一组基线配置虽然不是最优解但具备很好的参考价值新手可以直接拿去做初始实验。配置项取值说明预训练模型中文预训练语言模型用全词掩码版本比通用版本在中文任务上更稳最大序列长度128绝大多数关系样本不超过这个长度Batch Size32单卡 16G 显存实测可以跑学习率2e-5BERT 类模型微调的标准区间Warmup 比例0.1前 10% 步数做学习率预热训练轮数6早停 patience 设 2权重衰减0.01防止过拟合梯度裁剪1.0避免 loss 震荡初始阈值0.45实际以验证集扫描结果为准关于最大序列长度这里解释一下为什么不是越长越好。关系分类的样本绝大多数是单句长度超过 128 的句子通常包含多个实体对与其硬截断不如按句切分。CAS-KG 在推理阶段对长文本做了滑窗处理窗口长度 256相邻窗口重叠 64 个字符确保跨句关系不因为切分而丢失。这个细节在上线后验证很有效后面会展开说。3. 实操过程与关键模块实现3.1 数据标注与预处理全流程标注工具我们用的是开源的标注平台支持实体和关系的联合标注。这个环节的成败不看工具看规范。CAS-KG 的标注规范里专门写了几条硬规则所有关系必须基于句子字面意思不能靠常识推理补全。比如“A 是 B 的老师B 是 C 的老师”模型只抽 A 和 C 之间有没有师生关系不行因为句子没有直接表达。跨句关系不标。如果关系跨越两个句子宁可漏掉也不要在单句样本里强行标。实体嵌套时优先标最长完整实体。比如“某医科大学附属医院”如果 NER 输出“医科大学”和“医院”两个候选关系模块只对“某医科大学附属医院”这个完整实体做关系判定。两人标同一句话结果不一致时必须有第三人仲裁不能简单投票了事。预处理流程大致是这样的原始语料先去 HTML 标签、统一中文引号和破折号、把全角数字转半角然后按句号、分号切分句子接着把 NER 输出的实体 span 转成字符偏移量最后把偏移量和原始文本对齐生成训练数据。这一步看着不起眼实际上最容易出错。实体偏移量错一个字符模型输入和标签就对不上训练损失会居高不下。负样本构造是另一个重要环节。我们用了三种方式直接取句子里真实存在但没有任何关系的实体对标为 OTHER。随机替换头实体或尾实体生成语义上不存在的关系对。从历史错判样本中挑一部分加入训练集强化模型对易混淆关系的辨识能力。负样本和正样本的比例要控制我们实测下来负正比 2:1 左右比较合适。负样本太多模型会过度偏向“无关系”负样本太少模型会把没有关系的实体对也硬套上一个关系。3.2 模型训练与推理管线实现训练部分的核心代码其实比很多人想象中简单。下面这个简化版示例展示了模型主干结构完整代码还包含数据处理和阈值扫描但核心就是“编码器 分类头”。import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer class RelationModel(nn.Module): def __init__(self, model_name_or_path: str, num_labels: int): super().__init__() self.encoder AutoModel.from_pretrained(model_name_or_path) self.classifier nn.Linear( self.encoder.config.hidden_size, num_labels ) def forward(self, input_ids, attention_mask): outputs self.encoder( input_idsinput_ids, attention_maskattention_mask ) cls_vec outputs.last_hidden_state[:, 0, :] logits self.classifier(cls_vec) return logits这里的关键不在模型结构而在特征构造。训练样本的格式是 JSON 行每行大概长这样{text: A公司在2020年收购了B公司, h: {name: A公司, pos: [0, 3]}, t: {name: B公司, pos: [8, 11]}, relation: 收购}送入模型前要把实体位置转成 token 位置并且在实体前后插入特殊标记。CAS-KG 使用分词器里预留的 [unused] 标记来标注实体位置这样模型就能直接感知“头实体从哪个 token 开始到尾实体哪个 token 结束”。推理阶段的管线是这样跑的输入一段文本先做句子切分。对每个句子跑 NER得到候选实体列表。对句子里的实体两两组合成候选实体对。每个实体对构造一个样本送进关系分类器。输出概率按阈值过滤生成三元组列表。这里有一个非常隐蔽但影响巨大的工程问题实体对数量是实体数的平方。一个长句如果 NER 抽出 20 个实体那就是 190 个实体对全部送进模型推理速度会非常感人。CAS-KG 的应对策略是推理时只保留置信度最高的前 K 个实体比如每句最多取 10 个实体那么最多生成 45 个候选对并且对关系打分做一个初步过滤低于 0.1 的直接丢弃不进入后续阈值判断。这个操作让单句推理时间从几百毫秒降到了几十毫秒。3.3 部署落地与知识图谱入库模型训练好后部署是另外一片雷区。CAS-KG 的服务端用的是 FastAPI模型加载一次常驻内存对外只暴露一个接口输入一段文本返回抽到的三元组列表。序列化环节我们最初直接用 PyTorch 的模型文件部署后来切到了 ONNX Runtime推理速度快了 30% 左右而且内存占用更稳定。要注意的是转 ONNX 时不能只检查跑通还要对比原始模型和 ONNX 模型的输出差异尤其是动态输入长度和 batch size 下的一致性。入库流程是我们踩坑最多的地方。最开始的方案是“模型输出 → 直接写图数据库”上线第一天就出了问题同一个事实在十篇文档里出现十次图数据库里就插入了十条重复关系边。后面改成了这样模型输出三元组附带各自的置信度和证据句子。高置信度0.8的三元组进入自动入库通道。0.5 到 0.8 之间的三元组进入人工审核队列以置信度从低到高排序优先审最不确定的。写入前先做实体和关系的去重检查存在相同三元组就跳过。提示关系抽取的结果一定不要“全自动直接入库”。模型 F1 再高线上数据一变错误照样会出现。哪怕只保留 1% 的人工抽检长期来看都能帮你提前发现很多模型退化的问题。4. 效果评测与调优实录4.1 评测口径别被一个漂亮 F1 骗了关系抽取的评测口径直接决定了你看到的是“真效果”还是“假效果”。CAS-KG 内部同时统计两种口径。严格匹配关系类型、头实体边界、尾实体边界、实体类型全部一致才算预测正确。宽松匹配只要实体文本对得上、关系类型对就算正确不管 NER 是否把实体边界多切了一个字。两种口径的指标差距经常在 5 到 10 个点之间。开发阶段用宽松匹配方便观察关系分类本身的能力但是在决定是否入库这个环节必须看严格匹配的指标因为边界多一个字实体对不上入库就是错的。另外测试集切分有一个特别容易犯的错误直接把标注好的样本随机划分导致同一个句子的不同实体对同时出现在训练集和测试集里模型相当于见过标准答案了。CAS-KG 的做法是训练集、验证集、测试集按句子维度划分保证任何一条句子不会同时出现在两个集合里。评测指标方面不要只看 accuracy。关系抽取这种正负样本极度不平衡的任务里accuracy 会膨胀到 98% 以上但正类召回率可能只有 20%。我建议重点关注 macro-F1 或者按关系类型分别看 F1尤其是低频长尾关系的表现这才是真实业务最关心的地方。4.2 三类典型错误与改进方式CAS-KG 在调优过程中整理过一批典型错误下面用表格列出最有代表性的三类以及对应的处理手段。错误类型现象根因改进方式语义等价但句式变体漏召回“B 由 A 一手创建”没有被识别为“创始”关系训练集里典型句式占比过高模型只记住了“A 创办 B”加入同义句式增强用人工改写和同义词替换扩充训练集实体类型混淆导致误判“某公司”和“某系统”同名模型把没有关系的实体对判成“收购”模型过度依赖实体表面文本忽略了上下文和实体类型约束在输入中拼接实体类型特征或用 NER 类型过滤不合法关系候选长尾关系几乎全挂“获奖”“参与”等低频关系 F1 不到 0.1样本太少模型学不到区分度对这些关系做过采样损失函数按类别频率加权第一类问题最头疼因为它反映的是训练数据覆盖度不足。我们的补救办法是专门找人写了一版“句式变体库”把每个关系最常见的 20 种表达写出来然后利用实体替换自动扩充训练样本。比如“创始”关系的变体库就包括“A 创办了 B”“B 由 A 一手创建”“A 是 B 背后的创办人”“B 是在 A 的推动下成立的”等等。第二类问题的根子在于 NER 类型和关系 schema 的约束没有打通。实际改造时我们在输入序列里额外加了一个形如“[TYPE:公司] A公司 [TYPE:机构] B公司”的类型提示效果立竿见影很多明显不合法的关系被滤掉了。这比单纯加样本省力得多。第三类长尾问题不能全靠模型硬扛。CAS-KG 对高频关系和低频关系分了两个不同的阈值低频关系用更低的阈值换取召回然后靠人工审核兜底保证尽可能不让低频但重要的关系被漏掉同时用人工把关控制入库噪声。4.3 调优实操阈值扫描与分类型调参调参阶段一定要记录每个实验的完整参数和指标别嫌麻烦。CAS-KG 里有一个专门的实验记录表每条记录包含模型版本、训练数据版本、阈值、各关系类型的 F1、总 macro-F1、备注方便横向对比。阈值扫描的 Python 伪代码如下这段逻辑可以嵌在训练脚本里每次训练完自动跑一遍best_score 0.0 best_threshold 0.5 for threshold in [t / 100 for t in range(20, 70, 5)]: val_score evaluate(val_dataloader, model, threshold) if val_score best_score: best_score val_score best_threshold threshold print(fbest threshold: {best_threshold}, macro-F1: {best_score:.4f})分类型调参的经验是高频关系用高阈值低频关系用低阈值。CAS-KG 最终线上配置如下高频关系“创始”“任职”“位于”阈值 0.65。中频关系“成立于”“收购”阈值 0.5。低频关系“获奖”“参与”阈值 0.35。统一阈值 0.45 时整体 F1 是 0.72分类型调参后整体 F1 涨到了 0.76其中长尾关系提升最明显。另外每训练完一个 epoch 都要在验证集上算一次指标保存效果最好的 checkpoint不要直接拿最后一个 epoch 的权重去上线。预训练语言模型微调时间久了容易过拟合验证集 F1 经常在第 5 或第 6 个 epoch 就开始掉宁少勿多。5. 高频问题排查与避坑记录5.1 数据问题比模型问题多得多做关系抽取半年我最大的感受是80% 的坏效果是数据问题不是模型问题。下面这些问题基本都能在训练日志里看到信号提前对照排查能省很多时间。现象可能原因排查方向解决建议训练 loss 不降学习率过大 / 标签错乱 / 实体偏移量不对打印一批训练样本人工检查输入和标签学习率降到 2e-5warmup 调到 10%并用脚本校验实体偏移模型总是输出“无关系”正负样本比例失衡统计训练集中各类别数量分布负样本下采样到正样本 2 倍左右或用 focal loss验证集 F1 高但线上效果差训练集和线上文本分布不一致用线上语料做一份“镜像测试集”定期从线上抽样加入训练集做增量训练验证指标忽高忽低训练集和验证集句子重叠检查数据划分是否按句子去重按句子维度重新切分数据同一实体对抽到多个矛盾关系实体对在长句中被切分到不同窗口检查滑窗策略确认实体是否完整在窗口内使用窗口重叠并保留完整实体的样本其中“训练集和线上分布不一致”是上线后最难发现的问题。CAS-KG 当时就吃过这个亏测试集 F1 0.8上线后线上抽检准确率只有 0.65。后来排查发现训练语料以正式文档为主线上实际输入里有大量口语化、碎片化的表达句式完全不同。从那以后我们每次上线前必须拿一批真实线上数据跑一遍效果达不到阈值就不允许上线。5.2 标注环节的隐蔽坑标注环节有几个坑不做到位后面模型再怎么调都白搭。第一个坑是标注员会把“自己脑补出来的关系”标进去。比如一句“A 参加了 B 组织的会议”标注员可能会标“A 是 B 的成员”但句子并没有表达“成员”这层含义。我们的标准是句子怎么表达就怎么标不推理、不补全、不脑补。第二个坑是关系类型定义太抽象。前面已经说过“关联”“相关”这类类型一定不要出现在 schema 里。如果你真的发现两个实体就是存在一种说不清的关系那就先不标不要硬套一个模糊标签。第三个坑是缺少仲裁机制。两个人标同一句话结果不一致如果不处理模型学到的就是冲突的标签。比较好的方案是三人标注加仲裁或者至少做到“双人标注不一致时自动进入复核队列”。这个流程看起来慢实际上能显著提升标注质量模型少学一堆噪声后期调参时间能省回来。5.3 推理与部署的工程坑工程层面有几个坑是用到才发现的。第一个坑是 tokenizer 对英文和数字的 token 切分不一致。中文文本里夹着英文单词或数字时字符偏移量转 token 索引很容易错位。CAS-KG 专门写了一个对齐函数转换后还会检查实体 token 的文本是否和原符号一致不一致就报错。这个校验非常重要但很多代码库里被省略了。第二个坑是 ONNX 动态 shap。导出 ONNX 模型时如果不配置动态轴服务端就只能接受固定长度的输入。我们一开始图省事固定了 max_len128结果上线后发现有些长句被硬截断跨句关系大量丢失。后来重新导出了一版支持动态序列长度的模型并把滑窗逻辑和长度校验做了绑定。第三个坑是同一实体在不同句子抽到不同关系。这是关系抽取的正常现象因为不同句子的上下文信息不一样。但如果入库时不处理知识图谱里就会出现同一个实体对不同对象的矛盾关系。CAS-KG 的处理方式是在入库前做实体对级别聚合同一个头实体和尾实体出现多组关系候选时取置信度最高的那个关系同时把证据句子一起存进图数据库的属性里。这样即使抽错了人工审核时也能看到原始依据。真要说做完 CAS-KG 关系抽取模块的体会我最大的感受是关系抽取的瓶颈从来不在模型结构而在对数据的理解。预训练模型再强也不能凭空学会你没标注过的关系表达评测指标再漂亮也不能掩盖线上分布偏移的问题。如果让我把这件事重做一遍我会在动手写模型之前先把 schema 定义、标注规范、样本去重流程做到位然后再谈什么参数和网络结构。后续如果要继续迭代可以考虑把关系抽取和事件抽取合并起来做让模型对“谁在什么时候对谁做了什么”有更完整的建模这可能又会是一个让人又爱又恨的大工程了。