FinBERT-QA实战:金融问答检索与排序微调全流程

📅 发布时间:2026/10/8 2:19:27
FinBERT-QA实战:金融问答检索与排序微调全流程
简介FinBERT-QA 是一套面向金融领域问答检索的深度学习项目资源适合具备自然语言处理与信息检索基础的研究者、算法工程师及高年级学生研读。它针对 FiQA 数据集的任务2解决从金融语料中精准检索相关段落的问题先用 Lucene 召回每个查询的前50个候选答案再借助预训练 BERT 模型对候选重新排序。项目基于 Huggingface 库实现 Transfer and Adapt 方法先将通用 QA 任务上微调的 BERT 迁移至金融领域并在 nDCG、MRR、Precision 三项指标上平均提升约20%。资源包共62个文件约142.71MB以 pickle 数据缓存、Python 源码、tsv 语料与 ipynb 实验笔记为主另含 Lucene 索引文件、Dockerfile 与依赖清单覆盖数据生成、模型训练、预测与评估全流程。目前已有2087人学习下载可帮助读者快速复现金融问答检索基线并理解迁移微调思路。1. FinBERT-QA金融问答为什么不能直接套通用 BERT金融问答和开放域问答的差别不在问题长短而在“词面相同、语义相反”的密度极高。比如“这家公司流动性怎么样”在通用语料里可能指向现金流健康度在金融语境里却可能指市场成交量、资产变现能力甚至监管口径下的流动性覆盖率。通用 BERT 在维基百科和新闻语料上预训练对这类术语的向量表示并不敏感直接拿来做检索式问答召回阶段就会把大量“看起来像”的段落排到前面后面再强的排序模型也救不回来。FinBERT-QA 要解决的就是这个断层用金融领域继续预训练的 BERT 作为语义编码器把“问题—答案段落”匹配做成可复现的检索加排序流水线。它适合三类人手里有金融公告、研报、财报电话会议记录想搭内部问答检索的工程师做智能投研、合规问答、客服知识库需要把非结构化文本变成可查询资产的产品团队以及想理解“领域预训练到底在问答任务里值多少钱”的算法同学。这一章先把边界划清楚后面几章从数据构造、模型加载、训练参数到排查一步步落到能跑的命令和代码。2. 金融问答的数据从哪来段落切分与负样本构造2.1 为什么金融文档不能按固定长度硬切通用问答数据集通常已经给好了段落但金融场景里你拿到的是 PDF 财报、HTML 公告、会议记录转写稿。直接按 512 token 硬切会把一张资产负债表的表头和数字切散也会把“风险因素”段落拦腰截断。常见做法是两级切分先按文档结构切成语义块再在块内按句子边界滑窗。我一般会保留原始文档的标题层级把每个最小语义单元控制在 200 到 400 字之间重叠 50 字。重叠不是为了增加数据量而是防止答案正好落在切分点上。下面是一个可复现的切分脚本输入是带段落标记的 JSONL输出是候选段落库。import json import re from typing import List, Dict def split_financial_doc(text: str, max_len: int 400, overlap: int 50) - List[str]: # 先按中文/英文句号、分号、换行切句保留标点 sentences re.split(r(?[。;.!?])\s*, text) chunks, buf, buf_len [], [], 0 for sent in sentences: sent sent.strip() if not sent: continue # 单句超长时强制截断避免一个句子撑爆窗口 if len(sent) max_len: if buf: chunks.append(.join(buf)) buf, buf_len [], 0 for i in range(0, len(sent), max_len - overlap): chunks.append(sent[i:i max_len]) continue if buf_len len(sent) max_len: chunks.append(.join(buf)) # 保留尾部 overlap 字符作为下一块开头 tail .join(buf)[-overlap:] buf, buf_len [tail], len(tail) buf.append(sent) buf_len len(sent) if buf: chunks.append(.join(buf)) return [c for c in chunks if len(c) 30] with open(raw_docs.jsonl, r, encodingutf-8) as f, \ open(passages.jsonl, w, encodingutf-8) as out: for line in f: doc json.loads(line) for idx, chunk in enumerate(split_financial_doc(doc[text])): out.write(json.dumps({ doc_id: doc[doc_id], passage_id: f{doc[doc_id]}_{idx}, text: chunk }, ensure_asciiFalse) \n)逻辑说明max_len控制单块最大字符数金融中文场景 400 字大约对应 300 到 400 token留出问题拼接空间overlap只保留尾部避免块与块之间重复过多导致负样本泄漏。参数上如果文档表格密集把max_len降到 250overlap提到 80能减少表格跨块断裂。2.2 负样本怎么选才不“假”检索式问答训练最容易被忽视的是负样本质量。随机抽段落做负样本模型很快学会“只要不是同一篇文档就判负”但真实推理时干扰项往往来自同一篇文档的相邻段落或者同一行业其他公司的相似表述。我的做法是三层负样本同文档不同段落、同行业不同公司、全局随机。比例大致 5:3:2同文档负样本最难也最能拉开模型差距。构造时还要注意一个问题如果问题和答案段落来自同一份财报而负样本也来自同一份财报模型可能靠文档风格而不是语义匹配来区分。所以同文档负样本要排除答案段落前后各一个段落避免答案被切分到相邻块造成标签噪声。import random from collections import defaultdict def build_triplets(passages, qa_pairs, same_doc_neg5, same_ind_neg3, rand_neg2): # passages: {passage_id: {doc_id:..., text:...}} # qa_pairs: [{qid:..., question:..., pos_pid:..., industry:...}] doc2pids defaultdict(list) ind2pids defaultdict(list) for pid, p in passages.items(): doc2pids[p[doc_id]].append(pid) ind2pids[p.get(industry, unknown)].append(pid) triplets [] for qa in qa_pairs: pos qa[pos_pid] pos_doc passages[pos][doc_id] pos_ind passages[pos].get(industry, unknown) negs [] # 同文档负样本排除正样本和相邻段落 candidates [p for p in doc2pids[pos_doc] if abs(int(p.split(_)[-1]) - int(pos.split(_)[-1])) 1] negs random.sample(candidates, min(same_doc_neg, len(candidates))) # 同行业负样本 ind_candidates [p for p in ind2pids[pos_ind] if passages[p][doc_id] ! pos_doc] negs random.sample(ind_candidates, min(same_ind_neg, len(ind_candidates))) # 全局随机 all_pids list(passages.keys()) negs random.sample(all_pids, rand_neg) for n in negs: triplets.append({qid: qa[qid], question: qa[question], pos: passages[pos][text], neg: passages[n][text]}) return triplets参数说明same_doc_neg越大训练难度越高但太大容易让正负样本比例失衡一般不超过 8same_ind_neg需要行业标签如果没有可以先去掉这一层用全局随机补足。负样本去重很重要同一段落被多次抽到会让模型过拟合到特定负例。3. 加载 FinBERT 做问答匹配模型结构、池化与相似度计算3.1 用预训练权重初始化编码器FinBERT-QA 的核心不是从零训练一个 BERT而是在金融领域继续预训练过的 BERT 权重上做问答匹配微调。常见做法是加载bert-base-chinese或已有的金融 BERT 权重接一个双塔或交叉编码器。双塔适合大规模召回交叉编码器适合精排。下面以交叉编码器为例因为它在小规模金融问答里效果更稳训练代码也更容易复现。import torch import torch.nn as nn from transformers import BertModel, BertTokenizer class FinBertQAMatcher(nn.Module): def __init__(self, model_namebert-base-chinese, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout nn.Dropout(dropout) # 二分类匹配 / 不匹配 self.classifier nn.Linear(self.bert.config.hidden_size, 2) def forward(self, input_ids, attention_mask, token_type_ids): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) # 取 [CLS] 向量做分类金融问答里 [CLS] 比 mean pooling 更稳 cls outputs.last_hidden_state[:, 0, :] logits self.classifier(self.dropout(cls)) return logits tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model FinBertQAMatcher()逻辑说明token_type_ids用来区分问题和答案段落输入格式是[CLS] 问题 [SEP] 段落 [SEP]。[CLS]池化在句子对分类任务里通常比平均池化好因为 BERT 预训练时[CLS]就被用来聚合整句信息。如果显存紧张可以把max_length从 512 降到 384金融问答里问题通常很短段落截断到 350 token 左右影响不大。3.2 相似度计算与阈值选择训练完分类头后推理时需要对每个候选段落打分。如果候选库有几十万段落交叉编码器逐对打分太慢常见做法是先用双塔模型召回 top 100再用交叉编码器精排。双塔模型可以把问题和段落分别编码成向量用余弦相似度快速检索。import torch.nn.functional as F def encode_texts(model, tokenizer, texts, max_length384, batch_size32): model.eval() vectors [] with torch.no_grad(): for i in range(0, len(texts), batch_size): batch texts[i:i batch_size] encoded tokenizer(batch, paddingTrue, truncationTrue, max_lengthmax_length, return_tensorspt) outputs model.bert(**encoded) # 双塔场景用 mean pooling保留更多段落信息 mask encoded[attention_mask].unsqueeze(-1).float() vec (outputs.last_hidden_state * mask).sum(1) / mask.sum(1) vectors.append(F.normalize(vec, p2, dim1)) return torch.cat(vectors, dim0)参数说明max_length在双塔召回阶段可以降到 256因为只需要粗粒度语义batch_size根据显存调整16GB 显存下 32 一般安全。阈值选择上不要用固定 0.5而是用验证集画 P-R 曲线选 F1 最高点。金融问答里漏召回比误召回代价高阈值可以适当调低让更多候选进入精排。4. 训练参数怎么设学习率、批次与金融领域微调策略4.1 分层学习率与 warmup金融领域继续预训练过的 BERT底层已经包含大量金融术语表示微调时不需要大改。常见做法是分层设置学习率底层 1e-5顶层 3e-5分类头 1e-4。这样既保留预训练知识又让任务头快速收敛。warmup 比例设 0.1避免一开始梯度太大破坏预训练权重。from transformers import AdamW, get_linear_schedule_with_warmup def build_optimizer(model, base_lr1e-5, head_lr1e-4, weight_decay0.01): no_decay [bias, LayerNorm.weight] optimizer_grouped_parameters [ # BERT 底层 {params: [p for n, p in model.bert.named_parameters() if not any(nd in n for nd in no_decay)], lr: base_lr, weight_decay: weight_decay}, {params: [p for n, p in model.bert.named_parameters() if any(nd in n for nd in no_decay)], lr: base_lr, weight_decay: 0.0}, # 分类头 {params: model.classifier.parameters(), lr: head_lr, weight_decay: weight_decay} ] optimizer AdamW(optimizer_grouped_parameters) return optimizer # 假设总训练步数 2000warmup 200 步 scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_steps200, num_training_steps2000 )逻辑说明weight_decay对 LayerNorm 和 bias 不生效这是 BERT 微调的常规操作。base_lr如果设到 5e-5 以上金融小数据集上很容易灾难性遗忘表现为验证集 loss 先降后升。head_lr可以设大一些因为分类头是随机初始化。4.2 批次大小与梯度累积金融问答数据集通常不大几千到几万条三元组。单卡 16GB 显存下交叉编码器max_length384时batch_size只能到 8 或 16。这时用梯度累积模拟大 batch累积步数设 4等效 batch 到 32 或 64。注意梯度累积时 loss 要除以累积步数否则梯度会放大。accum_steps 4 optimizer.zero_grad() for step, batch in enumerate(dataloader): logits model(**batch) loss F.cross_entropy(logits, batch[labels]) / accum_steps loss.backward() if (step 1) % accum_steps 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()参数说明clip_grad_norm_设 1.0 是 BERT 微调的常见值金融文本里长段落多梯度容易尖峰。如果发现 loss 震荡先把base_lr降到 5e-6再检查数据里有没有空段落或超长段落。5. 避坑与排查金融问答微调里最容易翻车的 5 个点5.1 现象验证集准确率很高线上召回一塌糊涂原因训练时负样本太简单同文档负样本没构造模型只学会了区分文档风格。解决按第 2 章的三层负样本重新构造同文档负样本比例不低于 50%并且排除相邻段落。5.2 现象训练 loss 正常下降但推理时所有分数都接近 0.5原因分类头初始化和学习率不匹配或者[CLS]向量被 dropout 过度破坏。解决检查head_lr是否低于 1e-5把它提到 1e-4dropout 从 0.1 降到 0.05 试试。另外确认推理时model.eval()已调用否则 dropout 仍在生效。5.3 现象中文金融术语被 tokenizer 切得太碎原因bert-base-chinese词表对“同比”“环比”“归母净利润”这类词可能按字切分。解决统计训练语料里高频金融词用tokenizer.add_tokens()加入自定义词表然后model.resize_token_embeddings(len(tokenizer))。注意新增 token 的 embedding 需要更多步数才能学好学习率可以给新 token 单独设 1e-4。5.4 现象显存溢出batch 只能设到 2原因max_length设了 512且段落没有截断。解决金融问答里问题通常 20 到 50 字段落截断到 384 token 足够。如果还溢出用gradient_checkpointing_enable()换显存训练速度会慢 20% 左右但 batch 能翻倍。5.5 现象模型对否定句和数字不敏感原因BERT 对“增长 5%”和“下降 5%”的区分依赖注意力金融文本里数字密集注意力容易被分散。解决在输入里显式保留数字和单位不要预处理时去掉百分号训练时加入数字替换增强把“5%”换成“8%”构造负样本让模型学会关注数值本身。6. 进阶技巧用对比学习提升金融问答的排序鲁棒性交叉编码器精排效果好但推理慢。如果候选库上万每次请求都跑 BERT 不现实。我一般会加一层对比学习预训练用双塔结构在金融问答三元组上做 InfoNCE 损失让问题和正样本段落的向量更近和负样本更远。这样召回阶段就能把 top 100 压到 top 20再交给交叉编码器精排整体延迟降一半以上。def info_nce_loss(q_vec, p_vec, temperature0.07): # q_vec: [B, D], p_vec: [B, D]batch 内其他样本作为负例 q_vec F.normalize(q_vec, dim1) p_vec F.normalize(p_vec, dim1) logits q_vec p_vec.T / temperature labels torch.arange(q_vec.size(0), deviceq_vec.device) return F.cross_entropy(logits, labels)温度系数temperature设 0.07 是对比学习里的常见起点金融问答里如果负样本区分度低可以调到 0.1 让分布更平滑。训练时 batch 内负例数量等于 batch size所以 batch 越大效果越好显存不够就用梯度累积。验证对比学习是否有效不要只看 loss要看召回率。具体做法从验证集里抽 500 个问题每个问题配 1 个正样本和 100 个随机负样本算 Recall10 和 MRR。如果 Recall10 比微调前提升不到 5 个点说明负样本还是太简单回去检查同行业负样本比例。最后一个习惯每次改完数据或参数先跑一个 200 步的小实验看验证集 MRR 有没有动。金融问答的数据噪声大全量训练一次成本不低小步快跑比一次跑完再调更省时间。希望帮到你。本文还有配套的精品资源点击获取