文本表示与相似度计算全解析:从TF-IDF到Sentence-BERT实战 1. 文本表示与相似度计算到底在解决什么问题做NLP项目的人迟早会撞上一堵墙模型效果怎么调都不行回头一查发现是文本表示这一步就没做好。文本表示和相似度计算说白了就是让机器“看懂”两段文字到底在说什么、它们之间有多像。这件事听起来简单做起来坑极多。举个最常见的场景你做一个客服工单自动分类系统用户输入“我买的手机屏幕碎了怎么办”和“刚收到的电话手表显示屏裂了”人一眼就知道这俩是同一类问题但机器如果只做关键词匹配“手机”和“电话手表”、“屏幕”和“显示屏”全都对不上直接判成两个类别。这时候就需要文本表示把语义信息编码进去再用相似度计算来判断它们是不是一回事。这套东西的应用面非常广。搜索排序、推荐系统去重、问答匹配、舆情聚类、论文查重、智能客服意图识别底层都绕不开文本表示和相似度计算。适合谁看如果你已经写过一些Python调过sklearn或者transformers的API但总觉得效果差口气或者你正准备从传统方法切换到预训练模型那这篇内容就是给你写的。我会从最朴素的词袋模型一路讲到Sentence-BERT把每个阶段的核心思路、实操代码、参数选择、踩坑经验全部摊开讲。先给一个全局认知文本表示解决的是“怎么把一句话变成一串数字”相似度计算解决的是“两串数字之间怎么算距离”。前者决定了信息保留了多少后者决定了你用什么尺子去量。两件事必须配套考虑单独优化任何一个都容易白费功夫。2. 文本表示的四代技术路线与选型逻辑2.1 第一代词袋模型与TF-IDF词袋模型Bag of Words是最直观的思路把一句话里所有词扔进一个袋子里不管顺序只统计每个词出现了几次。比如“猫追老鼠”和“老鼠追猫”在词袋模型里完全一样因为词频都是{猫:1, 追:1, 老鼠:1}。这显然丢掉了语序信息但在很多分类任务里居然够用。TF-IDF在此基础上加了一个权重如果一个词在很多文档里都出现比如“的”、“是”那它就不重要如果一个词只在少数文档里出现那它可能是关键词。计算公式是TF词频 某个词在文档中出现的次数 / 文档总词数IDF逆文档频率 log(总文档数 / 包含该词的文档数 1)TF-IDF TF × IDFfrom sklearn.feature_extraction.text import TfidfVectorizer corpus [ 我买的手机屏幕碎了怎么办, 刚收到的电话手表显示屏裂了, 手机电池续航太差了, 手表表带断了能换吗 ] vectorizer TfidfVectorizer(analyzerchar, ngram_range(1,2)) X vectorizer.fit_transform(corpus) print(X.shape) # (4, N) N是字符bigram特征数 print(vectorizer.get_feature_names_out()[:20])这里我故意用了analyzerchar和ngram_range(1,2)因为中文分词本身就是一个坑。用字符级bigram可以在不做分词的情况下捕捉到“屏幕”、“显示”、“示屏”这类局部组合对短文本效果往往比先分词再TF-IDF更稳。实测下来在工单分类这种短文本场景字符bigram的F1通常比jieba分词词级TF-IDF高3到5个百分点。但TF-IDF的硬伤也很明显它完全没有语义。 “手机”和“电话”在它眼里是两个正交的维度相似度为0。而且向量维度等于词表大小动辄几万维稀疏得可怕。2.2 第二代静态词向量Word2Vec与GloVeWord2Vec的核心思想是一个词的含义由它周围的词决定。通过在大规模语料上训练一个浅层神经网络让每个词学到一个稠密向量通常100到300维语义相近的词在向量空间里距离就近。Word2Vec有两种训练方式CBOW用上下文预测中心词和Skip-gram用中心词预测上下文。Skip-gram对低频词效果更好但训练更慢。GloVe则是基于全局共现矩阵做矩阵分解思路不同但目标类似。拿到词向量之后怎么表示一句话最粗暴的做法是把所有词的向量取平均。这叫做平均词向量。它比TF-IDF强的地方在于有语义信息“手机”和“电话”的向量余弦相似度可能在0.6以上。但平均操作会把所有信息搅在一起“猫追老鼠”和“老鼠追猫”的平均向量完全一样语序信息依然丢失。import numpy as np from gensim.models import Word2Vec sentences [[手机, 屏幕, 碎], [电话, 手表, 显示, 裂]] model Word2Vec(sentences, vector_size100, window5, min_count1, sg1) def avg_vector(words, model): vecs [model.wv[w] for w in words if w in model.wv] return np.mean(vecs, axis0) if vecs else np.zeros(model.vector_size) v1 avg_vector([手机, 屏幕, 碎], model) v2 avg_vector([电话, 手表, 显示, 裂], model) cos_sim np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) print(f余弦相似度: {cos_sim:.4f})注意Word2Vec需要大量语料才能训出好向量小数据集上训出来的向量基本是噪声。实际项目中建议直接用预训练好的中文词向量在自己的语料上做微调。2.3 第三代ELMo与BERT动态词向量静态词向量的根本问题是一个词只有一个向量无法处理一词多义。“苹果手机”和“吃苹果”里的“苹果”应该是不同的向量但Word2Vec给的是同一个。ELMo用双向LSTM根据上下文动态生成每个词的向量。BERT则用Transformer编码器通过掩码语言模型Masked Language Model预训练能捕捉更深层的双向语义。BERT的输出是每个token对应一个768维base版或1024维large版的向量。但BERT做句子相似度有个坑原生BERT的[CLS]向量并不是为相似度任务设计的。如果你直接拿两个句子的[CLS]向量算余弦效果可能还不如GloVe平均。因为BERT的预训练目标是预测被mask的词不是让相似句子的[CLS]靠近。你需要用Sentence-BERTSBERT这类专门微调过的模型。2.4 第四代Sentence-BERT与对比学习SBERT的做法很巧妙用孪生网络结构把两个句子分别过同一个BERT得到各自的句向量通常是[CLS]经过一个池化层然后用余弦相似度算分再用对比损失或三元组损失去微调。这样训出来的句向量相似句子的余弦相似度会明显高于不相似的。from sentence_transformers import SentenceTransformer, util model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) sentences [ 我买的手机屏幕碎了怎么办, 刚收到的电话手表显示屏裂了, 手机电池续航太差了 ] embeddings model.encode(sentences, normalize_embeddingsTrue) cos_scores util.cos_sim(embeddings, embeddings) print(cos_scores)实测下来SBERT在中文短文本相似度任务上比直接拿BERT的[CLS]向量算余弦Spearman相关系数能高出20到30个点。这个差距是决定性的。选型逻辑总结成一张表方法维度语义能力语序训练成本适用场景TF-IDF万级稀疏无无极低关键词匹配、粗排Word2Vec平均100-300弱无中资源受限的语义粗筛BERT-[CLS]768强有高不推荐直接用于相似度SBERT384-768强有中微调语义相似度、检索、聚类3. 相似度计算的数学原理与实操选择3.1 余弦相似度为什么是默认选择余弦相似度衡量的是两个向量在方向上的夹角不关心长度。公式是cos(A,B) (A·B) / (||A|| × ||B||)取值范围从-1到11表示方向完全一致0表示正交-1表示完全相反。在文本场景下向量长度往往跟文本长度相关我们更关心语义方向而不是长短所以余弦比欧氏距离更合适。但余弦有个隐藏问题如果向量所有分量都是非负的比如TF-IDF余弦值永远在0到1之间不会出现负值。而且高维稀疏向量下任意两个向量的余弦往往都不低区分度差。这就是为什么TF-IDF做相似度时经常需要配合停用词过滤和IDF加权。3.2 欧氏距离与点积的适用边界欧氏距离算的是直线距离对向量长度敏感。如果你已经做了L2归一化欧氏距离和余弦相似度是单调等价的||A-B||² 2 - 2×cos(A,B)。所以归一化之后用哪个都行。点积相似度 ||A|| × ||B|| × cos(A,B)同时受长度和方向影响。在推荐系统里如果向量长度代表热度或置信度点积可能比余弦更合适。但在纯语义相似度任务里还是余弦更稳。import numpy as np from numpy.linalg import norm def cosine_sim(a, b): return np.dot(a, b) / (norm(a) * norm(b)) def euclidean_dist(a, b): return norm(a - b) def dot_product(a, b): return np.dot(a, b) # 归一化后验证等价性 a np.random.randn(768) b np.random.randn(768) a_norm a / norm(a) b_norm b / norm(b) print(f余弦: {cosine_sim(a_norm, b_norm):.6f}) print(f欧氏平方/2的补: {1 - euclidean_dist(a_norm, b_norm)**2/2:.6f})3.3 编辑距离与Jaccard非向量方法什么时候用不是所有场景都适合向量方法。如果你要做的是拼写纠错、DNA序列比对、或者短文本去重编辑距离Levenshtein Distance可能更直接。它计算的是把一个字符串变成另一个字符串所需的最少插入、删除、替换次数。Jaccard相似度则是看两个集合的交集除以并集。在文本去重里把每句话切成shingle连续n个字符然后算Jaccard速度极快适合大规模粗筛。def levenshtein(s1, s2): if len(s1) len(s2): return levenshtein(s2, s1) if len(s2) 0: return len(s1) prev range(len(s2) 1) for i, c1 in enumerate(s1): curr [i 1] for j, c2 in enumerate(s2): curr.append(min(prev[j1]1, curr[j]1, prev[j](c1 ! c2))) prev curr return prev[-1] def jaccard_shingle(s1, s2, k2): set1 {s1[i:ik] for i in range(len(s1)-k1)} set2 {s2[i:ik] for i in range(len(s2)-k1)} return len(set1 set2) / len(set1 | set2) if (set1 | set2) else 0 print(levenshtein(手机屏幕碎了, 手机显示屏裂了)) print(jaccard_shingle(手机屏幕碎了, 手机显示屏裂了))实操心得在大规模去重场景我通常用Jaccard做第一层粗筛阈值设0.3把候选对降到原来的5%以下再用SBERT做精排。这样比直接全量跑SBERT快20倍以上召回损失不到2%。4. 完整实操从原始文本到相似度排序4.1 数据准备与预处理假设你手头有一批客服工单每一条是一个短文本。第一步是清洗去掉HTML标签、统一全半角、去掉多余空格和特殊符号。中文还需要考虑是否分词。我的经验是如果用SBERT不需要分词直接送原始文本如果用TF-IDF字符bigram通常比词级更稳。import re def clean_text(text): text re.sub(r[^], , text) text re.sub(r\s, , text) text text.strip() return text raw_texts [ 【紧急】手机屏幕碎了怎么办, 电话手表显示屏裂了刚收到, 电池续航太差一天充三次, 表带断了能换吗 ] texts [clean_text(t) for t in raw_texts]4.2 向量化与索引构建用SBERT把每条文本编码成归一化向量然后用FAISS建索引。FAISS是Facebook开源的向量检索库支持十亿级向量的毫秒级检索。import faiss import numpy as np from sentence_transformers import SentenceTransformer model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) embeddings model.encode(texts, normalize_embeddingsTrue) dim embeddings.shape[1] index faiss.IndexFlatIP(dim) # 内积索引配合归一化向量等价于余弦 index.add(embeddings.astype(np.float32)) query 手机屏碎了怎么处理 q_vec model.encode([query], normalize_embeddingsTrue).astype(np.float32) scores, indices index.search(q_vec, k3) for score, idx in zip(scores[0], indices[0]): print(f相似度: {score:.4f} | 文本: {texts[idx]})4.3 阈值选择与效果评估相似度阈值定多少直接决定误报和漏报的平衡。我的做法是人工标注200对样本画出相似度分布找等错误率EER点。通常SBERT在中文短文本上0.75到0.85之间是一个合理起点。但不同模型、不同领域差异很大必须用自己的数据校准。评估指标用Spearman相关系数衡量排序一致性和F1衡量二分类判断。如果业务更看重不漏掉相似对阈值往低调如果更看重准确率阈值往高调。from scipy.stats import spearmanr # 假设有标注数据 human_scores [0.9, 0.8, 0.2, 0.1] model_scores [0.85, 0.78, 0.25, 0.15] corr, p_value spearmanr(human_scores, model_scores) print(fSpearman相关系数: {corr:.4f}, p值: {p_value:.4f})注意SBERT模型在领域外数据上会明显掉点。如果你的工单是医疗或法律领域通用模型可能不够需要用领域数据做微调。微调时用MultipleNegativesRankingLoss正样本对从业务日志里挖负样本用in-batch negatives通常5000到10000对就能看到明显提升。5. 常见问题与排查技巧实录5.1 相似度普遍偏高怎么办这是最常见的问题。原因通常是向量空间各向异性BERT类模型的向量并不是均匀分布在球面上而是集中在一个窄锥形区域导致任意两个向量的余弦都偏高。解决方法有三个一是用SBERT这类经过对比学习的模型二是做白化处理Whitening把向量协方差矩阵变成单位矩阵三是减掉均值向量再归一化。def whitening(embeddings): mu embeddings.mean(axis0) cov np.cov(embeddings.T) U, S, V np.linalg.svd(cov) W U np.diag(1.0 / np.sqrt(S 1e-6)) U.T return (embeddings - mu) W5.2 长文本相似度怎么处理SBERT有最大序列长度限制通常128或256个token长文本会被截断。我的做法是把长文本切成段落每段编码后取平均或者用滑动窗口编码后取最大值。更精细的做法是用层次化模型先编码句子再编码文档但工程复杂度高。实际项目中分段平均已经能拿到80%的效果。5.3 中文分词对结果影响多大如果用SBERT分词基本不影响因为模型内部有自己的tokenizer。如果用TF-IDF或Word2Vec分词影响巨大。我的建议是短文本用字符bigram长文本用jieba分词加自定义词典。自定义词典一定要加业务术语比如“屏幕总成”、“电池健康度”这类词通用分词器会切错。5.4 模型推理太慢怎么优化SBERT在CPU上编码一条文本大约10到50毫秒GPU上1到5毫秒。如果QPS要求高有三个方向一是用更小的模型MiniLM只有6层比base快3倍二是用ONNX Runtime或TensorRT加速三是把向量预计算好存FAISS线上只做检索不做编码。实测ONNX Runtime能让CPU推理速度提升2到3倍。问题排查方向解决方案相似度普遍偏高向量各向异性白化、减均值、换SBERT长文本效果差截断丢失信息分段编码取平均中文分词错误通用词典不覆盖加自定义词典或改字符级推理慢模型大、无加速换小模型、ONNX、预计算领域外掉点模型未适配领域数据微调踩过的坑有一次用SBERT做论文查重发现两篇完全不相关的论文相似度0.82。排查后发现是模型把“本文”、“研究”、“方法”这些高频词编码得太接近。后来在编码前把这些词替换成占位符相似度分布立刻正常了。这个技巧在特定领域非常管用。6. 从相似度到业务落地几个真实场景的拆解6.1 智能客服意图匹配客服场景下用户问法千奇百怪但意图是有限的。做法是把标准问法库编码成向量存FAISS用户输入编码后检索Top5如果最高分超过阈值就直接命中否则转人工。阈值用业务数据校准通常0.78到0.85。这个方案比传统规则匹配的覆盖率提升40%以上而且新问法不用改代码加一条标准问法就行。6.2 内容去重与聚类UGC平台每天产生大量重复内容。先用Jaccard做粗筛再用SBERT精排最后用层次聚类把相似内容归并。聚类时用余弦距离阈值设0.7左右。注意聚类数量不要预设用距离阈值自动决定。这个流程在百万级数据上跑单机一天能处理完。6.3 语义搜索排序传统搜索用BM25做召回但BM25只看关键词匹配。加入SBERT做语义召回后长尾query的效果提升明显。做法是BM25召回Top100SBERT对这100条重排取Top10返回。重排时可以把BM25分数和SBERT分数加权融合权重用A/B测试调。实测下来NDCG10能提升15到25个点。6.4 跨语言相似度多语言SBERT模型可以把中文和英文编码到同一空间直接算余弦。但跨语言场景下模型对文化特定表达的处理会差一些。如果业务涉及小语种建议用LaBSE这类专门的多语言模型或者用翻译API先统一到中文再编码。翻译方案虽然多一步但效果通常更可控。7. 模型微调与持续迭代的实操建议通用SBERT模型在垂直领域往往不够用。微调时数据构造是关键。正样本对可以从业务日志里挖用户点击了某个搜索结果、客服把两个工单合并了、两篇文章被同一个用户收藏了这些都是弱监督信号。负样本用in-batch negativesbatch size越大越好通常64到128。损失函数用MultipleNegativesRankingLoss它把同batch内其他样本的positive当作negative效率高。训练时用AdamW学习率2e-5到5e-5warmup 10%训练3到5个epoch。评估用验证集的Spearman相关系数早停patience设2。from sentence_transformers import losses, InputExample from torch.utils.data import DataLoader train_examples [ InputExample(texts[手机屏幕碎了, 显示屏裂了]), InputExample(texts[电池不耐用, 续航太差]), # ... 更多正样本对 ] train_dataloader DataLoader(train_examples, shuffleTrue, batch_size64) train_loss losses.MultipleNegativesRankingLoss(model) model.fit( train_objectives[(train_dataloader, train_loss)], epochs3, warmup_steps100, optimizer_params{lr: 2e-5}, output_path./finetuned-model )实操心得微调时不要冻结BERT底层全量微调效果更好但需要更多数据。如果数据少于2000对建议只微调最后两层或者用Adapter。另外训练集和测试集一定要按时间划分不能随机划分否则会高估效果。我见过太多项目随机划分后F1 0.95上线后掉到0.7。8. 向量数据库选型与工程化考量FAISS适合单机、离线、批量场景但不支持实时增删改。如果业务需要实时更新向量考虑Milvus、Qdrant或Weaviate。选型时看几个维度数据规模、QPS要求、是否需要过滤条件、运维成本。方案数据规模实时更新过滤运维复杂度FAISS亿级不支持无低Milvus十亿级支持支持中Qdrant亿级支持支持低Weaviate亿级支持支持中如果数据量在百万级以内FAISS加定期重建索引完全够用。千万级以上再考虑分布式方案。不要一上来就上重型数据库运维成本会吃掉大部分收益。向量维度方面384维的MiniLM在多数场景下和768维的base模型效果差距不到2个点但存储和计算成本减半。如果QPS压力大优先降维度而不是降精度。9. 效果监控与线上问题排查上线不是终点。需要监控几个指标相似度分布突然偏移说明数据分布变了、阈值命中率突然下降说明模型退化、人工反馈率用户点“不相似”的比例。这些指标异常时触发模型重新评估或微调。线上排查时把query和Top5结果的向量、相似度、原始文本都打日志。出问题时先看是不是预处理出了问题比如特殊符号没清干净再看是不是模型对某些表达不敏感。我遇到过一次线上事故用户输入带emoji预处理没处理导致向量完全偏移。后来在清洗步骤加了emoji过滤才解决。最后分享一个小技巧如果业务允许把用户点击行为作为弱标签每天增量微调一次模型。用对比学习的方式点击的作为正样本未点击的作为负样本。这样模型能持续适应数据分布变化效果衰减慢很多。这个方案在多个项目上验证过比定期全量重训省资源效果也更稳。