1. 实体抽取到底在解决什么问题
实体抽取,行内更常叫命名实体识别,也就是 NER,其实做的事情就一句话:从一段非结构化文本里,把“人名、地名、机构、时间、金额、编号”这类有明确指向的片段挑出来,并且告诉下游它属于哪一类。你如果做过后台系统,可以把它理解成一次“带类型的正则匹配”,只不过匹配规则不是人写死的,而是模型从标注数据里学出来的。它解决的核心痛点是:业务系统需要结构化字段,而人给进来的永远是自由文本。合同里的甲方乙方、病历里的症状和药品、商品评论里的品牌型号、客服工单里的设备编号、简历里的学历和工龄,本质上都是同一件事——把一段话变成一张能入库、能查询、能统计的表。
我把这个方向当成长期在做的活,踩过的坑比看过的论文多。这篇文章面向的是准备上手做实体抽取的人:有 Python 基础、大致知道什么叫训练集验证集、但还没完整跑通过一个抽取项目。我会把技术选型的逻辑、标注规范的写法、训练参数怎么算、后处理怎么补、线上出了 badcase 怎么定位,一条线讲完。中间涉及的代码和配置都是可以直接改成自己的数据跑起来的,参数我会解释为什么取这个值,而不是甩一个数字给你。标题叫“NLP 之实体抽取”,那我就只围绕这一件事往深里挖,不铺开讲整个 NLP 的版图。
1.1 从业务需求倒推任务定义
很多人一上来就问“用 BERT 还是用大模型”,这个问法本身就有问题。正确的顺序是先问清楚三件事:下游拿这些实体干什么、实体类型定义到什么粒度、允许多大的漏召。举个具体的例子,某公司做售后工单分流,需要从用户描述里抽出产品型号和故障现象,然后路由到对应的处理组。这时候产品型号必须是精确跨度,多一个字少一个字都路由错;而故障现象允许模糊,因为后面还有人工确认。同样是“实体抽取”,两个字段的精度要求完全不同,你不可能用一套阈值去卡。
再往下拆,还有“跨度”和“关系”的区别。实体抽取只负责圈出片段并打类型标签,不负责判断两个实体之间的关系。比如“某公司于 2023 年 5 月收购了另一家公司”,实体抽取只输出三个片段:机构、时间、机构;谁收购谁,那是关系抽取的活。我见过不少项目把这两件事混在一个模型里做,最后标注规范写得一塌糊涂,标注员都不知道该不该把“收购”标进去。所以任务定义这一步,必须把“只标什么、不标什么”写成白纸黑字的清单,而不是靠口头约定。
粒度问题也值得单拎出来说。以“时间”为例,你是要标“2023 年 5 月”,还是要标“2023 年 5 月 12 日下午三点”?前者够用于统计月度趋势,后者才能做日程提醒。粒度定得越细,标注一致性越差,因为“下午三点”和“15:00”在不同标注员眼里边界完全不同。我的经验是:先按最小可用粒度定义,跑通一轮看 badcase 再决定要不要拆细,一上来就追求最细粒度,标注返工率会高得让你怀疑人生。
1.2 主流技术路线的取舍
实体抽取能走的路就那么几条,我把它们摆在一起对比过很多次,结论是:没有一条路能包打天下,关键看你的数据量和迭代节奏。
| 路线 | 典型做法 | 优点 | 短板 | 适合场景 |
|---|---|---|---|---|
| 词典 + 规则 | 实体词典 + 正则 + 前后缀约束 | 冷启动快,可解释,改动即时生效 | 泛化差,维护成本随词典膨胀 | 实体封闭、形态固定,如型号、编号 |
| 传统序列标注 | 特征工程 + CRF | 小样本表现稳,训练快 | 特征要人工设计,上限有限 | 数据量几百条以内 |
| 深度序列标注 | 预训练模型 + BiLSTM + CRF | 泛化好,是当前主力 | 需要标注数据,推理有成本 | 大多数通用场景 |
| 生成式抽取 | 用大模型做指令式抽取或 few-shot | 零样本可用,改需求快 | 稳定性、延迟、成本、幻觉 | 冷启动探索、长尾类型 |
| 指针/片段分类 | 先召回候选片段再分类 | 对长实体友好 | 流程长,工程复杂度高 | 实体特别长、边界模糊 |
我的实际打法是“两条腿走路”:主链路用预训练模型做序列标注保底,旁路挂一个词典规则做兜底和纠错。为什么?因为模型对高频实体很稳,但对“新出现的型号命名规则”几乎必然漏,而这类东西用一条正则就能补上。反过来,规则对上下文依赖强的实体(比如“苹果”是水果还是机构)无能为力,模型恰好擅长这个。两者不是替代关系,是互补关系。
至于要不要上大模型,我的判断标准很朴素:如果标注数据能凑到两三千条以上,先训小模型;如果类型经常变、每条样本都要重新定义,先用大模型顶一阵。大模型做抽取最大的问题不是效果,是同一个输入跑两次结果可能不一样,这在需要入库的业务里是致命的。所以它更适合放在“辅助标注”或者“冷启动探路”的位置,而不是直接扛线上流量。
1.3 先把评价标准钉死再动手
这一条我吃过亏,所以放在最前面说。实体抽取的评价有两套口径:token 级和实体级。token 级就是逐个字判断对错,实体级是整段完全匹配才算对。很多开源脚本默认输出 token 级 F1,看着能到 0.95,你换实体级一算掉到 0.7,因为边界错一个字整条就废了。
业务上真正在意的是实体级。用户要的是“这个型号被完整抽出来了”,不是“这个型号里 8 个字对了 7 个”。所以我在项目启动时就会把评估脚本固定下来:按实体类型分别算 precision、recall、F1,统计口径统一用完全匹配,同时额外记录一个“边界偏移一字符以内”的比例,用来区分“完全没抽到”和“抽到了但边界差一点”。这两类错误的修复成本差得很远,前者要补数据,后者往往改个后处理规则就行。
还有一点,别只看总体 F1。如果某一类实体只占样本的 2%,它在总体指标里几乎没有存在感,但对业务可能是关键字段。我在一个项目里就遇到过:整体 F1 0.89 看着挺好,拆开看“金额”这一类只有 0.52,因为标注里金额写法太杂,有带千分位的、有带中文单位的、有写“约”的。这种问题只有分类别看才暴露得出来。
2. 数据准备:八成胜负在这一步
模型结构那点差异,在数据质量面前基本可以忽略。我带过的一个模拟项目,同一套 BERT + CRF 结构,第一版标注返工前 F1 是 0.71,规范重写、重标一遍之后直接到 0.86,模型一行没改。所以这一章我讲得细一点,都是真金白银换来的。
2.1 标注规范怎么写才不返工
标注规范不是写给标注员看的说明书,是给“未来的你和标注员之间的仲裁依据”。写规范时最容易偷懒的一句话是“标注出文本中的实体”,这句话等于没说。规范的每一节都应该回答一个具体的边界问题。我一般会按下面这个清单逐条写:
- 边界规则:实体前后的修饰词算不算?比如“大型机械设备”,“大型”是修饰语,通常不标;“某某科技有限公司深圳分公司”,全称还是只标到“公司”,要明确。
- 嵌套处理:实体里套实体怎么办?“北京大学附属医院”里“北京大学”是机构,“北京大学附属医院”也是机构。序列标注体系天然不支持重叠,所以要提前定:要么只标最外层,要么拆成两条独立样本。
- 指代与省略:“该公司”“上述产品”这类指代要不要标?我一般建议不标,因为抽出来也没法用,但要在规范里写明,否则标注员会自由发挥。
- 并列缩写:“甲、乙、丙三家公司”,是标三个还是标一个整体?这个不定清楚,标注一致性直接崩。
- 归一化字段:时间、金额、数量要不要同时给出标准化写法?“二〇二三年五月”要不要转成“2023-05”?如果下游要入库,强烈建议在标注阶段就一并做了,比后期再写规则转换省事得多。
规范写完之后,我会挑 30 到 50 条样本,让两个标注员独立标一遍,然后逐条对齐分歧。这个动作叫“预标注对齐”,花半天时间,能省掉后面几千条返工。分歧点全部补进规范,形成带例子的条款。规范里有例子和没例子,执行效果完全是两回事——标注员遇到模糊情况时,第一反应是找例子,不是读定义。
2.2 标注质量抽检与一致性计算
标完不是就完事了,必须抽检。我的做法是:按 10% 比例随机抽样,由第二个人重标,计算一致性。这里用 Cohen's Kappa 比简单算一致率更靠谱,因为它扣掉了“瞎猜也能对上”的部分。经验值上,Kappa 低于 0.75 就说明规范还有歧义,得回去改;0.85 以上基本可以放心往下走。
抽检的重点不是找标注员的错,而是找规范的漏洞。我一般会把分歧样本按类型归类,通常是三类:边界分歧、类型分歧、漏标。边界分歧最多,占比往往过半,解决办法是把典型例子直接贴进规范;类型分歧往往是类型定义有交叉,比如“品牌”和“机构”在某些语境下重叠,这时候要么合并类型,要么加一条优先级规则(比如“能归品牌就归品牌”);漏标则通常是因为标注员疲劳,可以通过分批标注、每批不超过 500 条来缓解。
还有一个容易被忽略的点:标注工具的交互设计会显著影响质量。如果工具不支持快捷键、不支持跨行选择、不支持撤销,标注员的注意力会被大量消耗在操作上,漏标率会明显上升。我宁愿花两天把标注界面做顺,也不愿意事后花两周清洗数据。简单说,文本要能一眼看清上下文,实体类型用数字键切换,选完自动弹下一个,这套交互做好,效率至少提升三成。
2.3 难例构造与数据增强
真实数据里,简单样本占大多数,模型很快就能拟合,但线上出问题的永远是长尾。所以训练集里必须人为掺入难例。我常用的几种构造方式:
第一种是同类实体替换。把“某某科技有限公司”换成另一个等长的公司名,让模型学到的是上下文模式而不是死记字符串。第二种是边界扰动,把实体前后各加一两个字,造出“边界差一点”的负样本,让模型学会区分修饰语。第三种是类型混淆样本,专门收集“苹果”“小米”这种既可以是品牌也可以是其他含义的词,配上不同上下文,逼模型看上下文而不是看词表。
数据增强要有节制。我见过有人用同义词替换把数据扩了十倍,结果验证集 F1 涨了,线上一塌糊涂,因为替换破坏了实体边界,模型学到的是噪声。增强的原则是:只做不改变实体标注的变换。比如随机截断长文本、调整实体在句中的位置、加入无关的前后文,这些是安全的;替换实体内部字符、同义改写,必须重新校验标注,成本反而更高。
另外,负样本也很重要。全是正例的训练集会让模型过度预测,见到什么都想标一个。我一般会保证负样本(完全不含实体的句子)占 15% 到 25%,具体比例看业务文本的实际分布。如果你的业务文本里大部分句子确实不含实体,那负样本比例就按真实分布来,别硬拉平。
3. 模型方案设计与实操落地
这一章讲具体怎么跑。我默认你用 Python,环境里有 PyTorch,数据是“文本 + 实体列表”的格式。整个流程分四步:标签体系设计、数据编码、模型搭建与训练、解码与后处理。每一步我都会说清楚为什么这么做。
3.1 标签体系与编码方式的坑
序列标注最常用的是 BIO 体系:B 表示实体开始,I 表示实体内部,O 表示非实体。看着简单,坑不少。第一个坑是类型前缀,多类型场景下标签应该是B-品牌、I-品牌、B-型号这样,而不是只标 B/I。第二个坑是I 标签的合法前驱,I-品牌前面只能是B-品牌或I-品牌,不能是O或其他类型,这个约束在解码时必须强制,否则会解出非法序列。
还有一种 BMES 体系,把实体拆成 Begin、Middle、End、Single 四类。它比 BIO 更细,对单字实体和多字实体的区分更清楚,代价是标签数量翻倍,小数据集上容易欠拟合。我的建议是:实体平均长度超过 3 个字、数据量五千条以上,可以用 BMES;否则老老实实 BIO。
编码时还有个细节:中文按字切还是按词切?按字切简单、无未登录词问题,是当前主流;按词切需要分词工具,分词错误会传导到实体边界。我的选择是按字切,然后在中文字符和标点之间保留原始顺序,不做额外处理。下面这段是 BIO 编码的核心逻辑,注意I标签的合法性检查:
def to_bio(tokens, entities, label2id): # entities: [(start, end, type), ...] end 为开区间 labels = ["O"] * len(tokens) for start, end, etype in sorted(entities, key=lambda x: x[0]): if start >= end: raise ValueError(f"非法实体跨度: {(start, end)}") labels[start] = f"B-{etype}" for i in range(start + 1, end): labels[i] = f"I-{etype}" # 校验:I 标签前驱必须合法 for i, lab in enumerate(labels): if lab.startswith("I-"): prev = labels[i - 1] if i > 0 else "O" if not (prev == lab or prev == "B-" + lab[2:]): raise ValueError(f"位置 {i} 的 {lab} 前驱非法: {prev}") return [label2id[l] for l in labels]这段代码里我特意加了两个校验,因为标注数据里出现“孤立 I 标签”是家常便饭,不校验的话训练时 loss 会莫名其妙地高,你还找不到原因。
3.2 训练配置与参数怎么定
模型结构我用得最多的是“预训练编码器 + BiLSTM + CRF”。为什么加 BiLSTM?预训练模型本身已经有很强的上下文表示,但序列标注需要显式的相邻依赖建模,BiLSTM 在这一层做一次序列融合,对小数据集的边界判断有稳定提升。数据量上万条之后,这个提升会变小,可以直接用编码器接分类头加 CRF。CRF 是必须的,它保证输出序列合法,尤其在实体边界密集的文本上,效果比 softmax 明显。
关键参数我一般这么定:最大序列长度 256,覆盖 95% 以上的样本,超长的截断并记录;batch size 按显存来,24G 显存下编码器用 base 级别可以到 32;学习率编码器部分 2e-5,新增的 BiLSTM 和分类头用 1e-3,这就是常说的分层学习率,原因是预训练权重已经很稳,大学习率会把它冲坏;训练轮数 5 到 8 轮,配合早停,监控指标用验证集实体级 F1,连续两轮不涨就停。
这里解释一下为什么学习率要分层。预训练编码器是在海量语料上训出来的,它的参数处在一个比较优的位置,你用 1e-3 去冲,前几个 step 就会把它带偏,表现为 loss 先降后剧烈震荡。而新加的 BiLSTM 和分类头是随机初始化的,需要较大的步长才能快速收敛。分开设置,两边都能各得其所。
损失函数用 CRF 的负对数似然,注意要传 mask,把 padding 位置排除掉,否则 padding 也会参与转移矩阵计算,导致结果偏移。还有一个小细节:标签 id 里O最好放在 0 号位,因为很多框架默认把 0 当 padding,如果 0 是某个实体标签,padding 位置会被误算成实体,评估指标会虚高。
3.3 解码与后处理:把模型的毛边修掉
模型输出的是每个位置的标签概率,经过 CRF 维特比解码得到最优路径。但最优路径不等于业务上最合理的结果,后处理这一层必须做。我常用的几条规则:
- 碎片合并:相邻的同类实体,中间只隔一个标点或连词,考虑合并。比如“某某科技、有限公司”被切成两段,合并后才是完整机构名。
- 长度约束:长度超过 30 字的实体大概率是错的,直接丢弃,因为真实实体很少这么长。
- 词典校正:维护一份高频实体词典,模型输出的片段如果和词典里的实体只差一个字,且上下文匹配,直接替换成词典版本。这一步对型号、编号类实体特别有效。
- 非法组合过滤:某些类型组合在业务上不可能出现,比如“时间”紧挨着“时间”,直接合并或丢弃。
后处理规则要克制。我见过有人写了上百条规则,结果模型的效果被规则覆盖掉了,改一处崩三处,最后没人敢动。原则是:每条规则都要能用一条 badcase 说明它解决了什么问题,说不出来的就删掉。规则数量控制在 10 条以内,按优先级排序,并且要有开关,出问题能单独关掉。
下面是一个简化的解码加后处理骨架:
def decode_and_postprocess(tokens, pred_labels, id2label, entity_dict): spans, cur = [], None for i, pid in enumerate(pred_labels): lab = id2label[pid] if lab.startswith("B-"): if cur: spans.append(cur) cur = {"start": i, "end": i + 1, "type": lab[2:]} elif lab.startswith("I-") and cur and lab[2:] == cur["type"]: cur["end"] = i + 1 else: if cur: spans.append(cur) cur = None if cur: spans.append(cur) cleaned = [] for s in spans: text = "".join(tokens[s["start"]:s["end"]]) if len(text) > 30: # 长度约束 continue if text in entity_dict: # 词典对齐 s["type"] = entity_dict[text] cleaned.append({**s, "text": text}) return cleaned代码不复杂,但每一条分支背后都对应过我踩过的坑。比如那个长度约束,就是某次“金额”类实体把一个整段落都吞进去之后加的。
4. 评估、调优与线上排查实录
模型训完只是开始,真正的活在后头。这一章讲怎么评估、怎么定位 badcase、怎么把问题分类解决。
4.1 指标之外还得看什么
实体级 F1 是主指标,但只看它不够。我会额外看三个东西:分类别指标、混淆矩阵、边界误差分布。分类别指标前面说过了,能暴露长尾类型的崩塌;混淆矩阵能看出类型之间的串扰,比如“品牌”被大量判成“机构”,那说明这两类在语料里分布重叠,需要补区分性样本;边界误差分布则是统计“预测片段和真实片段的重叠情况”,是完全错位、只差左边界、只差右边界,还是多吞了一个字。这个分布直接决定你下一步是补数据还是写规则。
还有一个指标是推理延迟的 P99。序列标注模型在长文本上延迟会明显上升,如果你的业务有实时性要求,必须按 P99 而不是平均值来评估。我在一个项目里就吃过这个亏,平均延迟 20ms 看着很漂亮,P99 到了 300ms,因为少数超长文本触发了截断和多次推理。
4.2 badcase 的归类与对症下药
我把 badcase 固定分成五类,每类的处理方式完全不同:
| 类型 | 典型表现 | 根因 | 处理方式 |
|---|---|---|---|
| 完全漏抽 | 该有的实体没出现 | 训练集缺同类样本 | 补同分布样本,优先补难例 |
| 边界偏移 | 抽到了但多字或少字 | 标注规范边界不清 | 统一规范后重标受影响样本 |
| 类型错判 | 抽出来了但类型不对 | 类型定义交叉 | 补区分性样本或合并类型 |
| 过度预测 | 非实体被标成实体 | 负样本不足 | 增加负样本比例 |
| 碎片化 | 一个实体被切成多段 | 长实体样本少 | 补长实体样本,加合并规则 |
这五类里,边界偏移是最常见的,也最容易被误判成模型能力问题。实际上大部分边界问题来自标注不一致——同一类实体,有的标了修饰语有的没标,模型学出来就是摇摆的。所以遇到边界问题,第一步不是调模型,是回头查标注。
碎片化问题值得单独说一下。它通常出现在实体中间夹了标点或特殊符号时,比如“某某-科技-有限公司”。模型在“-”处断开,是因为训练集里这类写法太少。解决办法有两个:一是在预处理阶段把这类符号规整掉,二是补样本让模型见过。我一般优先做预处理,成本低见效快。
4.3 常见问题速查表
下面这张表是我这些年攒下来的,遇到问题先对照一遍,能省不少时间。
| 现象 | 可能原因 | 排查动作 | 解决办法 |
|---|---|---|---|
| 训练 loss 不降 | 学习率过大或标签 id 错位 | 打印前几个 batch 的标签 | 分层学习率,检查O是否在 0 位 |
| 验证 F1 高但线上差 | 训练验证同分布,线上分布漂移 | 抽样比对两边文本特征 | 补充线上分布样本,做领域适配 |
| 某类实体 F1 长期低 | 样本量不足或定义模糊 | 统计该类样本数与一致性 | 定向补标,细化类型定义 |
| 输出全是 O | 类别极度不平衡或 mask 错误 | 看预测分布 | 调整损失权重,检查 mask 传递 |
| 长文本效果骤降 | 截断丢失上下文 | 统计长度分布 | 分句处理或滑动窗口 |
| 同一输入两次结果不同 | 未固定随机种子或用了采样解码 | 检查推理配置 | 固定种子,用贪心或 beam 解码 |
| 实体被切成碎片 | 中间有标点或特殊符号 | 看 badcase 原文 | 预处理规整符号,补样本 |
| 推理延迟毛刺 | 长文本或首次加载 | 看 P99 与冷启动 | 预热,长度分桶,批处理 |
关于“训练 loss 不降”这一条,我再补一句:除了学习率,最常见的原因是标签 id 映射在两个脚本里不一致。训练脚本一套映射,评估脚本另一套,模型学的东西和评估看的东西对不上,指标自然乱。我的做法是把映射表存成 json,训练、评估、推理三处统一加载,从根上杜绝。
5. 工程侧的几个实际问题
模型跑通到上线之间,还有一段路。这一章讲两个最容易被低估的问题:推理性能和数据闭环。
5.1 推理服务的性能与稳定性
实体抽取服务通常是同步调用的,qps 不高但延迟敏感。我的经验是:模型加载一次常驻,请求进来走批处理。单条推理的 GPU 利用率极低,把 100ms 内到达的请求攒成一批一起跑,吞吐能提升三到五倍,而单条延迟只增加几十毫秒。批大小要设上限,否则排队延迟会失控,一般 16 到 32 比较稳。
长度分桶也很关键。同一批里如果混了一条 20 字的短文本和一条 512 字的长文本,整个 batch 会按最长的那条补齐,短文本白白浪费算力。按长度分桶后再组批,能显著降低平均耗时。另外,服务启动后要跑一次预热,把 kernel 编译、显存分配这些一次性开销提前消化掉,否则第一个真实请求的延迟会非常难看。
还有一个稳定性问题:模型对超长输入的处理。如果业务文本可能超过最大长度,必须有截断或滑窗策略,并且要记录被截断的比例。截断比例高说明长度参数设小了,需要重新评估;滑窗则要注意窗口重叠,避免实体被切断在窗口边界上。我一般用重叠 64 个字符的滑窗,合并时按置信度去重。
5.2 持续迭代的闭环怎么搭
模型上线不是终点。线上会持续产生新的表达方式,模型的效果会缓慢衰减。要挡住这种衰减,必须有一个轻量的闭环:日志采样、人工复核、回流训练。
具体做法是:按天采样一定比例的线上请求,把模型输出和原文存下来,由业务方抽查确认对错,错的标成 badcase。每周汇总一次,把确认的 badcase 补充进训练集,每月重训一次。这个流程不需要很重,关键是稳定执行。我在一个项目里搭了这么一套,三个月后线上 F1 从 0.82 稳到 0.90,靠的不是换模型,是持续补数据。
采样要讲究策略。全随机采样效率低,因为大部分样本模型都是对的。我一般用置信度采样:模型输出置信度低的、或者同一条文本多次推理结果不一致的,优先采样。这类样本的信息量最大,往往一条能顶十条随机样本。另外,类型分布也要监控,如果某一类的线上占比突然上升,说明业务在变,训练集需要跟着调整。
最后说个容易被忽略的点:版本管理。模型文件、标签映射、后处理规则、词典,这四样必须一起版本化,绑定成一个发布单元。我见过只版本化模型、规则随手改的情况,出问题回滚时回滚不干净,排查半天。把四样打包成一个 artifact,带版本号和变更说明,回滚就是一行命令的事,这个投入绝对值得。