先讲一个我实际遇到的场景。当时在做一个知识库问答的召回模块,用户的问法五花八门:“怎么注销手机号绑定的宽带”和“解除宽带与手机号的捆绑关系怎么办”,这两句话字面上重合的只有“宽带”和“号”,但语义上几乎是一回事。如果只靠词面匹配,第二句大概率会被漏掉。后来我把这类问题收敛成一种统一的处理方式:先把每条文本映射成一个固定维度的向量,再在向量空间里比较相似度,这也就是标题里说的“表示型模型”(representation-based model)。如果你在做搜索召回、FAQ匹配、文本去重、智能客服,或者只是想搞懂NLP里“文本匹配”到底有哪些套路,这篇文章应该能给你一个可以直接落地的参考。
这类模型的核心逻辑并不复杂:把匹配问题拆成“编码”和“比较”两个阶段。编码阶段,query和doc各自独立地过一个编码器,得到两个稠密向量;比较阶段,用余弦相似度、点积这类简单函数去衡量两个向量的相近程度。因为query和doc两条文本从输入到产出向量的过程完全独立,doc侧的向量可以在离线阶段全部算好存起来,线上只需要算query的向量,然后用向量索引做近似检索。这是交互型模型做不到的,也是表示型模型在工业界长盛不衰的根本原因。
1. 语义匹配到底在匹配什么:先从问题边界说起
1.1 四个最常见的文本匹配场景
做文本匹配这些年,我发现它很少以“我是文本匹配模型”的面貌单独出现,多数时候是作为某个功能的一部分。我梳理了四个高频场景:
- 搜索召回与排序:给一个query,从百万级文档里挑出语义相关的候选。难点不仅有语义多样性,还有规模——不可能让query和每个文档都做一次深度计算。
- FAQ问答匹配:用户提问,从知识库里找出最合适的标准问题。难点是用户表达和人工整理的标准问法之间差距可能很大,用户不会照着你写的标准问句说话。
- 近义句识别与文本去重:判断两句话是否表达同一件事,常见于评论去重、商品标题查重、新闻聚类。
- 语义相似度打分与回归:给一对句子输出一个相似度分数,常用于训练语料构造、数据清洗、内容推荐。
这些场景名字不同,抽象出来却是一件事:给定两个文本s1和s2,输出一个标量——相似度或匹配分。真正的差异在候选集规模、时间预算,以及“匹配”到底指什么:是语义等价、主题相关,还是蕴含关系。搞清楚这一点,你才能判断该用表示型还是交互型。
1.2 为什么字面匹配不够用
很多人一开始会想:我用BM25、用词面重合不就行了吗?在部分场景下确实够用,比如关键词指向性很强的搜索词。但有两个问题很难绕过去。
第一个是同义替换与说法变化。同一个意思可以有上百种表达方式,字面重合度可能很低。字面匹配(包含、前缀、BM25稀疏词项)对同义词基本无能为力。表示型模型在向量空间里学到的是一种“分布相似性”:出现在相似上下文里的词会被映射到相近的区域,所以“注销”和“解除”即使字面上毫无关系,也能在语义上靠近。
第二个是一词多义与语境依赖。同一个词在不同上下文里含义完全不同,稀疏词项模型无法携带上下文。表示型模型如果用好上下文编码器,就能把“苹果手机”里的苹果和“吃苹果”里的苹果分开。当然,“能”和“一定能”之间还有差距,这是后面实战部分要解决的问题。
1.3 表示型与交互型:两条技术路线
文本匹配模型大致可以分成两派。一派是表示型(representation-based),两个文本先各自编码成向量,最后再做简单交互(余弦、点积、MLP)。另一派是交互型(interaction-based),两个文本在编码阶段就充分交互——注意力、匹配矩阵、各种token级对齐——最后再聚合出分数。
交互型通常效果更好,因为两条文本里的信息在细粒度上真实流通了,典型代表包括ESIM这类强交互模型。但交互发生在两条文本之间,query和doc无法提前独立计算,规模化成本很高。表示型的优势就在于“解耦”:两条文本各自压缩成向量之后,doc侧可以离线预计算、建索引,代价是细节交互信息在压缩过程中会有损失。
这两条线不是替代关系,而是互补关系。工业界最常见的架构是表示型做召回,交互型做精排。这个搭配我在第5章会展开。
2. 双塔结构的核心细节:编码、比较与权重共享
2.1 编码器的三代选择
表示型模型从“如何把文本变成向量”的角度看,经历了三代编码器。
第一代是词袋/统计向量,比如TF-IDF。优点简单、可解释、速度快,适合当基线;缺点是没有上下文、向量稀疏、不能泛化到OOV(未登录词),严格来说算不上表示学习。
第二代是深度序列编码器。CNN在词向量序列上提取n-gram局部特征,可以并行计算,速度较快;LSTM/GRU捕捉顺序和长程依赖,但顺序计算慢,长文档上信息被压缩得更厉害。这一代的核心改进是“参数化的可训练编码器”,能够从数据中学习如何把句子压缩成向量,匹配效果比词袋有明显提升。
第三代是预训练Transformer,比如BERT及其后续模型。用它们作为双塔底座,再接一个pooling层和投影层,效果最好,能携带真正意义上的上下文语义,缺点是计算开销大。在搜索场景里,这一代模型经常要配合蒸馏、量化、提前退出这些工程技巧使用。
| 编码器 | 上下文建模 | 训练成本 | 推理速度 | 适合场景 |
|---|---|---|---|---|
| 词袋/TF-IDF | 无 | 低 | 最快 | 基线对比、小规模 |
| CNN | n-gram局部 | 中 | 快 | 对速度敏感的双塔 |
| LSTM/GRU | 顺序+长程 | 中 | 较慢 | 中等长度文本 |
| 预训练Transformer | 全局+深层 | 高 | 最慢 | 精度优先的双塔 |
2.2 相似度函数不是随便选的
双塔编码出来后,最后一步的相似度函数同样关键。常见的几种选择:
- 余弦相似度:对向量模长不敏感,只关注方向,是归一化embedding最常用的度量。
- 点积:保留模长信息,也是向量索引里最常对接的度量方式。
- 双线性(可训练的矩阵W):在两个向量之间加一层线性变换,灵活度高,但容易过拟合,而且搜索时没法直接用标准向量索引做内积。
这里有一条必须记住的原则:训练时的相似度函数、损失函数、推理时的检索度量三者要一致。你训练时用的是带温度的余弦损失,线上检索却用L2距离,排名会出现系统性的偏差;如果你训练时做了向量归一化,那么doc向量和query向量在入库和查询时也必须走同样的归一化。我见过太多项目线下指标涨了、线上没动静,最后查下来就是因为训练和推理的度量不是一套。
2.3 共享权重与非对称双塔
双塔的两个编码器可以共享权重,这通常叫Siamese结构;也可以不共享,各管一端。什么时候该不共享?当query和doc的文本分布差异很大时,比如query短、口语化,doc长、书面化。给它们各配一个编码器会提升效果,但代价是训练数据要足够,而且线上要维护两套参数。
我的建议是:小数据集上先用共享权重训练,稳定又不容易过拟合。如果数据量够大,再把最后一层或projection层拆成两套,形成“共享底座+非对称头”的结构。这样做既能利用共享语义,又能适配两端分布差异。不要一上来就搞两个完整编码器,训练难度会明显上升,收敛也会变慢。
3. 从DSSM到预训练句向量:表示型模型的演进主线
3.1 DSSM的思想遗产:word hashing与负采样
在深度学习文本匹配刚起步时,搜索系统的候选集通常是百万到亿级。DSSM是这条路线最早的标志性工作之一:把query和doc分别过几层MLP,映射到128维向量,用余弦相似度打分,再用softmax把“哪个doc被点击”当作标签训练。它有两个细节即使在今天也很有启发。
一个是word hashing,也叫词哈希:把每个词切成字母n-gram(比如“good”切成带有边界标记的字母片段),既能解决OOV,又能把词表压到几万维以下。另一个是负采样,用随机抽样的方式近似全量softmax,降低训练复杂度。
我后来回头想,DSSM真正解决的不是“把句子变成向量”这个单一问题,而是“在千万级候选上怎么做深度语义匹配”。它把匹配拆成了可以离线预计算的步骤,这在当时是非常工程化的选择。今天很多新的双塔模型,本质上仍然延续这个范式,只是编码器更强了、负样本构造更精细了。
3.2 序列编码器进双塔:CNN与LSTM
DSSM的MLP对词序和短语结构不够敏感,于是后续工作开始换用更强的编码器。基于CNN的双塔用卷积在词向量序列上提取n-gram特征,再接池化和MLP,优点是卷积可以并行、速度快,局部短语捕捉好;基于LSTM的双塔用共享权重的LSTM分别编码两个句子,取最后一个或者平均隐状态,然后用曼哈顿距离或余弦做相似度,在语义相似度任务上表现很稳,但LSTM顺序计算慢,长文档上隐状态信息被压缩得厉害。
这段时期大家其实已经把“表示质量”当成核心问题了:句子该怎么压缩成向量,才能不丢失关键的语义信息?CNN和LSTM证明了一件事——只要编码器有足够的上下文建模能力,双塔结构就能在匹配任务上取得不错的效果。到了Transformer时代,这个直觉被进一步放大。
3.3 预训练时代的表示型模型
BERT出来之后,大家发现直接把BERT的CLS向量当句向量用,效果并不稳定。于是出现了专门训练句向量的方向:把预训练Transformer当成双塔的编码器,两个句子各自过编码器,对token输出做mean pooling,再接一个投影头,用对比损失或相似度回归损失微调。这种“句向量模型”本质上仍然是表示型模型。
后来还有一个非常巧妙的思路:无监督对比学习。同一句话过两次编码器,由于dropout的随机性会产生两个略有不同的表示,把这两次输出当作正例对,同一个batch里的其他句子当作负例,模型就能在完全没有人工标注的情况下学到不错的句向量。这个思路刚出来的时候,效果直接逼近了有监督方案,给后续很多通用向量模型提供了基本训练范式。
这里要提醒一点:表示型模型的“表示”不是随便拿个预训练模型就能得到的。直接拿一个语言模型的CLS向量去算余弦,结果通常不如专门为匹配任务微调过的句向量。语言模型优化的是“预测下一个词”,句向量需要的是“语义空间的紧凑排布”,两者的目标并不完全一致。
3.4 稠密检索把双塔用到了极致
如果把表示型模型放进检索链路,就得到稠密检索(dense retrieval)的典型形态:query过编码器得到向量,doc过编码器得到向量,所有doc向量离线建索引,线上只用向量索引做近似召回。这套思路在大规模知识库检索、内容推荐、语义搜索场景里已经是标配。
稠密检索真正拉开差距的地方不在模型结构,而在负样本。纯随机负样本会让模型只学会区分“表面不同的文本”,学不到细粒度的语义差异。工程上普遍用BM25挑出的高分但不匹配的文档作为困难负样本;训练几轮之后,再用当前模型去挖掘更难的负样本,进入下一轮迭代。这种“挖负样本—训练—再挖”的循环,是稠密检索效果持续变好的关键,具体操作我在第6章再讲。
4. 手把手训练一个表示型匹配模型
4.1 训练数据怎么造:正样本、负样本与困难负样本
需要准备的是配对数据,常见有三种形态:
- 检索式数据:query + 正文档 + 若干负文档。正文档可以是点击文档、标准问法、用户最终选择的答案。
- 自然语言推理数据:前提 + 假设 + 标签(蕴含/矛盾/中性),可以把蕴含当正样本、矛盾当负样本。
- 相似度打分数数据:两个句子 + 0到5分的相似度评分,适用于回归损失。
数据质量比数量更重要。我最常遇到的问题是负样本太“假”:从随机库里抽的负样本跟query毫无关系,模型很快就能靠表层特征区分它们,学到的都是“懒特征”。所以至少要让一部分负样本是困难负样本,比如BM25得分高但语义不匹配的文档,或者用户点过但质检发现确实不相关的历史配对。
一个非常实用的技巧是in-batch negative。每个batch包含N个(query, positive)对,batch内部第i条doc是第i条query的正样本,而其他N-1条doc天然就是负样本。这样做既省了人工构造负样本的成本,又大幅提升了负样本的多样性和训练效率。
# 每个样本的格式示例 # {"query": "怎么注销宽带", "positive": "宽带注销办理流程"} # {"query": "手机掉水里怎么办", "positive": "手机进水紧急处理方法"}4.2 模型结构、对比损失与训练循环
下面是一个完整的双塔模型定义。编码器用预训练Transformer,投影层负责把向量压缩到合适的维度,再用L2归一化把向量搬到超球面上。
import torch import torch.nn as nn import torch.nn.functional as F class DualEncoder(nn.Module): def __init__(self, encoder, projection_dim=256, temperature=0.05): super().__init__() self.encoder = encoder self.projection = nn.Sequential( nn.Linear(encoder.config.hidden_size, projection_dim), nn.LayerNorm(projection_dim), ) self.temperature = temperature def encode(self, input_ids, attention_mask): outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask) # 用mean pooling,别直接取CLS token_embeddings = outputs.last_hidden_state mask = attention_mask.unsqueeze(-1).float() pooled = (token_embeddings * mask).sum(dim=1) / mask.sum(dim=1) pooled = self.projection(pooled) return F.normalize(pooled, p=2, dim=-1) def forward(self, q_ids, q_mask, d_ids, d_mask): q_vec = self.encode(q_ids, q_mask) d_vec = self.encode(d_ids, d_mask) logits = torch.matmul(q_vec, d_vec.T) / self.temperature return logits训练循环比想象中简单:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-5) model.train() for step, batch in enumerate(loader): q_ids, q_mask, d_ids, d_mask = [x.to(device) for x in batch] logits = model(q_ids, q_mask, d_ids, d_mask) labels = torch.arange(logits.size(0), device=device) loss = F.cross_entropy(logits, labels) loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()为什么labels是torch.arange?因为batch里第i条doc对应第i条query的正样本,其余B-1条doc默认是负样本。于是query和doc的相似度矩阵[B, B]天然形成了一个分类任务:行i表示第i条query对batch内所有doc的得分,正确答案是第i列。
这里有几个细节值得说清楚:
第一,温度系数temperature。太小会让softmax分布过于尖锐,训练不稳定;太大会让正负样本梯度都趋近于零,学不动。常见区间是0.02到0.1,小batch时尤其不要把温度调大。也可以把temperature设成可学习参数,但初始值不要偏离经验区间太多。
第二,mean pooling而不是直接取CLS。CLS会过pooler层,那层非线性是为分类头设计的,不一定适合表示整个句子的语义。对token输出做mean pooling更稳。如果担心个别token带来噪声,可以先对padding位置mask掉再求平均。
第三,跨batch负样本。上面用的是batch内负样本,batch越大、负样本越丰富。显存不够时,梯度累积能稳住梯度,但它不会增加单步的负样本数量;真正需要更多负样本时,用一个内存队列保存最近M个历史向量,每次训练都把队列里的向量当作额外负样本,这是常见的解决方式。
4.3 用正确的指标做验证
训练完别只看loss,要看你业务真正关心的指标。不同任务要分开看:
- 检索场景:看Recall@k和MRR。Recall@k是编码检索后前k个候选中包含正样本的比例;MRR衡量第一个正确答案出现的位置。
- 语义相似度打分:看Spearman相关,因为阈值往往不重要,排序一致性才重要。
- 分类或去重:看准确率、F1,并且要关注哪些类别最容易错。
评估时有个坑:如果直接从训练集里抽样做验证,结果会偏高,因为随机负样本太好区分。我建议额外构建一个“困难验证集”,负样本来自BM25高分、但人工标注为不匹配的文本。这样能暴露模型是不是只靠表面词形得分,而不是真正理解了语义。
4.4 从模型到向量检索一条龙
推理阶段的关键步骤是提前把全部doc向量算好,保存成矩阵,然后交给向量检索引擎。
# 离线:把所有候选文档编码成向量矩阵 doc_texts = [...] # 业务侧全部候选 doc_vectors = model.encode_docs(doc_texts) # [N, proj_dim] # 在线:query编码成一个向量,再做近似最近邻检索 query_vec = model.encode_one(query) # [proj_dim] scores, ids = index.search(query_vec, top_k=100)这里最容易被忽略的仍然是归一化一致性。训练时最后一步是L2归一化,那么保存doc向量时也要归一化,query也要归一化,索引度量用内积;如果你训练时不归一化,那向量模长会携带信息,检索距离度量的选择就复杂得多。我的习惯是:永远在训练和推理里使用同一套“编码—归一化—相似度”流程,并且把这段流程单独封装成函数,避免两端各自实现导致偏差。
5. 工程落地中的真实取舍:召回、精排与向量检索
5.1 两阶段检索是表示型模型的最佳打开方式
线上检索系统几乎不会只用双塔一路走到黑。最常见的做法是两级流水线:
全量候选(N) → 双塔召回 top100 → 交叉精排 → 最终top10召回阶段用表示型双塔,从全量候选里取top100。因为doc向量已经离线算好,线上只需要对query编码一次,再走向量索引,耗时在毫秒级。精排阶段用交叉注意力模型(交互型),对top100个候选再做更细的语义匹配打分,输出最终排序。
为什么一定还要精排?因为表示型模型把两条文本各自压缩成一个向量,最后只做一次相似度计算,细粒度的线索已经丢了不少。比如“猫坐在垫子上”和“垫子上趴着猫”这种角色位置关系,双塔很容易混淆;而交互型模型因为有token级对齐,对细微的语义差异要敏感得多。所以精度要求高的系统,两阶段基本是标配。双塔解决“找得到候选”,交叉模型解决“排得对顺序”。
5.2 向量索引的参数与召回率的关系
当候选量到百万以上,暴力计算点积就不现实了,必须上近似最近邻(ANN)索引。常用的两类索引结构:
- 基于图的索引(如HNSW结构):核心参数是M、efConstruction、efSearch。M控制图密度,efSearch越大召回越高但耗时越高。适合单机部署、时延要求高的场景。
- 基于量化的倒排索引(如IVF-PQ):先聚类定nlist,检索时看nprobe个聚类;PQ把向量拆成子空间做码本量化,降低存储和带宽。适合候选集超大、内存紧张的场景。
我的调参建议是:先跑一遍暴力检索,记录Recall@k作为黄金标准;然后调ANN参数,让ANN的召回率尽量接近暴力检索(比如99%),再考虑延迟。ANN本身有信息损失,如果一上来就调ANN,效果差的时候你分不清是模型问题还是索引问题。线上还需要关注一个指标叫“召回覆盖率”,即索引里实际能搜到多少比例的真实正样本——有些doc向量在索引聚类里分布很偏,nprobe太小会直接漏掉。
5.3 什么时候不要用表示型模型
并不是所有文本匹配任务都适合双塔。下面几种情况我会优先选交互型:
- 候选集很小,比如几百条以内,计算量完全扛得住,直接上交互模型效果更好。
- 任务对细粒度语义非常敏感,例如蕴含关系判断、需要角色对齐的句子对。
- 训练数据很少,双塔很难学到好的表示;交互型更容易从预训练权重里迁移。
- 需要非常低的延迟,但候选只有几十个,交叉模型反而更直接。
反过来,如果你面对百万级候选、要毫秒级响应、要支持离线预计算,那表示型模型基本是唯一现实的选择。做技术选型时别先问“哪个模型效果好”,先问“候选集多大”“能不能离线算”“线上延迟预算多少”。这三个问题会告诉你答案。
6. 训练和部署中踩过的坑
6.1 Pooling选错,句向量质量差一截
我第一批句向量栽在pooling上。当时直接用了BERT的pooler_output,想着官方设计肯定靠谱,结果在语义相似度验证集上分数明显偏低。原因很简单:pooler_output就是CLS token过一层线性加tanh,这个向量是为分类头设计的,不是为句向量设计的。CLS在预训练时并不承担“整句语义向量”的稳定功能。
后来改成对所有token的last_hidden_state做mean pooling,先把padding mask掉,再过一个LayerNorm和一个投影层,效果立刻提升。如果实在想保留CLS信息,可以试试“CLS拼接mean”等组合方式,但别默认pooler一定好。每个任务最好都拿验证集快速对比一下再定,这种成本很低。
6.2 温度系数和batch size究竟怎么定
温度系数的坑我踩得很深。一开始用temperature=1.0,loss下降很慢,学出来的向量区分度很低。后来想明白了:温度太高会把所有相似度都软化,正负样本难以分离。调到0.05左右,效果立竿见影。
batch size直接决定in-batch负样本数量。batch=8时,每个query只有7个负样本,模型很容易只区分“表面不同”而不是“语义不同”。我的经验是batch至少32,最好64到128。显存不够怎么办?梯度累积能让训练更稳,但它只相当于把多个小batch的梯度合起来更新,并没有增加单步的负样本数量。要真正增加负样本,需要用内存队列保存最近几个batch的向量,把它们也当成负样本,这样每个step的负样本量可以远大于单次batch。
6.3 困难负样本要一轮一轮挖
第一次训练用随机负样本,模型学到的基本是表面区分,会把“字面相似但语义不同”的样本误判成相似。后来我把BM25高分不匹配的样本加进训练,模型才开始被迫学习细微的语义差异。再往后,我用当前模型对训练集挖掘“模型觉得最像但实际不是答案”的样本,加入下一轮训练,效果又上了一个台阶。这就是所谓迭代挖负样本。
挖负样本有三点要注意。一是别全用困难负样本,要混合一些随机负样本保持训练的稳健性,否则模型容易在困难样本附近过拟合。二是挖出来的困难负样本要做清洗,防止标注错误的正样本被当成负样本送进训练集——模型觉得很像的,可能本身确实是等义句,这种情况必须过滤。三是负样本集合不要每轮全部替换,保留上一轮的一部分,避免模型反复震荡。
6.4 线下涨点,线上没涨:一致性问题的三道关
这个问题我遇到过好几次。线下指标明明涨了,线上实验却没有动静,甚至更差。排查下来通常是三个原因。
第一是分数分布漂移。训练集的query和doc分布与线上不一致,导致模型输出的相似度分数整体偏移。解决办法是采集线上真实query,重新构造验证集,再定期增量训练。
第二是度量不一致。训练用的是带温度的余弦损失,线上检索索引却配成了L2距离,排名自然会变。解决办法是归一化逻辑全局统一:训练、入库、检索三个环节都调用同一个函数,再用一套线下测试脚本覆盖。
第三是硬阈值依赖。很多人喜欢设一个“相似度大于0.6就算匹配”的阈值,但线下调好的阈值不能直接搬到线上,因为线上分布变了,分数分布也会变。本质上,排序类任务应该尽量避免依赖硬阈值,而是用排名或比例来控制召回与精度的平衡。
另外还有一个容易被忽略的坑:双塔在搜索场景做召回时,如果索引里不断增加与训练分布完全不同的新内容,向量空间的覆盖就会不足。这不是调整模型结构能解决的,只能通过定期微调和索引重建来缓解,相当于要给线上的知识库做“保鲜”。
最后分享一个我个人的体会。表示型模型的训练里,模型结构的选择其实只是及格线,真正拉开差距的是三件事:数据质量、负样本构造、训练与推理的一致性。不少人把精力花在换更大的编码器、调更好的pooling上,结果不如花两天时间整理困难负样本、统一训练推理度量。这算是我做过几个文本匹配项目之后,最想提醒你的经验。