限定领域与开放领域三元组抽取实战:规则、BERT与生成式大模型

发布时间:2026/10/4 12:12:52
限定领域与开放领域三元组抽取实战:规则、BERT与生成式大模型 信息抽取、三元组抽取这两个词放到一起通常意味着一个很具体的业务场景你手里有一堆非结构化文本希望自动把它们变成subject, relation, object这样的结构化三元组为知识图谱、RAG检索、智能问答或数据中台供数。这篇文章我想聊聊在限定领域和开放领域两种路线下的三元组抽取如何做并附上可以直接跑的代码和我在真实项目里的选型思路。先说结论限定领域和开放领域不是谁替代谁而是解决不同问题的两套打法。限定领域的特点是关系集合可控适合用规则或垂直微调模型把精度打上去开放领域的特点是关系不可预穷举适合用生成式大模型做零样本发现。你只要做对一道选择题后面的事就顺了。1. 先想清楚你的关系 schema再谈选型1.1 三元组抽取到底在解什么问题三元组抽取在技术链条上其实是两个子任务的合体实体识别NER和关系抽取RE。实体识别负责把文本里的主体和客体找出来关系抽取负责判断这两个实体之间是什么关系。最后拼成“安小宁 - 担任CEO于 - 星河科技”这样的三元组写入知识图谱或搜索索引。这里有个新手很容易忽略的关键点关系抽取的前提是关系已经被明确定义。比如“担任CEO于”“成立于”“总部位于”“融资事件”这些关系你是不是能提前全部列出来如果能列出来你的任务就是限定领域三元组抽取如果列不出来或者今天列完明天又冒出新的关系类型那你面对的就是开放领域三元组抽取。我见过很多团队在这道选择题上纠结了很久根源是没想清楚下游到底怎么用数据。如果是做企业知识图谱关系就那么几十种完全可枚举如果是做多源资讯的开放事件梳理今天可能抽“收购”明天出来一个“对赌协议”后天出现“股权质押”这种场景你不可能靠预定义schema硬撑。1.2 限定领域和开放领域的边界在哪我用一个表把你需要判断的问题列出来对着这个表做决策比任何抽象概念都直观判断维度限定领域开放领域关系数量通常少于50个可预先枚举不可枚举随数据动态增加实体类型相对固定类别有限实体类型不确定跨度大典型场景企业信息图谱、电商商品属性、医疗病历结构化开放新闻事件、社交媒体情报、学术文献挖掘技术路线规则、BERT微调、UIE等OpenIE、生成式大模型精度要求高直接入库需要过滤和确认通常作为候选维护方式定期加关系、重训模型靠提示词迭代必要时沉淀新关系回到限定域一句话总结限定领域是在一个封闭集合里做精准映射开放领域是在开放集合里做推测和发现。2. 限定领域路线规则基线到 BERT 微调2.1 先用少量正则模板把“冷启动”跑起来很多人一上来就想微调BERT但我建议先写规则。原因有两个第一规则代码透明、可解释业务方和你对齐时能逐个模板确认第二规则产生的抽取结果可以作为后续模型的弱标注数据省下大量人工标注时间。你需要做的第一件事是把关系schema定义成一份可配置的列表每一条都包含关系名、匹配模板和三元组构造函数。我用中文财经新闻举个例子比如“安小宁出生于1990年目前担任星河科技的CEO”这句话我希望抽到两个三元组安小宁, 出生日期, 1990年安小宁, 担任CEO于, 星河科技代码可以这样写import re SCHEMAS [ { relation: 出生日期, pattern: re.compile(r([\u4e00-\u9fa5]{2,4})出生于(\d{4}年\d{1,2}月\d{1,2}日)), build_spo: lambda m: (m.group(1), 出生日期, m.group(2)), }, { relation: 担任CEO于, pattern: re.compile(r([\u4e00-\u9fa5]{2,4})(?:现任|担任)([\u4e00-\u9fa5]{2,20})的CEO), build_spo: lambda m: (m.group(1), 担任CEO于, m.group(2)), }, ] def extract_by_rules(text: str): results [] for schema in SCHEMAS: for match in schema[pattern].finditer(text): spo schema[build_spo](match) results.append({ subject: spo[0], relation: spo[1], object: spo[2], }) return results if __name__ __main__: text 安小宁出生于1990年目前担任星河科技的CEO print(extract_by_rules(text)) # 输出[{subject: 安小宁, relation: 出生日期, object: 1990年}, # {subject: 安小宁, relation: 担任CEO于, object: 星河科技}]这段代码没有任何花哨技巧但它是整条流水线的地基。每发现一种新的句式变体就往SCHEMAS里加一条pattern。我实际项目里规则量通常控制在50个模板以内再多就会出现模板打架比如“成立于”和“正式成立于”会重复命中同一条三元组。规则路线的劣势也很明显召回率低、正则越写越复杂、对口语化和长难句几乎没有抵抗力。所以规则只适合冷启动不适合作为终极方案。2.2 用 BERT 做实体识别与关系分类的简化实现当数据量积累到一定程度就该上模型了。对做工程的人来说我建议直接走“实体识别 关系分类”两步走不要一上来就套复杂的图神经网络比如GCN那套。原因很简单在标注数据只有几千条的情况下BERT系列模型的表现已经足够稳而且排查问题容易。实体识别部分你可以直接用Hugging Face的TokenClassification pipeline也可以自己写一个BIO序列标注的训练脚本。我这里重点展示关系分类部分因为这是限定领域工程里更常见、也更难调好的模块。关系分类任务设计成给定一句话和两个实体位置判断它们之间是哪一种预定义关系。对中文来说最朴素的做法是把[CLS]句子[SEP]主体[SEP]客体[SEP]拼起来输入BERT取CLS向量过一层线性分类器import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer PREDICATES [出生日期, 担任CEO于, 成立于, 总部位于, 注册资本, 所属行业, 不相关] class SPOClassifier(nn.Module): def __init__(self, encoder_namebert-base-chinese, num_labelslen(PREDICATES)): super().__init__() self.encoder AutoModel.from_pretrained(encoder_name) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.encoder.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask): outputs self.encoder(input_ids, attention_maskattention_mask) cls_token outputs.pooler_output return self.classifier(self.dropout(cls_token)) tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model SPOClassifier() def make_input(text, subject, obj): encoded tokenizer( text, subject, obj, paddingmax_length, truncationTrue, max_length128, return_tensorspt, ) return encoded # 示例判断语句中安小宁与星河科技的关系 input_data make_input(安小宁目前担任星河科技的CEO, 安小宁, 星河科技) logits model(**input_data) pred_id torch.argmax(logits, dim-1).item() print(PREDICATES[pred_id])这种输入的构造看起来很“暴力”但实际效果不差。BERT天然能借助SEP分隔符把主体和客体位置分开CLS向量在微调之后会倾向于聚合“整句话 两个实体”的语义。我测试过在1000条标注数据上这类模型几十个关系类别可以做到约0.85的F1已经完全足够做知识库入库了。这里要提醒一个训练细节不要只把“正关系”数据喂进去必须保留一个“不相关”类把那些实体出现但没有任何业务关系的样本也标注出来。否则推理时模型会把所有实体对强行预测成某一种关系这在真实数据里会制造大量垃圾三元组。2.3 微调之后仍要保留规则层的兜底模型上线之后我建议不要把规则模块删掉而是做成双通道召回规则通道匹配速度极快命中即高置信直接产出三元组模型通道对规则没有覆盖到的句子做召回产出候选三元组合并策略同一条关系如果双方都命中以规则结果为准如果只有模型命中并且置信度高于阈值则保留并进入人工抽检队列。这么做的好处是即使BERT模型后续迭代翻车规则层也能挡住最核心的那部分结构化抽取不至于全链路崩掉。我在真实项目里见过很多次模型重新训练之后对某些老样本突然水土不服这时候有一条规则兜底排查和生产切换都从容很多。3. 开放领域路线不写死关系让生成式模型直接给三元组3.1 开放域抽取的真正价值不是“没有 schema”很多文章把开放领域抽取理解成“什么关系都不管模型自己看着办”这个理解有些片面。开放领域的价值在于你允许模型在推理时动态生成关系名而不是从你预设的几十个标签里选一个。举个业务例子。你在监控科技媒体的新闻今天冒出来“某某公司和某某高校签署联合实验室协议”你预先没有定义过“签署联合实验室协议”这种关系。放在限定领域路线里这只能被模型分到“不相关”或者错误的关系类别但开放领域希望模型输出的是[ {subject: 星河科技, relation: 签署联合实验室协议, object: 北川大学} ]关系名是模型现场造的。造得准不准是另一回事但至少这个三元组已经能被索引后续知识图谱上可以挂着这个关系等数据多了再决定要不要沉淀成正式schema。3.2 本地生成式大模型做零样本抽取现在做开放域三元组抽取最省事的路径就是直接上生成式大模型。我自己的选择偏向本地部署开源模型比如Qwen系列的Instruct模型而不是每次请求都走外部API原因有三数据不出本机、对数据敏感场景友好没有按次调用费用推理prompt可以任意调整方便做迭代实验。下面是一段可以直接跑起来的最小实现假设你本地显卡能装下7B模型如果显存不够把模型名换成同系列的小尺寸版本即可import json import re import torch from transformers import AutoTokenizer, AutoModelForCausalLM model_name Qwen/Qwen2.5-7B-Instruct tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForCausalLM.from_pretrained( model_name, torch_dtypeauto, device_mapauto, ) PROMPT_TEMPLATE 你是信息抽取专家。给定一段文本抽取所有能被明确判断的关系三元组。 输出严格JSON数组格式为[{subject: ..., relation: ..., object: ...}]。 如果文本里没有三元组直接输出[]。不要输出任何解释。 文本{input_text} 输出 def openie_extract(sentence: str): prompt PROMPT_TEMPLATE.format(input_textsentence) messages [{role: user, content: prompt}] model_input tokenizer.apply_chat_template( messages, tokenizeFalse, add_generation_promptTrue ) encoded tokenizer(model_input, return_tensorspt).to(model.device) with torch.no_grad(): outputs model.generate( **encoded, max_new_tokens256, do_sampleFalse, temperature0.1, ) generated tokenizer.decode(outputs[0][encoded[input_ids].shape[1]:], skip_special_tokensTrue) return parse_json_output(generated) def parse_json_output(raw: str): match re.search(r\[.*\], raw, re.S) if not match: return [] try: return json.loads(match.group(0)) except json.JSONDecodeError: return [] if __name__ __main__: test_sentence 星河科技今日宣布完成B轮融资投资方包括红杉资本和蓝湖创投资金将主要用于海外市场扩张。 print(openie_extract(test_sentence))这段代码的运行流程很简单把提示词套进chat模板用贪心解码生成输出再通过正则抓取JSON数组部分并解析。这里我故意把temperature设成了0.1do_sampleFalse目的就是让输出尽量稳定。做批量抽取时宁可牺牲一点多样性也不要让同一条文本两次抽取结果完全不一样。3.3 大模型输出解析与幻觉控制用大模型做抽取代码本身不难难在输出控制和可靠性。我踩过一个典型坑模型偶尔会在JSON数组前后加注释比如“json”这种markdown标记或者“以下是抽取结果”这种废话。如果直接用json.loads解析大概率报错。所以我写了上面的parse_json_output用正则先把[...]部分截出来再解析能处理绝大多数脏输出。幻觉问题是个更麻烦的事。大模型可能抽出一个在原文里根本不存在的object比如原文只写了“融资主要用于扩张”模型却脑补出“星河科技成立于2015年”。我对幻觉的应对办法是抽取后在代码里做一次“anchor检查”要求subject和object的关键词必须出现在原句中如果object没出现在原文就把这条三元组降级为“待确认”不直接入库对关键业务场景让模型把支撑原文片段也输出例如扩成{subject: ..., relation: ..., object: ..., evidence: 原文中的一句证据}。考虑到推理成本我会优先选择第一个办法代码就是普通字符串匹配几行就能写完但能过滤掉大量幻觉三元组。3.4 UIE另一种值得关注的开放域抽取方案如果你不想为开放域抽取专门部署一个生成式大模型PaddleNLP里的UIE模型是更轻量的选择。UIE的思路是把实体、关系、事件等抽取任务统一建模成“文本到结构”的生成问题用前缀提示来控制输出格式一个模型可以处理多种schema。使用起来非常简洁from paddlenlp import Taskflow schema [人物, 企业, 职位] ie Taskflow(information_extraction, schemaschema, modeluie-base) result ie(安小宁目前担任星河科技的CEO) print(result)如果你传的是关系抽取场景schema可以写成一组事件名或关系名schema [担任CEO, 融资, 收购] ie Taskflow(information_extraction, schemaschema, modeluie-base) result ie(星河科技今日宣布完成B轮融资并收购了本地一家小型技术团队。) print(result)UIE的优势是模型体积小、CPU也能跑适合批量离线抽取缺点是在极端开放关系上能力不如大模型灵活。我的建议是预算充足且关系高度开放用生成式大模型预算有限且关系类型还是能大致列出一波候选用UIE更香。4. 我做的 300 条新闻测试两条路线的实测对比4.1 测试场景与评测口径为了不空谈我拿手头一个企业舆情项目的数据做了小规模评测。测试文本是300条中文科技新闻短讯每条平均40字关系集合预定义了10种包括“成立时间”“总部地点”“董事长”“CEO”“大股东”“收购事件”“融资事件”“所属行业”“注册资本”“产品发布”。评测方式我统一采用“抽样 人工核对”的办法从300条里随机抽100条由两个标注员独立标注三元组不一致处讨论后确定最终标准答案。模型预测结果和人工标注结果做精确率、召回率、F1计算。这不是严格意义上的学术基准评测只代表我在这个数据规模下的实测感受结论供参考。四条路线分别是规则基线50个正则模板覆盖面以常见句式为主BERT微调训练数据150条验证50条测试100条实体识别用预训练模型关系分类用上一节的自定义模型大模型零样本直接用Qwen2.5-7B-Instruct不提供示例大模型few-shot在提示词里塞入3个示例每个示例都严格按JSON格式输出。4.2 实测结果与我的取舍建议方法精确率召回率F1平均单条耗时备注规则基线92%38%54%2ms精确率高但召回拉胯BERT微调84%79%81%35ms整体均衡需要标注数据大模型零样本62%71%66%1.2s召回不错但关系名自由发挥大模型few-shot76%74%75%1.3s加了示例后关系名规范很多这个表里最扎眼的是规则基线的召回率只有38%。原因很好理解真实新闻里句式千变万化“星河科技成立于2015年”“星河科技是2015年创立的”“星河科技创立至今已走过8年”表达的是同一件事但正则模板只能覆盖前两种比较规整的形态。BERT微调的F1最高说明这种垂直场景拿到一小批标注数据之后微调仍然是最稳的路但它在长尾关系上表现一般比如“对赌协议”“股权质押”这类低频关系训练数据太少就学不动。大模型few-shot比零样本F1高出9个百分点提升主要来自关系名的规范输出。零样本时模型会创造“宣布完成融资”“获投资”等和schema不一致的关系名导致匹配时被判错加了示例之后模型会学着复现示例里的关系名比如统一输出“融资事件”。我的取舍建议是纯入库级精度要求选BERT微调但一定要保“不相关”类冷启动或数据稀疏直接用大模型few-shot跑候选再人工抽检规则不丢掉作为高置信通道精确率高就是它最大的价值大模型跑出来的新关系名定期人工审核后沉淀进限定领域的schema形成“开放发现 - 限定固化”的闭环。这个闭环我后来在实际项目里用得很顺每周用大模型对新到的增量文本跑一遍把模型新造出来的关系名和对应三元组拉出来看如果某个新关系出现频次超过10次就把它加入BERT的关系集合安排人工标注一批下一轮迭代时模型就覆盖住了。5. 落地时绕不开的坑与工程化建议5.1 实体的边界与跨句三元组中文实体切分这件事比大多数人想的更坑。比如“北京市朝阳区星河科技大厦”这种文本如果实体识别阶段把“北京市”“朝阳区”“星河科技”“大厦”切成了四段后续关系抽取就会产生四个候选实体两两组合出16对关系对大部分都是垃圾。我的处理办法是在实体识别后加一层规则合并根据后缀词表把“市”“区”“公司”“集团”“科技”等词尾合并到前一个实体上。合并时机要放在关系抽取之前否则后续所有实体对都会受影响。跨句三元组是另一个隐蔽问题。比如“星河科技团队在今天发布了新产品。”下一句是“该产品主打AI辅助编程。”“该产品”指代上一句的“新产品”关系“发布”的主体和客体分属两个句子。这种问题规则的解法是维护一个简单的指代槽位把上一句的实体名作为候选下一句出现指示词“该”“其”“它”时替换成槽位里的实体。不要在这个问题上追求完美能解决最常见的“该名词”结构就够了复杂指代交给大模型处理更划算。5.2 实体对的组合爆炸关系抽取阶段如果选择“全实体对 分类器”的组合一个句子有10个实体就有了90对pair。即便BERT关系分类的单条推理只有几十毫秒90对算下来也会明显拖慢吞吐而且大量pair属于“完全不相关”白白消耗算力。我建议先用规则做“位置邻近过滤”只有当两个实体在句中的位置距离小于某个阈值时才进入关系分类器。大多数关系的主体和客体在句法上距离很近比如“总部位于”的主客体之间最多隔着两三个词跨过一整个从句再发生关系的占比很低。这个过滤规则能从90对里砍掉一半以上准确率几乎不受影响。5.3 大模型输出解析失败怎么办生成式模型的输出解析失败是每天都要面对的破事。除了前面提到的前后缀脏内容还会出现输出的JSON里存在单引号而不是双引号某个元素末尾漏了逗号模型把整个JSON用markdown代码块包起来关系名里带着引号比如relation: 担任CEO导致json.loads直接报错。我最终形成了解析容错节奏先去代码块标记再提取最外层的方括号接着用json.loads尝试解析失败后用ast.literal_eval一次最后如果还失败就直接丢弃并记录一条日志。丢弃率控制在5%以内就不影响整体效果如果超过5%说明提示词约束不够或生成长度太短需要回去调模板。5.4 从“能抽”到“好用”的三级漏斗结构化数据真正进入业务系统前我会把抽取链路设计成三级漏斗避免模型把垃圾直接灌进知识库第一级规则通道秒级召回结果高置信直接放行第二级BERT模型通道百毫秒级召回置信度超过阈值的结果放行其余进入待确认第三级大模型通道对前两级都没有命中的句子做兜底抽取结果全部打上“待人工审核”标记。三级漏斗的流量比例大概是40%、50%、10%。前两级已经覆盖面足够大大模型真正要兜底的只是长尾部分这样既控制了成本也保证了新关系能被持续发现。我个人在实际项目里体会最深的一点是三元组抽取模型永远不可能做到100%准确与其追求一个完美的模型不如把流程设计成“高精度通道 低成本人工抽检 持续沉淀新关系”的体系。规则、BERT、大模型三条腿走路彼此互补才是这个东西真正能落地的形态。如果你也正卡在开放关系和精确抽取之间的摇摆上我建议你把业务数据的真实样本拉出来先跑一遍按我这个对比框架测一轮答案会比你坐在会议室里拍脑袋清楚得多。