资讯动态

Bert预训练模型fine-tune实战:文本相似度计算与向量检索

发布时间:2026/10/9 1:03:27 来源:尧图企业网站定制
简介这份资源面向自然语言处理方向的开发者与学习者聚焦Bert预训练模型的fine-tune实践用于计算文本相似度。包内提供完整的工程代码与训练数据可帮助读者理解如何基于预训练模型完成下游分类任务的迁移学习适合具备一定深度学习基础、希望动手复现文本匹配方案的中高级学习者。资源共50个文件以30个Python源码为主辅以13个pyc编译文件、2个txt说明、2个csv数据集、1个json配置及index、meta索引文件压缩包约2.77MB结构紧凑便于快速部署。训练数据集采用蚂蚁金服文本匹配数据存放于chinese_data文件夹内读者可运行run_classifier_modify2脚本进行fine-tune并通过run_classifier_class完成测试验证。目前已有480人学习下载可借此掌握Bert微调流程、数据组织方式与相似度评估思路为文本匹配类项目提供可复用的工程参考。1. 从一份 Bert 预训练模型 fine-tune 项目说起文本相似度到底该怎么落地搜索“Bert 预训练模型 fine-tune 计算文本相似度”的人十有八九手里已经有一份带.zip的项目包或者正准备从零搭一个。真正卡住他们的不是“Bert 是什么”而是预训练权重怎么加载、句向量怎么取、相似度标签怎么构造、fine-tune 之后为什么线上效果和离线差一截。文本相似度这个任务看起来简单——给两句话打个分——但它对数据构造、池化策略、损失函数的选择极其敏感稍有不慎就会得到一个“离线 0.9、线上瞎猜”的模型。这篇文章面向的是想把这个方向真正跑通的人不管你是做搜索召回、问答匹配、还是内容去重只要你想用 Bert 系模型做语义相似度下面这套从数据到推理的路径可以直接抄。我会把选型理由、可执行代码、必调参数和踩过的坑都摊开讲新手能跟着跑熟手能对着边界调。2. 先搞清楚 Bert 做文本相似度的三条技术路线2.1 为什么不能直接拿 [CLS] 向量算余弦很多人第一次做文本相似度会直接把两句拼成[CLS] A [SEP] B [SEP]丢进 Bert取[CLS]位置的输出做余弦相似度。这个做法在原始 Bert 论文里确实出现过但它有个致命问题[CLS]向量是在预训练阶段为下一句预测任务训练的它编码的是“两句是否相邻”而不是“两句语义是否相似”。这两件事在分布上并不一致。更麻烦的是未经 fine-tune 的 Bert 输出向量存在各向异性——所有句向量都挤在一个窄锥形空间里任意两句的余弦相似度都偏高区分度极差。我见过最离谱的情况是随机取两句无关文本余弦相似度能到 0.85而真正相似的句子只有 0.87阈值根本没法卡。所以第一条路线是用[CLS]可以但必须 fine-tune而且最好加一个专门的池化层或归一化层来矫正向量分布。2.2 句向量方案Sentence-Bert 式的双塔结构第二条路线是双塔Siamese结构也就是常说的 Sentence-Bert 思路。两句分别过同一个 Bert 编码器各自做池化得到固定维度向量再用余弦或欧氏距离算相似度。这种结构的好处是推理时可以预先算好所有候选句的向量线上只做向量检索延迟从几百毫秒降到几毫秒。池化方式常见的有三种取[CLS]、对最后一层所有 token 做平均、对最后一层做 max-pooling。实测在中文相似度任务上平均池化通常比[CLS]稳定因为中文的语义信息更分散平均能保留更多 token 级线索。但平均池化也有坑padding token 如果没被 mask 掉会把向量往零向量方向拉导致短句和长句的向量模长差异巨大。所以池化时必须用 attention mask 做加权平均这是硬性要求。2.3 交互式方案Cross-Encoder 的精度与代价第三条路线是交互式Cross-Encoder两句拼在一起过 Bert最后接一个分类头输出相似/不相似的概率。这种结构因为两句在每一层都有 attention 交互精度通常比双塔高 3 到 5 个点但代价是推理时必须两两配对过模型无法预计算。假设你有 10 万条候选一条 query 就要过 10 万次 Bert线上根本扛不住。所以常见做法是双塔做召回Cross-Encoder 做精排。如果你的场景是离线评估、小规模匹配或者对精度极度敏感可以直接上 Cross-Encoder如果是线上检索双塔是唯一选择。选型上没有绝对优劣只有场景匹配。路线精度推理速度适用场景[CLS] 向量 余弦低快快速验证、baseline双塔 池化中高快可预计算线上召回、大规模检索Cross-Encoder高慢不可预计算精排、离线评估3. 用 HuggingFace 跑通 fine-tune 的最小闭环3.1 数据构造相似度标签到底怎么打文本相似度的数据通常有三种格式0/1 二分类、0 到 5 的等级标注、以及三元组anchor, positive, negative。如果你手里只有二分类数据可以直接用 Cross-Encoder 做二分类 fine-tune如果是三元组更适合双塔加 triplet loss。我一般会先把数据统一成(句子A, 句子B, 标签)的三列格式标签为 0 或 1。这里有个血泪经验负样本的构造比正样本更重要。很多人随便从语料里抽两句不相关的当负样本结果模型学到的只是“话题不同”而不是“语义不相似”。好的负样本应该是话题相同但语义有差异的句子比如“如何学习 Python”和“Python 怎么入门”是正样本“如何学习 Python”和“Python 的安装包在哪下载”就是高质量的负样本。如果负样本太容易模型在困难样本上会翻车。import pandas as pd from sklearn.model_selection import train_test_split # 假设原始数据是三列sentence_a, sentence_b, label data pd.read_csv(similarity_data.csv) # 检查标签分布避免严重不均衡 print(data[label].value_counts()) # 划分训练集和验证集stratify 保证标签比例一致 train_df, val_df train_test_split( data, test_size0.15, random_state42, stratifydata[label] ) # 转成 HuggingFace Dataset 格式 from datasets import Dataset train_dataset Dataset.from_pandas(train_df) val_dataset Dataset.from_pandas(val_df)这段代码做了三件事读数据、检查标签分布、分层划分。stratify参数很关键如果正负样本比例是 1:5不做分层可能导致验证集里正样本极少评估指标波动巨大。random_state固定后每次划分结果一致方便复现。数据量少于 5000 条时验证集比例可以提到 0.2否则验证指标噪声太大。3.2 模型加载与 tokenizer 的隐藏参数加载预训练模型时中文场景我一般会选bert-base-chinese或hfl/chinese-roberta-wwm-ext。后者在中文任务上通常更强因为用了全词 mask 和更多中文语料。tokenizer 有几个参数必须注意max_length、truncation、padding。max_length设成 128 还是 256取决于你的句子长度分布。我一般会先统计一下 95 分位数的 token 长度然后取那个值。设太大浪费显存设太小截断关键信息。padding用max_length而不是longest因为动态 padding 在 batch 内虽然省显存但不同 batch 的序列长度不一致某些算子会反复编译反而拖慢训练。from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name hfl/chinese-roberta-wwm-ext tokenizer AutoTokenizer.from_pretrained(model_name) # 统计 token 长度分布决定 max_length token_lengths [ len(tokenizer.encode(a, b)) for a, b in zip(train_df[sentence_a], train_df[sentence_b]) ] import numpy as np print(f95分位长度: {np.percentile(token_lengths, 95)}) MAX_LEN 128 def tokenize_fn(examples): return tokenizer( examples[sentence_a], examples[sentence_b], truncationTrue, paddingmax_length, max_lengthMAX_LEN ) train_dataset train_dataset.map(tokenize_fn, batchedTrue) val_dataset val_dataset.map(tokenize_fn, batchedTrue)tokenizer.encode(a, b)会自动插入[CLS]和[SEP]这是 Cross-Encoder 的标准输入格式。batchedTrue让 map 操作批量执行比逐条快几十倍。paddingmax_length会统一补齐到 128配合truncationTrue保证不超长。注意map之后要设置格式为 PyTorch tensor否则 Trainer 会报错。3.3 训练参数学习率、warmup 和 batch size 的三角关系fine-tune Bert 的学习率通常设在 2e-5 到 5e-5 之间比从头训练小两个数量级。学习率太大预训练学到的语言知识会被迅速冲掉模型在几百步后就过拟合太小则收敛太慢可能还没到最优就 early stop 了。warmup 比例一般设 0.1也就是前 10% 的步数线性升温避免一开始就大梯度更新。batch size 在显存允许的前提下尽量大16 或 32 是常见选择。如果显存不够可以用梯度累积比如gradient_accumulation_steps2配合batch_size16等效于 32。但要注意梯度累积会改变 BatchNorm 的统计量不过 Bert 用的是 LayerNorm所以影响不大。from transformers import TrainingArguments, Trainer training_args TrainingArguments( output_dir./bert_similarity, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate2e-5, warmup_ratio0.1, weight_decay0.01, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy, logging_steps50, fp16True, # 如果显卡支持混合精度开启后显存减半 ) def compute_metrics(eval_pred): logits, labels eval_pred preds np.argmax(logits, axis-1) acc (preds labels).mean() return {accuracy: acc} trainer Trainer( modelAutoModelForSequenceClassification.from_pretrained(model_name, num_labels2), argstraining_args, train_datasettrain_dataset, eval_datasetval_dataset, compute_metricscompute_metrics, ) trainer.train()weight_decay0.01是 AdamW 的默认推荐值对 Bert 微调有效。fp16True在 RTX 系列显卡上能省一半显存但某些老卡可能不支持报错就关掉。load_best_model_at_endTrue配合metric_for_best_model保证训练结束加载验证集最优的 checkpoint而不是最后一个 epoch 的。logging_steps50控制日志频率太频繁会拖慢训练。compute_metrics里用argmax取预测类别二分类下就是 0 或 1。4. 推理阶段从单条预测到批量向量检索4.1 单条相似度打分与阈值选择训练完之后最直接的用法是给两句打分。但这里有个坑模型输出的是 logits经过 softmax 后得到概率概率大于 0.5 判为相似。但 0.5 不一定是最优阈值。如果业务对误召回容忍度低阈值要调高到 0.7 甚至 0.8如果对漏召回更敏感阈值可以降到 0.3。我一般会在验证集上画一条 P-R 曲线根据业务需求选 F1 最大或某个召回率下的阈值。不要直接用 0.5这是新手最容易踩的坑。import torch import torch.nn.functional as F def predict_similarity(sentence_a, sentence_b, model, tokenizer, threshold0.5): inputs tokenizer( sentence_a, sentence_b, return_tensorspt, truncationTrue, paddingmax_length, max_length128 ) model.eval() with torch.no_grad(): outputs model(**inputs) probs F.softmax(outputs.logits, dim-1) sim_prob probs[0][1].item() return sim_prob, sim_prob threshold # 示例 score, is_sim predict_similarity( 如何学习自然语言处理, 自然语言处理怎么入门, model, tokenizer, threshold0.6 ) print(f相似概率: {score:.4f}, 判定: {is_sim})model.eval()关闭 dropout保证推理结果稳定。torch.no_grad()省显存。probs[0][1]取的是“相似”类别的概率因为标签 1 代表相似。阈值作为参数传入方便在不同业务场景下调。如果要做批量预测把单条循环改成 batch 输入即可但要注意 padding 对齐。4.2 双塔向量化与 FAISS 快速检索如果候选集很大Cross-Encoder 逐对打分不现实。这时候要用双塔把句子编码成向量然后用 FAISS 做近似最近邻检索。双塔的训练和 Cross-Encoder 略有不同需要 triplet loss 或对比学习损失让相似句的向量靠近不相似句的向量远离。训练好之后把所有候选句编码成向量矩阵建 FAISS 索引查询时只编码 query 向量然后检索 top-k。import faiss import numpy as np def encode_sentences(sentences, model, tokenizer, max_length128): 将句子列表编码为归一化向量 model.eval() embeddings [] for sent in sentences: inputs tokenizer( sent, return_tensorspt, truncationTrue, paddingmax_length, max_lengthmax_length ) with torch.no_grad(): outputs model(**inputs, output_hidden_statesTrue) # 用最后一层 hidden state 的平均池化 last_hidden outputs.hidden_states[-1] # (1, seq_len, hidden) mask inputs[attention_mask].unsqueeze(-1).float() pooled (last_hidden * mask).sum(dim1) / mask.sum(dim1) # L2 归一化保证内积等于余弦相似度 pooled F.normalize(pooled, p2, dim1) embeddings.append(pooled.cpu().numpy()) return np.vstack(embeddings) # 假设 corpus 是候选句列表 corpus_embeddings encode_sentences(corpus, model, tokenizer) dimension corpus_embeddings.shape[1] # 建内积索引因为向量已归一化内积即余弦相似度 index faiss.IndexFlatIP(dimension) index.add(corpus_embeddings) # 查询 query_vec encode_sentences([如何学习自然语言处理], model, tokenizer) scores, indices index.search(query_vec, top_k5) for score, idx in zip(scores[0], indices[0]): print(f相似度: {score:.4f}, 句子: {corpus[idx]})output_hidden_statesTrue让模型返回所有层的 hidden state取最后一层做平均池化。attention_mask加权平均是关键否则 padding 会污染向量。F.normalize做 L2 归一化后FAISS 的IndexFlatIP内积检索等价于余弦相似度。top_k根据业务定一般召回阶段取 50 到 100后面再接精排。注意双塔模型和 Cross-Encoder 的模型结构不同不能混用同一个 checkpoint。5. 避坑与排查那些让相似度模型翻车的细节5.1 现象验证集准确率 0.95线上效果却像随机原因通常有三个一是训练集和线上数据的分布不一致比如训练集是新闻标题线上是用户口语 query二是负样本构造太简单模型只学到了表面特征三是阈值直接用了 0.5没有根据线上分布调整。解决方法是从线上采样一批真实数据做验证集重新评估用难负样本挖掘hard negative mining替换简单负样本在线上验证集上重新选阈值。5.2 现象训练 loss 震荡剧烈不收敛原因多半是学习率太大或 batch size 太小。Bert fine-tune 的学习率超过 1e-4 时loss 经常先降后升然后震荡。解决方法是把学习率降到 2e-5 或 3e-5同时增大 batch size 到 32。如果显存不够用梯度累积。另外检查一下 warmup 是否开启没有 warmup 时前几百步的梯度更新可能破坏预训练权重。5.3 现象推理时相同输入两次结果不一致这是 dropout 没关。model.eval()必须在推理前调用否则 dropout 层仍然随机丢弃神经元导致每次输出不同。另外如果用了fp16某些算子可能有数值误差但通常不会导致标签翻转。如果两次结果差异很大检查是否在推理时误开了torch.enable_grad()梯度计算会引入额外噪声。5.4 现象长文本相似度效果明显差于短文本Bert 的最大长度是 512但实际 fine-tune 时通常只用到 128 或 256。超过这个长度的文本被截断关键信息可能丢失。解决方法是对长文本做分段编码再聚合或者用 Longformer 等支持长序列的模型。另一个原因是平均池化在长文本上被 padding 稀释检查 attention mask 是否正确应用。5.5 现象FAISS 检索结果和暴力检索不一致FAISS 的IndexFlatIP是精确检索结果应该和暴力一致。如果用了IndexIVFFlat或IndexHNSWFlat等近似索引会有精度损失。检查nprobe参数设得太小会漏掉近邻。另外确认向量是否归一化没归一化时内积不等于余弦相似度排序会错。6. 进阶技巧用对比学习把双塔相似度再提一截如果你已经跑通了上面的流程想让双塔模型的向量质量再上一个台阶可以试试对比学习Contrastive Learning。核心思路是一个 batch 内每句话的相似句作为正样本其他句作为负样本用 InfoNCE 损失拉近正样本、推开负样本。SimCSE 是这方面的经典做法它甚至不需要标注数据直接把同一句话过两次 Bert不同 dropout作为正样本对。中文场景下SimCSE 配合chinese-roberta-wwm-ext能在无标注语料上训出不错的句向量。# SimCSE 风格的对比学习损失简化版 def simcse_loss(embeddings, temperature0.05): embeddings: (batch_size * 2, hidden_dim)前一半和后一半互为增强视图 batch_size embeddings.shape[0] // 2 # 归一化 embeddings F.normalize(embeddings, p2, dim1) # 计算相似度矩阵 sim_matrix torch.matmul(embeddings, embeddings.T) / temperature # 对角线上的正样本对i 和 ibatch_size labels torch.arange(batch_size, deviceembeddings.device) labels torch.cat([labels batch_size, labels], dim0) loss F.cross_entropy(sim_matrix, labels) return losstemperature0.05是 SimCSE 论文的推荐值控制相似度分布的尖锐程度。labels的构造是让第 i 个样本和第 ibatch_size 个样本互为正样本。这个损失函数可以直接替换掉 Cross-Encoder 的分类损失训练双塔模型。注意 batch size 越大负样本越多效果通常越好但显存也越大。我一般用 64 或 128 的 batch size配合梯度累积。另一个实用技巧是“白化”whitening后处理。即使 fine-tune 之后Bert 句向量仍然存在一定各向异性。对向量矩阵做 PCA 白化把均值归零、协方差矩阵变为单位阵能显著提升余弦相似度的区分度。这个操作不需要训练纯后处理几行代码就能试。def whitening(embeddings, eps1e-5): 对向量矩阵做白化处理 mean embeddings.mean(axis0, keepdimsTrue) embeddings_centered embeddings - mean cov np.cov(embeddings_centered, rowvarFalse) U, S, Vt np.linalg.svd(cov) W np.dot(U, np.diag(1.0 / np.sqrt(S eps))) return np.dot(embeddings_centered, W)白化之后向量不再归一化需要重新做 L2 normalize 再建 FAISS 索引。实测在中文短文本相似度上白化能带来 2 到 3 个点的召回提升但会略微损失向量的语义线性性如果下游还有分类任务要评估是否值得。最后说一个我自己的习惯每次 fine-tune 完不要只看准确率一定要把验证集里预测错误的样本导出来看。我见过太多次模型在“如何学习 Python”和“Python 怎么入门”上判对但在“苹果手机好用吗”和“iPhone 值得买吗”上判错原因是训练集里没有这类跨表达的正样本。看错误样本比调参更能发现问题。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑