文本匹配里那组“双塔”,其实水很深。别急着反驳,我知道一提到双塔,很多人第一反应就是推荐系统里的用户向量和物品向量。但放到自然语言处理里,双塔结构的另一个名字,就是今天要聊的“表示型模型”。它干的事情很纯粹:把一段文本编码成一个稠密向量,然后靠向量之间的相似度(通常是余弦相似度或内积)来判断文本关系。你觉得它简单,但它撑起了搜索引擎召回、FAQ问答、文档去重、语义检索这类最吃量的场景。这篇文章,我想把表示型模型从原理到落地拆开来讲,包括它的血统、训练细节、样本构造、加速手段,还有那些文档里不会告诉你的坑。
什么场景会用到它?我举几个例子,你大概率都见过。微信搜一搜里你输入一句大白话,它能从海量公众号文章里捞回相关结果,底层就有表示型模型在干活。企业内部的智能客服,用户的问题要先从无数历史问答里匹配出最相似的几个候选,再做后续的精排或生成,这步召回也基本是表示型模型。就连你手机相册里的“以图搜图”换到文本域,本质也是一回事。适合谁看?如果你正在做语义搜索、问答匹配、相似文本识别,或者刚接触对比学习、Sentence-BERT这类概念,这篇文章应该能帮你把散落的知识串成一条线,并且直接照着搭出一套能跑的流程。
1. 表示型模型到底是什么,为什么工业界离不开它
1.1 文本匹配这个问题的两种解法
文本匹配是个老大难问题。你要判断两句话“意思”是否相同,或者相关,这比判断“字符串是否相等”难太多了。比如“怎么退订流量包”和“流量包如何取消”,字面上几乎不重叠,但语义上是一回事。想要机器学会这种能力,目前主流的方法分成两派:表示型和交互型。
表示型模型的思路是“先各自编码,再比较”。两句话分别送进编码器,各自变成一整个句子的向量,然后对这两个向量做点积、算余弦、或算欧氏距离,得到相似度分数。整个过程里,两句话在编码阶段完全不碰头,所以也叫双塔结构。交互型模型则是“先碰撞,再判断”。两句话的每一个词都先做交叉,用注意力机制捕捉词与词之间的对齐关系,最后汇总成一个匹配分数。经典代表比如ESIM、BIMPM这些,效果通常更好,因为交互发生在更细的粒度上。
为什么大家仍然疯狂用双塔?一句话:效率和效果之间的平衡。交互型模型做一次推理,需要对句子对做联合计算,假设每句话50个词,那注意力矩阵就是50乘50的规模;如果需要匹配的候选库有1000万条,你必须把每一条都和用户query拼在一起跑一遍,完全没法上线。双塔模型则是线下把1000万个候选全部提前编码好、建好索引,线上只需要把用户query编码一次,然后就去做近邻检索,毫秒级返回。牺牲一点点效果的上限,换来系统可以扛住百亿级数据的规模,这笔账太划算了。
1.2 表示型模型的技术路线图
这个领域的技术演进其实有清晰脉络,大致经历了三个阶段:早期基于深度网络的向量映射,中期基于预训练模型的句子编码,再到后期对比学习带来的表示能力跃升。每个阶段解决的核心问题不一样。
最早的DSSM(Deep Structured Semantic Model)出现在2013年前后,把词通过字级别的n-gram映射到低维语义空间,再用余弦相似度建模query和doc的相关性。它的缺点很突出:词袋式的输入丢失了词序信息,“我喜欢你”和“你喜欢我”在它眼里没区别。之后出现了一些改进,比如用CNN或LSTM做编码器,但整体思路没变:把语义压进一个向量,然后算相似度。
真正改写游戏规则的是预训练模型时代的到来。BERT这类模型出来后,有人直接把BERT当作编码器,把[CLS]位置的输出当成整句话的向量。但直接用BERT做句子向量的效果并不理想,原因是BERT的训练目标和句子语义表示的需求不完全匹配,它学到的空间存在严重的各向异性,就是各方向上的方差差异很大,导致向量之间的相似度不能很好地反映语义相似度。Sentence-BERT就是在这个时期出现的,它用孪生BERT结构加有监督的匹配数据微调,一举奠定了现代表示型模型的基本范式。再往后,SimCSE这类方法引入对比学习,用无监督的方式也能训练出相当不错的句子向量,一下子把门槛又拉低了一大截。
2. 核心细节拆解:从向量到相似度的全链路
2.1 编码器输出的向量到底该怎么用
很多初学者最容易忽略的细节是:模型的输出层不是拿来直接用的。以BERT为骨架的模型,你可以拿最后一层的[CLS]向量,也可以拿所有token向量的均值池化或最大池化,不同选择对效果有实打实的影响。根据我做过的实验,均值池化在绝大多数语义相似度任务上优于直接取[CLS],尤其是当句子比较短、[CLS]向量没有充分吸收信息的时候。训练和推理阶段的池化方式必须保持一致,否则你训出来的模型一到线上就失灵,这是非常常见的低级错误。
向量本身还需要做归一化。很多框架里默认会对编码后的向量做L2正则化,也就是把向量长度变成1,这有两个好处:一是让向量只保留方向信息,长度变成无关因素;二是方便用内积代替余弦相似度,因为向量归一化之后,余弦相似度等于内积,而内积在向量检索里实现效率极高。我记得早期用DSSM的时候就吃过亏,那个年代还没有强制归一化的习惯,线上检索和线下评测的相似度分数分布对不上,排查了半天才发现是长度信息在捣乱。
2.2 相似度计算的数学直觉
把向量算出来之后,最常用的相似度度量就三种:余弦相似度、内积、欧氏距离。在表示型模型里,如果你做的是语义相似度任务,默认就用余弦相似度或内积,两者在向量归一化后等价。欧氏距离也能用,它和余弦相似度的关系是:对归一化向量,欧氏距离平方等于2减去两倍余弦相似度,所以信息等价,只是数值尺度和直观感受不同。
为什么余弦相似度是语义匹配的默认选择?因为它对向量的缩放不敏感,只关注方向。两个句子向量,哪怕整体数值幅值差异很大,只要指向的方向接近,就认为是相似的。这在语义空间里符合直觉:语义空间的方向编码了“说什么”,幅值往往编码了“说得有多绝对”或者“信息的丰富程度”,而匹配关心的是前者。如果任务本身需要关注文本的强度信息,比如情感倾向的强弱程度,那内积或欧氏距离可能有额外价值。但绝大多数匹配场景,余弦已经够了。
2.3 训练时的损失函数选择
训练表示型模型,最核心的就是损失函数。主流的选择有三种:三元组损失、对比损失和排序损失。它们在本质上是同一件事的三种变体。
三元组损失是经典的排序思路,让正样本对的相似度比负样本对的相似度高出至少一个margin。公式是loss = max(0, d(anchor, positive) - d(anchor, negative) + margin),这里的d通常用距离而不是相似度,margin一般设置在0.2到0.5之间。对比学习的InfoNCE损失则更像一个分类问题:给定anchor,从所有样本里识别出那个正样本,温度系数控制分布的锐利程度。批次越大,负样本越多,任务越困难,学到的表示就越精细,所以对比学习的一个核心诉求就是“把batch做大”。
实际训练里,我发现很多人对温度系数的调整不够敏感。温度太大会让所有样本的梯度都差不多,学不到区分度;温度太小则会让模型过度关注最难的负样本,训练不稳定,容易崩塌。一般的经验是初始温度取0.05左右,然后在验证集上微调。另外要注意,如果你的任务本身有多个类别,类别标签和文本语义不完全对应,那用纯粹的对比损失可能不够,可以把它和交叉熵损失加权组合,效果往往会更好。
3. 从零搭建表示型文本匹配系统
3.1 数据准备:数据集格式与预处理
我拿最典型的语义相似度任务来演示,就是给定两个句子判断是否同一个意思。数据格式最简单可以是一个TSV文件,每行包含文本A、文本B和一个相似度标签,标签可以是0和1的二值,也可以是0到5的连续分数。
预处理这一步有三个坑,都是我自己踩过的。第一,中文文本不要随意去除标点,尤其是问号、感叹号这些承载语气的符号,“你喜欢我吗”和“你喜欢我?”在语义上差异巨大。第二,不要粗暴去除停用词。在表示型模型里,停用词有时反而是重要信号,“A和B的区别”跟“A或B的区别”,去掉停用词之后向量就没区别了。第三,如果你做的是FAQ问答匹配,要对文本做一定程度的归一化,比如把繁体转简体,把全角标点转半角,这样能显著减少同义重复的难度。下面的代码展示了一个基础的样本读取流程,顺便把标签从整数映射成浮点数,方便后续计算。
import csv import random def load_pairs(path, max_len=128): pairs = [] with open(path, "r", encoding="utf-8") as f: reader = csv.reader(f, delimiter="\t") for row in reader: if len(row) < 3: continue a, b, label = row[0], row[1], float(row[2]) pairs.append((a, b, label)) return pairs # 示例:读取训练集 train_pairs = load_pairs("train.tsv") print(f"train pairs: {len(train_pairs)}") random.shuffle(train_pairs)3.2 模型选型与训练配置:用BI-Encoder范式实现
以当前工程生态来看,用已有的预训练中文模型做底座,在上面加一个均值池化层,再接一个损失函数来微调,是最稳妥的方案。如果你没有大算力,单卡也能跑,因为做双塔微调时,句子对的两个塔是共享权重的,每个句子独立过编码器,反向传播时梯度会叠加。
下面我就按Sentence-BERT的风格,给出一套可以直接跑的伪训练代码,用的是PyTorch和HuggingFace生态。这个代码同时体现了三个关键设计:共享权重、均值池化、对比损失的实现。
import torch import torch.nn as nn import torch.nn.functional as F from transformers import AutoTokenizer, AutoModel class MeanPooling(nn.Module): def __init__(self): super().__init__() def forward(self, last_hidden_state, attention_mask): # 对非padding位求平均,避免padding把向量“拉偏” input_mask_expanded = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float() sum_hidden = torch.sum(last_hidden_state * input_mask_expanded, dim=1) sum_mask = torch.clamp(input_mask_expanded.sum(dim=1), min=1e-9) return sum_hidden / sum_mask class BiEncoder(nn.Module): def __init__(self, model_name="your-base-model"): super().__init__() self.encoder = AutoModel.from_pretrained(model_name) self.pooler = MeanPooling() def forward(self, input_ids, attention_mask): outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask) # 取最后一层隐状态做池化 vector = self.pooler(outputs.last_hidden_state, attention_mask) # 归一化:让向量变成单位向量 return F.normalize(vector, p=2, dim=1) def contrastive_loss(query_vec, doc_vec, temperature=0.05): # 这里用批次内负样本,即每个query在batch内其他doc都是负例 logits = torch.matmul(query_vec, doc_vec.t()) / temperature labels = torch.arange(logits.size(0), device=logits.device) return F.cross_entropy(logits, labels) # 训练循环示意 model = BiEncoder() optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5) for batch in train_dataloader: query_ids = batch["query_ids"] query_mask = batch["query_mask"] doc_ids = batch["doc_ids"] doc_mask = batch["doc_mask"] q_vec = model(query_ids, query_mask) d_vec = model(doc_ids, doc_mask) loss = contrastive_loss(q_vec, d_vec) optimizer.zero_grad() loss.backward() optimizer.step()这里的contrastive_loss用了批次内负样本的技巧,就是没显式构造负样本,直接把同批次的其他doc当作负例。这是提升训练效率的性价比最高的方式,没有之一。因为一个batch如果有64个query-doc对,理论上就能提供64乘以63个负样本,几乎不需要额外花时间去挖掘。唯一的代价是batch里的doc如果不满足“互不相同”的假设,就会产生假负例,干扰训练。这个可以通过采样策略来控制。
3.3 训练轮次与收敛判断
微调表示型模型,轮次真不是越多越好。我见过太多人拿着BERT微调文本分类的经验,一上来就跑10个epoch,结果句子向量质量反而下降。原因在于对比学习任务本身不复杂,模型很快就过拟合到训练集上那些表面模式上了。我的经验是,基础BERT类模型,从2到4个epoch开始看效果;如果用了数据增强或在更大规模语料上预训练过的模型,可以略微放宽到5个epoch。
收敛判断不要只看训练损失降没降,一定要看固定评测集上的指标。语义相似度任务上,可以每保存一个checkpoint就计算一次Spearman相关系数,就是预测相似度和人工标注之间的秩相关性。这个指标比准确率更能反映向量空间的质量。训练损失持续下降但Spearman不涨,往往是模型在走捷径,学习了一些和语义无关的关联模式。
还有一点,学习率的选择对双塔微调非常敏感。预训练模型微调一般用2e-5到5e-5,但如果你用对比学习,稍微大一点的学习率配合warmup,有时候反而效果更好。我习惯的做法是:先用默认的2e-5跑一个epoch,观察损失下降速度,如果太慢就加一倍,如果出现震荡就减半。另外一定要加warmup,通常是总步数的10%左右,让模型从预训练状态平稳过渡到匹配任务的状态。
3.4 推理:把相似度计算变成向量检索
训练完模型之后,我们要让它上生产。关键一步是把所有候选文本离线编码成向量,存进向量索引。线上来一条query,先编码成向量,再去索引里做近似最近邻搜索,返回TopK候选。这个流程里,向量索引的选择直接决定了你的服务能力和成本。
近似最近邻搜索的主流方案有两大类:基于量化的(如乘积量化)和基于图的(如HNSW)。前者压缩比率高、内存占用小,但精度有损;后者召回率高、查询速度快,但在极高维和极大基数的场景下内存开销较大。我自己在十亿级样本场景下比较常用的是乘积量化加倒排索引的组合,在可控的精度损失下能把内存压到原来的十分之一以内。如果数据量在百万级以下,直接用暴力计算都行,GPU上一批向量算内积矩阵,延迟也在毫秒级,完全没必要上复杂的索引。
import numpy as np import faiss # 假设 doc_vectors 是形状 (N, embedding_dim) 的归一化向量 index = faiss.IndexFlatIP(embedding_dim) index.add(doc_vectors.astype("float32")) # 查询 query_vector = model.encode(["如何取消流量包"], ...) scores, indices = index.search(query_vector, k=10)这段代码里IndexFlatIP是暴力内积索引,因为向量已归一化,所以等价于余弦相似度。它简单、准确,但没有加速。当N达到百万以上,应该换成IndexIVFFlat或IndexHNSWFlat。另外我强烈建议,上索引之前先做一遍全量暴力检索和索引检索的召回对齐测试,统计召回率掉了多少。如果Top10召回率低于95%,说明索引参数没调好,这时候盲目上线就等于把效果白白扔掉几个点。
4. 训练样本的构造策略:正负样本的艺术
4.1 正样本怎么找:领域内匹配数据的价值
表示型模型训练效果的上限,很大程度由正样本质量决定。正样本就是你希望模型判定为“匹配”的句子对,它应该尽可能覆盖你目标场景里真实出现的同义表达方式。如果目标是FAQ问答匹配,那就应该有大量真实用户问法和标准问法的配对;如果目标是文档检索,那就应该有用户搜索词与文档标题、正文关键段的配对。
自己构造正样本,最省力的方式是“回译”。把一句话翻译成英文,再翻译回中文,得到的新句子和原句语义几乎一致,但表达有差异。这种方式生成的样本数量可以很大,但要注意回译往往只改变用词和句式,不太会改变语体风格,所以它模拟不了太夸张的口语和书面语之间的跨度。另一个办法是从点击日志里抽取,用户搜了一个query,又点击了某个文档,这就算一个弱正例。弱正例会有噪声,但胜在真实,量大。
我见过一些做专业领域问答的人,直接拿通用领域的匹配数据训练模型,然后期望它在自己的垂直场景里表现好,结果往往不理想。领域差异真的很大,法律条文之间的“相似”和娱乐话题里的“相似”,绝不是同一个概念。如果预算有限,最经济的做法是先用通用数据做一轮预训练,再标注几百条甚至几十条领域内的高质量数据微调,就能获得很大提升。
4.2 负样本的层次感:简单负例与难负例的搭配
负样本比正样本更容易被忽略,但它的设计直接决定了模型的判别边界。一句话总结:负样本要有层次,全是简单的,模型就飘;全是难的,模型就懵。
简单负例就是那些一眼看上去就不相关的句子,比如用户问“怎么办理宽带”,给他配一个“今天天气很好”。这类样本帮助模型学会基础区分能力,把语义空间拉开。难负例则是看上去相关但不匹配的句子,比如“如何取消流量包”和“如何开通流量包”,它们只差一个词,但语义方向相反。难负例能推动模型学到更深层的区分能力,不至于只记住词面重叠。
实际操作中,一个epoch里不要全用难负例,我的习惯是简单负例、中等负例、难负例各占三分之一左右。难负例可以用训练过程中模型动态挖掘得到的,就是每训练几步,把当前batch里相似度最高的假负例当作新的难负例,再加进去。这种“课程学习”式的渐进方式,训练过程会更稳定。
4.3 In-batch Negatives为什么高效,以及它的问题
前面代码里已经用了批次内负样本的方式,我再展开说说它的底层逻辑和坑。所谓in-batch negatives,就是把同一个batch里其他query对应的doc,当作当前query的负样本。这种方式的最大优点是不需要额外计算,因为doc向量本来就要参与前向传播,拿过来当负样本用零成本。
它的局限也明显。假设你处理的是一个candidate池非常大的检索问题,batch里只有64个doc,那这63个负样本对整个候选空间来说太稀疏了。模型很容易学到一个偷懒的策略,凡是词面差异大的就判负,对词面相似但语义不同的样本则无能为力。解决方案是增加跨batch的缓存负样本,用一个队列保存之前若干个batch的doc向量,当前batch的query同时和缓存里的向量做对比。这在实现上也不难,维护一个固定大小的队列即可,效果提升很显著。
另一个问题是假设性冲突:如果两条doc在语义上本来就是近似等价的,比如两个不同的FAQ标准问法指向同一个答案,它们互相充当负样本时就成了假负例。做检索类任务时这种冲突尤其常见。解决方法是每个batch采样时尽量保证doc之间互不相同,或者对冲突样本降低权重。我在实际项目中会用“近似去重”先处理一遍候选池,把相似度超过阈值的样本合并或抽出,这样既减少了训练噪声,也减轻了线上索引的冗余。
5. 常见问题与排查技巧实录
5.1 相似度分数整体偏高或偏低
上线后经常遇到的一个现象是:线下评测效果挺好,线上返回的相似度分数普遍偏高,几乎都在0.9以上,区分度很弱。这种情况八成是推理阶段和训练阶段的预处理不一致。最常见的元凶是padding方式不同。训练时batch内有长度不一的句子,真实长度不到max_len的就补上[PAD];线上你如果单条query没有走同一个padding逻辑,或者长度分配策略变了,编码出的向量就不可比。
解决办法是做一个线下线上一致性检查:抽出几十条真实线上query,分别走训练时的预处理流程和线上实时流程,对比encode出来的向量是否完全一致。不一致就逐字段排查,从分词、截断、特殊token到向量归一化,每一步都打日志。这类问题定位不难,难在你会先入为主认为模型出了问题,而其实是在模型外面。
5.2 长文本匹配效果明显下降
如果你的场景包含长文本,比如整篇文档和query的匹配,表示型模型的短板会集中暴露。原因是固定长度的向量要压缩的信息量是有限的,一句话压缩成768维还能保留主要信息,一整篇5000字的文章压缩成768维,信息损失就太惨烈了。
几种缓解策略按推荐程度排序如下。第一是在编码前做摘要,把文档提炼成几个关键句,再对句子级向量做聚合表示。第二是分段编码加融合,把长文档切成若干片段,每个片段独立编码,再用类似“取均值”或“注意力加权”的方式融合成一个文档向量,这个方案在工程上更通用。第三是改用“稀疏表示加稠密表示”混合检索,用BM25这种词项匹配方法兜底长尾词汇信息,稠密向量表示语义主方向,二者融合后再排序,效果通常比单纯堆模型要好。你观察那些真正在线上扛得住大流量的检索系统,就会发现没有一个是用单一向量硬刚长文本的,都是混合检索的套路。
5.3 表示型模型的“各向异性”退化
进一步说下去,表示型模型始终存在一个问题:向量空间往往是各向异性的,意思是向量倾向于挤在一个狭窄的锥形区域里,而不是均匀铺开。这不是某个模型的问题,而是语言模型表示空间的普遍现象,尤其在没有充分对比学习训练的模型里更明显。它带来的恶果是:两个完全不相关的句子,算出来的余弦相似度也高达0.8,因为大家都在同一个方向上。
业界有几个成熟的缓解手段。一个是白化操作,把向量先减去均值,再通过一个线性变换把协方差矩阵变成单位阵,相当于给向量空间“整形”。另一个是流行的方法叫flow,它把向量映射到一个更各向同性的分布上,工程上可以看作一个可学习的后处理层。还有一个简单但有效的手段:在微调阶段加入一些正则化项,约束向量空间的各向同性程度。如果你在评测时发现所有相似度分数都异常集中,二话不说,先查各向异性,再做模型层面的优化。
5.4 评测指标选不对,调参全是白费
最后想强调一个很多人不在意但影响致命的问题:评测指标和你的业务目标要对齐。语义匹配模型最常用的两个指标是Spearman相关系数和Recall@K。前者适合评测“相似度的排序质量”,后者适合评测“TopK检索能力”。你如果用后者做目标,却在训练里只优化排序质量,效果自然不理想;反之亦然。
我做检索召回时,评测指标一定要包含Recall@10和Recall@50,具体K值根据线上真实需求来定。然后还要评测一个“有害召回率”,就是TopK里有多少条是明显不相关但被硬塞进来的,这个指标能反映模型是不是偏科。聊天机器人里的FAQ匹配又不一样,它更看重Top1的准确率,因为答案只能给一个。这时候我会把评测重点放在“Top1是否命中标准问法”上,模型选择的损失函数也要倾向这个目标,比如对Top1错误施加更大的惩罚。说白了,模型是工具,指标是你的导航仪,导航仪不准,路径全白跑。
6. 一些没写在文档里的体会
表示型模型的工作流走到这里,基本上全链路都打通了。其实回头看,训练一个效果不错的双塔模型,真正花时间的往往不是模型结构本身,而是数据、评估和工程对齐这三件事。很多失败的尝试,最后都能追溯到同一个根源:线下和线上不一致。
还有一点值得多说一句。我认识不少刚开始接触文本匹配的朋友,第一反应都是“交互型模型效果更好,为什么不用”,这是真事。交互型效果好,但那是离线评测环境下的好,在真实检索场景里它跑不起来,除非你只做精排阶段。表示型模型的核心优势是它可以极大规模地做召回,配合精排模型组成一个两阶段的漏斗。理解了这个分工,你才不会被效果数字迷惑,而是从系统角度看问题。
最后分享一个小技巧。如果你预算有限,没法标注太多数据,优先保证几百条高质量负样本的覆盖度,而不是盲目追求样本数量。更具体地说,把你线上真实检索到的、相似度在阈值边界徘徊的那些case,认认真真累积下来,每两周清洗一次,当作专门的难负样本集重新加入训练。这个方法不需要任何高级技巧,但它是我见到的投入产出比最高的优化手段,没有之一。方向对了,慢就是快。