ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

金融问答两段式架构:BM25召回、BERT重排与答案span抽取

金融问答两段式架构:BM25召回、BERT重排与答案span抽取 简介FinBERT-QA 是一套面向自然语言处理与信息检索学习者的金融问答检索方案聚焦于从大量金融段落中为查询精准定位答案这一任务。项目先用 Lucene 工具包为每个查询召回前 50 个候选答案再以预训练 BERT 模型对候选答案重排并采用 Transfer and Adapt 思路先在通用问答数据上微调再借助 FiQA 数据集适配金融领域最终在 nDCG、MRR、Precision 三项排序指标上平均提升约 20%。压缩包共 62 个文件、约 142.71MB含 9 个 Python 脚本覆盖数据处理、训练、预测与评测、23 个 pickle 序列化特征与模型文件、4 个 tsv 数据集与索引文件另有 2 个 Jupyter Notebook 用于检索分析与数据流程演示配合 Dockerfile 与 requirements 可复现实验环境。已有 2091 人学习下载适合想理解“召回重排”两阶段问答架构、上手 BERT 微调与排序评估的中高级读者参考。1. 金融问答为什么不能直接套用通用 BERT一句话里的数字和立场都是坑把在 SQuAD 上刷到 F1 90 以上的通用 BERT 直接搬到金融领域问答准确率常常掉到及格线以下这是很多做信息检索和问答的团队踩过的第一个坑。金融文本里的答案往往不是一个干净的实体而是一段带条件的观点、一组对比数字甚至是一句“要看情况”。FinBERT-QA 的思路不是把模型换得更大而是承认金融问答本质上是“先找对段落、再抽出答案”的两段式任务用预训练 BERT 分别微调检索器与阅读器并在 FiQA 数据集上把这两段各自校准。这套拆分为什么站得住、两段各自怎么实现值得从底层拆一遍。2. Retriever 的两段式设计BM25 召回加 BERT 重排的工程动机先给结论FinBERT-QA 的检索阶段不是让 BERT 直接在全量语料里算相似度而是先用 BM25 这类词法检索把候选段落从数万条压到几十条再用微调后的 BERT 对这几十条做相关性重排。这个设计不是偷懒而是被计算复杂度和金融语料特性同时逼出来的。2.1 为什么粗排不能省BERT 的平方复杂度与候选规模BERT 的 self-attention 对序列长度是平方复杂度把一个问题和一个段落拼成 512 长度的输入跑一次前向在单张普通 GPU 上大约几毫秒到十几毫秒。FiQA 这类数据集里每个问题平均对应近九十个候选答案段落如果对每个候选都跑一次 BERT 打分一个问题的检索开销就是几百毫秒放到整个测试集上直接不可接受。更关键的是候选段落大多和问题毫无关系让 BERT 去逐条判断“不相关”是巨大的算力浪费。BM25 的价值在这里就体现出来了它靠词频和逆文档频率做倒排索引百万级文档的召回可以在毫秒级完成代价是它只认字面匹配对同义改写和隐含语义无能为力。金融问答里“美联储加息对银行股的影响”和“利率上行如何传导到金融板块估值”这种同义表达BM25 很容易漏掉。所以合理的分工是BM25 负责高召回、低成本地把候选池缩小两个数量级BERT 负责在这个小池子里做语义级的精排。常见的实现里召回阶段取 top 50 到 top 100精排阶段再截断到 top 10 到 top 20 交给阅读器。提示粗排的召回率是整条链路的硬上限。如果 BM25 的 top 100 里根本不含正确段落后面 BERT 再强也救不回来。调参时先盯 Recall100别急着看精排指标。2.2 Pointwise、Pairwise、Listwise三种微调范式的目标函数差异检索器微调的核心问题是怎么把一个“问题-段落”对变成一个可训练的监督信号。FinBERT-QA 里讨论的三种范式差异不在模型结构而在损失函数如何构造样本之间的关系。Pointwise 最直白把每个“问题-段落”对独立送进 BERT做二分类判断相关与否。它的好处是实现简单、推理时可以并行打分缺点是它只学“这个段落相关吗”不学“这个段落比那个段落更相关”排序能力靠分数绝对值撑训练数据里正负比例失衡时容易退化成全部预测负类。Pairwise 把正样本和负样本组成一对只要求正样本的分数比负样本高出一个 margin不关心分数的绝对大小。这更贴合排序任务的目标因为检索评估看的是相对顺序MRR、NDCG不是绝对相关性。缺点是样本对的数量随候选数平方增长训练时要控制采样。Listwise 把一个问题下的所有候选作为一个列表用 softmax 把分数转成概率分布再用交叉熵对齐相关性标签。它最接近真实排序目标但需要每个问题都带完整的候选列表和相关性等级数据构造成本最高。下面用 PyTorch 把三种损失的核心逻辑写出来模型主体都是同一个 BERT 编码器差别只在输出层和 loss 上。import torch import torch.nn as nn from transformers import BertForSequenceClassification class PointwiseRetriever(nn.Module): def __init__(self, model_namebert-base-uncased): super().__init__() # 单塔结构query 和 passage 用 [SEP] 拼接后一起进 BERT # num_labels2 表示相关 / 不相关二分类 self.bert BertForSequenceClassification.from_pretrained( model_name, num_labels2 ) def forward(self, input_ids, attention_mask, token_type_ids, labelsNone): out self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, labelslabels, ) # logits[:, 1] 是相关这一类的分数拿来排序 return out.logits[:, 1], out.loss这段代码的关键在num_labels2和最后的logits[:, 1]。二分类用的是标准交叉熵labels是 0/1 的硬标签。为什么不用回归去拟合一个 0 到 1 的相关性分数因为 FiQA 的相关性标注本身是离散等级硬标签加交叉熵对标注噪声更鲁棒回归容易被个别标错的样本带偏。参数上token_type_ids在单塔结构里其实可以全 0因为它区分不了 query 和 passage真正区分靠的是[SEP]的位置。def pairwise_loss(pos_score, neg_score, margin0.5): # pos_score / neg_score: [batch]正负样本各自的打分 # 只要求正样本比负样本高出 margin不约束分数绝对值 return torch.relu(margin neg_score - pos_score).mean()Pairwise 的 margin 取 0.5 是经验值。margin 太小模型学不到区分度margin 太大正样本分数被推得过高反而过拟合到少数简单样本。训练时每个 batch 里正负对的比例建议控制在 1:4 到 1:8负样本从 BM25 召回但未标注为相关的段落里采这就是所谓的“难负样本”。def listwise_loss(scores, labels, tau1.0): # scores: [batch, num_candidates]每个问题下所有候选的分数 # labels: 同形状相关性等级0/1 或分级 log_probs torch.log_softmax(scores / tau, dim-1) # 用相关性标签做加权正样本越靠前 loss 越小 return -(labels * log_probs).sum(dim-1).mean()tau是温度系数控制分布的陡峭程度。tau越小softmax 越接近 argmax训练越激进tau越大分布越平滑梯度更稳。Listwise 的坑在于如果 labels 全是 0/1 且正样本只有一个log_softmax会把大量概率分给负样本需要配合 label smoothing 或者对负样本降权。2.3 训练数据构造负采样比例决定检索器的实际表现模型结构定下来之后真正拉开效果差距的是训练数据怎么造。FinBERT-QA 用的 FiQA 数据集只标了哪些段落和问题相关没标“不相关”的负样本负样本得自己采。常见的做法是混合三种来源BM25 召回里排名靠前但未标注相关的难负样本、随机从语料里抽的易负样本、以及同一问题下其他不相关段落。难负样本让模型学会区分“看起来像但其实不对”的段落易负样本防止模型把所有分数都压到负类。负正比不是越高越好。比例到 1:16 以上时pointwise 的交叉熵会被负类主导模型倾向于输出低分排序时区分度反而下降。实践中 1:4 到 1:8 是比较稳的区间具体要看 BM25 的召回质量。如果 BM25 本身召回就很差难负样本里混进了真正相关的段落模型会被教坏这时候要么先修 BM25要么把难负样本的权重降下来。3. Reader 的抽取式答案机制SQuAD 式 span 预测如何在金融语料上收敛检索器把候选段落排好序之后阅读器的任务是从 top-k 段落里定位答案的起止位置。FinBERT-QA 的阅读器沿用 BERT 在 SQuAD 上的做法把问题和段落拼成一个序列让模型输出两个概率分布分别表示答案起点和终点落在每个 token 上的概率。听起来简单但金融语料的答案形态和 SQuAD 差别很大直接套用会出问题。3.1 起止指针网络BERT 输出如何变成两个概率分布BERT 的最后一层对每个 token 输出一个 768 维向量阅读器在它上面接两个线性层一个映射到起点 logits一个映射到终点 logits维度都是序列长度。训练时用真实答案的起止 token 位置做交叉熵推理时对所有合法的 (start, end) 组合算start_prob * end_prob取分数最高的那个 span。这里有个容易忽略的细节起点和终点的联合概率不是独立的start end这个约束必须在解码时强制。如果只对起点和终点分别取 argmax很可能得到start end的非法 span。常见的实现是在解码时遍历所有满足start end且长度不超过max_answer_len的组合取联合概率最大的。import torch from transformers import BertForQuestionAnswering model BertForQuestionAnswering.from_pretrained(bert-base-uncased) def decode_span(start_logits, end_logits, max_answer_len30): # start_logits / end_logits: [1, seq_len] start_probs torch.softmax(start_logits, dim-1) end_probs torch.softmax(end_logits, dim-1) best None seq_len start_probs.size(-1) for s in range(seq_len): # 强制 end start且答案长度受限 for e in range(s, min(s max_answer_len, seq_len)): score start_probs[0, s] * end_probs[0, e] if best is None or score best[0]: best (score.item(), s, e) return best # (score, start_idx, end_idx)max_answer_len的取值很关键。SQuAD 的答案大多是短语长度限制在 30 以内效果很好但金融问题的答案经常是一整句甚至几句限制太短会把正确答案截断在中间模型输出的 span 分数反而比错误答案低。常见的做法是根据数据集里答案长度的分位数来定比如取 95 分位作为上限。参数start_probs * end_probs用的是概率乘积等价的 log 形式是log_start log_end数值上更稳避免长序列下概率下溢。3.2 从 FiQA 的观点型答案到可抽取 span 的转换FiQA 的答案标注和 SQuAD 有一个本质区别SQuAD 的答案是原文里连续的一段子串FiQA 的答案常常是“观点”需要从多个段落里综合标注的是“这个段落相关答案大致在这段话里”。直接拿这种标注训练 span 抽取模型学到的起止位置会不稳定因为同一段话里可能有多个合理的答案片段。FinBERT-QA 的处理思路是把阅读器训练和检索器训练解耦阅读器先在一个标准的 SQuAD 式抽取数据集上预训练学会“给定问题和段落定位答案 span”这个通用能力然后在 FiQA 上做微调时只用那些标注了明确答案片段的样本标注模糊的样本降权或丢弃。这相当于承认 FiQA 的标注噪声不硬让模型去拟合不可靠的起止位置。这个取舍的代价是阅读器的训练样本变少对长尾问题的覆盖不足。一个补偿手段是把检索器召回的多个相关段落都送进阅读器让阅读器在多个段落上分别抽 span再按检索分数和 span 分数加权融合。这样即使单段落的标注不精确多段落投票也能稳定结果。3.3 答案后处理把 span 还原成可读答案阅读器输出的是 token 索引要还原成文本还有几步去掉[CLS]、[SEP]这类特殊 token处理 WordPiece 的子词拼接比如fin和##ance要拼回finance再根据原始文本的字符偏移映射回原文。这一步看起来是纯工程但出错率很高尤其是金融文本里的数字和缩写。数字尤其要小心。BERT 的 WordPiece 分词器会把2023拆成202和##3把3.5%拆成3、.、5、%。如果答案恰好在这些 token 边界上截断还原出来就是残缺的数字。常见的做法是在预处理阶段对数字做归一化或者在解码后检查 span 两端是否落在数字中间如果是就扩展到完整的数字边界。金融问答里一个数字错了整个答案就失去意义这点比通用问答更敏感。4. FiQA 数据集的标注结构为什么这份数据决定了系统的上限聊完两阶段模型得回到数据本身。FinBERT-QA 选 FiQA 作为金融问答的基准不是因为它大而是因为它的标注结构恰好覆盖了“检索 抽取”两段式任务需要的全部监督信号。理解这份数据的组织方式才能理解模型指标为什么卡在某个水平上不去。4.1 问题-段落-答案的三元组结构FiQA 的每条样本可以拆成三层一个金融问题、若干候选答案段落、以及每个段落与问题的相关性等级。问题和段落来自真实的金融社区问答段落是用户回答的原始文本相关性标注由人工判断。这种结构的特别之处在于它同时提供了检索信号段落相不相关和抽取信号答案在段落里的位置刚好对应 FinBERT-QA 的两个阶段。层级内容对应任务典型量级问题层金融领域自然语言问题输入数百条段落层社区回答原文每条几十到几百字检索数万条相关性层段落与问题的相关等级检索监督每个问题近九十条候选答案层段落内的答案片段标注抽取监督覆盖部分段落表格里的“典型量级”按公开描述的量级给出具体数字以数据集官方发布为准。问题数只有数百条意味着训练集很小模型很容易过拟合候选段落数万条意味着检索阶段的负样本空间很大BM25 召回的 top-100 里真正相关的可能只有个位数。这两个特点叠加决定了 FinBERT-QA 必须靠预训练 BERT 的迁移能力来弥补数据量不足而不是从头训练。4.2 划分方式与标签噪声为什么按问题划分比按段落划分更靠谱数据划分看起来是小事实际上直接决定评估指标可不可信。FiQA 的候选段落是跨问题共享的同一个段落可能同时出现在多个问题的候选列表里。如果按段落随机划分训练集和测试集同一个段落在训练时见过、测试时又出现模型会记住段落的表面特征而不是学会匹配指标虚高。正确的做法是按问题划分训练集和测试集的问题完全不重叠测试集里的问题在训练时从未出现过。这样评估的是模型对未见问题的泛化能力接近真实上线场景。代价是测试集里会出现训练时没见过的段落检索器的词表覆盖和阅读器的 span 定位都要真正泛化指标会比按段落划分低不少但这个低才是真实的。标签噪声是另一个绕不开的问题。相关性标注是人工判断的不同标注者对“这个段落算不算回答了问题”的标准不一致尤其是金融问题经常有多个合理答案。这种噪声在检索阶段表现为同一个问题的正样本数量波动很大有的问题标了十几个相关段落有的只标了一两个。训练时如果对正样本数量少的问题用同样的损失权重模型会偏向正样本多的问题。常见的补偿是按问题做损失归一化或者对正样本过少的问题降采样。4.3 MRR 与 NDCG评估指标背后的排序逻辑检索阶段的评估不看准确率看排序质量因为下游阅读器只消费 top-k。MRR 衡量的是第一个相关段落排在第几位如果排第一贡献 1排第二贡献 0.5排第 n贡献 1/n再对所有问题取平均。它关心的是“最快找到第一个对的”适合答案唯一或少数几个的场景。NDCG 考虑的是整个排序列表每个位置的相关性贡献按1/log2(位置1)折损再把所有位置加起来除以理想排序的得分。它关心的是“相关段落整体排得靠不靠前”对多个相关段落的场景更友好。金融问答里一个问题常有多个相关段落NDCG 比 MRR 更能反映真实体验。这两个指标有个共同的盲区它们只评估检索不评估最终答案对不对。检索指标高但阅读器抽错 span用户看到的还是错答案。所以完整的评估要分两段看先看 Recallk 确认正确段落进了候选池再看阅读器的 Exact Match 和 F1最后看端到端的答案准确率。任何一段掉链子端到端指标都会暴露。5. 训练与推理的常见坑从 loss 不降到答案跑偏的排查路径这套两阶段架构跑起来不难难的是训练过程中各种不报错但效果不对的情况。下面几条是实际调试里反复出现的按“现象、原因、解决”拆开写遇到时可以直接对照。第一条检索器 loss 卡在 0.69 不动。现象是训练几个 epoch 后 loss 几乎不变验证集 MRR 和随机猜差不多。原因是正负样本比例严重失衡pointwise 的二分类被负类主导模型学会了全部输出“不相关”。解决是先统计训练集的正负比把负正比压到 1:8 以内再检查labels是不是真的按 batch 传进去了很多实现里 label 维度对不上会被广播成全 0。第二条检索指标很高端到端答案准确率却很低。现象是 Recall10 到了 0.8 以上但用户看到的答案经常答非所问。原因是阅读器训练时喂的是“标准答案段落”推理时喂的是“检索器实际召回的段落”两者分布不一致阅读器没见过检索器犯错的样本。解决是把检索器在训练集上的召回结果直接作为阅读器的训练输入让阅读器学会在有噪声的候选里定位答案这个做法本质上是一种数据增强。第三条答案总是被截断在半句话。现象是阅读器输出的 span 明显不完整经常停在逗号或数字中间。原因有两个一是max_answer_len设得太小二是 FiQA 的答案标注本身就跨句而 span 抽取只能输出连续片段。解决是先画一下训练集答案长度的分布把上限设到 95 分位对于跨句答案在解码后做一次后处理把 span 扩展到完整句子边界或者让阅读器对多个段落分别抽取再拼接。第四条验证集指标虚高上线后大幅下降。现象是本地验证 F1 很漂亮换一批真实问题就崩。原因是数据划分按段落做了随机切分训练集和测试集共享段落模型记住了段落表面特征。解决是改成按问题划分确保测试集的问题在训练时完全没见过改完指标会掉一截但那才是真实水平。第五条金融数字被 tokenizer 切碎导致答案错误。现象是答案里的数字缺位或错位比如3.5%变成5%。原因是 WordPiece 把数字拆成子词span 边界正好落在数字中间。解决是在预处理阶段用占位符保护数字或者在解码后检查 span 两端是否在数字内部是则扩展到数字完整边界。金融问答里数字错一位答案就完全错了这条比通用问答更值得较真。注意这五条里有三条的根因都在数据而不是模型。调参之前先把数据分布、划分方式、tokenizer 行为查一遍比盲目换学习率有效得多。6. 设计取舍两段式架构的边界在哪里什么时候该换路线两段式架构最大的价值是把一个复杂问题拆成两个可独立评估、独立优化的子问题。检索器负责“找对”阅读器负责“抽准”任何一段出问题都能通过分阶段指标定位而不是面对一个端到端黑盒猜。这种可观测性在工程上比多拿一两个点指标更重要。但它的边界也很清楚。第一检索器的召回上限就是整个系统的上限BM25 加 BERT 重排这套组合对词法匹配依赖仍然很重遇到大量同义改写或者跨语言场景粗排就会漏。第二阅读器只能抽连续 span对需要跨段落综合、需要推理计算的金融问题无能为力比如“比较两家公司近三年毛利率变化”答案不在任何单个段落里。第三两阶段意味着两次推理延迟是单阶段的近两倍对实时性要求高的场景不友好。判断要不要换路线我一般看两个信号。一是 Recall100 长期卡在 0.7 以下说明粗排已经成为瓶颈这时候该考虑换成稠密检索或者混合检索把语义召回补上。二是端到端错误里超过一半是“检索对了但阅读器抽错”说明 span 抽取的范式不适合这批数据该考虑生成式阅读器或者抽取加生成的混合方案。这两个信号都比单纯看 F1 更有指导性。验证方法上我习惯把评估拆成三层先单独跑检索器看 Recallk 和 NDCG再单独跑阅读器在“给定正确段落”条件下的 EM 和 F1最后跑端到端。三层指标一起看才能分清是检索的问题还是阅读的问题。只看端到端指标出了问题根本不知道从哪下手。从那以后我每次接这类两阶段问答任务都强制先把这三层评估脚本跑通再动模型血泪经验是曾经在一个项目里调了两周阅读器最后发现是检索器把正确段落排到了第 30 位开外。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表