☰
限定领域与开放领域三元组抽取实战:规则、BERT与生成式大模型
2026/10/4 12:12:42 网站建设 项目流程

信息抽取、三元组抽取这两个词放到一起,通常意味着一个很具体的业务场景:你手里有一堆非结构化文本,希望自动把它们变成(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_name="bert-base-chinese", num_labels=len(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_mask=attention_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, padding="max_length", truncation=True, max_length=128, return_tensors="pt", ) 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_dtype="auto", device_map="auto", ) PROMPT_TEMPLATE = """你是信息抽取专家。给定一段文本,抽取所有能被明确判断的关系三元组。 输出严格JSON数组,格式为[{"subject": "...", "relation": "...", "object": "..."}]。 如果文本里没有三元组,直接输出[]。不要输出任何解释。 文本:{input_text} 输出:""" def openie_extract(sentence: str): prompt = PROMPT_TEMPLATE.format(input_text=sentence) messages = [{"role": "user", "content": prompt}] model_input = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) encoded = tokenizer(model_input, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model.generate( **encoded, max_new_tokens=256, do_sample=False, temperature=0.1, ) generated = tokenizer.decode(outputs[0][encoded["input_ids"].shape[1]:], skip_special_tokens=True) 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.1,do_sample=False,目的就是让输出尽量稳定。做批量抽取时,宁可牺牲一点多样性,也不要让同一条文本两次抽取结果完全不一样。

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", schema=schema, model="uie-base") result = ie("安小宁目前担任星河科技的CEO") print(result)

如果你传的是关系抽取场景,schema可以写成一组事件名或关系名:

schema = ["担任CEO", "融资", "收购"] ie = Taskflow("information_extraction", schema=schema, model="uie-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、大模型三条腿走路,彼此互补,才是这个东西真正能落地的形态。如果你也正卡在开放关系和精确抽取之间的摇摆上,我建议你把业务数据的真实样本拉出来先跑一遍,按我这个对比框架测一轮,答案会比你坐在会议室里拍脑袋清楚得多。

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

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

立即咨询