刚开始做这个项目的时候我其实挺没底的。“投资知道”是一个面向普通用户的投资理财问答知识库里面沉淀了几万条人工整理过的问答对。用户进来问一句大白话系统得判断这个提问能不能被现有的答案回答、应该匹配到哪条答案。这个「匹配检测」环节做不好知识库再有货也白搭——用户问的是A系统答的是B两三次之后用户就流失了。我们最开始用过一段时间的传统关键词检索加规则打分。在上线初期够用因为用户问法相对固定比如“基金定投是什么”“怎么买国债”。但跑了一段时间就暴露问题了投资领域的口语表达实在太发散同一种需求可以问出十几种花样“定投和一次性买入有什么区别”和“分批买基金跟一把梭哪个好”字面重合度极低但语义几乎一致。这类问题在关键词检索下基本是废的。后来我们把整个匹配核心切成了基于BERT的中文问答匹配检测方案这也是这篇博文想完整复盘的东西为什么选BERT、训练数据怎么造、调参过程中有哪些坑、上线之后怎么兜底。如果你手头也在做知识库问答、智能客服或者语义检索类的项目这篇文章应该能帮你少走不少弯路。1. 为什么投资问答场景必须先过「匹配检测」这一关1.1 一个真实的反面案例关键词命中了答案却是错的先说一个让我印象特别深的线上案例。当时有用户问“债券基金会不会亏本金”知识库里恰好有一条高度相关的答案“债券基金不是保本产品净值会有波动极端情况下也会亏损”。按说这个问题很简单但旧系统返回的却是另一条“债券基金和货币基金的区别”。原因是这两条答案里都高频出现了“债券基金”四个字关键词命中得分最高的是后者。用户看到答非所问的页面大概率直接退出。这种案例在投资问答里不是少数。投资问题的表达有两个特点一是术语密度高二是意图高度依赖语义而非字面。一个句子翻来覆去就那几个词但问的是收益、风险、操作、规则还是对错判断差别非常大。关键词检索对词频敏感对语义不敏感所以在高术语重合的领域特别容易翻车。这个反面案例最终让我确认了一件事匹配检测必须升级成语义层面的任务而不能停留在词面层。1.2 投资问答的三个技术难点做这个项目之前我总结了投资问答区别于通用问答的三个难点这也是后来设计模型和数据方案时一直围绕的三个核心命题。第一个难点是短句同义改写太多。用户不会用标准术语提问而是用口语、缩写甚至带错别字的方式提问。比如“基金分红是啥意思”“分红是分钱吗”“基金分红的钱去哪了”这三句话语义接近但不完全等价机器很难通过规则穷举。第二个难点是实体边界模糊。投资领域的产品名称、利率、期限、风险等级往往只差一两个字就是完全不同的答案。“存款利率”和“贷款利率”、“基金净值”和“基金估值”、“申购费”和“赎回费”这些词在向量空间里距离很近但对应的答案完全不同。匹配模型如果只看局部词共现很容易被骗。第三个难点是否定和条件边界。用户会问“定投亏损了怎么办”也会问“定投怎么避免亏损”还会问“定投一定赚钱吗”。这些句子里有共同的实体“定投”有共同的主题“盈亏”但用户需要的答案是截然相反的。这类样本如果负样本构造不到位模型会倾向于把所有带“定投”和“亏”的问题都判成一类结果就是答非所问。1.3 技术目标拆解这个项目到底在检测什么“投资知道”项目里的「匹配检测」不是一个宽泛的文本相似度计算而是拆成三个具体任务来做的。第一层是可答性判断也就是给定一个用户问题判断知识库中是否存在能回答它的答案。这解决的是“能不能答”的问题是一个二分类任务。第二层是相似问召回在确定可答之后把用户问题和知识库里的标准问法做语义匹配找出语义等价的候选。第三层是答案排序打分对召回的若干条候选答案按相关性从高到低排序取最高的那一条返回给用户。这三层任务在工程上可以合并成一个模型也可以拆开做。我们的做法是召回层用轻量方案BM25加向量检索精排层用BERT模型做句子对相关度打分。所以这个项目最终的核心模型本质上就是一个“BERT句子对分类器”输入是“用户问题 候选答案”这对组合输出是一个匹配分数。这个分数既要承担可答性判断也要承担排序阈值的选择很关键后面我会专门讲。2. BERT问答匹配的技术选型为什么直接上句子对分类2.1 三种主流方案的对比双塔、交互式、生成式确定用BERT之后第一个要回答的问题是用哪种结构来做匹配。市面上主流的有三类做法我们当时做了一个对比这里直接把权衡过程写出来。第一类是双塔模型比如DSSM、Sentence-BERT。用户问题和答案分别过两个编码器得到向量再算向量余弦相似度。这类模型最大的优势是速度快、可以预计算答案向量百万级知识库也能做到毫秒级召回。但它的缺点是问题和答案各自独立编码交互信息是在最后算相似度时才发生语义交互不够充分。对于“债券基金会亏吗”和“债券基金不保本”这种高度依赖词间深层交互的句子对双塔很容易被打败。第二类是交叉编码器Cross-Encoder也就是把问题当作第一句、答案当作第二句拼在一起喂给BERT通过在每一层的自注意力机制中完成问题与答案的充分交互最后用[CLS]向量做分类。这类模型交互充分效果通常比双塔好一个档次缺点是必须逐对计算无法预计算答案向量只能用于精排。第三类是生成式模型比如用T5或者生成式大模型直接生成答案。这类方案在开放域闲聊里效果好但在严谨的投资知识库场景里有“幻觉”风险模型自由发挥出来的一段话用户很难分辨对错。我们的知识库答案都是经过人工审核的不想冒这个险。最终选择很明确召回层用双塔思路精排层用交叉编码器。“投资知道”的BERT模型定位就是精排器输入用户问题和经过粗筛的候选答案输出可答性得分。方案速度语义交互适合场景本项目角色双塔模型极快可预计算较弱百万级召回召回粗筛BERT交叉编码器较慢需逐对计算强几十个候选精排核心匹配生成式模型慢有幻觉风险强开放域问答未采用2.2 模型结构设计与输入输出细节模型结构本身不复杂用的是HuggingFace的BertForSequenceClassification。输入序列的构造方式如下[CLS] 用户提问原文 [SEP] 候选答案正文 [SEP][CLS]这个特殊标记在BERT预训练阶段就被设计成聚合整个输入序列语义的向量。我们把这句话对过完BERT后取出[CLS]位置的向量接一个线性层映射到2维logits上用交叉熵损失训练。输出层用softmax归一化后取第1类的概率值就是匹配分数。有一个细节值得展开说输入拼接的顺序选问题在前还是答案在前对结果有影响。我实测过两种顺序问题在前、答案在后在验证集F1上比反过来普遍高1到2个百分点。原因可能是BERT预训练时更熟悉“类似问题的一句话”出现在开头位置的语序模式。这类细节文档里不会写但影响是实打实的资源充裕的话可以都试试。代码层面的核心类可以简化成下面这个样子from transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels2 ) def encode_pair(question: str, answer: str, max_len: int 128): return tokenizer( question, answer, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt )训练的时候就是喂input_ids、attention_mask、token_type_ids这三件套标签是0或1没有任何花哨的改动。如果你做过图像分类会发现这个流程非常熟悉预训练模型加一个分类头微调一下。2.3 中文预训练模型怎么选关于中文预训练模型的选择我们做了三轮对比。第一轮用bert-base-chinese这是最常规的选择102M参数量在通用中文语料上预训练效果稳定作为基线没有问题。第二轮换了roberta-wwm-ext它用全词掩码策略做预训练对中文这种“词是基本语义单元”的语言更友好因为中文分词后相邻字之间的语义关联很强全词掩码能让模型学到更完整的词级表示。在实际效果上roberta-wwm-ext在验证集上比bert-base-chinese的F1高了约0.5个百分点提升不算巨大但计算成本几乎一样所以最终选了它。第三轮我们还试过macbert-large效果确实更好但推理时间从10毫秒左右涨到30多毫秒在精排阶段还能接受但整体性价比不高。加上后面计划把模型蒸馏回小模型大模型只用来当教师所以线上主推的还是roberta-wwm-ext。我的选型经验是在小规模领域数据下中文BERT的细节差异远不如数据和训练策略的影响大与其纠结换模型不如沉下心处理训练数据。3. 训练数据构建正负样本比例和难负样本是效果分水岭3.1 数据从哪里来三种来源的清洗方式模型结构确定之后真正花时间的地方来了——训练数据。BERT微调不是从零训练它已经具备很强的通用语义理解能力微调数据的作用是教会它“投资问答语境下什么叫匹配”。数据质量直接决定效果上限这一点在我们项目里体现得特别明显。我们的数据有三个来源。第一个来源是知识库里的人工QA对这些问答是编辑审核过的语言规范当作标准正样本的基础。第二个来源是社区历史问答用户真实提问过的问题经过脱敏处理后保留这类数据口语化程度高能帮助模型适应真实的用户表达。第三个来源是历史搜索日志里的点击行为用户在搜索结果里点了哪条答案可以作为弱正样本没点的可以作为弱负样本但这一类噪声很大只用来做预筛选不直接进入训练集。清洗过程有几个硬性规则。长度低于10个字的问题删掉因为信息量不足模型学了容易产生偏见包含乱码、纯数字、广告内容的问题删掉答案超过512字的长文截断处理避免输入长度压力同一条用户问题在短时间内重复出现多次的只保留一条。清洗完之后大概剩下两万条高质量正样本对。这里强调一个观点正样本数量不足的时候不要硬凑。与其把弱相关的样本塞进去污染模型不如把数据质量守住再用负样本构造去平衡。3.2 负样本的三种构造方法负样本是整个数据工程里投入产出比最高的一块。没有负样本模型只会学会“所有句子对都很相关”负样本太简单模型学会的是“字面不重叠就不相关”这仍然是词面匹配不是语义匹配。我们分三个阶段构造负样本难度逐渐加大。第一阶段是随机负样本。从知识库里随机抽一条答案和当前问题组成负样本对。这类负样本很简单问题与答案明显不相关模型很快就能学会区分。它们的作用是给模型建立基础判别边界防止把所有样本都判成正样本。初期正负比例控制在1比4左右。第二阶段是BM25难负样本。用BM25算法检索出与当前问题字面重合度较高的几条答案但其中有些在语义上其实不能回答这个问题将它们标为负样本。这类样本对模型是最有效的中等难度训练因为它逼迫模型认识到“字面像还不够语义必须匹配才是匹配”。第三阶段是模型挖掘难负样本这是效果提升最明显的一步。先用当前模型对候选知识库做一个全量打分找出“模型给了高分但人工标注为不匹配”的那些样本把它们加入训练集让模型在新的迭代中纠正自己的错误判断。这也就是业内常说的hard negative mining。实际做下来把这类样本逐步增加到负样本总量的50%以上后验证集F1一次性提升了两个百分点以上。3.3 标注一致性什么样的答案算“匹配”数据构造过程中最容易扯皮的问题就是到底什么算匹配最开始标注同学和算法同学对“匹配”的理解不完全一致。算法认为只要用户问题能从答案里找到答案就算匹配标注有时按“问题和标准问法是否同义”来标标准不一样模型学起来就晕。后来我们把标注规则收敛成一句话用户问题的主旨意图是否能在答案文本中得到直接回应。围绕这句话定了几个边界情况答案完整回应了问题算正样本。答案只回应了问题的一部分但用户能从该部分得到有效信息算正样本。答案围绕同一个话题但没有直接回应用户问题比如用户问收益答案是风险算负样本。答案对问题给出了相反或否定性的判断但逻辑上正确仍然算正样本因为“能回答”不等于“顺着用户预期回答”。这个规则看起来简单但为标注一致性和后续检查提供了明确的判断依据。统一规则之后标注员之间的一致性从勉强及格提升到了0.85以上。4. 训练过程实录与调参经验4.1 核心参数配置与选择理由微调BERT的参数业界已经有比较成熟的默认配置但具体到自己的数据集上还是要调。我们的配置如下参数数值说明预训练模型roberta-wwm-ext全词掩码适合中文学习率2e-5BERT微调常用区间Batch Size32显存允许时尽量大一些Max Length128投资问答句子普遍不长Epochs3小数据下3轮足够Warmup10%缓解训练早期损失震荡Weight Decay0.01轻度正则防过拟合为什么学习率选2e-5BERT在预训练阶段已经收敛到了较好的参数空间微调阶段如果学习率太大容易破坏预训练学到的通用语义表示导致灾难性遗忘。2e-5、3e-5、5e-5这个区间我都试过2e-5在验证集上的表现最稳5e-5虽然训练集loss降得更快但验证集上波动明显。归根结底小学习率是“谨慎地在预训练基础上做局部调整”这也符合我们数据量不算大的事实。Max Length选128是因为投资问答句子普遍比较短。用户问题一般几十个字答案虽然可以很长但匹配判断主要靠核心语义段落不必全量代入。长度选短能显著加快训练和推理速度同时避免因padding浪费计算资源。如果答案超过128个字直接截断即可对匹配效果影响不大。4.2 训练中观察到的三个关键现象训练过程中的几个现象比参数本身更有参考价值。第一个现象出现在第一轮epoch快结束时训练集loss下降到0.3以下但验证集F1停在0.83附近不再上涨。这是典型的过拟合信号模型开始死记训练集里的匹配模式对没见过的问法泛化能力不足。应对措施是提前停止不再跑满3个epoch同时在BERT输出和分类头之间加一层nn.Dropout(p0.3)。这改动不大但验证集F1稳步提升了0.5个百分点。第二个现象在加入难负样本时出现训练loss出现回弹从0.2反弹到0.4左右初看像训练出了问题其实恰恰是模型在被难样本“逼着”学习更细微的区分维度。难负样本和正样本在字面和主题上都很接近之前模型的决策边界太粗糙现在必须重新校准边界来区分它们。这个阶段只要验证集F1没有掉就坚持喂下去熬过两三个批次后loss会再次下降而验证集F1会跨过0.90。第三个现象是温度系数带来的增益。我们尝试在softmax之前给logits一个温度系数取0.8。温度系数的作用是让输出分布更尖锐使正样本和难负样本之间的差距更明显。效果上F1提升不足一个点但模型分数分布更清晰了阈值更容易选算是一个性价比很高的trick。4.3 数据增强尝试与取舍数据量不足的时候第一反应往往是做数据增强。我们试过三种方法。第一种是同义词替换。把“基金”换成“基金产品”、“定投”换成“定期定额投资”意思没变。但这种替换引入了一个问题投资术语的替换词往往不只一个同一个概念在不同语境下可能对应不同的标准问法无脑替换容易产生语义漂移的误导样本。实测效果是验证集F1原地不动甚至略有下降最终放弃了大规模同义词替换。第二种是回译增强。把中文问题翻译成英文再翻译回中文能生成语法稍有不同的新问题。这个方法在通用领域效果不错但投资术语回译后经常面目全非比如“定投”变成“固定投资”、“净值”变成“净资产”语义发生了微妙偏移。后来只对非术语类问题做回译数量压得很低。第三种是基于真实用户日志改写。把线上日志里同一回答命中过的不同问法配对成同义问题对再生成对应的答案匹配样本。这个方向效果最好因为来源真实没有人工改造的痕迹。所以我后来形成了一个判断领域数据增强不一定非要用花哨的NLP技巧把产品里的真实交互数据挖掘好远比“制造假数据”可靠。5. 评估指标与误差分析不是准确率高就万事大吉5.1 分层评估集把问题拆开看做评估之前如果不把易错类型拆解出来只看一个总分很容易被整体数字欺骗。我们把评估集分成了四个子集每个子集考察一种能力。第一类是同义改写集包含同一意图的各种口语化问法测试模型的语义等价识别能力。第二类是主题干扰集问题包含相同关键词但实际意图不同。比如“基金会不会清盘”和“基金清盘了钱怎么办”共享“基金清盘”这个主题但一个是事前风险判断一个是事后处理。第三类是实体敏感集重点测试一字之差但答案完全不同的样本。“存款利率”对“贷款利率”“申购费”对“赎回费”。第四类是否定边界集包含“不建议”“不推荐”“一定吗”“怎么办”这类表达测试模型对否定和条件的区分能力。分项评估的价值在项目中期充分体现了出来。整体F1在0.93时我们以为效果可以了拆开一看否定边界集F1只有0.86实体敏感集F1也只有0.88。这说明模型的主要短板集中在“词相近但义相反”的场景而这个场景恰恰是投资问答里最不容出错的。总分是会把短板掩盖掉的拆开看才真实。5.2 四个典型错误案例拆解这里记录几个最典型的badcase它们的共性非常值得研究。第一个是主题干扰型错误“债券基金有没有风险”被模型匹配到了“债券基金的收益率怎么算”。两个句子共享“债券基金”模型学到了“债券基金”的高权重却忽略了“风险”和“收益率”是两种完全不同意图。这类错误的改进手段是在训练数据中增大此类“共享主题不同意图”的负样本占比。第二个是否定边界型错误“定投亏损了怎么办”匹配到了“定投怎么避免亏损”。句子都涉及“定投”“亏损”但一个是怎么应对已亏损一个是怎么防止未来亏损。这个案例告诉我们匹配模型不能只学词级关系还要捕捉“问题时态和动作方向”。后来我们尝试把问题里的动词短语单独抽出来作为注意力提示但工程复杂度高最终靠负样本堆积解决了大部分问题。第三个是实体混淆型错误“存款利率下调了对债基有影响吗”匹配到了“贷款利率下调对债基的影响”。一个“存”一个“贷”差一个字答案逻辑完全不同。这类错误用词共现几乎无法避免只有靠让模型见过足够多“存贷对比”的负样本才能学会把“存款”和“贷款”当不同实体。我们专门构造了一批“一字之差”的负样本对针对性地提升效果。第四个是答案粒度错误“基金赎回要手续费吗”匹配到了“基金赎回流程”。这是业务上很典型的问题用户要的是费率信息匹配到的是操作流程。两条答案主题相同但目标信息完全不同。这个案例再次提醒我们匹配不是“相关即可”而是“能直接回答问题才可”所以在标注时反复强调“主旨意图是否被直接回应”。5.3 阈值怎么定0.5不是默认答案模型输出层是2分类很多人习惯性把判别阈值定在0.5但这对匹配检测是不够的。0.5意味着“正负样本概率均等时判为正”而业务场景里正负样本比例远远不是1比1而且答非所问的代价远大于不回答。我们做了一件事在验证集上绘制精确率和召回率曲线观察不同阈值下“误匹配率”和“漏匹配率”的变化。取0.5时误匹配率偏高大概有6%到7%的无关答案会被返回给用户。把阈值提高到0.72后误匹配率降到2%左右漏匹配率虽然从5%涨到9%但在产品上表现为“这个问题暂时无法回答”用户不会产生反感。漏检可以兜底误检却会让用户失去信任。这是做问答系统非常重要的判断。所以最终线上阈值不是0.5而是0.72并且按问题类型做了细分简单名词解释类的匹配分普遍高阈值可以放宽到0.65复杂建议类问题容易产生语义偏差阈值收紧到0.78。这个细分策略让整体答非所问率下降了近一半。6. 上线部署的工程化细节6.1 推理加速与服务化设计模型定下来之后部署环节同样有不少坑。第一个坑就是推理速度。直接用PyTorch的eager模式跑roberta-wwm-ext单条句子对在CPU上大概要25毫秒左右这在知识库场景里勉强能用但一旦请求并发上来就顶不住。我们把模型做了ONNX导出用ONNX Runtime进行推理。同样的模型在CPU上单条推理降到10到15毫秒提速接近一倍。导出的过程中有几个细节要注意一是把动态轴设置好input_ids的序列长度维度设为动态这样可以支持不同长度的输入二是用optimize工具打开图优化选项三是token_type_ids必须一起导出否则双句输入的segment信息会丢失。服务化设计上我们把模型包成一个标准的推理服务输入是一个JSON数组包含“问题”和“候选答案列表”输出是每个候选的匹配分数。为了减少调度开销支持批量请求一次请求可以带最多50个候选答案模型内部按batch推理。这样单次请求的延迟反而比逐个调用更低平均在30毫秒左右返回结果。6.2 召回与精排的两级流水线线上架构不是拿BERT直接扫整个知识库而是两级流水线。第一步是召回用BM25加一个轻量向量检索先把候选从几万条缩到50条以内。BM25保证字面召回率高向量检索保证语义召回覆盖广两者做结果合并。这一步的延迟控制在5毫秒以内。第二步是精排把用户问题和50条候选答案分别拼成句子对送入BERT服务打分按分数排序取Top1返回。这里有一个工程细节候选答案不能直接从库里原文截断输入模型而是先把答案里的要点结构化提取一遍比如把“费率”“风险”“规则”分段再和问题拼接。这样模型更容易定位到对应信息段匹配分数更准确。两级流水线的好处是各司其职召回层追求“不遗漏”精排层追求“别答错”。同时如果召回层就没召回正确答案BERT精排再强也无能为力所以召回层我们做了很多兜底规则比如把用户问题里的数字、百分比、产品代码都单独抽出来强制参与召回避免这类强特征在BM25里被忽略。6.3 降级兜底与线上监控任何模型服务都不可能100%稳定上线前要做两件最坏的打算。第一件是降级策略。当BERT服务超时或不可用时系统自动降级到纯BM25召回用关键词相关性直接返回结果。虽然效果差一些但至少不会让对方完全不可用。降级的触发条件做了三层单次请求超时、连续出错比例超标、服务健康检查失败。运维监控上对降级次数做了单独告警降级太频繁说明模型服务需要扩容或优化。第二件是答案置信度兜底。BERT返回的匹配分数低于设定阈值时前端不返回任何答案而是提示用户“这个问题我暂时回答不了换个问法试试”。这个设计看似很简单但对用户体验至关重要。我们做过的线上对比测试显示给出低置信度的错误答案比“不回答”带来的负反馈高出三倍。很多团队会把BERT匹配分低于0.5的样本直接丢弃但我们的经验是把阈值调得比理论值更严宁可漏掉一些模糊问题也不要答非所问。线上监控方面除了常规的请求量、延迟、错误率我们重点盯两个业务指标无匹配率和用户负反馈率。无匹配率突然升高往往召回层出了问题用户对某类问题的负反馈变多往往需要重新沉淀badcase回训练集。我们每个月会人工抽检一批新增badcase补充进训练数据形成“数据增强-模型迭代-线上验证”的闭环。这个闭环跑通之后模型每隔两周更新一次每次更新都能看到误匹配率的稳定下降。最后再分享一个小技巧。如果你也在做类似的问答匹配项目我强烈建议上线之前给每个知识库答案都准备一条“标准问法”和至少三条“用户口语问法”。这不只是为了匹配训练更是为了给模型制造清晰的正样本源头同时给后续的人工抽检提供对照基准。我们对所有知识库问答额外维护了这组信息后期不管是做评估集、badcase分析还是模型蒸馏都方便了很多。问答匹配这条路越往后做越会发现模型只是天上的云数据和工程才是托住云的地基。