简介:命名实体识别(NER)是自然语言处理领域的一项基础任务,目标是从非结构化文本中抽取出人名、地名、机构名等关键实体,为信息抽取、知识图谱构建和智能问答提供底层支撑。在实际工程中,如何兼顾识别精度、推理速度与可解释性,是技术选型的关键。BERT凭借动态词向量能够有效解决一词多义问题,BiLSTM进一步捕捉局部序列特征,而CRF则通过标签转移矩阵约束输出序列的合法性。这三者组合而成的序列标注方案,在金融、医疗、法律等专业场景下依然展现出训练成本可控、推理高效、结果可解释的优势。围绕该模型构建的知识图谱与问答系统,能够将实体识别的结果转化为可检索、可推理的结构化知识,并直接服务于业务决策。本文从数据标注、模型训练、关系抽取、图谱存储到自然语言问答的完整链路,给出了一套可落地的工程实践指南。 做这个项目之前,我先说说结论:BERT+BiLSTM+CRF这条技术链路,放到今天依然能打。你可能会听到“直接用大模型做抽取不香吗”“现在不都上RAG了”这类声音,但在实际业务里,尤其在特定领域(金融、医疗、工业制造、法律文书这类专业场景),这套传统序列标注方案有它不可替代的优势:训练成本可控、推理速度快、结果可解释、方便做规则兜底。而围绕它搭建的知识图谱和问答系统,才是真正把实体识别价值落到业务端的关键一环。
这篇文章我按自己实际趟完的完整流程来写,从模型选型、数据标注、模型训练,到知识图谱构建、问答系统实现,最后再聊聊几个容易翻车的细节。内容会比较长,但每一步都是可落地的,不是那种“原理讲完就完事”的文章。
1. 为什么偏偏是BERT+BiLSTM+CRF:三个模型的分工逻辑
这个组合不是随便拼出来的,每个组件解决序列标注里的不同层次问题。
1.1 BERT负责什么:消除一词多义
先回忆一下传统做法。在BERT出现之前,做中文NER普遍用Word2Vec或GloVe静态词向量,然后接BiLSTM+CRF。静态向量最大的问题就是一个词只有一个向量,不管上下文是什么。“苹果”这个词,出现在“苹果发布新手机”和“我吃了一个苹果”里,向量完全一样,模型只能靠大量样本和上下文去硬猜。
BERT用Transformer的注意力机制做双向编码,每个token的向量表示是动态的,会根据周围上下文实时变化。同样是“苹果”,在手机相关语境里会偏向实体类别,在水果语境里就是普通名词。这一步直接让实体识别精度往上跳了一个台阶。
在中文场景下,用的是BERT-base-chinese,12层Transformer,768维隐层。如果你的语料是纯英文,用BERT-base-uncased或者RoBERTa都可以。如果涉及领域术语特别多的文本(比如医学、法律),用领域预训练模型效果会更好,比如BioBERT之于生物医学。
1.2 BiLSTM负责什么:捕捉局部序列特征
BERT已经给出了很好的token向量,为什么还要再接一个BiLSTM?
两个原因。第一,BERT输出的每个token向量基本是“自注意力+全连接”后的结果,它擅长捕捉长距离依赖和全局语义,但对局部序列结构(比如相邻标签之间的作用关系)的表达不如BiLSTM直接。第二,BiLSTM通过前向和后向两个LSTM单元,能把一个词前后的信息再融合一遍,输出一组同时包含历史和未来信息的特征向量,这个向量喂给CRF层做标签推断会更柔顺。
我试过直接用BertForTokenClassification接分类头而不加BiLSTM,精度上是有所下降的。小数据集上可能只差零点几个点,但到了几千条样本规模,差距会拉开到两三个百分点。换言之,BiLSTM是那种“加法不贵但有效”的组件,值得保留。
1.3 CRF负责什么:给标签序列定规矩
这是整套链路里最关键的一环,也是最容易被忽略的一环。
纯Softmax分类对每个token单独做预测,它不知道标签之间的顺序约束。比如模型可能把“北”预测为B-LOC,“京”预测为I-PER,这就完全乱了。CRF引入一个标签转移矩阵,直接对整条标签序列的联合概率建模。它能学到类似这样的规则:一个实体的第一个标签只允许是B开头,I-PER前面必须是B-PER或I-PER,B-PER后面不能跟I-ORG。
举个例子,如果你只做“人名、地名、机构名”三类实体,CRF会自动学到B-PER后面只能跟I-PER或O,B-LOC后面只能跟I-LOC或O,绝不会出现B-PER后面跟I-ORG这种非法组合。这类约束在做长文本、嵌套实体少的场景下,作用非常明显。
一句话总结:BERT负责把词的语义变成好用的向量,BiLSTM负责把序列特征再揉一遍,CRF负责给标签序列定规矩。“眼力”“记忆力”“规则意识”三件事,各司其职。
1.4 什么时候可以砍掉组件
不是所有场景都需要完整三件套:
- 如果你做的是短文本、领域窄、实体类型少,比如只抽“人名+手机号”,那纯BERT甚至规则正则就够了,不需要CRF。
- 如果你的语料非常规范,比如证件照文本,那BiLSTM带来的提升很有限,可以去掉简化模型。
- 如果对推理速度要求极高,可以把BERT替换成蒸馏版,比如
distilbert-base-chinese或者albert-chinese-tiny,精度会掉但速度翻几倍。 - 如果样本量极小(几百条),建议用预训练的
bert-base-chinese直接微调,不要自己从零预训练,那是自讨苦吃。
我的建议是:默认用完整组合,然后通过消融实验决定是否裁剪。大部分情况下,三件套就是性价比最高的基线。
2. 数据准备:实体识别项目里最花时间的部分
模型选型其实是最简单的一步,真正让你加班到深夜的,永远是数据。不要说“我有开源数据集可以直接训练”,等你真正打开那些数据集,你会发现标注规范、标签体系跟你的业务场景根本不是一回事。
2.1 标签体系:BIO还是BIOES
做序列标注,第一件事是确定标签方案。最常见的两种:
- BIO:B-实体开头,I-实体内部,O非实体
- BIOES:B开头,I内部,E结尾,S单字实体
我个人的经验是:如果实体边界比较规整,比如人名、地名、机构名,BIO就够用。BIOES在实体比较长、嵌套多的时候,边界预测会更稳,但它把标签数量翻了一倍,每类实体拆成B/I/E/S四类,标签不均衡问题会更严重,模型收敛也要更久。
如果你做的是中文场景,BIOES还有一个隐性好处:中文单字成词的情况很多,S(single)标签能显式告诉模型这是一个独立实体,不需要纠结这个字到底是B还是I。
下面是我在金融文本用的标签示例:
张三 在 2021年 加入 了 腾讯 控股 有限公司 。 B-PER O O O O O B-ORG I-ORG I-ORG I-ORG O注意“腾讯控股有限公司”是一个整体机构名,不是三个实体。这就是标注规范里最容易打架的地方,项目启动前一定要定清楚。
2.2 标注工具怎么选
不建议自己写标注界面,浪费时间。我用过几个工具,简单对比一下:
| 工具 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| doccano | 小团队、快速起步 | 部署简单,支持序列标注和文本分类,中文支持好 | 多人协作稍弱,没有复杂权限管理 |
| Label Studio | 多模态、复杂标签体系 | 功能全,支持嵌套标注、关系标注,API完善 | 界面相对重,学习成本略高 |
| brat | 老牌标注工具 | 速度快,轻量,适合纯序列标注 | 界面朴素,配置要改文件 |
个人推荐:项目起步阶段用doccano,一天就能跑起来,标注完可以直接导出JSON或CoNLL格式。等团队大了再迁移到Label Studio。标注工具的切换成本主要是标注规范迁移,所以在初期就要把标签定义和标注指南写清楚。
2.3 半自动标注:用裸BERT先跑一轮
很多人不知道,标注工作也可以“预标注”来提效。
我常用的流程是:先拿一个通用NER模型或者规则集跑一遍原始语料,自动标出候选实体,然后人工去修正。修正比从零标注快得多,特别是在实体密度比较高的文本上,效率能提升三四倍。
这一步有个坑:如果预标注的准确率太低,比如不到六成,人工修正反而比从零标注更累,因为修的时候要盯着看哪里有错,比从头找实体更容易疲劳。所以要么用规则把准确率拉高,要么干脆不用预标注。
从实际经验来看,一个领域从零开始,标注3000~5000条文本就能训练出一个能用的模型,到8000~10000条基本能到达生产环境基线。当然,同样是5000条,实体密度高、句式变化少的语料比长尾场景语料要求的数据量少得多。
2.4 数据增强怎么搞
NER领域的数据增强没那么好做,因为序列标签是逐词对齐的。简单词汇替换很容易把标签搞乱。几个比较稳妥的方式:
- 同义词替换:把实体周围的名词替换成同义词,标签保持不变。
- 随机遮蔽/回译:用翻译模型把句子翻译成英文再翻回来,但中文NER场景下这个操作有风险,实体可能被翻译成不同写法。
- 句式改写:手动或者用模板生成同一句子的不同表达方式。
我的看法是,数据增强在NER里的收益没有在分类任务里那么大,投入产出比一般。真正有用的是“领域外数据补充”而不是“数据改写”,比如从行业报告、公告里多挖一些没标注的句子,然后用模型自助标注+人工抽检,不断滚动扩充训练集。
3. 模型搭建与训练:从HuggingFace到CRF自定义层
环境这块我不展开装环境了,默认你已经有PyTorch和transformers。下面直接上核心结构代码和参数配置。
3.1 模型结构串联
用HuggingFace加载BERT,后接BiLSTM和CRF。最简单的方式是用BertForTokenClassification换掉分类头,但那样没法插BiLSTM和CRF。我习惯自己拼一个,也不算复杂:
import torch import torch.nn as nn from transformers import BertModel, BertConfig from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_dir, num_tags, lstm_hidden=256, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_dir) self.lstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) sequence_output = outputs.last_hidden_state # [batch, seq_len, hidden] lstm_out, _ = self.lstm(sequence_output) lstm_out = self.dropout(lstm_out) emissions = self.fc(lstm_out) if labels is not None: loss = -self.crf(emissions, labels, mask=attention_mask.bool()) return loss predictions = self.crf.decode(emissions, mask=attention_mask.bool()) return predictions注意几个细节:
- CRF解码时传了
attention_mask,这样padding位置的标签不会参与计算。这是最容易踩的坑,忘记传mask,CRF会对padding位置算标签损失,指标看起来很漂亮,实际全废。 lstm_hidden一般取128或256,太大容易过拟合,太小特征提取不够。dropout设0.1左右就可以,太大了BERT的输出信息会被随机抹掉太多。
3.2 训练参数设置
不同层用不同学习率是标配技巧。BERT预训练层的学习率要小,因为它的权重已经很好了,调太狠会灾难性遗忘。BiLSTM和CRF层是随机初始化的,学习率可以大一些,让它们快速收敛。
我常用的配置:
| 参数 | 值 | 说明 |
|---|---|---|
| BERT层学习率 | 2e-5 到 5e-5 | 微调阶段 |
| BiLSTM+CRF层学习率 | 1e-3 到 5e-3 | 新层需要更大步长 |
| batch_size | 16 或 32 | 看显存,序列长度超过128建议16 |
| epochs | 5~15 | 看验证集F1,不要死守固定epoch |
| max_len | 128 或 256 | 超过512显存会爆炸,注意截断策略 |
| warmup | 10% steps | 防止开头震荡 |
| optimizer | AdamW | 标配 |
| weight_decay | 0.01 | 抑制过拟合 |
代码实现分层学习率:
from transformers import AdamW no_decay = ["bias", "LayerNorm.weight"] named_parameters = list(model.named_parameters()) bert_params = [p for n, p in named_parameters if "bert" in n and not any(nd in n for nd in no_decay)] bert_params_no_decay = [p for n, p in named_parameters if "bert" in n and any(nd in n for nd in no_decay)] other_params = [p for n, p in named_parameters if "bert" not in n] optimizer_grouped_parameters = [ {"params": bert_params, "lr": 2e-5, "weight_decay": 0.01}, {"params": bert_params_no_decay, "lr": 2e-5, "weight_decay": 0.0}, {"params": other_params, "lr": 3e-3, "weight_decay": 0.01}, ] optimizer = AdamW(optimizer_grouped_parameters)3.3 评估指标怎么算才靠谱
NER任务有两个口径的评估:token级别和entity级别。
- token级:每个token的标签预测对不对,算precision/recall/F1。这个指标容易被“O类”拉高,因为大量非实体token被正确预测为O,基线都能有90%+。
- entity级:先拼实体,要求实体边界和类型完全一致才算预测成功,再算F1。这才是真正代表业务效果的指标。
网上很多教程只报token级F1,新手看了以为模型很强,实际一跑端到端的抽取精度惨不忍睹。一定用entity级F1作为主指标。
我的评估脚本里会先把真实标签和预测标签通过decode_tags还原成实体列表,再用精确匹配统计。这里贴一个简化评估逻辑:
def extract_entities(tags, id2label): entities = [] current = None for idx, tag_id in enumerate(tags): label = id2label[tag_id] if label.startswith("B-"): if current: entities.append(current) current = {"type": label[2:], "start": idx, "end": idx} elif label.startswith("I-") and current and current["type"] == label[2:]: current["end"] = idx else: if current: entities.append(current) current = None if current: entities.append(current) return [(e["type"], e["start"], e["end"]) for e in entities]很多模型预测的问题就出在“I-前面没有B”,CRF能解决一部分,但在数据集小、标签不均衡的情况下还是会出现,所以要养成看实体级F1的习惯。
3.4 训练过程中常见的“小事故”
我把自己在训练中实际遇到的几个问题列出来,你大概率也会碰到:
- 验证集F1震荡严重。多半是学习率太大或者batch_size太小。BERT微调的学习率超过1e-4就会不稳定,尤其在小数据集上特别明显。
- 实体完全预测不出来。检查一下标签映射表,尤其是
label2id和id2label是否对齐。我曾经因为标签列表排序不一致,导致B-PER和I-PER的id错位,模型训练了很久F1还是零。 - O类回退。如果实体类别极不均衡,比如1000条文本里只有50条有人名实体,模型会倾向于把所有token预测成O。解决办法:给实体类更高的loss权重,或者上采样含实体的文本。
- GPU显存不够。max_len从256降到128,batch_size从32降到8,基本能解决。还不行就换
gradient_accumulation_steps,模拟大batch的效果。
4. 从实体到知识图谱:关系抽取和实体对齐是真正的难点
这一步是很多项目“烂尾”的地方。很多人费尽精力把模型跑出来,F1挺好,然后就停在了实体列表上。你问他知识图谱呢?他说还没开始。为什么?因为只有实体没有关系,图谱是建不起来的。
实体识别做出来是一堆孤零零的节点,比如“张三”“腾讯”“深圳”。谁跟谁有关系?什么关系?张三任职于腾讯?腾讯总部在深圳?这些关系不补上,知识图谱就是个空壳。
4.1 关系抽取:Pipeline还是联合抽取
关系抽取有两条路线:Pipeline式和联合抽取式。
Pipeline式就是先做NER,再做关系分类。关系分类可以是一个多标签分类器,输入是“实体1的span向量+实体2的span向量+句子向量”,输出是关系类型。这个方案实现简单,每一步都有现成工具,缺点是错误会传导,NER错了关系分类必然错。
联合抽取式(比如CasRel、TPlinker)在一个模型里同时出实体和关系,比如“已知一个句子,给定一个主语实体,预测它对应的所有关系和宾语实体”。联合抽取的精度上限更高,但实现复杂度大不少。
我的建议是:项目刚开始,先做Pipeline。好处是调试起来方便,NER有问题只看NER,关系分类有问题只看关系分类。等数据量齐了、模型性能还不够,再上联合抽取。
Pipeline关系分类的核心代码思路:
# 对每对候选实体构造输入 # [CLS] 句子原文 [SEP] 实体1 [SEP] 实体2 [SEP] # BERT编码后,取CLS向量过全连接,做多标签分类关系分类的类别设计才是决定成败的。比如金融领域,“张三任职于腾讯”是任职关系,“腾讯总部在深圳”是位于关系,“腾讯控股有限公司”整体是一个节点,它和母公司“腾讯集团”之间是子公司关系。关系类别的定义直接决定图谱的结构,在设计阶段最好画一个ER图预演一遍。
4.2 实体对齐:同名实体和同义表达的处理
实体对齐这个坑,很多人是做到图谱查询才发现问题的。
第一种情况是同义不同名。“腾讯”和“腾讯公司”在文本里都指同一个实体,如果直接都建成节点,图谱里就会出现两个实体,查询时数据分散,问答系统答非所问。解决办法是构建一个别名表,把“腾讯公司”“深圳市腾讯计算机系统有限公司”等映射到规范名“腾讯”。
第二种情况是同名不同指。比如“苹果”在水果语境和公司语境指向完全不同。这种情况光靠NER解决不了,需要结合实体的上下文或属性做消歧。常见做法是借助实体所在的句子、共现词、已有图谱的邻居节点,计算一个相似度阈值来判断是否属于同一个实体。
第三种情况是多源数据合并。如果实体来自不同批次的数据,比如第一批从新闻抽取,第二批从公告抽取,规范名可能不同,需要做标准化。
在实际项目中,我的操作流程是:
- 先用NER抽取全部实体。
- 对实体名做文本归一化:去空格、全半角转换、大小写归一、繁体转简体。
- 用别名表做合并。
- 剩下的用向量相似度(比如BERT句子向量计算余弦相似度)做候选,人工审核。
这套流程跑下来,实体节点质量会好很多,后续做知识问答的“实体链接”也准确得多。
4.3 图谱存储选型:Neo4j还是图数据库之外的方案
知识图谱的存储方案,当前主流是属性图数据库,Neo4j是最典型的一个。选它的理由很直接:Cypher查询语法直观,可视化方便,不用自己造轮子。
在Neo4j里,一个完整知识图谱的知识表示是三元组:
(张三) -[:任职于]-> (腾讯) (腾讯) -[:总部位于]-> (深圳)导入方式一般有两种:
- 如果实体量很大(百万级以上),用
neo4j-admin import批量导入CSV文件。 - 如果数据量中等(几万级),直接用Python的
py2neo或neo4j驱动逐条写入。
下面是用py2neo写入三元组的示例:
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) # 节点 person = Node("Person", name="张三") company = Node("Company", name="腾讯") # 关系 relation = Relationship(person, "任职于", company) graph.create(relation)这里需要注意的是,别一条一条create,几千条数据还好,十万条会慢到怀疑人生。批量用graph.begin()/graph.commit()事务,或者直接用UNWIND批量传list,速度差一个数量级。
Cypher导入示例:
UNWIND $batch AS row MERGE (p:Person {name: row.person}) MERGE (c:Company {name: row.company}) MERGE (p)-[:任职于 {start_year: row.start_year}]->(c)4.4 图模式设计:节点标签和关系方向
设计图模式时最核心的决策是“节点标签怎么定,关系怎么建模”。
节点标签(Label)建议按实体类型设置:Person、Organization、Location、Product等。不要按文本上下文建标签,比如“受害者”“嫌疑人”这种业务标签,容易让图谱模式混乱,后续扩展性差。
关系方向也很讲究。**关系方向要与实际语义匹配,并且查询时保持一致。**比如“任职于”方向是从Person到Organization,“总部位于”方向是从Organization到Location。查询时“张三在哪家公司”用(p:Person {name:'张三'})-[:任职于]->(c:Company),反过来“腾讯有哪些高管”就要把方向反转。
属性字段也要想清楚。节点的属性不只是name,还可以加id、行业、官网等,关系的属性可以加时间、来源、置信度。特别是来源和置信度,在问答系统里做答案溯源和筛选时非常有用。
5. 基于知识图谱的问答系统:从问句到Cypher再到答案
知识图谱建好了,问答系统本质上就是把“自然语言问句”翻译成“图查询”的过程。这个翻译过程拆开看,是四步:问句分类、实体链接、查询生成、答案生成。
5.1 整体架构
我用的架构是这样的:
用户问句 ↓ 【意图识别】——这条问句是问人物关系、公司信息还是地理位置? ↓ 【实体链接】——从问句里抽取出实体,并映射到图谱节点 ↓ 【查询构建】——根据意图和实体,生成Cypher查询模板 ↓ 【图数据库执行】——返回查询结果 ↓ 【答案生成】——把结构化结果拼成自然语言每个环节都可以用不同方案,我逐个说。
5.2 意图识别:规则优先,模型兜底
问句分类和普通文本分类不太一样。用户问“张三在哪个公司”和“张三的公司是什么”其实是同一个意图。把常见问法模板提前枚举出来,用正则或模板匹配就能覆盖大多数情况,这比直接上BERT分类器更加可控。
多个类型的问句配上多个模板:
意图类型:人物->公司 匹配模式:["{person}在哪个公司", "{person}在哪家公司任职", "{person}的公司是什么"] 意图类型:公司->总部 匹配模式:["{company}总部在哪", "{company}总部位于" , "{company}的地址是什么"]意图识别的关键点是:宁可识别不出,也不要识别错误。识别不出可以走兜底话术“这个问题我暂时不会”,识别错了会直接回答错误信息,体验更差。规则模板的好处是所有行为都可解释。
如果问句类型特别多、模板无法覆盖,再用BERT分类器做意图识别。注意训练数据要人工标注意图,不能把模板匹配出来的直接当训练集,这样会有很多边界case学不到。
5.3 实体链接:从问句到图谱节点
实体链接是问答系统准确率的瓶颈。用户问“腾讯的CEO是谁”,NER模型可能抽出“腾讯”和“CEO”,但“CEO”不是实体,需要过滤掉。“腾讯”需要精确匹配到图谱节点。
实际操作中,实体链接分三步:
- 从问句里做NER,得到候选实体。
- 对候选实体做名称归一化(去空格、别名表映射)。
- 在图谱里查询匹配节点,如果匹配到多个,用上下文信息消歧。
一个常见的问题是:用户说的实体名跟图谱里的规范名不完全一致。比如“腾讯”和图谱里的“腾讯公司”。解决方案就是前面提到的别名表。问答系统的实体链接模块,我的实现方式是维护一个全局别名映射,把常用简称、全称、别名都映射到图谱实体的规范名。
如果图谱里根本查不到这个实体,不要硬答,直接返回“我没有找到相关信息”比猜一个答案更靠谱。
5.4 Cypher查询构建
每个意图对应一段Cypher模板。模板里放实体名作为参数,执行时注入即可。以“人物->公司”为例:
MATCH (p:Person {name: $entity_name})-[:任职于]->(c:Company) RETURN c.name AS company_name如果图谱里有同名的多个节点(比如不同维度的人名),需要用更多约束条件筛选,比如加类型限定(Person/Organization/Location),或者按置信度排序取最高。
复杂一点的问句,比如“腾讯有哪些高管”,需要做关系反向遍历:
MATCH (c:Company {name: $entity_name})<-[:任职于]-(p:Person) RETURN p.name AS high_manager这里就体现出关系方向设计的重要性了,方向反了的模板写起来会绕。
5.5 答案生成:模板拼接还是大模型
图谱查询结果是一堆结构化的行,比如“张三”的“任职于”返回了“腾讯”。答案生成有两种做法:
模板拼接是最稳的:查询结果取出来,直接填到预设的句子里,例如:
answer = f"{person}曾在{company}任职"好处是可控、不会瞎编、上线快。缺点是回答风格固定,不自然。
大模型生成是现在比较流行的做法:把图谱检索结果作为上下文,让LLM用自然语言组织答案。比如:
根据知识图谱查询结果:张三 -> 任职于 -> 腾讯;腾讯 -> 总部位于 -> 深圳。 请回答:张三在哪里工作?他的公司总部在哪里?LLM输出的答案会自然得多,但要注意幻觉问题,模型可能生成图谱里不存在的信息。我的经验是:查询结果必须作为强约束先行校验,LLM只做语言组织和润色,不要让它自由发挥。
另外,现在很多项目把知识图谱和向量数据库(RAG)结合起来,用向量数据库做文档级召回,用知识图谱做结构化关系回答,两个互补。具体路径是:先判断问题是否涉及明确实体和关系,涉及就查图谱,不涉及就RAG。这个混合方案在我实际项目中效果很好。
6. 真实项目里翻过的车:五个高频坑
最后这部分是我相对私人的经验,每个坑都是真金白银换来的。
6.1 标注规范没定死,模型反复返工
我第一个NER项目,三个人标注,每个人对“边界”的理解不一样。有人把“腾讯控股有限公司”整体标成一个机构名,有人只标“腾讯控股”,模型训练出来后实体级F1比token级F1低十多个点。折腾了两轮才想起来是标注规范的问题。
解决方案:动笔标注之前,先做一本《标注规范手册》,对每个实体类型给出正例、反例、边界情况说明。刚开始找几个人标一二十条,逐条对齐,确认所有人理解一致后再大规模标注。哪怕多花几天,也比后期返工强。
6.2 用验证集F1来选模型,结果上线崩了
这种情况通常是因为验证集和训练集分布太像了。一个领域的小规模数据标注时,很容易把同一文档的段落拆进训练集和验证集,模型等于“见过”验证集了。我吃过这个亏,线上表现比验证集差了好几个点。
解决方案:按文档而不是按句子划分数据集,或者干脆按时间来切,过去的训练、未来的验证。这样能更接近真实线。
6.3 CRF和BERT不兼容的版本坑
torchcrf库更新相对不频繁,如果你用新版本的PyTorch,可能会遇到一些API兼容问题。另外,transformers的BertModel输出格式变动,也会导致接BiLSTM时代码报错。
解决方案:项目早期就锁版本。requirements.txt里写好transformers==4.41.0、torch==2.0.1、torchcrf==0.7.2,不要随便升级。这种组合项目最大的敌人就是版本漂移。
6.4 关系太少,图谱太稀疏
NER模型效果好,但关系抽取效果一般,导致图谱里很多孤立节点,比如“张三”节点没有“任职于”的关系,问“张三在哪工作”就答不上来。这时候不是你要不要去优化关系模型的问题,而是业务价值直接受影响。
解决方案:先盘点高频问题,针对高频问题优先补关系数据。如果你的业务里最常见的问题是“公司总部在哪里”,那就先把“总部位于”关系抽精确,其他关系后面再说。用二八法则来分配精力和数据标注资源。
6.5 图谱更新策略没想好
知识图谱不是一锤子买卖。文本数据是持续流入的,今天抽的三元组明天可能就变了,比如某人离职了、公司搬迁了。如果图谱不更新,问答系统会给出过时答案。
解决方案:建图时给每个关系加时间属性和来源文档ID。每天跑批增量抽取,比对新增三元组和旧三元组,用发布时间、置信度作为优先级。如果冲突,保留时间戳新且置信度高的那个关系。
关于这个项目,我还有一个实践上的体会:整条链路做下来,最值得花时间的地方不是模型结构,而是数据质量和图谱模式设计。模型结构有无数开源方案可以参考,但数据规范、图谱模型、问答策略这些,才是真正需要你基于业务去思考、去调试的。如果你正打算做类似的项目,先把业务里最高频的十个问题列出来,反推需要什么实体、什么关系、什么答案,再决定怎么标数据、怎么建图谱,这样整个项目的路径会清晰很多。
本文还有配套的精品资源,点击获取