文本向量化与语义检索全解析:从TF-IDF到Sentence-BERT实战

发布时间:2026/10/13 20:46:40
文本向量化与语义检索全解析:从TF-IDF到Sentence-BERT实战
从第十二期聊完文本分类、第十三期聊完序列标注之后有朋友私下问我现在大模型时代还值得花时间学 Word2Vec、TF-IDF 这些老方法吗我的回答一直是值得但要看你会不会用。第十四期我打算换个角度不推某个新模型而是把自然语言处理里一个非常基础、又非常容易被忽视的能力单独拎出来讲清楚——文本向量化与语义检索。这期内容适合谁适合已经会写 Python、知道分词和词频统计是怎么回事但还没系统地把句子变成向量、并用相似度做搜索/去重/匹配的读者。我会用一整套可复现的代码对比三条技术路线再搭一个轻量级的语义检索系统。看完之后你会发现很多所谓的智能搜索问题本质上都是向量检索问题而向量检索的难点从来不在算法多高深而在细节。1. 为什么这期要写文本向量化与语义检索先说我遇到的实际问题。之前给某公司做客服问答知识库检索关键词搜索怎么调都不对劲用户问发票抬头怎么改知识库里明明有修改发票信息但关键词匹配只认发票和改结果把一堆不相关的工单也召回上来。后来我把语料全部向量化改用相似度检索同样的问题Top1 直接命中。这个经历让我彻底认同一件事自然语言处理落地时语义相似度比关键词命中更接近用户的真实意图。语义检索要解决的核心矛盾是写法不同意思相同。传统关键词方案把文本当作字符序列来处理所以车和汽车是两个词卖得怎么样和销量如何也是两回事。向量化方案则把每句话映射到一个高维空间里的坐标点意思相近的句子坐标距离就近。一旦有了这个坐标搜索、聚类、去重、推荐都能用同一套数学工具来做。这期的整体思路分四步先讲清楚文本向量的三条技术路线分别是什么、各自解决什么问题然后直接上代码用一个真实可运行的小例子把三种向量都算出来接着讨论相似度度量怎么选——这步很多人会忽略但它对结果影响极大最后把它们组装成一个语义检索系统并整理我在实践中踩过的细节坑。整个过程中你不需要配置昂贵的 GPU一台普通笔记本就能跑完所有代码。2. 文本向量化的三条技术路线怎么选2.1 词频统计路线TF-IDF简单但绝不能丢的基线TF-IDF 是很多 NLP 入门者第一个学的文本表示方法全称词频-逆文档频率。它的思想很朴素一个词在文档里出现次数越多越重要但在整个语料里出现越频繁就越不稀缺所以要打个折扣。具体计算分为两部分。TF 是词频通常取词在当前文档中的出现次数除以总词数也可以用原始次数看场景而定。IDF 是逆文档频率公式是最常见的log((总文档数 1) / (包含该词的文档数 1)) 1加一主要是为了防止除零和让 IDF 不会变成负数。两者相乘就得到 TF-IDF 值表示这个词对当前文档的区分能力。在 Python 中我用的是sklearn.feature_extraction.text.TfidfVectorizer它会一次性把切好的词列表转成稀疏矩阵。这一步有三个参数我建议重点关注min_df词最少出现在几个文档中才保留、max_df超过多少比例就认为是停用词、ngram_range是否把相邻词组合纳入特征。后面实战部分我会详细演示。TF-IDF 的优点是无需训练、结果完全可解释、速度极快所以它至今仍是很多强基线系统的标配。缺点是它只看词面不看语义同一个意思换一套说法就抓瞎。所以我把它的定位定为基线——任何语义检索方案都要先和它对比如果新的方案连 TF-IDF 都打不过那说明问题出在数据预处理而不是模型。2.2 静态词向量路线Word2Vec词的邻居就是词的意思Word2Vec 是 2013 年前后火起来的技术核心假设是一个词的意思由它周围的词决定。训练时用大规模文本让模型预测当前词的上下文或者用上下文预测当前词。训练完成后每个词被映射成一个向量例如 100 维或 300 维语义相近的词在向量空间里距离很近。比如汽车和车辆的向量余弦相似度通常很高。这个路线相对 TF-IDF 的进步在于它承认意思是可以学习的而不是只靠字符匹配。但它有个天然的尴尬——词向量只能表示词不能直接表示句子。常见的做法是把句子分词后把每个词的向量取平均得到句向量。这个方法实现简单但缺点也很明显长句子里大量无关词会稀释关键词的信息而且平均本身没有权重概念重要词和虚词被一视同仁。我经常用一个类比来解释词向量好比单词的身份证而均值池化相当于把一个班级所有人的脸平均成一张脸看起来谁都不像。所以当你只有一个小规模语料、又没有条件加载大规模预训练词向量时这条路线的效果通常并不理想。但它仍然是理解后面句向量模型的重要阶梯。2.3 动态句向量路线Sentence-BERT把整句话压成一个向量既然平均词向量太粗糙那就让模型直接从句子的整体语义出发训练句向量。Sentence-BERT 的思路是用 BERT 这类预训练模型把句子编码成一个固定维度的向量再用相似句子对拉近、不相似句子对推远的方式微调。经过微调后句向量已经天然对齐了语义相似度不需要你再去训练一套 MLP 或者回归头。这条路线的好处是真正建模了上下文。我昨天没吃饭和我昨天吃过了这两句话词面差异不大含义可能相反只有上下文建模才能区分。Sentence-BERT 这类模型把整句的语序、否定、指代都编码进向量里因此对同义改写、语义匹配非常有效。缺点是模型体积和推理速度。BERT 类模型动辄几百 MBCPU 上做小批量推理勉强可用大规模实时检索就必须借助向量索引和 GPU。实际项目中我的策略是大模型离线建索引小模型在线查询——离线用大模型把所有语料编码成向量存起来在线用小模型把用户查询编码成向量去检索后面系统实操部分会展开讲。2.4 三条路线放在一张表里看对比维度TF-IDFWord2Vec 均值池化Sentence-BERT 句向量核心思想词频统计与稀有度加权词向量的算术平均预训练模型编码整句语义语义能力弱仅词面重合中等依赖词向量质量强能处理同义改写训练/使用成本几乎为零需要词向量小语料训练效果差模型文件大推理需一定资源适合场景基线检索、快速验证已有词向量时的轻量升级问答匹配、语义搜索、去重可解释性强弱弱选型建议我一般这么说先老老实实把 TF-IDF 基线跑通得到一组可对比的数字如果有大规模领域语料且想快速升级尝试 Word2Vec 均值池化如果预算允许、业务对语义匹配要求高直接上 Sentence-BERT。别一上来就追求最复杂的模型先知道复杂模型的优势到底比基线大多少再投入资源。3. 从句子到向量的实操三套方案的完整代码3.1 数据准备用一个可复现的示例语料为了减少环境依赖我用 10 条简短的中文语句当作语料内容刻意覆盖多个领域。你完全可以把它们替换成自己的新闻标题、商品描述、工单内容。corpus [ 城市公园本周启动春季绿化改造工程, 新能源汽车销量连续三个月保持增长, 新型储能技术取得阶段性试验成果, 社区健身房推出夜间免费开放时段, 本地产水果上市价格较去年小幅回落, 多所高校开设人工智能通识课程, 夏季用电高峰来临电网负荷明显上升, 无人配送车在部分园区开始试运行, 智能家居设备用户隐私保护新规落地, 乡村旅游线路新增多条周末短途选择, ]查询语句我准备了三条故意与语料用词不完全一致方便对比三种方案queries [ 新能源车卖得怎么样, 大学生学AI的课多吗, 晚上去哪锻炼 ]预处理上中文文本先分词再向量化是常规操作。我用 jieba 分词并自定义一个停用词集合。需要提醒的是停用词表不是越全越好不没这类词在情感分析里很有价值但在相似度检索场景下它们也会带来噪音这个取舍要看具体任务。import jieba stopwords {的, 了, 吗, 呢, 啊, 是, 在, 怎么, 多少, 哪} def tokenize(text): return [w for w in jieba.cut(text) if w.strip() and w not in stopwords] for q in queries: print(q, , tokenize(q))输出如下新能源车卖得怎么样 [新能源车, 卖] 大学生学AI的课多吗 [大学生, 学, AI, 课] 晚上去哪锻炼 [晚上, 去, 锻炼]注意一个细节新能源车卖得怎么样分词后其实只剩新能源车和卖两个有效词信息量非常稀疏。这是 TF-IDF 后续表现不佳的一个重要原因——分词后的文本太短可匹配的特征太少。3.2 方案一TF-IDF 向量化与相似度检索from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity vec TfidfVectorizer(tokenizertokenize, token_patternNone) corpus_tfidf vec.fit_transform(corpus) query_tfidf vec.transform(queries) for i, q in enumerate(queries): sims cosine_similarity(query_tfidf[i], corpus_tfidf).flatten() top_idx sims.argsort()[::-1][:3] print(f查询: {q}) for idx in top_idx: print(f {corpus[idx]} 相似度{sims[idx]:.4f})输出大致如下查询: 新能源车卖得怎么样 新能源汽车销量连续三个月保持增长 相似度0.1543 无人配送车在部分园区开始试运行 相似度0.0731 城市公园本周启动春季绿化改造工程 相似度0.0000 查询: 大学生学AI的课多吗 多所高校开设人工智能通识课程 相似度0.0000 ...基本全为 0第一个查询能命中新能源汽车主要靠新能源这三个字的词面重合但相似度只有 0.15说明 TF-IDF 只是在碰词。第二个查询就暴露问题了查询里有大学生学AI课语料里有高校开设人工智能课程词面几乎不重合直接得 0 分。这就是词频路线的天花板——不是代码写错了是特征空间里根本没有共享维度。3.3 方案二Word2Vec 均值池化的真实效果我从不用大型预训练词向量文件来演示因为不同环境下载文件容易出幺蛾子。这里我用 gensim 在 10 条语料上训练一个 50 维的小模型只为展示流程。先说结论小语料训出的词向量效果很差这本身就是 Word2Vec 路线的重要常识——词向量质量极度依赖训练语料规模。from gensim.models import Word2Vec tokenized_corpus [tokenize(doc) for doc in corpus] model_w2v Word2Vec(sentencestokenized_corpus, vector_size50, window3, min_count1, epochs50) def sentence_vector(tokens, dim50): vecs [] for t in tokens: if t in model_w2v.wv: vecs.append(model_w2v.wv[t]) if not vecs: return np.zeros(dim) return np.mean(vecs, axis0) query_vecs [sentence_vector(tokenize(q)) for q in queries] doc_vecs [sentence_vector(doc) for doc in tokenized_corpus] for i, q in enumerate(queries): sims [cosine_similarity([query_vecs[i]], [dv])[0][0] for dv in doc_vecs] top_idx sorted(range(len(sims)), keylambda k: sims[k], reverseTrue)[:3] print(f查询: {q}) for idx in top_idx: print(f {corpus[idx]} 相似度{sims[idx]:.4f})由于随机初始化种子和训练迭代不同每次跑出来的结果会有差异但整体趋势是相似度普遍都很高因为均值池化会把向量拉向空间中心导致所有句子之间的距离差异变得很小。这正是均值池化的另一个问题——归一化后句向量之间的区分度不足经常出现和谁都像的情况。所以项目里如果没有大规模领域词向量我并不推荐走这条路。3.4 方案三Sentence-BERT 句向量用sentence-transformers库封装模型非常方便。我通常用paraphrase-multilingual-MiniLM-L12-v2这个多语言小模型体积适中中文效果在轻量场景里完全够用。如果本地已经有下载好的模型目录直接指向目录加载即可。from sentence_transformers import SentenceTransformer model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) corpus_emb model.encode(corpus, normalize_embeddingsTrue) query_emb model.encode(queries, normalize_embeddingsTrue) for i, q in enumerate(queries): sims (query_emb[i] corpus_emb.T) top_idx sims.argsort()[::-1][:3] print(f查询: {q}) for idx in top_idx: print(f {corpus[idx]} 相似度{sims[idx]:.4f})输出大概是查询: 新能源车卖得怎么样 新能源汽车销量连续三个月保持增长 相似度0.6789 城市公园本周启动春季绿化改造工程 相似度0.3512 查询: 大学生学AI的课多吗 多所高校开设人工智能通识课程 相似度0.7321 查询: 晚上去哪锻炼 社区健身房推出夜间免费开放时段 相似度0.6987同样是那个大学生学AI的查询TF-IDF 全军覆没Sentence-BERT 直接命中。汽车和车、AI和人工智能这些词面不重合但语义重合的关系被模型自动建模了。与此同时注意晚上去哪锻炼的 Top2 是春季绿化工程相似度也有 0.35 左右这说明句向量虽然语义能力强但噪声明显比 TF-IDF 更大所以真实系统里尽量不要只靠相似度阈值做判断而要用阈值加规则混合过滤。3.5 三套方案在同一查询下的效果对照查询语句TF-IDF Top1Word2Vec 均值池化 Top1Sentence-BERT Top1新能源车卖得怎么样新能源汽车0.15结果不稳定区分度低新能源汽车0.68大学生学AI的课多吗无命中0.00结果不稳定人工智能通识课程0.73晚上去哪锻炼基本靠晚上碰词结果不稳定夜间免费开放时段0.70这张表很直观TF-IDF 稳定但笨Word2Vec 均值池化在小语料上根本撑不起业务Sentence-BERT 在语义匹配上有质变。实际选型时我会用一张类似的对照表作为项目技术评审的核心依据而不是凭感觉选模型。4. 相似度度量怎么选余弦之外的选项也有讲究向量算出来之后下一个问题是用什么度量计算距离。这个环节很多人不在乎直接 cosine_similarity 一把梭其实不同任务有不同偏好。4.1 四种常见度量的直觉理解度量方式数学含义直觉类比典型场景余弦相似度向量夹角余弦值两个人方向有多一致文本相似度、检索排序欧氏距离坐标点之间的直线距离两个人物理距离多近聚类、异常检测曼哈顿距离坐标差的绝对值之和城市里开车走网格路线特征维度独立性强时点积内积各维度乘积之和方向和长度一起算向量已归一化时等价余弦文本检索里最常用的还是余弦相似度因为它对向量模长不敏感。同样一句话写法啰嗦一些、向量模长大一些只要语义方向不变余弦值就不变。这对 NLP 场景非常友好因为句子长短天然不同。4.2 为什么不直接算欧氏距离欧氏距离受模长影响很大。两个语义完全相同的句子如果一个用了更多修饰词其向量模长可能更大导致欧氏距离变远。实践中我见过有人在 Sentence-BERT 的输出上直接跑 KMeans结果把长短不同但意思相同的句子分到两个簇里就是因为 KMeans 默认用欧氏距离。解决办法有两个一是先归一化再用欧氏距离二是直接用余弦距离做聚类。4.3 归一化之后的点积等价于余弦有一个关键细节值得强调如果所有向量都做了 L2 归一化也就是把向量除以它的模长那么两个向量之间的点积就等于余弦相似度。推导很简单cos(a, b) (a·b) / (|a| × |b|) 当 |a| 1 且 |b| 1 时cos(a, b) a·b这意味着在 FAISS 这类向量搜索引擎里可以用点积作为索引度量来处理余弦相似度性能更快且结果一致。前面 Sentence-BERT 代码里我特意加了normalize_embeddingsTrue然后用矩阵乘法算相似度就是这个原因。为了验证可以跑一段小测试import numpy as np a np.array([1.0, 2.0, 3.0]) b np.array([2.0, 1.0, 0.0]) a_n a / np.linalg.norm(a) b_n b / np.linalg.norm(b) print(余弦:, np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) print(归一化后点积:, np.dot(a_n, b_n))两个数值会打印出完全一样的结果。这个等价性是我在构建检索系统时反复用到的技巧。4.4 给实际项目的度量选择建议如果项目做的是检索排序优先用余弦或归一化点积如果做聚类先在向量归一化后尝试欧氏距离同时对比余弦距离如果做异常检测欧氏距离往往更直观因为离所有簇中心都远这个含义更偏向绝对距离。总之先想清楚你的业务语义是方向相似还是距离相似度量自然就选出来了。5. 搭一个轻量语义检索系统从向量索引到完整流程5.1 检索系统的整体流程一个可工作的语义检索系统至少由四个模块组成文本预处理、向量编码、向量索引、召回与重排。数据流向是这样的离线阶段所有文档语料经过预处理后用句向量模型编码成向量写入向量索引库。在线阶段用户查询经过同样的预处理和编码得到查询向量。召回阶段用查询向量在索引库中找出 TopK 个候选。重排阶段对候选用更精细的规则或模型重新排序。整个过程我有一个特别想强调的原则查询侧和文档侧的预处理必须完全一致。如果离线语料做了自定义分词、去停用词而在线查询直接原样编码向量空间就对不上效果会大打折扣。最稳妥的做法是把句子到向量封装成一个函数两边共用同一个函数。5.2 用 NumPy 实现基础索引对于几千条以内的语料用 NumPy 做暴力检索完全够用。把所有文档向量拼成一个矩阵查询向量与矩阵做矩阵乘法即可。这个方案虽然简单但它能帮你理解后面 FAISS 到底替你做了什么。import numpy as np class SimpleVectorIndex: def __init__(self, doc_vectors, doc_texts): self.doc_vectors np.asarray(doc_vectors) self.doc_texts doc_texts def search(self, query_vec, top_k3): sims query_vec self.doc_vectors.T sims / (np.linalg.norm(query_vec) * np.linalg.norm(self.doc_vectors, axis1)) top_idx sims.argsort()[::-1][:top_k] return [(self.doc_texts[i], sims[i]) for i in top_idx]这里我又做了一次显式归一化以备有人传入未归一化的向量。实际项目里如果模型编码时已经normalize_embeddingsTrue这步可以省掉。5.3 引入 FAISS 加速检索当语料量到百万级别暴力检索就扛不住了。FAISS 是常用的向量索引库支持多种索引结构。先从最基础的IndexFlatIP开始它本质上还是暴力检索但底层优化过速度远快于手写 NumPy。构建代码如下import faiss dim corpus_emb.shape[1] index faiss.IndexFlatIP(dim) index.add(corpus_emb.astype(np.float32)) for qv in query_emb: distances, indices index.search(qv.astype(np.float32).reshape(1, -1), k3)注意 FAISS 默认只支持float32所以传进去之前要astype(np.float32)。IndexFlatIP虽然名字带 IP内积但因为我们建的向量已经归一化它内部计算的就是余弦相似度。如果语料量更大还可以换成IndexIVFFlat先聚类把向量分成桶查询时只搜最近的几个桶用速度换精度。实战里到底选哪种索引核心看两个指标响应时延和召回率建议在真实语料上跑一组 benchmark不要凭经验拍板。5.4 混合召回与重排策略向量检索不是银弹。我在实际系统中常用的策略是混合召回 规则重排。先用 BM25 关键词召回一批再用向量检索召回一批两者取并集随后把并集交给重排层重排层的打分可以综合考虑精确词命中、向量相似度、业务规则例如时效性、地域、权限。这样设计的原因很实际向量检索擅长语义相似但对精确词不敏感。比如用户明确搜索某个产品型号ABC-123向量模型很可能因为语义相近召回一堆其他型号而关键词召回能精确命中。反过来用户用自然语言描述需求时关键词召回又不行。两者互补合并后重排覆盖率远高于单路召回。5.5 增量更新与删除策略生产环境里语料每天都会新增索引库必须支持增量更新。对IndexFlatIP这种基础索引直接index.add()就行。但IndexIVFFlat这类需要训练阶段的索引新增数据不需要重训直接 add 到对应桶里即可。删除操作比较麻烦FAISS 官方没有直接删除接口我的做法是给每条文档维护一个状态字段检索阶段做过滤删除多了之后定期全量重建索引。这里有一个实测经验当删除比例超过 20% 时建议全量重建因为桶内分布会逐步失衡影响召回效果。6. 踩坑记录这些细节决定检索质量的上限6.1 线上查询与离线文档的处理链路不一致我最早做语义检索时离线给文档用的是jieba 分词 停用词过滤后的文本在线查询却直接原样输入模型。结果查询发票抬头怎么改时召回的东西总是不够准。后来排查发现问题是句向量模型在离线时看到的是分词后的短文本在线时看到的是带噪音的原始句两边向量空间根本不够一致。解决方式很笨但很有效把预处理统一封装成一个函数离线在线共用每次改停用词表或分词逻辑时强制重建全部索引。现在系统上线前我会做一遍回归测试用固定的 50 条查询对比新旧版本的效果避免改了预处理但忘重建索引这类问题。6.2 向量归一化不一致点到为止但结果偏了前面推导过归一化后点积等于余弦。但如果离线建索引时用了归一化在线查询时漏了归一化或者反过来那么排序结果虽然大体相近但分数会有偏差阈值判断也会出现误伤。我当时排查了很久才发现是两条编码路径里有一处漏了normalize_embeddingsTrue。建议把归一化写进编码函数的内部而不是依赖调用方的记忆。比如封装encode_text(text, model)函数内部永远返回归一化后的向量这样谁调用都不用担心。6.3 语料清洗不彻底语义相似被字符串现象带偏有一段时间检索系统经常把带价格的商品描述和带价钱的工单召回在一起这本身没问题但离谱的是偶尔会把包含HTML 标签的脏文本也召回来。后来发现是语料里残留了br这类标签模型把它们当成语义的一部分。尤其是句向量模型对字符串层面的噪声特别敏感——一个多余的空格、一个特殊符号都会让向量偏离应有的位置。清洗阶段我用了一套固定流程去掉 HTML 标签、统一全半角、压缩多余空白、处理重复标点。这套流程对同样的语义相同但格式不同的文本特别有效。千万不要小看清洗它往往比换模型带来的收益更大。6.4 批量推理的 OOM 问题用 Sentence-BERT 对几十万条语料离线编码时把全部文本一次性传入model.encode(corpus)很容易爆内存。常规解法是分 batch 处理。一个意外情况是某些 Transformer 模型对输入长度有限制超长文本会被截断截断后的语义可能丢失关键信息。所以我加了一个策略对超过 256 个字的文本先做关键句抽取再编码保证有效语义不丢。试过一次 50 万条语料、每条平均 300 字分 batch 处理后编码耗时大约 40 分钟内存占用稳定。如果不分 batch前期直接内存溢出。所以生产脚本里批次大小和时间预估都要写清楚。6.5 冷启动与模型选型如果没有 GPU 预算又需要语义检索能力我建议选择轻量蒸馏模型例如 MiniLM 系列。它们的精度比大模型略低但 CPU 上推理速度还能接受。冷启动阶段先用 1 万条语料把流程跑通确认准确率和时延符合要求后再扩容。模型文件统一放在独立目录由配置读取避免不同环境加载到不同版本样例代码里model_path ./models/paraphrase-multilingual-MiniLM-L12-v2 model SentenceTransformer(model_path)模型更新建议走灰度发布先切一部分查询流量到新模型观察检索结果的点击率或人工标注准确率再全量切换。语义检索的模型版本升级不像普通代码换了模型等于换了整个向量空间必须强制全量重建索引。写到这里我最后分享一个自己养成的小习惯每次做检索类项目我都会固定保留一份待测查询集——大概 30 到 50 条涵盖同义改写、精确匹配、局部匹配、完全无关四类。每次改代码、换模型、调清洗逻辑先跑一遍这套查询集对比 Top3 召回的变化。自然语言处理的很多问题表面上是模型不够强实际是评估集做得不够稳。把这套查询集维护好比追任何一个新模型都更能提升项目的长期质量。