文本相似度工程:余弦相似度只是开始,不是终点
文本相似度工程余弦相似度只是开始不是终点一、同一个语义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_embeddingsFalse) emb_b self.bi_encoder.encode(text_b, normalize_embeddingsFalse) # 方向相似度 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) # 设计原因alpha0.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(axis1).mean() max_sim_b sim_matrix.max(axis0).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(NK)在万级文档上可行。 # 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(keylambda x: x[1], reverseTrue) 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% 相似带幅度感知的加权余弦保留了部分语义重心信息是低成本升级的最优解词级对齐软匹配对关键实体差异敏感适合细粒度比较交叉编码器精度最高但效率最低混合流水线粗筛精排是工程最优解升级度量方式的前提是明确知道目前方案在什么场景下失效了最后的建议是在项目中同时保留多种方案根据候选集规模和延迟预算自动切换。一个好用的相似度系统不是选对了算法是知道什么时候该用哪个算法。