文本匹配实战:单塔与双塔模型原理与调参指南

发布时间:2026/10/1 4:16:16
文本匹配实战:单塔与双塔模型原理与调参指南
简介一份基于Python实现的文本匹配算法源码包聚焦两个文本之间相似度计算的应用场景适合自然语言处理学习者、高校学生及算法从业者参考使用。项目基于PyTorch和Transformers框架完整实现了PointWise单塔、DSSM双塔、Sentence BERT双塔三类常用模型并附带数据集与详细使用说明各模型均配有独立的训练、推理及Embedding获取脚本可直接运行复现实验结果。压缩包共34个文件以18个Python脚本为核心辅以4个Shell启动脚本、4个文本文件、4张图片、2个TSV数据集及2个Markdown文档整体容量约7.86MB目录划分清晰覆盖模型定义、数据加载、训练、推理、日志记录等模块。目前已有270人学习下载代码经过测试作为毕设项目完成度高既可用于课程设计和作业也可以在修改后扩展其他文本匹配功能服务于语义检索、相似问答或推荐系统等场景。1. 为什么文本匹配要同时看单塔和双塔这份源码包的拆解方式不太一样文本匹配Text Matching是搜索引擎召回、智能客服匹配、判重去重、相似问题推荐里最先要跨过的一道坎给两个句子判断它俩是不是同一件事。这个标题里的源码包做的是同一件事的两套解法——单塔模型把两个句子拼成一个输入让 token 之间自由交互再打分双塔模型把两个句子各自压成向量再用余弦相似度给相关性。前者精度高后者能离线建索引支撑百万级召回。对于刚拿到这份源码的 Python 选手来说最需要先搞清楚的不是怎么改网络结构而是这两条路线各自的边界什么时候该用哪一个、它们的不可用场景在哪、为什么工业级匹配基本都是“双塔召回 单塔精排”。带着这个问题去读源码比逐行盯代码有用得多。2. 单塔和双塔的选型逻辑同一个任务两种相反的拆法2.1 文本匹配任务的长相与评估口径先看任务本身。文本匹配的输入永远是两个文本片段一般叫query和doc或者直接叫text1和text2。输出有两种形态一种是二分类标签0 表示不匹配、1 表示匹配另一种是连续相似度分数比如 0 到 1 之间的实数。源码包里的数据集大概率用的是第一种因为二分类标签标注成本低自动化评估也方便。评估指标不能只看 accuracy。匹配任务里负样本往往远多于正样本假设负样本占 95%你把所有样本都判成负样本accuracy 也有 95%这个数字没有任何说服力。我一般会同时看 AUC、P1 和 RecallK。AUC 衡量排序能力P1 看首条命中RecallK 用在双塔的召回验证里。如果你拿到的数据集里带相似度分数而非标签评估指标还得换成 Spearman 相关系数或 Pearson 相关系数这是另一个口径先记着。还有一个容易忽略的点文本匹配分对称匹配和非对称匹配。对称匹配指两个句子地位对等典型场景是语义相似度计算比如“怎么退火车票”和“火车票如何退”非对称匹配指一个短 query 配一篇长文档典型场景是搜索。单塔模型处理这两类问题都顺手双塔模型在非对称匹配里需要额外处理长文本截断句子长度差异大的时候短句一侧的语义容易被长句稀释。读源码时先看数据是哪种形态再决定优先调哪个模型。2.2 单塔模型的交互红利与复杂度代价单塔模型的做法很朴素把text1和text2拼成一个序列中间加一个[SEP]分隔符然后一次性送进编码器。编码器里的每一层 self-attention 都能看到两边的 token也就是说第一句话里的词和第二句话里的词在最底层就开始互相计算注意力权重。这种“早交互”是单塔模型效果好的根本原因——匹配信号在深层网络里被反复提炼而不是到最后一步才撞在一起。代价是推理复杂度。假设你有 10000 个 query 和 100000 个 doc 要做配对双塔模型只需要把 100000 个 doc 各编码一次存成向量之后每个 query 只做一次编码加一次向量检索单塔模型则需要把每个 query 和每个 doc 拼接后重新前向计算最坏情况要跑 10 亿次拼接样本。这个量级在 CPU 上根本跑不动哪怕上 GPU 也扛不住。所以单塔模型的适用场景非常明确候选集已经缩小到几十到几百条需要做精排的时候。比如双塔先从 100 万条里召回 200 条单塔再对这 200 条逐个打分这个量级单塔毫无压力。把单塔用在全量匹配上是典型的误用等于把 O(N) 的问题硬生生改成 O(N²)。2.3 双塔模型的离线索引能力与“最后一层”的交互双塔模型把两个文本分别编码成两个固定维度的向量比如 768 维或 256 维。两个塔可以是同一个编码器也就是共享权重也可以是两个独立的编码器。向量算完之后用一个相似度函数做交互最常见的是余弦相似度也有用点积的。注意这种交互只发生在最后一层前面的网络层完全不知道对面文本长什么样。双塔最大的优势是向量可以离线算好、建好索引。doc 侧的向量在服务启动前就可以全部算完存进向量索引里线上来了一个 query只需要算一次 query 向量然后去索引里做近邻搜索。索引技术用 faiss 或者 Milvus 都行百万级向量在 faiss 的 IVF 索引下单次查询耗时能做到几十毫秒。这就是双塔能上生产的原因也是它和单塔之间最本质的差异。但“最后一层才交互”也带来一个天然的短板两个句子在编码阶段完全没有信息往来模型只能靠“各自理解”去猜测对面可能关心什么。这导致双塔对细粒度匹配不敏感。比如“我要退票”和“我要退款的流程”单塔能通过“票”和“款”的近距离交互推断出这俩不是一回事双塔很可能因为“退”字权重过高而误判为相关。想缓解路子只有一个在训练阶段用足够多的 hard negative 逼着模型学会区分细微差异这属于后面的调参话题。2.4 落地时怎么选召回用双塔精排用单塔真实项目的标准答案几乎是固定的双塔负责召回单塔负责精排。双塔先把候选集从百万级压到百级单塔再在这个小集合上做精细打分最后按分数截断输出。这两者不是二选一的关系而是流水线上前后两道工序。源码包把两个模型放在一起目的就是让你把这条流水线跑通。如果你只是做一个小 demo比如几千条数据的相似问题匹配直接上单塔就行没必要引入双塔和向量索引徒增复杂度。反过来如果你要做的是一个真实的服务比如智能客服的相似问题推荐老老实实先把双塔训练好建好索引再决定要不要加单塔精排。顺序别搞反。3. 把源码跑起来的第一次训练数据集格式与最小可运行脚本3.1 源码包的目录预期与依赖拿到源码包先别急着跑train.py。我一般会先花两分钟把目录结构过一遍。按照标题里“含单塔模型 双塔模型 数据集 使用说明”的说法常见的组织方式是data/放数据集models/放单塔和双塔的定义train.py和predict.py放训练和推理入口README.md或使用说明.md写环境搭建和运行步骤。这个结构不是硬性标准但大多数项目都这么排。依赖方面核心就是 Python 3.8 以上加 PyTorch。文本编码器一般用预训练模型常见做法是用 Hugging Face 的transformers库加载 BERT 系列模型。如果你是在 VSCode 里配的 Python 环境先确认torch和transformers已经装进当前解释器别装错环境这是 Python 新手最常见的翻车点。装依赖用一行命令解决pip install torch transformers scikit-learn pandas逻辑说明torch提供模型和训练框架transformers负责加载预训练 BERT 并给出 tokenizerscikit-learn用来算评估指标pandas读数据集。参数说明如果你的机器没有 NVIDIA GPUtorch会自动装 CPU 版本训练会慢但小规模数据能跑通。3.2 TSV 数据的读法超过两个字段并不一定是坏事文本匹配数据集最常见的格式是 TSV三列text1、text2、label。label 为 0 或 1。有些数据集会多出第四列比如原始 ID 或标注置信度读的时候别直接按三列解包会报错。更稳妥的做法是先把每一行拆开再按需取列。下面这个加载函数我反复用改成你自己的路径就能跑import pandas as pd def load_text_pair_data(path: str, max_len: int 128): df pd.read_csv(path, sep\t, headerNone, names[text1, text2, label]) # 有些数据文件带表头第一行不是数据需要跳过 df df[df[label].isin([0, 1])] df[label] df[label].astype(int) return df逻辑说明先按 TSV 读进来并显式指定列名避免因为文件里多了 ID 列导致解包失败然后用isin([0, 1])把异常标签过滤掉比如标注员写错的空值或字符串。参数说明max_len在这里没有生效真正的截断发生在 tokenizer 阶段我习惯把长度参数从上到下穿成同一个避免训练时改了这边忘了那边。如果你的数据里 text 是长文档而不是短句这个参数就是后面处理截断策略的关键。3.3 双塔模型的最小定义与共享权重双塔模型用 PyTorch 写核心就是两个编码器加一个相似度计算层。这里要注意两个塔默认应该共享权重——也就是同一个BertModel实例同时处理 text1 和 text2。这个设计不是省显存那么简单而是为了保证两个句子被映射到同一个语义空间里后面算余弦相似度才有意义。代码import torch import torch.nn as nn from transformers import BertModel class DualEncoder(nn.Module): def __init__(self, model_namebert-base-chinese): super().__init__() self.encoder BertModel.from_pretrained(model_name) def encode(self, input_ids, attention_mask): out self.encoder(input_ids, attention_mask)[0] # shape: (B, L, H) # 用 CLS token 的向量作为整句表征简单且效果稳定 return out[:, 0, :] def forward(self, a_input_ids, a_mask, b_input_ids, b_mask): vec_a self.encode(a_input_ids, a_mask) vec_b self.encode(b_input_ids, b_mask) sim torch.cosine_similarity(vec_a, vec_b, dim-1) return sim逻辑说明encode方法取出 BERT 最后一层输出的[CLS]位置向量作为句子向量这是最省事的做法效果不差。想更好可以试 mean pooling也就是对非 padding 位置的 token 向量取平均代码里对应out * attention_mask.unsqueeze(-1)).sum(1) / mask.sum(1, keepdimTrue)。forward里算的是余弦相似度输出范围是 -1 到 1。参数说明model_name在中文场景下我一般用bert-base-chinese如果你跑的是英文数据换成对应的英文模型即可编码器换成 RoBERTa、ERNIE 同理只需要改这一行。3.4 单塔模型的拼接输入与 segment_id单塔模型的关键在于把两个文本拼成一个序列。Hugging Face 的 tokenizer 支持直接传两个句子它会自动在中间加[SEP]在开头加[CLS]并且给出 token_type_ids 来区分第一句和第二句。这个 token_type_ids 就是单塔和双塔在输入侧最大的差别。代码class SingleTower(nn.Module): def __init__(self, model_namebert-base-chinese): super().__init__() self.encoder BertModel.from_pretrained(model_name) self.classifier nn.Linear(768, 1) def forward(self, text1_list, text2_list, tokenizer, max_len128): encoded tokenizer( text1_list, text2_list, paddingTrue, truncationTrue, max_lengthmax_len, return_tensorspt ) out self.encoder( encoded[input_ids], attention_maskencoded[attention_mask], token_type_idsencoded[token_type_ids] )[0] return self.classifier(out[:, 0, :])逻辑说明tokenizer一次吃两个列表内部完成拼接和 paddingtoken_type_ids会标记第几句。模型只用一层线性层把[CLS]向量压成 1 维输出是一个 logit训练时配BCEWithLogitsLoss使用。参数说明max_len对单塔尤其重要因为两个句子拼一起长度翻倍写太大会直接显存溢出。128 对短文本够用文本偏长就截断或换更长的句子切分策略。3.5 训练脚本走一遍把数据加载、模型定义、优化器和训练循环串起来整段跑通。这里给一个精简但能用的训练循环from torch.utils.data import DataLoader, TensorDataset def train(model, df, batch_size32, lr2e-5, epochs3): tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) # 这里以单塔为例双塔的 DataLoader 需要分别组织两组 input enc tokenizer(df[text1].tolist(), df[text2].tolist(), paddingTrue, truncationTrue, max_length128, return_tensorspt) dataset TensorDataset(enc[input_ids], enc[attention_mask], enc[token_type_ids], torch.tensor(df[label].values)) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) opt torch.optim.AdamW(model.parameters(), lrlr) loss_fn nn.BCEWithLogitsLoss() model.train() for epoch in range(epochs): for step, (input_ids, mask, token_ids, labels) in enumerate(loader): logits model.forward_with_encoded(input_ids, mask, token_ids) loss loss_fn(logits.squeeze(-1), labels.float()) opt.zero_grad() loss.backward() opt.step()逻辑说明DataLoader 产出的是 tokenizer 处理好的张量用 TensorDataset 包一层就行。这里假设SingleTower里加了一个接收已编码输入的方法训练时才不会反复调用 tokenizertokenizer 是 CPU 操作放进训练循环会拖慢速度。参数说明学习率2e-5是 BERT 微调的默认起点用 AdamW 而不是普通 Adam因为 BERT 需要权重衰减batch_size取决于显存单塔 32 不行就降到 16。如果你用的是源码包里的train.py大概率它的接口和上面类似。第一次跑通先别改任何超参数用默认配置在自带数据集上跑三个 epoch然后看 loss 是不是一路下降。如果 loss 震荡不降先查数据加载有没有混入空字符串这个问题在文本匹配数据里出现频率很高。4. 文本匹配训练的 5 个高频踩坑现象、原因、解决4.1 双塔两个塔各写一套参数向量空间对不上现象训练 loss 降得挺快但用余弦相似度做召回时效果一塌糊涂甚至不如随机排序。原因代码里 new 了两个BertModel两个塔各训各的没有共享权重。两边学到的是两套语义空间算出来的余弦相似度没有可比性。解决双塔必须共享编码器也就是只保留一个BertModel实例两个句子都过同一个模型。想确认是否共享打印模型参数量如果发现是单塔的两倍说明写重了。4.2 负样本占比太高accuracy 虚高现象训练完看 accuracy 有 0.93但 AUC 只有 0.7P1 也不好看。原因数据集里负样本占了 90% 以上模型学到的其实是“无脑输出负样本”因为这样损失最小。整体 accuracy 被多数类拉高了。解决训练时别只看 accuracy打印每个 batch 的正负样本比例评估时以 AUC 和 P1 为准。如果正样本实在太少考虑对正样本做重采样或者给 loss 加正样本权重。具体做法是把BCEWithLogitsLoss的pos_weight设成负样本数除以正样本数一行代码就能缓解。4.3 单塔一训练就显存溢出现象同样的配置双塔能训单塔跑几个 step 就 OOM。原因单塔输入是两个句子拼接后的长序列长度翻倍。假设 max_len 设成 256双塔每句 128单塔实际序列长度也到 256显存占用线性上涨BERT 的 attention 还是平方复杂度涨得更快。解决先把单塔的max_len降到 128看能不能跑起来。如果数据里真的有长文本要处理把长句先切段再做“段落级匹配”最后聚合段落分数而不是硬塞进 BERT。这个方法叫多段式推理比一味加显存靠谱。4.4 训练用交叉熵、推理用余弦相似度阈值怎么调都不对现象双塔在训练时用CrossEntropyLoss推理时换成余弦相似度打分结果分类阈值怎么试都不对。原因训练目标和推理目标根本不是同一个函数模型在训练时拟合的是 logits 的绝对分布推理时用的是向量夹角的相对关系两边数值分布不在一个尺度上。解决双塔的 loss 和相似度计算必须统一。我习惯的做法是训练时直接算余弦相似度再套BCEWithLogitsLoss这样训练和推理的分数口径一致阈值可以直接按 0.5 起步调。比较两个模型是否一致写个预测脚本对同一批数据分别走训练态和推理态比对输出分数的分布。4.5 数据集标注口径不一致模型学到的判断标准是错的现象看训练集的坏样本发现明明语义一致的句子对被标成 0或者反过来。模型学得很痛苦loss 降不下去。原因文本匹配的标注主观性很强“相关”和“不相关”之间没有硬边界。有的标注员认为“怎么退票”和“如何改签”不相关有的认为相关数据里两派意见混在一起。解决训练前先做一次数据体检抽 100 条数据人工复核标签如果发现超过 5% 的标签让你自己都拿不准这数据得先清洗。清洗方法对每条样本加一个“置信度”字段小于阈值的丢掉或者用投票制多条标注取多数。源码包里如果只有一份标注你至少要做一次异常值过滤把 text1 或 text2 为空的、label 不在 0/1 里的行全部清掉。这一步的优先级高于一切调参。5. 必调参数与 hard negative把匹配效果从及格拉到能用的门道5.1 temperature 是双塔绕不开的第一个参数双塔输出余弦相似度后直接套损失函数经常会遇到一个问题相似度分布太平滑所有分数挤在 0.3 到 0.7 之间很难区分正负样本。原因在于余弦相似度对向量模长不敏感数值压缩在 [-1, 1]如果不放大差异损失函数很难推动模型学习。常见做法是加一个温度系数tau把相似度除以tau再进损失函数。一般tau取 0.05 到 0.1数值越小对相似度差异的放大越狠。代码实现就是在cosine_similarity后面除一下sim torch.cosine_similarity(vec_a, vec_b, dim-1) / tau逻辑说明除以一个小于 1 的tau等于把相似度乘上一个大于 1 的倍数拉大正负样本的分数差距让损失函数对难样本更敏感。参数说明tau太小会导致梯度抖动、训练不稳我一般从 0.05 起步观察 loss 曲线如果震荡明显就调大一点。这个参数在不同数据集上敏感度很高换数据后必须重新调。5.2 margin 和 triplet loss 的取舍单塔模型用交叉熵没问题双塔模型我更喜欢用 triplet loss。原因是双塔的最终目的是排序不是分类——你真正关心的是正样本对分数比负样本对高而不是分数绝对等于多少。Triplet loss 的公式是让正样本对的距离比负样本对距离小一个margin这个margin是第二个关键参数。margin太小模型懒得学太大会让训练不收敛正负样本永远拉不到目标距离。短文本匹配我一般从 0.2 起步长文本或者相似度分数本来就集中的场景调到 0.5 也不奇怪。注意triplet loss 需要构造三元组(query, positive, negative)如果你的数据只有句子对和标签需要自己把正负样本组装成三元组这一步写起来不难但容易出错组装时注意别把同一个样本同时当正样本和负样本那会让模型训练直接失效。5.3 hard negative 怎么挖挖完放哪想让双塔模型有实际落地价值hard negative 是必须做的。所谓 hard negative是指那些和 query 字面很像、语义却不匹配的负样本。比如“怎么退火车票”和“怎么退火车票手续费”看起来几乎一样但一个是退票流程、一个是手续费规则这就是好的负样本。挖 hard negative 的常见做法是先用训练好的双塔模型跑一遍候选集把分数最高的前若干个负样本捞出来当成新的负样本加进训练集再训一轮。作为开源项目常配套的简单版本也可以在 batch 内做负采样——同一个 batch 里一个 query 的匹配文本当成正样本batch 里其他 query 对应的文本就当成它的负样本。这个做法不额外需要数据代价是引入了“人为负样本”偶尔会把真正匹配的样本误判为负造成标签噪声所以只能作为起步方案。# batch 内负采样示例每个 query 只用自己的正样本其余看作负样本 def in_batch_negative_candidates(vectors_a, vectors_b, labels, tau0.05): sim_matrix torch.matmul(vectors_a, vectors_b.T) / tau # 对角线是正样本其余都是 candidate negatives return sim_matrix, labels逻辑说明把 batch 内所有query向量和所有doc向量做矩阵乘法得到 batch_size × batch_size 的相似度矩阵对角线就是配好的正样本对其他位置的相似度对应的就是难负样本。参数说明这种负采样和tau强相关tau太小会让非对角线分数全部趋近于 0负样本梯度消失tau太大又会让难度区分度下降两个参数要一起调。5.4 一张参数表收住调参起点很多刚上手的朋友会陷入“一个参数调一整天”的泥潭我把自己常用的初始值、调整范围和翻车信号整理成一张表照它起步至少能少走一半弯路。参数名单塔常用值双塔常用值主要调整范围需要警觉的信号学习率2e-52e-51e-5 ~ 5e-5loss 不降或震荡batch_size16 ~ 3264 ~ 256内存允许范围内越大越好双塔说 batch 越大负采样越丰富max_len12864 ~ 128按文本真实长度显存溢出或信息截断过多温度 tau不适用0.050.02 ~ 0.2分数分布挤成一团margin不适用0.20.1 ~ 0.5正负样本距离拉不开训练轮数3 ~ 53 ~ 5太多必过拟合验证集 loss 回升参数说明单塔没有tau和margin是因为它走分类路线用交叉熵就够了双塔的batch_size不只是训练速度问题它直接影响 batch 内负采样的数量太小负样本池稀疏。调整原则就一条一次只动一个参数改了之后先跑到验证集 loss 稳定再放下一个。同时改三个效果变好你都不知道是谁的功劳。6. 验证方法向量建索引与线上 pipeline 的体检6.1 用 faiss 索引验证双塔向量的可区分性训练完双塔后除了看验证集指标我还建议做一个额外的体检把验证集里所有 doc 向量塞进 faiss 索引然后用 query 向量做检索人工看返回的 top 5 像不像话。这一步能在你上生产之前暴露很多指标上看不出来的问题比如模型是不是只学会了“字面重合”而不是“语义匹配”。代码骨架如下import faiss import numpy as np def build_faiss_index(vectors): dim vectors.shape[1] index faiss.IndexFlatIP(dim) # 内积索引配合归一化向量等价于余弦相似度 index.add(vectors) return index def recall_check(model, tokenizer, queries, docs, top_k5): # 先用模型把所有 doc 编码成向量再编码 query最后检索 doc_vecs encode_docs(model, tokenizer, docs) doc_vecs doc_vecs / np.linalg.norm(doc_vecs, axis1, keepdimsTrue) index build_faiss_index(doc_vecs) for q in queries: q_vec encode_query(model, tokenizer, q) q_vec q_vec / np.linalg.norm(q_vec) scores, idx index.search(q_vec.reshape(1, -1), top_k) print(q, -, [docs[i] for i in idx[0]])逻辑说明IndexFlatIP是暴力内积索引向量归一化之后算内积就等价于算余弦相似度数据集规模在十万以内用这个完全够如果到百万级以上再换IndexIVFFlat并训练量化器。参数说明检索前一定要归一化向量否则内积受向量模长影响结果会和训练时的余弦相似度口径不一致。这个体检脚本不需要多复杂能让你直观看到模型认知里的“相关”到底是什么样。6.2 从 RecallK 反推双塔要不要重训如果体检时发现召回的前几名全是字面相似但语义无关的样本说明模型对 hard negative 的区分度不够这时候直接调margin和tau往往解决不了根本问题。我更推荐的做法是先用当前模型跑一次全量召回把分数排在 20 到 100 名之间且标签为负的样本捞出来人工筛选一批合适的 hard negative加进训练集重训。这个流程可以迭代三轮每轮效果都会有可见提升。训练过程中我习惯记一个Recall10的曲线它比 loss 更能反映双塔的真实状态。双塔本质是个召回模型loss降得再低Recall10每轮都在涨才算正常。如果 loss 降了但Recall10不涨去看是不是温度系数设小了负样本梯度被压没了。6.3 一个退而求其次的“核对”习惯最后分享一个我自己的习惯每次改完参数我都会随机挑几条数据同时打印单塔的 logit 和双塔的余弦分数肉眼过一遍排序是否符合直觉。模型是黑匣子但这个核对步骤相当于给黑匣子开一个小窗。文本匹配项目里最贵的从来不是训练时间而是上线之后线上效果和离线指标对不上那时候再排查数据分布差异代价高得多。养成这个小习惯比任何调参技巧都值钱希望帮到你。本文还有配套的精品资源点击获取