☰
中文知识图谱关系抽取从0到1:CAS-KG完整工程实践
2026/10/14 22:42:17 网站建设 项目流程

关系抽取这件事,做过知识图谱的都知道,它是整条流水线上最磨人的一环。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 loss,gamma 设 2,alpha 设 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 转成字符偏移量;最后把偏移量和原始文本对齐,生成训练数据。这一步看着不起眼,实际上最容易出错。实体偏移量错一个字符,模型输入和标签就对不上,训练损失会居高不下。

负样本构造是另一个重要环节。我们用了三种方式:

  1. 直接取句子里真实存在但没有任何关系的实体对,标为 OTHER。
  2. 随机替换头实体或尾实体,生成语义上不存在的关系对。
  3. 从历史错判样本中挑一部分加入训练集,强化模型对易混淆关系的辨识能力。

负样本和正样本的比例要控制,我们实测下来,负正比 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_ids=input_ids, attention_mask=attention_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 结束”。

推理阶段的管线是这样跑的:

  1. 输入一段文本,先做句子切分。
  2. 对每个句子跑 NER,得到候选实体列表。
  3. 对句子里的实体两两组合成候选实体对。
  4. 每个实体对构造一个样本,送进关系分类器。
  5. 输出概率按阈值过滤,生成三元组列表。

这里有一个非常隐蔽但影响巨大的工程问题:实体对数量是实体数的平方。一个长句如果 NER 抽出 20 个实体,那就是 190 个实体对,全部送进模型推理,速度会非常感人。CAS-KG 的应对策略是:推理时只保留置信度最高的前 K 个实体(比如每句最多取 10 个实体,那么最多生成 45 个候选对),并且对关系打分做一个初步过滤,低于 0.1 的直接丢弃,不进入后续阈值判断。这个操作让单句推理时间从几百毫秒降到了几十毫秒。

3.3 部署落地与知识图谱入库

模型训练好后,部署是另外一片雷区。CAS-KG 的服务端用的是 FastAPI,模型加载一次常驻内存,对外只暴露一个接口:输入一段文本,返回抽到的三元组列表。

序列化环节,我们最初直接用 PyTorch 的模型文件部署,后来切到了 ONNX Runtime,推理速度快了 30% 左右,而且内存占用更稳定。要注意的是转 ONNX 时不能只检查跑通,还要对比原始模型和 ONNX 模型的输出差异,尤其是动态输入长度和 batch size 下的一致性。

入库流程是我们踩坑最多的地方。最开始的方案是“模型输出 → 直接写图数据库”,上线第一天就出了问题:同一个事实在十篇文档里出现十次,图数据库里就插入了十条重复关系边。后面改成了这样:

  1. 模型输出三元组,附带各自的置信度和证据句子。
  2. 高置信度(>0.8)的三元组进入自动入库通道。
  3. 0.5 到 0.8 之间的三元组进入人工审核队列,以置信度从低到高排序,优先审最不确定的。
  4. 写入前先做实体和关系的去重检查,存在相同三元组就跳过。

提示:关系抽取的结果一定不要“全自动直接入库”。模型 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(f"best 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-5,warmup 调到 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_len=128,结果上线后发现有些长句被硬截断,跨句关系大量丢失。后来重新导出了一版支持动态序列长度的模型,并把滑窗逻辑和长度校验做了绑定。

第三个坑是同一实体在不同句子抽到不同关系。这是关系抽取的正常现象,因为不同句子的上下文信息不一样。但如果入库时不处理,知识图谱里就会出现同一个实体对不同对象的矛盾关系。CAS-KG 的处理方式是在入库前做实体对级别聚合:同一个头实体和尾实体出现多组关系候选时,取置信度最高的那个关系,同时把证据句子一起存进图数据库的属性里。这样即使抽错了,人工审核时也能看到原始依据。

真要说做完 CAS-KG 关系抽取模块的体会,我最大的感受是:关系抽取的瓶颈从来不在模型结构,而在对数据的理解。预训练模型再强,也不能凭空学会你没标注过的关系表达;评测指标再漂亮,也不能掩盖线上分布偏移的问题。如果让我把这件事重做一遍,我会在动手写模型之前,先把 schema 定义、标注规范、样本去重流程做到位,然后再谈什么参数和网络结构。后续如果要继续迭代,可以考虑把关系抽取和事件抽取合并起来做,让模型对“谁在什么时候对谁做了什么”有更完整的建模,这可能又会是一个让人又爱又恨的大工程了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询