做知识图谱的人,八成都被非结构化文本喂数据这件事折磨过。数据库里一堆表格好歹能映射,但扔过来几百篇新闻稿、病历描述、法院文书,你能做的第一件事,就是把里面的实体和关系捞出来,整理成 (头实体, 关系, 尾实体) 这种三元组。三元组抽取在信息抽取里是绕不开的核心任务,往上接知识图谱的构建和推理,往下接问答、检索、推荐。而动手之前最重要的一次选择,就是搞清楚你面对的是固定 schema 的限定领域抽取,还是关系不固定、靠模型自由发挥的开放领域抽取。这两条路线的数据、模型、评测方式几乎是两套打法,选错了,后面都是白费功夫。这篇文章我会把两条路线的思路拆开讲,配合可以直接跑的代码,适合正在做知识图谱、搜索引擎或者刚入门 NLP 抽取任务的朋友参考。
1. 内容整体设计与思路拆解
1.1 先想清楚一个问题:你手里的任务到底能不能限定 schema
在动手写代码之前,要先定义问题。我自己踩过不少坑,把这个步骤省了,后面就是灾难。限定领域的定义是:所有的实体类型和关系类型都在一个封闭集合内,例如“人物、公司、职位、地点、时间”这类,关系是“任职于、成立、位于、出生于”。这种场景一般出现在垂直行业,比如招聘信息抽取、商品属性抽取、病历信息抽取。因为领域窄、关系固定,你完全可以把关系列表写死在配置里,让模型在有限的选择里做判断。
而开放领域没有固定的关系集合。比如你让模型从“乔布斯创立的苹果发布了 iPhone”里面抽取任意有意义的三元组,模型可能要抽出“(乔布斯, 创立, 苹果)”、“(苹果, 发布, iPhone)”,也可能抽出“(乔布斯, 是, 苹果创始人)”这种更口语化的表达。关系是开放、不可穷举的,今天出现“抗癌”,明天出现“获得专利”,后天出现“被制裁”,你没法提前把 schema 定死。
判断依据其实很简单:如果你能枚举出关系集合,而且这些关系很长时间不会变,就选限定领域的监督学习;如果关系经常变,甚至用户每天给的查询 schema 都不一样,就选开放领域的生成式抽取。我见过很多项目,明明业务场景是固定的那几种关系,却非要用大模型做开放抽取,结果效果好是好,可每个月光 API 费用就让人肉疼,延迟还动不动两秒起,属于典型的大炮打蚊子。
1.2 限定 vs 开放:两套完全不同的技术栈
这两条路线不只是模型不同,从数据到评测几乎完全另一套体系。我用一个表格把关键差异列出来:
| 对比维度 | 限定领域 | 开放领域 |
|---|---|---|
| 关系集合 | 固定、可枚举 | 开放、动态变化 |
| 典型方法 | 序列标注、联合抽取模型、规则依赖 | 生成式抽取、指令微调、大模型 ICL |
| 数据标注 | 按固定标签集标注实体和关系 | 文本 + 查询 schema 作为输入,输出三元组 |
| 准确率 | 高,垂直场景可到 95%+ | 中等,依赖模型语言理解能力 |
| 可解释性 | 高,每个决策都有对应标签 | 一般,生成结果有时无法溯源 |
| 算力需求 | 相对低,甚至 CPU 能跑 | 相对高,生成式模型开销更大 |
从技术原理上理解这件事,会更清楚为什么会有这种分水岭。限定领域本质上是一个分类问题:给定一段文本和一个候选实体对,判断它们之间是否存在预定义关系。分类问题的好处是边界清晰,模型学的是“判别边界”,所以数据充足的情况下效果非常稳。开放领域本质上是一个生成问题:模型要理解“用户想要哪种关系”,然后从文本中把对应的实体组合起来并组织成三元组。这要求模型具备更强的抽象能力和语义泛化能力,所以通常得靠大模型或生成式训练才能扛住。
我做知识图谱项目快十年了,两条路线都走过。说句实在的,如果业务环境允许你把关系限定到 20 个以内,我从来不推荐一上来就走开放抽取。因为开放抽取的输出自由度太高,下游入库时还要再做关系对齐、实体消歧,整体链路会复杂很多。限定领域更可控,规则也清晰,哪怕效果不够,至少你知道问题出在哪个环节。
1.3 为什么我不建议一上来就上大模型
很多新人看到开放领域就直接选大模型,其实不对。我理解这种心理:大模型即插即用,给一段 prompt 就出结果,看起来最省事。但省事是省在开发初期,后面的成本全跑到运维和生产阶段了。
大模型做抽取有几个绕不开的问题。第一是延迟,尤其在中文长文本上,输入 token 一多,自回归生成的时间会明显拉长,线上服务往往扛不住。第二是成本,无论是 API 计费还是自建 GPU 集群,都比本地小模型贵一个量级。第三是不确定性,同样的输入换一个时间跑,结果可能就不一样,这对知识图谱入库来说很难受——昨天入库一个三元组,今天同一个实体又抽出来一个不同的关系,数据一致性会出问题。
所以我的建议是:先用规则加小型模型搭一个基线,把数据逻辑跑通,再评估要不要升级到大模型。很多时候你会发现,一个基于依存句法的规则脚本,加上一个微调过的 BERT 序列标注模型,已经能覆盖大部分业务场景。大模型只是工具箱里的最后一块拼图,不是唯一的解。
2. 核心细节解析与实操要点
2.1 限定领域抽取的三种常见实现路线
限定领域虽然概念简单,但实现路线差别不小。我拆开讲一下各自的适用场景,方便你判断哪种更适合你的数据。
规则式抽取是代价最低的一种,本质是拿正则表达式或模板去文本里套。比如抽取“出生于”关系,可以写([\u4e00-\u9fa5]{2,4})出生于([\d]{4}年)之类的模板。它的优点是快、零标注、可解释性强,缺点也非常明显:中文的自然语言表达太灵活,“出生于”可以说成“生在北京”、“生于1985年”、“老家在山东”,规则一多维护起来就是灾难。我一般用规则式做冷启动,先抽一批数据看看有哪些典型表达,为后面的模型方案提供参考。
Pipeline 方式是先用实体识别模型把候选实体找出来,再对实体对一一做关系分类。这种方式实现起来最容易,因为实体识别和关系分类都是成熟的分类任务,可以分别用单独的模型训练。但它的缺陷是误差会累积:实体识别漏了一个实体,后面的关系分类再强也白搭。另外,如果文本中候选实体对很多,两两组合做关系分类的计算量会爆炸。
联合抽取模型是目前限定领域效果最好的路线,代表性工作有 CasRel、TPLinker 等。联合抽取的核心思路是让模型同时预测实体边界和实体间的关系,共享底层编码,避免了 pipeline 的误差累积问题。代价是模型结构复杂,训练调参门槛高,对标注数据的质量也很敏感。如果你的团队有算法工程师,数据量也够,联合抽取是首选;如果只是个人项目想快速验证,用 pipeline 就够了。
2.2 开放领域抽取的两种主流玩法
开放领域抽取我把它归成两类:一类是微调生成模型,另一类是大模型 In-Context Learning。
微调生成模型的典型代表是百度开源的 UIE,它把抽取任务统一成了“文本 + 指令”到“结构输出”的序列到序列问题。训练时给模型文本和一段 schema 描述,模型输出对应结构。这类模型的好处是经过特定领域微调后效果稳定,可以本地部署,不需要每次都写长篇 prompt。缺点是训练数据难搞,需要整理成“指令-输出”对,而且要持续维护。
大模型 In-Context Learning 玩法更灵活,核心是把当前要抽取的关系列表作为指令模板,让模型直接输出结构化内容。比如给模型这样的 prompt:请从以下文本中抽取所有三元组,关系类型包括:创始人、产品、总部地点。输出格式为 (头实体, 关系, 尾实体)。对于少量数据、快速验证的场景,这种方法很管用,不用标注一条数据就能跑起来。
我自己的经验是:如果关系 schema 以周为单位变化,大模型 ICL 是最优解,因为改 prompt 成本极低;如果关系稳定、数据量大、要求高准确率,那还是微调一个 UIE 或者序列到序列模型靠谱。因为大模型在“关系非常多”的场景下容易漏抽,而且输出格式越复杂,出错率越高。
2.3 数据与标签设计:最容易翻车的隐藏环节
很多人把注意力全放在模型选型上,却忽略了数据标注和 schema 设计,结果模型怎么调都上不去。我在这里把几个高频翻车点说一下。
实体类型定义要小心粒度。比如你做人物资料抽取,实体类型是“人物”就够了,还是需要区分“艺人”“科学家”“企业家”?粒度太粗,关系分类会失去很多语义;粒度太细,标注难度和模型学习难度都会增加。我的建议是先用粗粒度跑基线,确认有需要在细分。
关系集合要保证互斥且语义清晰。“任职于”和“供职于”这俩关系如果不做归一化,标注人员都会疯掉。你在 schema 阶段就要把同义关系合并,并给每条关系写一个示例。比如“出生于”=“出生地”,“毕业于”=“毕业院校”,示例可以让标注者少犯选择题错误。
还有负样本处理。限定领域关系抽取时,很多候选实体对之间根本没有关系,但很多人在标注时只标了正例,没有标负例。模型学了一堆“有关系的模式”,上线后面对大量无关系实体对,就会疯狂误报。所以标注数据里一定要包含明显没有关系的实体对作为负样本,让模型学会拒绝。
开放领域的数据设计又不太一样。核心是思考“查询 schema 怎么表达”。UIE 这类模型的输入里,schema 起着语法层面的控制作用,schema 描述得越清楚,输出越规范。比如“找出所有公司”和“找出文本中的机构实体”这两种 schema 写法,效果差距可能很大。我习惯在 schema 描述里包含实体类型的语义提示,而不只是一个空泛的名词。
3. 实操过程与核心环节实现
3.1 环境准备与最小依赖
先说环境这一块。以下代码我都基于 Python 3.9+,用到的核心库是transformers、spacy、torch。装好之后下载中文模型即可,整条链路 CPU 也能跑,只是速度慢一些。
pip install transformers torch spacy python -m spacy download zh_core_web_sm如果你想跑后面生成式模型那段,建议至少准备 8G 以上内存,有 GPU 更好。没有 GPU 也没关系,flan-t5-small这种模型在 CPU 上跑个短句子还是能出结果的,就是生成速度感人,一次可能要十几秒。
3.2 限定领域实战:基于规则和依存句法的三元组抽取
很多人一听规则就嗤之以鼻,但在垂直场景浓度很高的文本里,规则能发挥奇效。比如招聘 JD、简历、公告这种形式化文本,句子结构相对规范,用依存句法抽主谓宾效率非常高。
我拿一个例子演示:输入一句话,通过 spaCy 的依存解析核心动词,再找出主语和宾语,拼成一个三元组。
import spacy nlp = spacy.load("zh_core_web_sm") def extract_svo(text): doc = nlp(text) triples = [] for token in doc: if token.dep_ == "ROOT" and token.pos_ == "VERB": subject = None objects = [] for child in token.children: if child.dep_ in ("nsubj", "nsubjpass") and subject is None: subject = child.text elif child.dep_ in ("dobj", "attr", "dative"): objects.append(child.text) if subject and objects: for obj in objects: triples.append((subject, token.lemma_, obj)) return triples text = "乔布斯创立了苹果公司,后来发布了iPhone。" for t in extract_svo(text): print(t)这里有几个坑要提醒。第一,spaCy 中文模型的依存标注并不是百分百准确,尤其遇到长难句,“ROOT”可能落在非核心动词上。第二,抽取出来的三元组里,关系词直接用了动词原文,比如“创立了”,但实际业务通常需要映射成标准关系名。你可以加一个动词到关系的映射表,比如{"创立了": "创始人", "创立": "创始人", "成立": "成立时间"},这一步属于关系归一化,在后面的问题排查里我会详细展开。
这个脚本最大的价值不是拿去做生产系统,而是让你花十分钟就能在真实数据上跑出一个粗糙基线,看看句法结构大概长什么样,高频关系动词有哪些。有了这个基线,下面选择模型方案会更有底气。
3.3 开放领域实战:基于生成模型的灵活抽取
下面进入开放领域。我以 T5 家族为例,因为它结构简单、开源生态好,CPU 也能跑。代码逻辑是:先把文本和期望抽取的 schema 拼成一段 prompt,让模型生成三元组结果。为了保证表达统一,我在 prompt 里明确要求输出“头实体, 关系, 尾实体”的 CSV 格式,这样后处理解析容易很多。
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_name = "google/flan-t5-small" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSeq2SeqLM.from_pretrained(model_name) def extract_open_triples(text, schemas): schema_text = "、".join(schemas) prompt = ( f"请从下面文本中抽取所有关系三元组," f"关系类型只能是:{schema_text}。\n" f"输出格式为每行一个三元组:头实体,关系,尾实体\n\n" f"文本:{text}\n\n" f"结果:" ) inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512) outputs = model.generate( **inputs, max_new_tokens=256, do_sample=False, num_beams=4, ) result = tokenizer.decode(outputs[0], skip_special_tokens=True) triples = [] for line in result.strip().splitlines(): parts = [p.strip() for p in line.split(",")] if len(parts) == 3: triples.append(tuple(parts)) return triples text = "小米公司由雷军创立,总部位于北京,主要产品是小米手机。" schemas = ["创始人", "总部地点", "产品"] print(extract_open_triples(text, schemas))这里有几个参数值得解释。do_sample=False加上num_beams=4,是让模型在生成时走确定性更大的 beam search,而不是随机采样。开放抽取场景下,我强烈建议关闭采样,因为线上服务需要结果可复现,否则每次生成的实体边界都可能不一样。max_new_tokens设成 256 是因为三元组列表长度通常不会太长,但也不能设太短,否则长文本里多个三元组会被截断。
这个方案的优点是把关系列表做成函数参数,每次调用都可以动态传新的 schema,非常适合关系频繁变化的场景。缺点也很明显,flan-t5-small的能力有限,遇到复杂语义或者嵌套句式,输出质量会拉胯。实际项目里我一般会换用flan-t5-large或者更专业的 UIE 模型,但代码流程是差不多的,只需要把模型目录换一下。
3.4 把抽取服务封装成一个可调用的接口
模型写完总得上线,我这里用一个简单的 FastAPI 例子说明怎么把抽取函数封装成服务。注意,这个小服务不追求高并发,适合内部工具或者个人项目直接用。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class ExtractRequest(BaseModel): text: str schemas: list[str] mode: str = "open" @app.post("/extract") def extract_endpoint(req: ExtractRequest): if req.mode == "open": triples = extract_open_triples(req.text, req.schemas) else: triples = extract_svo(req.text) return {"triples": triples}启动方式用uvicorn main:app --reload就行。这里不展开生产级工程细节,但我要提一个很容易被忽略的问题:接口层一定要加超时控制和错误兜底。生成式模型的推理时间受文本长度影响很大,如果上游文本突然变长,服务可能迟迟不返回,导致调用方大量超时重试,进一步拖垮服务。我的做法是在接口层先对输入文本做长度截断,或者在 FastAPI 里配一个超时中间件,避免单个请求把进程拖死。
3.5 效果评估:别只看准确率,还要看抽取错误类型
三元组抽取的评测比一般分类任务复杂,因为错误有几种完全不同的形态。我们经常用的是人工抽检加错误类型归类。
常见错误类型至少有三种:实体边界错误,比如“苹果公司”被抽成“苹果”;关系判断错误,比如把“任职于”抽成“创立”;幻觉错误,模型自己编造了原文没有的三元组。如果你只算准确率召回率,这些错误混在一起,你根本不知道模型短板在哪。我的习惯是抽 100 条结果,人工逐条标记错误类型,然后按类型统计占比。如果实体边界错误占大头,就去优化 NER;如果幻觉多,就要检查 prompt 或者降低模型采样温度;如果关系混淆多,就要检查关系集合定义和训练数据的区分度。
开放领域评测更麻烦一点,因为没有一个固定的 ground truth。我的做法是先让两个人分别抽相同文本,计算抽取结果的一致性(类似 Inter-Annotator Agreement),不一致的地方就是模型和学习目标有歧义的地方。这一步看起来很费人力,但对判断数据质量非常关键。
4. 常见问题与排查技巧实录
4.1 规则式抽取对长难句失效怎么办
用依存句法做规则抽取时,最头疼的就是并列结构、嵌套从句和插入语。比如“乔布斯,这位苹果公司的创始人,在 1976 年创立了这家公司”,spaCy 很容易把“创始人”当成核心名词而不是“创立”的关联成分。
我的临时解法是分层处理:先用规则把句子按标点拆成短句,然后对每个短句单独做依存分析,最后再合并结果。虽然拆句会损失一些跨分句的关联,但单个短句的抽取准确率会明显提升。如果拆句后依然抽不准,我建议别死磕规则,直接切到序列标注或生成式方案,性价比更高。
4.2 生成式模型输出 JSON 解析失败
生成式模型就像个不太靠谱的实习生,你让它输出 JSON,它偶尔会给你来个“JSON:\n{...}”或者带一堆解释文字。我在项目里碰到最离谱的一次,模型输出里混进了“抱歉,我无法回答”这种话,直接把解析器打崩了。
我的处理方式分两层。第一层是在 prompt 里做硬性约束,比如明确写“只输出 JSON,不要包含任何解释”。第二层是解析时做容错处理:先剥掉首尾空白和多余字符,再用正则直接抓{...}片段来解析,或者干脆像刚才的代码示例一样,不让模型输出复杂 JSON,只输出简单的逗号分隔文本。很多时候,降低输出结构的复杂度,比让模型学会精确输出 JSON 容易得多。如果你必须用复杂 JSON,可以尝试让模型按格式化模板输出,比如每个字段占一行,解析时按行还原。
4.3 中文长文本的滑窗切分策略
开放领域抽取时,如果文本太长,模型可能漏掉尾部内容,因为注意力被前面的信息占满了。直接截断又可能切断实体和关系的关键上下文。
最靠谱的做法是滑窗切分,让相邻窗口之间有重叠。窗口大小我一般设为 256 到 512 个字符,重叠 50 个字符左右。切分之后,每个窗口独立抽取三元组,最后再合并去重。合并时要以标准化后的实体作为 key,避免“苹果”和“苹果公司”被当成两个实体。重叠区域如果抽出了重复三元组,保留其中一条即可。
4.4 关系名不统一引发的抽取混乱
这个坑在限定领域和开放领域都会出现。规则抽取抽出来“创立了”,生成模型抽出来“创建”,下游知识图谱入库时,这两个会被当作两个不同的关系,图谱结构直接炸掉。
我的方案是维护一个关系归一化词典。把所有能表达“创建、成立、创办”这类语义的动词统一映射到标准关系名“创始人”或“成立时间”。归一化可以在抽取之后单独做一个后处理函数,也可以用规则批量替换。这个词典需要根据线上数据持续补充,通常我一个月会更新一次,把新出现的同义表达加进去。没有这一步,你的三元组系统越跑越脏。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 实体边界多字或少字 | NER 模型训练数据标注不一致 | 抽检标注质量,统一实体边界规则 |
| 大量幻觉三元组 | 生成模型采样随机性过高 | 设置 do_sample=False,降低 temperature |
| 开放领域漏抽尾部实体 | 输入超过模型有效长度 | 改用滑窗切分并做重叠 |
| 关系名称混杂 | 没有做关系归一化 | 维护关系同义词映射词典 |
| 限定领域误报率高 | 缺少负样本 | 在训练数据中增加负例实体对 |
| 长难句抽不出内容 | 依存句法解析出错 | 先拆句再抽取,或切换成生成式方案 |
这张表基本上覆盖了我日常问诊的大部分情况。当然,每个项目的具体错误类型分布都不一样,还是得按前面说的抽检方法先定位,再对症下药。
最后聊一个我个人的习惯。现在我做新的抽取项目,不管预期多复杂,都会先用规则加依存句法脚本跑一遍基线,拿一批真实数据人工看十分钟,把高频的错误类型列出来,再来决定后面的模型方案。这个习惯帮我省过很多次返工,也避免了一上来就投入大量标注成本。如果你在动手前想试试自己的数据适不适合开放领域抽取,一个小技巧是:先拿一二十条文本,手动写一遍三元组,再统计一下这些关系里有多少是重复的。重复率低得离谱,那就是开放领域没跑了,尽早把生成式方案的链路搭起来才是正事。