短文本语义匹配这个方向,我从早期做搜索召回一直跟到现在,踩过的坑比看过的论文还多。今天聊的 SimilarityNet(也叫 SimNet)就是一套专门解决「两段短文本到底是不是一个意思」的框架,核心用的是 bow_pairwise 模式。它能干的事很具体:给你两句话,输出一个相似度分数,判断它们语义上是否等价。适合谁看?做搜索、推荐、智能客服、问答系统、去重、意图识别的同学,尤其是那些手里没有海量标注数据、又不想一上来就硬怼大模型的团队。这套框架的价值在于,它把「文本匹配」这件事拆得足够清楚,从输入表示、网络结构到损失函数,每一层你都能自己动手改,而不是调个黑盒 API 就完事。
1. 短文本匹配到底难在哪,为什么值得单独搞一套框架
1.1 短文本的「短」本身就是最大的敌人
长文本匹配相对好办,因为词多、上下文足,模型能抓的信号多。短文本不一样,一句话可能就五六个词,比如「怎么退款」和「如何申请退货」,字面重叠只有一个「退」字,但语义几乎一致。你要是用传统的编辑距离或者 Jaccard 相似度,这俩分数低得可怜,直接判成不相关。这就是短文本匹配的核心痛点:字面信号稀疏,语义信号才是关键。
我早期做过一个客服工单去重的项目,工单标题平均长度 8 个字,用 TF-IDF 加余弦相似度,召回率惨不忍睹。后来换成基于表示的语义匹配,效果才起来。所以短文本匹配不是「文本匹配的简化版」,而是一个独立问题,它的难点集中在三点:一是特征稀疏,二是一词多义和多元一词,三是缺乏上下文。SimilarityNet 这套框架的设计,基本就是围绕这三点在做文章。
1.2 为什么是 pairwise,而不是 pointwise 或 listwise
这里得把三种模式说清楚,因为选错模式,后面全白搭。
- Pointwise:把每个样本单独打分,比如给一句话打个「质量分」。它不关心样本之间的关系,适合分类或回归任务。
- Pairwise:一次吃一对样本 (query, doc),直接学「这两个谁更相关」或者「这两个是不是同一个意思」。它天然适合相似度任务,因为相似度本质就是两个对象之间的关系。
- Listwise:一次吃一个列表,学整个排序。适合排序场景,但训练成本高,对短文本匹配这种「两两判断」的需求有点杀鸡用牛刀。
SimilarityNet 选 pairwise,逻辑很直接:短文本匹配的任务定义就是「给一对文本判关系」,pairwise 和任务形态完全对齐。而且 pairwise 有个隐藏好处——它可以做对称和非对称两种建模。对称就是「A 和 B 相似」等于「B 和 A 相似」,非对称就是 query 和 doc 角色不同,比如搜索里 query 短、doc 长,两者不能用同一个编码器硬套。bow_pairwise 模式里的 bow 指的是 bag-of-words 的输入表示思路,pairwise 指的是成对建模,合起来就是「用词袋式表示做成对匹配」。
提示:如果你的任务是「给单个文本打标签」,别用 pairwise,那是 pointwise 的活。模式选错,loss 怎么调都救不回来。
1.3 这套框架适合什么样的团队和场景
我总结下来,SimilarityNet 这类框架最适合三类场景。第一类是数据量中等、标注成本高的业务,比如法律条文匹配、医疗问句归一,你不可能标几十万条。第二类是需要可解释性和可干预性的场景,黑盒大模型给个分数你没法解释,但 SimilarityNet 的每一层你都能拆开看,哪层出问题一目了然。第三类是延迟敏感的线上服务,短文本匹配往往在召回阶段,QPS 高,模型不能太重,bow_pairwise 这种结构参数量可控,推理快。
反过来说,如果你手里有千万级标注对,且追求极致效果,那直接上预训练大模型微调可能更省事。框架不是万能的,选它是因为它在「效果、成本、可控性」之间给了一个不错的平衡点。
2. bow_pairwise 模式的原理拆解与设计考量
2.1 bow 表示:为什么老掉牙的词袋还能用
一听到 bag-of-words,很多人第一反应是「太老了,过时了」。但在短文本匹配里,bow 表示有它不可替代的位置。词袋的核心是忽略词序,只看词的出现情况,用向量表示文本。对于短文本,词序信息本来就少,很多时候词序丢了也不影响语义判断,比如「苹果手机维修」和「维修苹果手机」,词袋表示几乎一样,语义也确实一样。
bow_pairwise 里的 bow 不是简单的 one-hot 词袋,实践中通常会做几层加工。第一层是分词和归一化,把「退款」「退货」「退钱」归到相近的语义单元。第二层是加权,常用 TF-IDF 或者 BM25 权重,让高频无意义的词降权。第三层是降维或映射,把稀疏的高维词袋向量映射到低维稠密空间,这一步是 bow 和神经网络结合的关键。
我实测下来,纯 bow 加余弦相似度在短文本上大概能到 60 分(满分 100),加上神经网络映射后能到 80 分以上。这个提升主要来自「把字面表示变成语义表示」。所以 bow 不是终点,而是起点,它的价值在于提供了一个稳定、可解释、易调试的输入层。
2.2 pairwise 建模:对称与非对称的取舍
pairwise 建模有两种典型结构,选哪种取决于你的业务。
对称结构(Siamese 双塔):两个文本走同一个编码器,共享参数,最后算两个向量的距离或相似度。优点是参数少、训练快、可以预先算好每个文本的向量做索引,线上检索快。缺点是它假设两个文本角色对等,如果 query 和 doc 长度、分布差异大,效果会打折。
非对称结构(交互式):两个文本在编码过程中就有交互,比如用 attention 让 query 的每个词去看 doc 的每个词。优点是能捕捉细粒度交互,效果好。缺点是没法预先算向量,线上必须两两实时计算,延迟高。
bow_pairwise 模式通常默认走对称双塔,因为 bow 表示本身就是「各自独立编码」的思路,和双塔天然契合。但如果你的场景是「短 query 匹配长 doc」,我建议在双塔基础上加一层轻量交互,比如在顶层做一次 attention pooling,兼顾效果和速度。
| 结构类型 | 参数量 | 线上延迟 | 效果上限 | 适用场景 |
|---|---|---|---|---|
| 对称双塔 | 低 | 低(可预计算) | 中 | 召回、去重、大规模检索 |
| 非对称交互 | 高 | 高(实时计算) | 高 | 精排、小规模高精度匹配 |
| 双塔+轻交互 | 中 | 中 | 中高 | 短 query 匹配长 doc |
2.3 损失函数:pairwise 任务该用什么 loss
pairwise 任务的 loss 选择直接决定模型学什么。常见的有三种。
对比损失(Contrastive Loss):输入一对文本,如果标签是相似,就拉近它们的向量距离;如果不相似,就推远,但推远有个 margin,超过 margin 就不再惩罚。公式大概是L = y * d^2 + (1-y) * max(0, margin - d)^2。它的好处是直观,坏处是 margin 难调,太小了区分不开,太大了训练不稳。
三元组损失(Triplet Loss):输入三个文本,一个锚点、一个正例、一个负例,要求锚点到正例的距离比到负例的距离小至少一个 margin。它比对比损失多了一个「相对」约束,效果通常更好,但三元组的构造很讲究,负例选得不好,模型学不到东西。
交叉熵损失(基于分类):把 pairwise 当成二分类,相似为 1,不相似为 0,最后接一个 sigmoid 输出概率。这是工程上最常用的,因为稳定、好调、和框架兼容性好。我大部分项目都用这个,配合 label smoothing 效果更稳。
注意:loss 的选择要和你的评估指标对齐。如果你线上看的是 AUC,那用交叉熵最直接;如果看的是召回率@K,那 triplet 可能更合适。
3. SimilarityNet 框架的核心模块与实操实现
3.1 整体架构:从输入到分数的完整链路
SimilarityNet 的链路可以拆成五段:输入层 → 表示层 → 编码层 → 交互层 → 输出层。我逐个说。
输入层负责把原始文本变成 bow 向量或 token 序列。表示层把稀疏 bow 映射成稠密向量,常用做法是 embedding lookup 加 pooling,或者直接用一个全连接层做降维。编码层是核心,用 CNN、RNN 或 Transformer 提取语义特征。交互层负责两个文本之间的信息交换,对称结构里这层可以省略或很轻。输出层把交互后的表示映射成一个相似度分数。
这个架构的好处是每一层都可以替换。你数据少,编码层就用简单的 CNN;你数据多,就换 Transformer。你延迟敏感,交互层就砍掉;你追求效果,交互层就加重。框架给的是骨架,肉你自己填。
3.2 表示层实操:bow 向量怎么构造和降维
表示层是 bow_pairwise 的特色,我详细说下实操。假设你有一批短文本,第一步是分词。中文分词我用 jieba 或者 LTP,英文直接空格加小写化。第二步是构建词表,统计词频,去掉停用词和超低频词。第三步是构造 bow 向量,维度等于词表大小,每个位置是该词的权重。
权重计算我一般用 TF-IDF,公式是tf * idf,其中idf = log(N / (df + 1)) + 1,N 是文档总数,df 是包含该词的文档数。加 1 是防止除零。这个权重能让「的」「了」这种高频词权重趋近于 0,让「退款」这种有区分度的词权重高。
降维我用两种方案。方案一是SVD 分解,把高维稀疏向量压到 128 或 256 维,速度快,适合冷启动。方案二是全连接层,把 bow 向量直接喂给一个Linear(vocab_size, 256)加激活函数,让模型自己学映射。方案二效果更好,但需要更多数据。我一般先用 SVD 跑通,再换全连接。
import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.decomposition import TruncatedSVD # 假设 corpus 是短文本列表 vectorizer = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b", max_features=20000) bow_matrix = vectorizer.fit_transform(corpus) # SVD 降维到 256 维 svd = TruncatedSVD(n_components=256, random_state=42) dense_matrix = svd.fit_transform(bow_matrix) print("原始维度:", bow_matrix.shape[1]) print("降维后维度:", dense_matrix.shape[1]) print("解释方差比:", svd.explained_variance_ratio_.sum())这段代码里max_features=20000是控制词表大小,太大容易过拟合,太小丢信息。n_components=256是经验值,短文本一般 128 到 256 够用。explained_variance_ratio_能告诉你降维保留了多少信息,低于 0.7 就得考虑加维度。
3.3 编码层实操:CNN、RNN 还是 Transformer
编码层的选择看数据量和延迟要求。我按场景给建议。
数据量小于 1 万对:用 CNN。卷积核窗口设 2、3、4,分别抓二元、三元、四元组合,每个窗口 128 个核,最后 max pooling。CNN 参数少、训练快、不容易过拟合,短文本上效果不差。
数据量 1 万到 10 万对:用 BiLSTM 或 BiGRU。它能抓序列依赖,对「不」「没」这种否定词敏感。隐藏层设 128 或 256,双向拼接后做 pooling。
数据量大于 10 万对:上 Transformer。但别从零训,用预训练模型微调,比如把预训练模型的输出接一个 pooling 层。这时候 bow 表示可以作为额外特征拼进去,形成「预训练语义 + 词袋字面」的混合表示,效果通常比单用预训练好。
import torch import torch.nn as nn class CNNEncoder(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k) for k in [2, 3, 4] ]) self.dropout = nn.Dropout(0.3) def forward(self, x): # x: [batch, seq_len] emb = self.embedding(x) # [batch, seq_len, embed_dim] emb = emb.transpose(1, 2) # [batch, embed_dim, seq_len] pooled = [] for conv in self.convs: c = torch.relu(conv(emb)) # [batch, num_filters, seq_len-k+1] p = torch.max(c, dim=2)[0] # [batch, num_filters] pooled.append(p) out = torch.cat(pooled, dim=1) # [batch, num_filters*3] return self.dropout(out)这段代码里padding_idx=0让 padding 不参与梯度,num_filters=128配合三个窗口,输出维度是 384。dropout 设 0.3 是防过拟合,短文本数据少,dropout 很关键。
3.4 交互层与输出层:怎么算相似度分数
对称双塔里,两个文本各自编码成向量 u 和 v,然后算相似度。常见算法有三种。
余弦相似度:cos(u, v) = u·v / (|u| * |v|),范围 -1 到 1,最常用。它只看方向不看长度,适合文本。
欧氏距离:d = |u - v|,距离越小越相似。它对向量长度敏感,需要先归一化。
双线性交互:score = u^T W v,W 是可学习矩阵。它比余弦更灵活,能学出「哪些维度该匹配」,但参数多,数据少时容易过拟合。
我一般用余弦,简单稳定。输出层接一个 sigmoid 把分数压到 0 到 1,配合交叉熵 loss。如果要做排序,就用分数直接排,不接 sigmoid。
class SimilarityNet(nn.Module): def __init__(self, encoder): super().__init__() self.encoder = encoder self.fc = nn.Sequential( nn.Linear(384 * 2, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 1) ) def forward(self, x1, x2): u = self.encoder(x1) v = self.encoder(x2) # 拼接多种交互特征 feat = torch.cat([u, v, torch.abs(u - v), u * v], dim=1) score = self.fc(feat) return torch.sigmoid(score)这里我把u、v、|u-v|、u*v四种特征拼起来,这是工程上很实用的 trick。|u-v|抓差异,u*v抓共性,比单用余弦信息量大。实测这个改动能涨 2 到 3 个点。
4. 训练流程、参数调优与线上部署
4.1 数据构造:正负样本怎么来
pairwise 训练最头疼的是负样本。正样本好办,同义句、点击日志、人工标注都行。负样本有三种构造法。
随机负采样:从语料里随机抽一句当负例。简单,但太容易,模型学不到细粒度区分。我一般只在前几个 epoch 用。
BM25 负采样:用 BM25 召回一批「字面相似但语义不同」的句子当负例。这种负例难,模型能学到「字面像但意思不同」的区分能力。这是效果最好的方案。
在线负采样:每个 batch 里,用当前模型给其他样本打分,挑分数高的当负例。这是「难负例挖掘」,效果最好但实现复杂,训练也慢。
我的经验是:先用随机负例热身,再用 BM25 负例主训,最后用在线负例精调。正负比控制在 1:2 到 1:4 之间,负例太多会让模型偏向判不相似。
4.2 关键参数与调优经验
我把常用参数和我的经验值整理成表。
| 参数 | 常用范围 | 我的经验值 | 说明 |
|---|---|---|---|
| 学习率 | 1e-4 ~ 1e-3 | 3e-4 | 配合 warmup 更稳 |
| batch size | 32 ~ 256 | 64 | 太小不稳,太大泛化差 |
| embedding 维度 | 64 ~ 256 | 128 | 短文本 128 够用 |
| dropout | 0.1 ~ 0.5 | 0.3 | 数据少就调大 |
| margin(对比损失) | 0.5 ~ 2.0 | 1.0 | 归一化后 1.0 合适 |
| 训练轮数 | 5 ~ 30 | 10 | 看验证集早停 |
学习率我用 3e-4 配 Adam,加 10% 步数的 warmup。batch size 64 是平衡点,显存够可以上 128。早停看验证集 AUC,连续 3 轮不涨就停。
实操心得:短文本匹配最容易过拟合,因为样本短、特征少。我的做法是「强正则 + 早停 + 数据增强」。数据增强可以用同义词替换、随机删除词、回译,能涨 1 到 2 个点。
4.3 线上部署:延迟与吞吐的平衡
线上部署有两个关键点。第一是向量预计算。对称双塔的最大优势是 doc 侧向量可以离线算好存起来,线上只算 query 向量,然后做向量检索。这样延迟能压到毫秒级。第二是量化与裁剪。把 float32 量化成 int8,模型体积减 4 倍,推理快 2 到 3 倍,效果掉不到 1 个点。
如果用了交互层,就没法预计算,只能实时算。这时候要控制候选集大小,一般先双塔召回 top100,再用交互模型精排 top100,两级架构兼顾速度和效果。
# 离线预计算 doc 向量示例 def build_doc_index(model, doc_loader, device): model.eval() index = [] with torch.no_grad(): for batch in doc_loader: vecs = model.encoder(batch.to(device)) vecs = torch.nn.functional.normalize(vecs, dim=1) index.append(vecs.cpu().numpy()) return np.concatenate(index, axis=0)归一化后存向量,线上算余弦就变成点积,快很多。这个细节很多人忽略,但线上 QPS 高的时候,点积比余弦省一次除法。
5. 常见问题排查与避坑实录
5.1 模型不收敛或效果抖动
这是最常见的。原因通常有三个。一是学习率太大,loss 震荡不下降,降到 1e-4 试试。二是负例太简单,模型学不到东西,换成 BM25 负例。三是数据里有脏样本,比如标错的相似对,这种要人工清洗,模型救不了。
我遇到过一次 loss 一直卡在 0.69(二分类的随机水平),查了半天发现是标签全反了,相似标成 0,不相似标成 1。这种低级错误一定要先查。
5.2 线上效果比离线差很多
这个叫「离线在线不一致」,原因有几个。一是特征不一致,离线用了某些线上拿不到的特征。二是分布不一致,训练数据是历史数据,线上是新数据,分布漂移了。三是评估口径不一致,离线看 AUC,线上看点击率,两者不直接对应。
我的做法是:上线前做影子测试,让新模型和旧模型同时跑,对比打分分布。如果分布差异大,说明有问题。另外定期用线上数据回流重新训练,保持分布对齐。
5.3 短文本特有的坑
短文本有几个特有的坑。一是 OOV(未登录词),新词、网络热词模型没见过,bow 表示直接失效。解法是用 subword 或者字符级表示兜底。二是一词多义,「苹果」可能是水果也可能是品牌,bow 表示区分不了,需要上下文或外部知识。三是长度差异大,query 三个字,doc 三十个字,双塔编码后向量尺度不一致,需要做长度归一化或者用非对称结构。
| 问题 | 现象 | 排查思路 | 解决方案 |
|---|---|---|---|
| 不收敛 | loss 震荡或卡住 | 查学习率、标签、数据 | 降 lr、清洗数据 |
| 过拟合 | 训练涨验证跌 | 看训练验证曲线 | 加 dropout、早停、增强 |
| OOV | 新词效果差 | 查词表覆盖率 | subword、字符级兜底 |
| 离线在线不一致 | 线上指标差 | 对比打分分布 | 影子测试、数据回流 |
| 长尾效果差 | 低频类目差 | 分桶看指标 | 重采样、focal loss |
避坑技巧:训练前一定要做「标签一致性检查」,随机抽 100 对人工复核,错误率超过 5% 就先清洗再训。这个步骤能省你后面几天的调参时间。
6. 框架的扩展方向与个人实践体会
SimilarityNet 这套骨架搭好之后,扩展空间很大。往效果方向走,可以接预训练模型,把 bow 表示和预训练表示做融合,我试过在编码层同时喂词袋特征和 token 序列,用 gate 控制两者权重,效果比单用好。往速度方向走,可以把双塔换成 hash embedding 或者乘积量化,把向量检索做到亿级。往多模态方向走,可以把文本塔换成文本加图像的联合编码,做跨模态匹配。
我在实际项目里最大的体会是:短文本匹配的效果,七分靠数据,两分靠结构,一分靠调参。很多人一上来就纠结网络结构,其实把负例构造好、把脏数据清干净,比换十个模型都管用。另外别迷信大模型,在短文本这种信号稀疏的场景,一个精心设计的轻量模型配上好数据,往往能干翻一个没调好的大模型。
最后分享一个小技巧:评估的时候别只看整体 AUC,要分桶看。按 query 长度分桶、按类目分桶、按频率分桶,你会发现模型在某些桶上特别好,某些桶上特别差。针对差的桶做定向优化,比全局调参效率高得多。这个习惯我坚持了好几年,每次都能挖出意想不到的问题。