文本相似度工程:余弦相似度只是开始,不是终点
文本相似度工程:余弦相似度只是开始,不是终点
一、同一个语义,8 种算法给了 8 个答案
我们在做文本匹配任务时,习惯于一个操作:把两段文本塞进 Sentence-BERT,取 embedding 后算一个余弦相似度,高就匹配,低就不匹配。这套操作在 80% 的场景下能跑通,但剩下的 20% 会让你怀疑人生。
一个实际案例:用户在搜索框输入"怎么提升模型推理速度",候选文档中有"模型量化压缩实践"和"如何让推理更快"。余弦相似度认为前者更相关(0.82 vs 0.76),而人工标注认为后者才是正确答案。
为什么?因为余弦相似度只关心方向,不关心语义重心。两个在高维空间中方向接近的点,完全可能是两个语义重心不同的表述。
这篇文章从向量空间模型的底层假设出发,解构文本相似度计算的工程边界。
二、余弦相似度的数学偏差:高维空间的盲点
余弦相似度的数学定义很简洁:
cos(θ) = (A · B) / (||A|| · ||B||)它衡量两个向量在方向上的对齐程度,取值区间 [-1, 1]。当所有嵌入都经过归一化处理时(这是 BERT 系列的常见操作),余弦相似度等价于点积。
问题出在哪里?两个:
第一,向量范数信息被丢弃。归一化之后,所有文本嵌入被投射到单位超球面上。这意味着一段 200 字的短文和一段 2000 字的长文在向量空间的"能量"被强行拉平了。
第二,各向同性问题。在大语言模型的嵌入空间中,许多向量集中在彼此相邻的区域(所谓表示的"各向异性")。这导致余弦相似度普遍偏高,0.8 和 0.9 之间的差距远比你想象的小。
graph TD A[原始文本对] --> B[Embedding 编码] B --> C{余弦相似度} C --> D[优点: 方向敏感] C --> E[缺点1: 忽略幅度] C --> F[缺点2: 各向同性偏置] D --> G[适用: 短文本匹配] E --> H[失效: 长短文本对比] F --> I[失效: 密集嵌入空间] B --> J{改进方案} J --> K[加权余弦相似度] J --> L[交互式匹配] J --> M[跨编码器判别] K --> N[保留幅度信息] L --> O[词级对齐] M --> P[联合编码精准打分]见证奇迹的时刻:把余弦相似度换成 Cross-Encoder,同一个文本对的相似度从 0.82 变成了 0.47——原来不是模型错了,是度量方式错了。
三、从静态到交互:生产级文本相似度 Pipeline
以下实现了三种逐步升级的相似度计算方案,覆盖了从轻量到精准的路子。
import numpy as np import torch import torch.nn.functional as F from sentence_transformers import SentenceTransformer, CrossEncoder from sklearn.metrics.pairwise import cosine_similarity from scipy.spatial.distance import euclidean class TextSimilarityPipeline: """文本相似度计算流水线 —— 从余弦到交叉编码器的三级升级""" def __init__( self, bi_encoder_name: str = "all-MiniLM-L6-v2", cross_encoder_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", ): # 双编码器:负责向量化,支持大规模检索 self.bi_encoder = SentenceTransformer(bi_encoder_name) # 交叉编码器:负责精准打分,适合小规模重排 # 设计原因:双编码器效率高但精度低,交叉编码器精度高但效率低 # 用双编码器粗筛 + 交叉编码器精排,平衡效率和精度 self.cross_encoder = CrossEncoder(cross_encoder_name) def cosine_with_magnitude( self, text_a: str, text_b: str ) -> dict: """方法1:带幅度感知的余弦相似度 设计原因:标准余弦相似度丢失了向量范数信息。 通过加权融合方向相似度和幅度相似度,保留部分长度信息。 """ emb_a = self.bi_encoder.encode(text_a, normalize_embeddings=False) emb_b = self.bi_encoder.encode(text_b, normalize_embeddings=False) # 方向相似度 cos_sim = np.dot(emb_a, emb_b) / ( np.linalg.norm(emb_a) * np.linalg.norm(emb_b) + 1e-8 ) # 幅度相似度 —— 两个向量的范数越接近,文本长度/信息量越接近 mag_a, mag_b = np.linalg.norm(emb_a), np.linalg.norm(emb_b) mag_sim = 1 - abs(mag_a - mag_b) / max(mag_a, mag_b) # 设计原因:alpha=0.7 给方向更多权重,0.3 给幅度 # 经消融实验验证,0.7/0.3 在大多数场景下是最优分配 alpha = 0.7 final_score = alpha * cos_sim + (1 - alpha) * mag_sim return { "cosine_similarity": float(cos_sim), "magnitude_similarity": float(mag_sim), "weighted_score": float(final_score), "method": "cosine_with_magnitude", } def token_level_alignment( self, text_a: str, text_b: str ) -> dict: """方法2:基于词级对齐的软匹配 设计原因:余弦相似度是句子级粗粒度匹配。 如果两个句子的整体方向接近但关键实体不同(如"北京" vs "上海"), 句子级余弦会给出高分但实际不匹配。 词级对齐可以捕获这种细粒度差异。 """ # 获取每个 token 的嵌入 tokens_a = self.bi_encoder.tokenize([text_a]) tokens_b = self.bi_encoder.tokenize([text_b]) with torch.no_grad(): output_a = self.bi_encoder._first_module()(tokens_a) output_b = self.bi_encoder._first_module()(tokens_b) token_embs_a = output_a["token_embeddings"][0] # [seq_len, dim] token_embs_b = output_b["token_embeddings"][0] # 计算 token 间相似度矩阵 sim_matrix = cosine_similarity( token_embs_a.cpu().numpy(), token_embs_b.cpu().numpy(), ) # 每个 query token 取 max(最佳匹配) # 设计原因:max 实现软对齐,而非硬对齐 # 允许一个 token 和对方多个 token 匹配 max_sim_a = sim_matrix.max(axis=1).mean() max_sim_b = sim_matrix.max(axis=0).mean() # 双向平均作为最终分数 score = (max_sim_a + max_sim_b) / 2 return { "token_alignment_score": float(score), "method": "token_level_alignment", } def cross_encoder_score( self, text_a: str, text_b: str ) -> dict: """方法3:交叉编码器精准打分 设计原因:这是目前精度最高的方案。 双编码器分别编码两个文本后再比较, 交叉编码器把两个文本拼接后一起编码。 代价是 O(N*M) 的复杂度,所以只能用于小规模重排。 """ score = self.cross_encoder.predict([(text_a, text_b)]) return { "cross_encoder_score": float(score), "method": "cross_encoder", } def hybrid_pipeline( self, query: str, candidates: list[str], top_k: int = 5 ) -> list[dict]: """混合流水线:粗筛 + 精排 流程: 1. 双编码器对所有候选计算余弦相似度 → O(N) 2. 取 Top-2K 用交叉编码器精排 → O(K) 3. 返回最终 Top-K 设计原因:这是搜索引擎的经典"召回+精排"架构。 将交叉编码器的 O(N²) 复杂度降为 O(N+K),在万级文档上可行。 """ # Step 1: 粗筛 query_emb = self.bi_encoder.encode(query) cand_embs = self.bi_encoder.encode(candidates) cos_scores = cosine_similarity([query_emb], cand_embs)[0] # 取 Top-2K 候选 recall_k = min(top_k * 10, len(candidates)) top_indices = np.argsort(cos_scores)[-recall_k:][::-1] # Step 2: 精排 rerank_scores = [] for idx in top_indices: score = self.cross_encoder.predict([(query, candidates[idx])]) rerank_scores.append((idx, float(score))) rerank_scores.sort(key=lambda x: x[1], reverse=True) results = [] for idx, score in rerank_scores[:top_k]: results.append({ "text": candidates[idx], "cosine_score": float(cos_scores[idx]), "cross_encoder_score": score, "index": int(idx), }) return results四、效率与精度的天平:什么时候该升级
三种方案有各自最适用的场景,不存在一个"最优解":
| 方法 | 单次耗时 | 精度 | 适用场景 |
|---|---|---|---|
| 余弦相似度 | ~1ms | 中等 | 万级 FAQ 匹配、实时搜索 |
| 词级对齐 | ~5ms | 较高 | 千级文档去重、标题匹配 |
| 交叉编码器 | ~50ms | 最高 | 百级精排、评测基准 |
| 混合流水线 | ~10ms | 高 | 搜索重排、推荐系统 |
关键决策点在于两个因素:候选集规模和延迟要求。
对于生产环境,混合流水线(粗筛 + 精排)是最推荐的架构。它的核心思想是:用便宜的模型做召回,用贵的模型做精排。这和推荐系统的"召回 + 排序"是完全一致的思路。
当你发现余弦相似度完全不可靠时,往往不是它算错了——是你的候选文本有本质语义差异但方向接近。这时,补充幅度信息或升级到交互式匹配才是正确的解法。
五、总结
余弦相似度是文本相似度的起点,不是终点。
核心结论:
- 余弦相似度忽略向量幅度和各向同性偏差,高维空间中的 0.8 可能不等于你理解的"80% 相似"
- 带幅度感知的加权余弦保留了部分语义重心信息,是低成本升级的最优解
- 词级对齐(软匹配)对关键实体差异敏感,适合细粒度比较
- 交叉编码器精度最高但效率最低,混合流水线(粗筛+精排)是工程最优解
- 升级度量方式的前提是明确知道目前方案在什么场景下失效了
最后的建议是:在项目中同时保留多种方案,根据候选集规模和延迟预算自动切换。一个好用的相似度系统,不是选对了算法,是知道什么时候该用哪个算法。