1. 句子相似度任务的核心逻辑与方案选型
1.1 为什么句子相似度不是“比较两个字符串”这么简单
很多人第一次接触句子相似度,脑子里蹦出来的方案就是编辑距离或者杰卡德系数。我早期也这么干过,拿两个句子算一下字符重叠率,返回一个0到1之间的数,看起来挺像那么回事。但实际跑起来就会发现,“今天天气不错”和“今儿个天气挺好”的编辑距离很大,但语义几乎一致;反过来,“这个手机真好用”和“这个手机真不好用”只差一个字,编辑距离极小,语义却完全相反。
这就是句子相似度任务的核心难点:它要衡量的是语义层面的接近程度,而不是字面层面的重叠程度。PyTorch在这个任务里扮演的角色,是帮你构建一个能把句子映射到高维向量空间的模型,让语义相近的句子在向量空间里的距离也相近。说白了,就是把“意思”变成“坐标”,然后用数学工具去算坐标之间的距离。
这个任务在实际工程里出现频率极高。搜索召回要做query和doc的匹配,客服系统要做FAQ的相似问题归并,内容平台要做去重和推荐,甚至代码检索里也要判断两个函数描述是不是在说同一件事。不同场景对精度的要求不一样,有的场景只需要粗排,有的场景要求精排到能直接替代人工判断。方案选型的时候,第一步永远是先搞清楚你的场景到底需要什么级别的精度和什么样的响应速度。
1.2 三种主流技术路线的取舍逻辑
用PyTorch做句子相似度,绕不开三条路线,我按从轻到重的顺序说。
第一条是基于预训练词向量的平均池化。做法很简单,每个词查一个预训练好的词向量,然后整个句子所有词的向量取平均,得到句向量,再算余弦相似度。这条路线的优点是快、轻、不需要训练,几百行代码就能跑起来。缺点是丢失了词序信息,“狗咬人”和“人咬狗”算出来一模一样。适合做baseline或者对精度要求不高的粗筛场景。
第二条是基于孪生网络的双塔结构。两个共享权重的编码器分别编码两个句子,得到两个句向量,然后用余弦相似度或者欧氏距离作为相似度度量,用对比损失或者三元组损失来训练。这条路线的核心优势是推理时可以离线建库,把所有候选句子的向量预先算好存进向量索引,线上只需要编码query然后做近邻搜索,响应速度极快。工业界的召回阶段基本都用这个结构。
第三条是基于交叉编码器的交互式匹配。把两个句子拼在一起送进一个编码器,让模型在内部做token级别的交互,最后输出一个相似度分数。这条路线的精度最高,因为它能捕捉到两个句子之间细粒度的语义交互,但代价是每个句子对都要过一次模型,无法离线建库,推理成本随候选数量线性增长。所以它通常用在精排阶段,只对召回出来的少量候选做精细打分。
我个人的经验是,双塔做召回,交叉编码器做精排,这个组合在绝大多数场景下都是性价比最高的。下面这张表可以帮你快速判断自己该选哪条路线:
| 方案 | 精度 | 推理速度 | 是否需要训练 | 适合场景 |
|---|---|---|---|---|
| 词向量平均 | 低 | 极快 | 否 | baseline、粗筛 |
| 双塔孪生网络 | 中高 | 快(可离线建库) | 是 | 大规模召回 |
| 交叉编码器 | 高 | 慢(逐对计算) | 是 | 小规模精排 |
1.3 PyTorch在这个任务里的独特优势
选PyTorch而不是其他框架,理由很实际。第一是动态图机制,句子相似度模型的输入长度是变化的,动态图处理变长序列时调试起来直观得多,你可以在forward里直接print中间张量的形状,不用先编译再跑。第二是生态丰富,HuggingFace的transformers库对PyTorch支持最完善,加载预训练模型、自定义池化层、写训练循环都很顺手。第三是部署灵活,训练完可以导出TorchScript或者ONNX,也可以直接用TorchServe做服务化,从实验到上线的路径很短。
还有一点容易被忽略:句子相似度任务经常需要自定义损失函数,比如对比损失、三元组损失、MultipleNegativesRankingLoss这些。PyTorch的自动求导对自定义loss的支持非常干净,你只要把前向计算写清楚,backward不用操心。这一点在快速迭代实验的时候特别重要。
2. 数据准备与预训练模型加载的实操细节
2.1 训练数据的构造方式与坑点
句子相似度模型的训练数据,核心形式是句子对加相似度标签。标签可以是连续的相似度分数,也可以是离散的相似/不相似,还可以是排序关系(A比B更接近C)。不同损失函数对数据格式的要求不一样,这个后面讲损失函数的时候会细说。
构造训练数据有几个常见的坑。第一个坑是负样本太容易。如果你随机从语料里抽两个句子当负样本,模型很快就能学会区分,因为随机句子之间的语义差异太大了,模型学不到细粒度的判别能力。好的负样本应该是“hard negative”,也就是和正样本语义相近但实际不匹配的句子。比如query是“如何重置密码”,正样本是“密码忘记了怎么办”,hard negative可以是“如何修改密码”——话题相近但意图不同。
第二个坑是正样本的定义太宽松。有些数据集把同一篇文章里的任意两句话都当正样本,但实际上同一篇文章里相邻的两句话可能主题完全不同。正样本的语义一致性直接决定了模型学到的相似度概念是否符合你的业务需求。
第三个坑是数据不平衡。如果正负样本比例严重失调,模型会倾向于全部预测为多数类。我一般会用加权采样或者focal loss来缓解这个问题。
实际操作中,我通常这样组织数据:每条样本是一个三元组或者一个带标签的句子对,用PyTorch的Dataset封装,__getitem__返回tokenize之后的input_ids、attention_mask和标签。tokenize的部分用transformers的tokenizer,注意要设置统一的max_length和padding策略。
from torch.utils.data import Dataset from transformers import AutoTokenizer class SentencePairDataset(Dataset): def __init__(self, pairs, labels, model_name, max_length=128): self.pairs = pairs self.labels = labels self.tokenizer = AutoTokenizer.from_pretrained(model_name) self.max_length = max_length def __len__(self): return len(self.pairs) def __getitem__(self, idx): sent_a, sent_b = self.pairs[idx] encoding = self.tokenizer( sent_a, sent_b, max_length=self.max_length, padding='max_length', truncation=True, return_tensors='pt' ) return { 'input_ids': encoding['input_ids'].squeeze(0), 'attention_mask': encoding['attention_mask'].squeeze(0), 'token_type_ids': encoding.get('token_type_ids', torch.zeros_like(encoding['input_ids'])).squeeze(0), 'label': torch.tensor(self.labels[idx], dtype=torch.float) }注意:token_type_ids不是所有模型都需要,比如RoBERTa系列就没有。加载模型前先确认一下tokenizer的返回值里有没有这个字段,没有的话用zeros补齐或者干脆不传。
2.2 预训练模型选型的判断依据
选哪个预训练模型做底座,取决于你的语言、领域和算力预算。中文场景下,BERT-base-chinese是最稳妥的起点,参数量适中,社区资源多,遇到问题好查。如果追求更高精度且算力允许,可以上RoBERTa-wwm-ext或者ERNIE系列。英文场景下,all-MiniLM-L6-v2是性价比极高的选择,体积小、速度快,在句子相似度任务上的表现却相当能打。
领域适配也很关键。如果你的场景是法律文书或者医疗问答,通用预训练模型可能不够用,需要在领域语料上做继续预训练或者用领域内的标注数据做微调。我做过一个金融客服的FAQ匹配项目,直接用通用模型的效果只能到0.72的准确率,在金融语料上继续预训练了一轮之后提升到了0.86。
还有一个容易被忽略的点是模型的输出维度。有些预训练模型的句向量维度是768,有些是384,维度越高表达能力越强但存储和计算成本也越高。如果你的候选库有百万级别的句子,768维的向量光存储就要好几个G,这时候384维甚至256维的模型就更合适。
2.3 数据预处理中的隐藏陷阱
数据预处理阶段有几个隐藏陷阱值得单独说。第一个是文本清洗的度。有些人喜欢把标点、数字、特殊符号全部去掉,觉得这样能减少噪声。但在句子相似度任务里,标点和数字往往携带关键信息。“我要退款”和“我要退款?”语义上一个是陈述一个是疑问,去掉问号就区分不出来了。我的建议是只做必要的清洗,比如统一全角半角、去除多余空白字符,不要过度处理。
第二个是截断策略。预训练模型有最大长度限制,通常是512个token。如果你的句子超过这个长度,直接截断可能会丢掉关键信息。更好的做法是分段编码然后做池化,或者只保留句子的首尾部分。我试过在长文本匹配任务里用“首128+尾128”的截断策略,效果比直接截前512好不少。
第三个是padding的效率问题。如果每个batch都padding到全局最大长度,短句子会浪费大量计算。用DataCollatorWithPadding做动态padding,每个batch只padding到当前batch的最大长度,训练速度能提升20%到30%。
3. 模型架构设计与损失函数选择的深度解析
3.1 双塔结构的实现细节与池化策略
双塔结构的核心思想是把两个句子分别编码成固定长度的向量,然后用向量距离来衡量相似度。两个塔通常共享权重,这样参数量减半,训练也更稳定。PyTorch里实现起来很直接:
import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class DualEncoder(nn.Module): def __init__(self, model_name, pooling='mean'): super().__init__() self.encoder = AutoModel.from_pretrained(model_name) self.pooling = pooling hidden_size = self.encoder.config.hidden_size self.projection = nn.Linear(hidden_size, 256) def forward(self, input_ids, attention_mask, token_type_ids=None): outputs = self.encoder( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) last_hidden = outputs.last_hidden_state # (batch, seq_len, hidden) if self.pooling == 'cls': pooled = last_hidden[:, 0] elif self.pooling == 'mean': mask = attention_mask.unsqueeze(-1).float() pooled = (last_hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) elif self.pooling == 'max': mask = attention_mask.unsqueeze(-1).float() pooled = (last_hidden * mask - 1e9 * (1 - mask)).max(1)[0] return self.projection(pooled)池化策略的选择上,mean pooling在句子相似度任务里通常比cls pooling更稳。原因是BERT的cls token是在预训练阶段为下一句预测任务服务的,它携带的句子级信息不一定适合相似度度量。Mean pooling对所有token取平均,信息更均匀。Max pooling在某些场景下也有奇效,尤其是当句子里的关键信息集中在少数几个词上的时候。
投影层的作用是把高维的句向量压缩到低维空间,一方面减少存储和计算开销,另一方面给模型一个可学习的变换,让相似度计算在更适合的空间里进行。投影维度一般取128到256之间,太小会损失信息,太大则失去了压缩的意义。
3.2 交叉编码器的实现与适用边界
交叉编码器把两个句子拼成一个序列送进模型,让self-attention在token级别做交互。实现上更简单,因为不需要处理两个独立的编码过程:
class CrossEncoder(nn.Module): def __init__(self, model_name, num_labels=1): super().__init__() self.encoder = AutoModel.from_pretrained(model_name) hidden_size = self.encoder.config.hidden_size self.classifier = nn.Sequential( nn.Linear(hidden_size, 256), nn.ReLU(), nn.Dropout(0.1), nn.Linear(256, num_labels) ) def forward(self, input_ids, attention_mask, token_type_ids=None): outputs = self.encoder( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) cls_output = outputs.last_hidden_state[:, 0] return self.classifier(cls_output)交叉编码器的精度优势来自token级别的交互,但它有一个硬性限制:无法预计算句向量。每个候选句子对都要过一次完整的前向传播,如果候选库有十万条,线上请求一次就要跑十万次模型,这在任何生产环境里都是不可接受的。所以交叉编码器的定位很明确:只对双塔召回出来的Top-K候选做精排,K一般取50到200之间。
3.3 损失函数的选择与调参经验
损失函数的选择直接决定了模型学到的相似度空间长什么样。常用的有几种:
余弦相似度加均方误差是最直观的做法,模型输出两个句向量的余弦相似度,然后和标签的相似度分数算MSE。简单好用,但收敛速度一般,而且对负样本的利用效率不高。
对比损失的核心思想是让正样本对的距离尽可能小,负样本对的距离至少大于一个margin。公式是max(0, margin + d_pos - d_neg)。Margin的设置很关键,太小了模型学不到区分能力,太大了训练不稳定。我一般从0.5开始试,根据验证集的表现调整。
三元组损失需要构造anchor、positive、negative三个句子,让anchor和positive的距离比anchor和negative的距离小至少一个margin。三元组损失的效果通常比对比损失好,但训练数据的构造更麻烦,需要精心挑选hard negative。
MultipleNegativesRankingLoss是我在双塔训练里最常用的损失函数。它的思想很巧妙:在一个batch里,每个正样本对的另一个句子的负样本就是batch内其他所有句子的正样本。这样不需要显式构造负样本,batch size越大,负样本越多,效果越好。实现上用交叉熵就行:
def mnrl_loss(anchor_emb, positive_emb, temperature=0.05): # anchor_emb, positive_emb: (batch, dim), 已做L2归一化 logits = anchor_emb @ positive_emb.T / temperature labels = torch.arange(logits.size(0), device=logits.device) loss_a = nn.CrossEntropyLoss()(logits, labels) loss_b = nn.CrossEntropyLoss()(logits.T, labels) return (loss_a + loss_b) / 2Temperature参数控制softmax的锐度,太小了梯度爆炸,太大了学不到东西。0.05是一个比较安全的起点,实际调参范围在0.01到0.1之间。
实操心得:用MNRL的时候,batch size对效果影响极大。我做过对比实验,batch size从32提到128,验证集准确率能涨3到5个点。如果显存不够,可以用梯度累积来模拟大batch。
4. 训练流程与评估体系的完整搭建
4.1 训练循环的关键配置
训练循环看起来是模板代码,但句子相似度任务有几个配置项特别关键。学习率方面,预训练模型微调通常用2e-5到5e-5,双塔结构因为有两个编码器共享权重,学习率可以稍微大一点。Warmup比例建议设0.1,让模型在前10%的步数里慢慢把学习率从0升到目标值,避免一开始就大步更新破坏预训练权重。
优化器选AdamW,权重衰减设0.01。这里有个细节:不是所有参数都应该加权重衰减,LayerNorm的weight和bias、以及所有bias项通常不加。用PyTorch可以这样分组:
no_decay = ['bias', 'LayerNorm.weight'] optimizer_grouped_parameters = [ {'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], 'weight_decay': 0.01}, {'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], 'weight_decay': 0.0} ] optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=3e-5)混合精度训练用torch.cuda.amp,能省30%到40%的显存,训练速度也有提升。但要注意loss scaling,PyTorch的GradScaler会自动处理,你只需要在loss.backward()之前用scaler.scale(loss).backward(),optimizer.step()之前用scaler.step(optimizer)和scaler.update()。
梯度裁剪设max_norm=1.0,防止梯度爆炸。句子相似度任务里梯度爆炸不算常见,但一旦出现就会让训练前功尽弃,加上这个保险成本很低。
4.2 评估指标的选择与计算
句子相似度任务的评估指标不能只看准确率。如果正负样本比例是1比10,一个全部预测为负的模型也能有90%的准确率,但显然没有用。我通常同时看这几个指标:
AUC-ROC衡量模型区分正负样本的整体能力,不受阈值影响,是最常用的指标。F1分数在正负样本不平衡时比准确率更有参考价值。Spearman相关系数在标签是连续相似度分数时使用,衡量模型预测的排序和真实排序的一致性。Recall@K在召回场景下最直接,看前K个候选里有多少真正的正样本。
还有一个业务指标经常被忽略:阈值下的精确率和召回率。模型输出的相似度是一个连续值,实际使用时要卡一个阈值来决定是不是相似。这个阈值的选择需要根据业务对精确率和召回率的偏好来定。客服FAQ场景通常要求高精确率,阈值设高一点;搜索召回场景更看重召回率,阈值可以低一些。
from sklearn.metrics import roc_auc_score, f1_score import numpy as np def evaluate(model, dataloader, threshold=0.5): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in dataloader: input_ids = batch['input_ids'].cuda() attention_mask = batch['attention_mask'].cuda() emb_a = model(input_ids, attention_mask) # 假设batch里前一半是句子A,后一半是句子B # 实际实现根据数据组织方式调整 preds = torch.cosine_similarity(emb_a[::2], emb_a[1::2]) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch['label'].numpy()) all_preds = np.array(all_preds) all_labels = np.array(all_labels) auc = roc_auc_score(all_labels, all_preds) binary_preds = (all_preds >= threshold).astype(int) f1 = f1_score(all_labels, binary_preds) return {'auc': auc, 'f1': f1}4.3 过拟合与欠拟合的判断与应对
训练过程中最常遇到的两个问题就是过拟合和欠拟合。过拟合的典型表现是训练loss持续下降但验证loss开始上升,或者训练集AUC到0.99但验证集只有0.75。应对手段包括:增加dropout比例、加权重衰减、早停、数据增强。句子相似度任务里数据增强可以用回译(把句子翻译成另一种语言再翻译回来)或者同义词替换。
欠拟合的表现是训练loss和验证loss都居高不下。这时候要检查的是:学习率是不是太小了、模型容量是不是不够、特征是不是有问题。我遇到过一次欠拟合,排查了半天发现是tokenizer的max_length设成了32,很多句子被截断得只剩半句话,模型根本学不到完整语义。改成128之后问题就解决了。
还有一个介于两者之间的情况:训练loss正常下降,验证loss也下降,但验证指标就是上不去。这通常意味着模型学到的相似度概念和评估指标衡量的东西不一致。比如模型优化的是余弦相似度的MSE,但评估用的是AUC,两者对相似度分数的分布要求不一样。这时候可以考虑在评估指标上做文章,或者换一个和评估指标更对齐的损失函数。
5. 推理加速与线上部署的工程实践
5.1 向量索引的构建与近邻搜索
双塔模型训练好之后,线上推理的核心流程是:编码query得到句向量,然后在候选向量库里做近邻搜索,返回Top-K最相似的候选。候选库有几十万甚至上百万条的时候,暴力计算余弦相似度是不现实的,必须用向量索引。
Faiss是Facebook开源的向量检索库,和PyTorch配合得很好。对于百万级别的向量,我通常用IVF-Flat索引,先做聚类把向量空间划分成若干区域,搜索时只在最近的几个区域里做精确搜索。nlist参数控制聚类数量,一般取sqrt(N)左右,N是向量总数。nprobe控制搜索时访问的区域数,越大精度越高但速度越慢。
import faiss import numpy as np def build_faiss_index(embeddings, nlist=1000): dim = embeddings.shape[1] quantizer = faiss.IndexFlatIP(dim) index = faiss.IndexIVFFlat(quantizer, dim, nlist, faiss.METRIC_INNER_PRODUCT) index.train(embeddings) index.add(embeddings) index.nprobe = 10 return index # 使用前先做L2归一化,内积等价于余弦相似度 embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True) index = build_faiss_index(embeddings)如果候选库到了千万级别,可以考虑用IVF-PQ做量化压缩,牺牲一点精度换大幅度的存储和速度优化。PQ把向量切分成若干段分别量化,存储能压缩到原来的十分之一甚至更少。
5.2 模型导出与推理优化
PyTorch训练完的模型直接用于线上推理,性能往往不是最优的。有几个优化手段值得做:
TorchScript导出可以把模型从Python运行时里解放出来,去掉动态图的解释开销,推理速度能提升20%到30%。导出的时候注意要提供示例输入,让TorchScript能追踪计算图。
model.eval() example_input = ( torch.randint(0, 1000, (1, 128)).cuda(), torch.ones(1, 128).cuda() ) traced_model = torch.jit.trace(model, example_input) traced_model.save('sentence_encoder.pt')ONNX导出适合需要跨框架部署的场景,比如用TensorRT做推理加速。ONNX的导出比TorchScript麻烦一些,主要是动态轴的处理,需要在torch.onnx.export里指定dynamic_axes。
批处理是线上推理最重要的优化。单条请求编码一次和一百条请求一起编码,平均每条的时间能差五到十倍。线上服务应该攒一小批请求再一起推理,batch size根据延迟要求来定,通常16到64之间。
5.3 线上服务的监控与迭代
模型上线不是终点,而是另一个起点。线上服务需要监控几个关键指标:请求延迟的P50、P95、P99,相似度分数的分布,Top-K结果的点击率或采纳率。相似度分数分布的变化往往能提前预警数据漂移,比如某天开始所有query的最高相似度都掉到了0.5以下,很可能是输入数据的分布变了。
迭代方面,我习惯定期用线上积累的bad case做模型微调。具体做法是:把用户点击或者人工标注的反馈数据整理成新的训练样本,和原始训练数据混在一起做增量训练。增量训练的学习率要比初始训练小一个数量级,避免把之前学到的知识冲掉。
避坑经验:线上服务一定要做降级方案。向量索引加载失败或者模型推理超时的时候,要能退回到基于关键词的粗匹配,保证服务不挂。我见过不止一次因为索引文件损坏导致整个搜索服务不可用的事故。
6. 常见问题排查与实战避坑指南
6.1 训练不收敛的排查思路
训练不收敛是最高频的问题,排查要按顺序来。第一步看数据:标签是不是反了?正样本对是不是真的语义相似?我遇到过一次,数据标注的时候把相似和不相似的标签搞反了,模型训练了几轮loss都不降,排查了一下午才发现是数据的问题。第二步看学习率:太大导致震荡,太小导致下降缓慢。可以做一个学习率扫描,从1e-6到1e-3按对数间隔跑几个step,看loss下降最快的是哪个量级。第三步看损失函数:MSE对异常值敏感,如果标签里有噪声,梯度会被带偏。可以换成Huber损失或者先对标签做截断。
还有一个隐蔽的原因是预训练模型的输出被padding影响。如果用mean pooling但没有用attention_mask做掩码,padding位置的向量也会被平均进去,句子越长padding越多,句向量偏移越严重。这个bug不会报错,但会让模型效果莫名其妙地差。
6.2 相似度分数不可比的解决
双塔模型有一个常见问题:不同query的最高相似度分数不可比。比如query A和候选库里的句子最高相似度是0.85,query B的最高相似度是0.65,但这不代表A的匹配质量一定比B好,因为两个query的向量模长和分布可能不一样。
解决办法是在训练时对句向量做L2归一化,让所有句向量都在单位球面上,这样余弦相似度的取值范围就规整了。另一个办法是做分数校准,用验证集拟合一个映射函数,把原始相似度映射到校准后的概率值。Platt scaling和isotonic regression都是常用的校准方法。
6.3 多语言与跨语言场景的处理
跨语言句子相似度是一个独立的问题。比如用户用中文提问,候选库是英文的FAQ,直接用一个中文模型编码两边是不行的。解决方案有两种:一种是用多语言预训练模型,比如mBERT或者XLM-R,它们在预训练阶段见过多种语言,能把不同语言的句子映射到同一个语义空间。另一种是用翻译模型先把query翻译成候选库的语言,再做单语言匹配。前者端到端更简洁,后者在翻译质量有保障时效果更可控。
多语言模型的一个坑是语言间的向量分布不对齐。虽然mBERT理论上把不同语言映射到了同一空间,但实际上中文和英文的句向量分布还是有偏移。解决办法是用平行语料做对齐训练,或者用对抗训练让不同语言的向量分布尽可能重叠。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 训练loss不降 | 标签错误/学习率过小 | 检查数据标签、学习率扫描 | 修正标签、调整学习率 |
| 验证指标远低于训练 | 过拟合 | 对比训练验证曲线 | 加dropout、早停、数据增强 |
| 相似度分数普遍偏高 | 模型坍塌 | 看句向量方差 | 加温度系数、用hard negative |
| 推理速度慢 | 未建索引/未批处理 | profile各环节耗时 | 建Faiss索引、批处理推理 |
| 长句效果差 | 截断丢信息 | 分析长句case | 分段编码、首尾截断 |
| 跨语言效果差 | 语言分布不对齐 | 分语言评估 | 平行语料对齐训练 |
6.5 我踩过的几个印象深刻的坑
第一个坑是tokenizer版本和模型版本不匹配。有一次我加载了一个社区微调过的模型,但用了原版的tokenizer,结果token的id对不上,模型输出完全是乱的。排查的时候一直以为是模型结构的问题,最后对比tokenizer的vocab才发现问题。教训是:加载模型和tokenizer一定要用同一个路径或者同一个版本号。
第二个坑是Faiss索引的nprobe设得太小。为了追求速度把nprobe设成了1,结果召回率暴跌。nprobe和召回率的关系不是线性的,从1提到10召回率提升很明显,从10提到100提升就有限了。我一般设10到20之间,在速度和精度之间取平衡。
第三个坑是训练时的batch内负样本泄露。用MNRL的时候,如果同一个batch里有两个样本的正样本是同一个句子,那它们会互相成为对方的负样本,导致模型学到错误的信号。解决办法是在构造batch的时候确保每个样本的正样本在batch内唯一,或者用去重后的候选集。
第四个坑是线上服务的向量归一化遗漏。训练时做了L2归一化,但线上推理的时候忘了做,导致余弦相似度算出来完全不对。这个bug很隐蔽,因为不报错,只是结果变差。后来我在模型forward里直接内置了归一化,从根上杜绝了这个问题。
这个方向后续还可以往几个方向扩展:一是用对比学习框架做无监督的句子表示学习,减少对标注数据的依赖;二是结合知识蒸馏,用大模型教小模型,在保持精度的同时压缩模型体积;三是探索稀疏向量和稠密向量的混合检索,兼顾关键词匹配和语义匹配的优势。我自己在实际项目里体会最深的一点是,句子相似度没有万能的方案,只有和场景匹配的方案。同样的模型架构,换一个数据集、换一个业务场景,效果可能天差地别。所以与其追求最先进的模型,不如先把数据质量和评估体系做扎实,这两样东西的投入产出比远高于换模型。