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 × IDF
from sklearn.feature_extraction.text import TfidfVectorizer corpus = [ "我买的手机屏幕碎了怎么办", "刚收到的电话手表显示屏裂了", "手机电池续航太差了", "手表表带断了能换吗" ] vectorizer = TfidfVectorizer(analyzer='char', ngram_range=(1,2)) X = vectorizer.fit_transform(corpus) print(X.shape) # (4, N) N是字符bigram特征数 print(vectorizer.get_feature_names_out()[:20])这里我故意用了analyzer='char'和ngram_range=(1,2),因为中文分词本身就是一个坑。用字符级bigram可以在不做分词的情况下捕捉到“屏幕”、“显示”、“示屏”这类局部组合,对短文本效果往往比先分词再TF-IDF更稳。实测下来,在工单分类这种短文本场景,字符bigram的F1通常比jieba分词+词级TF-IDF高3到5个百分点。
但TF-IDF的硬伤也很明显:它完全没有语义。 “手机”和“电话”在它眼里是两个正交的维度,相似度为0。而且向量维度等于词表大小,动辄几万维,稀疏得可怕。
2.2 第二代:静态词向量Word2Vec与GloVe
Word2Vec的核心思想是:一个词的含义由它周围的词决定。通过在大规模语料上训练一个浅层神经网络,让每个词学到一个稠密向量(通常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_size=100, window=5, min_count=1, sg=1) def avg_vector(words, model): vecs = [model.wv[w] for w in words if w in model.wv] return np.mean(vecs, axis=0) 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-BERT(SBERT)这类专门微调过的模型。
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_embeddings=True) cos_scores = util.cos_sim(embeddings, embeddings) print(cos_scores)实测下来,SBERT在中文短文本相似度任务上,比直接拿BERT的[CLS]向量算余弦,Spearman相关系数能高出20到30个点。这个差距是决定性的。
选型逻辑总结成一张表:
| 方法 | 维度 | 语义能力 | 语序 | 训练成本 | 适用场景 |
|---|---|---|---|---|---|
| TF-IDF | 万级稀疏 | 无 | 无 | 极低 | 关键词匹配、粗排 |
| Word2Vec平均 | 100-300 | 弱 | 无 | 中 | 资源受限的语义粗筛 |
| BERT-[CLS] | 768 | 强 | 有 | 高 | 不推荐直接用于相似度 |
| SBERT | 384-768 | 强 | 有 | 中(微调) | 语义相似度、检索、聚类 |
3. 相似度计算的数学原理与实操选择
3.1 余弦相似度为什么是默认选择
余弦相似度衡量的是两个向量在方向上的夹角,不关心长度。公式是:
cos(A,B) = (A·B) / (||A|| × ||B||)
取值范围从-1到1,1表示方向完全一致,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[j+1]+1, curr[j]+1, prev[j]+(c1 != c2))) prev = curr return prev[-1] def jaccard_shingle(s1, s2, k=2): set1 = {s1[i:i+k] for i in range(len(s1)-k+1)} set2 = {s2[i:i+k] for i in range(len(s2)-k+1)} 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_embeddings=True) dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) # 内积索引,配合归一化向量等价于余弦 index.add(embeddings.astype(np.float32)) query = "手机屏碎了怎么处理" q_vec = model.encode([query], normalize_embeddings=True).astype(np.float32) scores, indices = index.search(q_vec, k=3) 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(f"Spearman相关系数: {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(axis=0) 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召回Top100,SBERT对这100条重排,取Top10返回。重排时可以把BM25分数和SBERT分数加权融合,权重用A/B测试调。实测下来,NDCG@10能提升15到25个点。
6.4 跨语言相似度
多语言SBERT模型可以把中文和英文编码到同一空间,直接算余弦。但跨语言场景下,模型对文化特定表达的处理会差一些。如果业务涉及小语种,建议用LaBSE这类专门的多语言模型,或者用翻译API先统一到中文再编码。翻译方案虽然多一步,但效果通常更可控。
7. 模型微调与持续迭代的实操建议
通用SBERT模型在垂直领域往往不够用。微调时,数据构造是关键。正样本对可以从业务日志里挖:用户点击了某个搜索结果、客服把两个工单合并了、两篇文章被同一个用户收藏了,这些都是弱监督信号。负样本用in-batch negatives,batch size越大越好,通常64到128。
损失函数用MultipleNegativesRankingLoss,它把同batch内其他样本的positive当作negative,效率高。训练时用AdamW,学习率2e-5到5e-5,warmup 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, shuffle=True, batch_size=64) train_loss = losses.MultipleNegativesRankingLoss(model) model.fit( train_objectives=[(train_dataloader, train_loss)], epochs=3, warmup_steps=100, 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过滤才解决。
最后分享一个小技巧:如果业务允许,把用户点击行为作为弱标签,每天增量微调一次模型。用对比学习的方式,点击的作为正样本,未点击的作为负样本。这样模型能持续适应数据分布变化,效果衰减慢很多。这个方案在多个项目上验证过,比定期全量重训省资源,效果也更稳。