1. 从字符串到向量:为什么文本必须变成数字
1.1 机器看不懂文字,但看得懂坐标
做自然语言处理这些年,我越来越觉得,这个领域最核心的矛盾就一句话:人用符号交流,机器用数字计算。你给计算机扔一句“今天天气不错”,它看到的只是一串Unicode码点,它不知道“天气”和“气候”有关系,也不知道“不错”和“糟糕”是反义词。所以整个NLP工程的第一道门槛,就是怎么把文本变成一串有意义的数字——这就是文本表示要解决的问题。
我见过不少刚入行的朋友,一上来就急着上大模型、调API,结果连最基础的文本相似度都算不准。问题往往出在底层表示上:你喂给模型的特征本身就是一锅粥,后面再花哨的架构也救不回来。所以这篇东西我想把向量语义这条线从头捋一遍,重点讲三个东西:余弦相似度、TF-IDF、Word2Vec。这三个不是并列关系,而是一条清晰的演进链——从“怎么比较两个向量”,到“怎么把词变成有区分度的向量”,再到“怎么让向量带上语义”。
先说清楚适合谁看。如果你正在做搜索、推荐、文本去重、问答匹配、评论聚类这类活儿,或者你刚学完Python想找个NLP方向练手,那这篇内容基本能覆盖你80%的日常需求。不需要你有深度学习背景,但得会一点Python和基本的线性代数概念——知道什么是向量、什么是点积就够了。
1.2 向量语义到底在解决什么问题
一句话概括:向量语义就是让“意思相近的文本,在向量空间里的距离也相近”。这句话听起来简单,但它是搜索、推荐、问答系统的地基。
举个我实际遇到的场景。之前帮一个团队做FAQ自动匹配,用户问“怎么修改绑定的手机号”,知识库里有一条“如何更换预留电话”。字面上看,这两句话只有“修改/更换”“手机号/电话”勉强对得上,如果按关键词匹配,很容易漏掉。但如果把两句话都映射成向量,算一下余弦相似度,发现相似度有0.82,那就能正确匹配上。这就是向量语义的价值——它捕捉的是语义层面的接近,而不是字面的重合。
再往深一层说,向量语义要解决三个递进的问题:
- 表示问题:一段文本怎么变成固定长度的向量?这是TF-IDF和Word2Vec各自回答的问题。
- 度量问题:两个向量怎么算“像不像”?这是余弦相似度回答的问题。
- 泛化问题:没见过的新词、新句子怎么办?这是Word2Vec这类分布式表示要解决的。
这三个问题环环相扣。你光有好的表示,不会度量,等于白搭;你光会度量,表示做得稀烂,算出来的相似度也没意义。所以下面我会按“度量→表示→语义”的顺序展开,这也是我建议的学习路径。
2. 余弦相似度:判断两个向量像不像的那把尺子
2.1 为什么是余弦而不是欧氏距离
先说结论:在文本场景下,余弦相似度几乎总是比欧氏距离更合适。原因在于文本向量的一个特点——长度(模长)往往反映的是文档长短,而不是内容差异。
我举个具体的例子你就明白了。假设有两个文档:
- 文档A:“苹果 手机 电池 续航”
- 文档B:“苹果 手机 电池 续航 苹果 手机 电池 续航 苹果 手机 电池 续航”
如果按词频向量来算,文档B的向量模长是文档A的3倍,但它们的方向完全一致。用欧氏距离算,这两个文档“距离很远”;用余弦相似度算,相似度是1.0,完全一样。显然,从语义角度,这两个文档讲的是同一件事,只是B重复啰嗦了三遍。余弦相似度只看方向、不看长度,正好符合这个直觉。
余弦相似度的公式是:
cos(A, B) = (A · B) / (||A|| * ||B||)其中A·B是点积,||A||是向量的L2范数。取值范围是[-1, 1],1表示方向完全相同,0表示正交(无关),-1表示方向完全相反。在TF-IDF这种非负向量里,取值范围实际是[0, 1]。
注意:余弦相似度对向量的绝对大小不敏感,但对方向极其敏感。这意味着如果你做了归一化(把向量除以模长),余弦相似度就退化成点积,计算会快很多。工程上我一般都会先归一化再算,省一次除法。
2.2 手算一遍,把公式变成肌肉记忆
光看公式没用,我带你手算一遍。假设有两个三文档的TF-IDF向量(后面会讲怎么来的,这里先当已知):
- A = [0.5, 0.8, 0.0]
- B = [0.4, 0.9, 0.1]
先算点积:0.5×0.4 + 0.8×0.9 + 0.0×0.1 = 0.2 + 0.72 + 0 = 0.92
再算模长:||A|| = √(0.25 + 0.64 + 0) = √0.89 ≈ 0.943;||B|| = √(0.16 + 0.81 + 0.01) = √0.98 ≈ 0.990
最后:0.92 / (0.943 × 0.990) ≈ 0.92 / 0.934 ≈ 0.985
相似度0.985,非常高,说明这两个文档语义高度接近。你把这个手算过程走一遍,比看十遍公式都管用。
2.3 工程实现里的三个坑
实际写代码的时候,余弦相似度有几个容易踩的坑,我一个个说。
第一个坑:稀疏向量直接算会爆内存。文本向量动辄几万维,而且绝大多数是0。如果你用numpy的稠密数组存,一个10万维的向量就是800KB,一万个文档就是8GB,直接崩。正确做法是用scipy的稀疏矩阵(scipy.sparse),只存非零元素。我实测过,同样一万个文档,稀疏存储能省95%以上的内存。
第二个坑:零向量。如果某个文档所有词都不在词表里,它的向量全是0,模长是0,除零会报错或者返回NaN。工程上必须加保护,遇到零向量直接返回相似度0。
第三个坑:批量计算的效率。如果你要算一个查询和一万个文档的相似度,千万别写for循环一个个算。用矩阵乘法一次性算完:把查询向量和文档矩阵做点积,再除以模长。numpy的@运算符或者sklearn的cosine_similarity都能做,速度差几十倍。
import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 假设 query_vec 是 (1, D),doc_matrix 是 (N, D) # 一次性算完,比循环快几十倍 sims = cosine_similarity(query_vec, doc_matrix)[0] top_k = np.argsort(sims)[::-1][:10]这段代码是我做检索时的标准模板,cosine_similarity内部做了归一化,直接出结果,省心。
3. TF-IDF:给词加上“重要性”的权重
3.1 从词袋模型说起
要理解TF-IDF,得先理解它要解决的前一个问题——词袋模型(Bag of Words)。词袋模型的思路极其朴素:把一段文本里出现的词,按词表顺序数一遍,每个词出现几次就填几。比如词表是[苹果, 手机, 电池, 续航],文档“苹果 手机 电池 续航 苹果”就变成[2, 1, 1, 1]。
词袋模型能用,但有个致命缺陷:它把所有词看得一样重要。可实际上,“的”“了”“是”这种词满篇都是,但它们对区分文档内容毫无帮助;而“电池”“续航”这种词虽然出现次数少,却是文档的核心。TF-IDF就是来解决这个问题的。
3.2 TF和IDF各自在干什么
TF-IDF拆开看是两部分:
- TF(词频):一个词在当前文档里出现得越多,它对这个文档越重要。最朴素的算法就是出现次数,但工程上一般用对数平滑:
TF = 1 + log(词频),避免长文档里高频词权重爆炸。 - IDF(逆文档频率):一个词在多少个文档里出现过。出现的文档越多,说明它越普通,权重应该越低。公式是
IDF = log(总文档数 / 包含该词的文档数)。
两者相乘就是TF-IDF权重。直觉上就是:一个词在当前文档里出现得多,同时在其他文档里出现得少,它才最有区分度。
我举个实际例子。假设有1000篇文档,“的”出现在999篇里,IDF = log(1000/999) ≈ 0.001,权重几乎为0;“电池”只出现在5篇里,IDF = log(1000/5) ≈ 5.3,权重很高。这样算出来的向量,自然就把区分度高的词凸显出来了。
3.3 参数选择:平滑、归一化、子线性
TF-IDF看着简单,但sklearn的TfidfVectorizer有一堆参数,选错了效果差很多。我把几个关键参数拎出来说。
| 参数 | 作用 | 我的建议 |
|---|---|---|
sublinear_tf | 用1+log(tf)代替原始词频 | 长文档场景建议开True |
norm | 向量归一化方式 | 默认'l2',配合余弦相似度用 |
smooth_idf | IDF加1平滑,避免除零 | 保持默认True |
min_df | 忽略出现次数太少的词 | 小数据集设2,大数据集设5 |
max_df | 忽略出现太频繁的词 | 设0.8~0.9,过滤停用词 |
ngram_range | 词组合范围 | 中文建议(1,2),英文(1,1)够用 |
sublinear_tf这个参数特别值得说。假设一个词在文档里出现100次,原始TF是100,对数平滑后是1+log(100)≈5.6。差距巨大。为什么?因为一个词出现10次和出现100次,对文档主题的贡献并不是10倍关系,而是递减的。对数平滑正好模拟这个递减效应。我做过对比实验,在长文档分类任务上,开sublinear_tf能提升2-3个百分点的准确率。
3.4 中文场景的特殊处理
TF-IDF在英文里天然好用,因为英文有空格分词。中文不行,得先分词。这里有个坑:分词粒度直接决定TF-IDF的效果。
我试过用“苹果手机电池续航”这句话,用不同分词器:
- 粗粒度:
苹果手机 / 电池 / 续航→ 3个词 - 细粒度:
苹果 / 手机 / 电池 / 续航→ 4个词
粗粒度保留了“苹果手机”这个完整概念,但词表会膨胀;细粒度词表小,但“苹果”和“手机”分开后,可能丢失组合语义。我的经验是:做短文本相似度用细粒度,做长文档分类用粗粒度。另外,中文停用词表一定要加,像“的、了、是、在、和”这些,不加的话IDF算出来全是噪声。
实操心得:中文TF-IDF我一般会加一步“自定义词典”,把领域专有名词(比如产品名、术语)塞进分词器的词典里,防止被切碎。这一步对效果提升很明显,尤其是垂直领域。
4. Word2Vec:让向量真正带上语义
4.1 TF-IDF的天花板在哪
TF-IDF有个绕不过去的硬伤:它假设词与词之间是独立的。在TF-IDF的向量空间里,“手机”和“电话”是两个完全正交的维度,相似度是0。可实际上这两个词意思很接近。这就导致一个问题:如果用户搜“电话”,而文档里写的是“手机”,TF-IDF算出来的相似度很低,会漏掉。
这个问题的根源是:TF-IDF是稀疏的、高维的、基于字面共现的表示。它没法表达“词与词之间的语义关系”。要突破这个天花板,就得换一种表示方式——把词映射到一个低维稠密的空间里,让语义相近的词在空间里也靠近。这就是词嵌入(Word Embedding),而Word2Vec是其中最经典的代表。
4.2 Word2Vec的两个训练套路
Word2Vec的核心思想一句话:一个词的含义,由它周围的词决定。这个假设叫分布式假设,是所有这些词嵌入方法的理论基础。
它有两种训练方式:
- CBOW(连续词袋):用上下文预测中心词。比如句子“我 爱 吃 苹果”,用“我、爱、吃”预测“苹果”。适合小数据集,训练快。
- Skip-gram:用中心词预测上下文。用“苹果”预测“我、爱、吃”。适合大数据集,对低频词效果更好。
我一般默认用Skip-gram,因为它在低频词上的表现明显更好,而低频词恰恰是TF-IDF处理不好的部分。代价是训练慢一些,但值得。
训练出来的结果是什么?每个词变成一个比如100维或300维的稠密向量。神奇的地方在于,这些向量满足一些语义运算关系,最著名的例子是:
向量(国王) - 向量(男人) + 向量(女人) ≈ 向量(女王)这说明Word2Vec学到的向量不只是“共现统计”,而是捕捉到了某种语义结构。我第一次跑出这个结果的时候确实挺震撼的。
4.3 关键参数怎么调
Word2Vec的参数比TF-IDF多,而且对结果影响很大。我把最关键的几个列出来。
| 参数 | 含义 | 常用值 | 说明 |
|---|---|---|---|
vector_size | 向量维度 | 100~300 | 太小欠拟合,太大过拟合且慢 |
window | 上下文窗口 | 5~10 | 大窗口捕捉主题,小窗口捕捉语法 |
min_count | 最低词频 | 5 | 低于此频率的词直接丢弃 |
sg | 训练方式 | 1 | 1是Skip-gram,0是CBOW |
negative | 负采样数 | 5~15 | 越大越准但越慢 |
epochs | 训练轮数 | 5~20 | 看语料大小,小语料多跑几轮 |
window这个参数特别有意思。窗口设小(比如2),学到的向量偏向语法相似,比如“跑”和“跳”接近;窗口设大(比如10),学到的偏向主题相似,比如“跑”和“运动”接近。所以做语法任务用小窗口,做主题任务用大窗口,这个经验很实用。
min_count也别小看。设成1的话,所有只出现一次的词都会进词表,这些词没有足够上下文,学出来的向量基本是噪声,还会拖慢训练。我一般设5,语料大的话设10。
4.4 从词向量到句向量:一个绕不开的坎
Word2Vec给的是词向量,但实际任务里我们要的是句向量或文档向量。怎么从词向量得到句向量?最朴素的做法是平均:把句子里所有词的向量加起来除以词数。
这个做法简单,但有个问题:它把每个词看得一样重。像“的”“了”这种词也参与平均,会稀释语义。改进做法是TF-IDF加权平均:用每个词的TF-IDF权重去加权它的词向量。这样重要的词贡献大,停用词贡献小。
import numpy as np def sentence_vector(words, w2v_model, tfidf_weights): vecs = [] weights = [] for w in words: if w in w2v_model.wv: vecs.append(w2v_model.wv[w]) weights.append(tfidf_weights.get(w, 1.0)) if not vecs: return np.zeros(w2v_model.vector_size) vecs = np.array(vecs) weights = np.array(weights).reshape(-1, 1) return (vecs * weights).sum(axis=0) / weights.sum()这段代码是我常用的句向量生成模板,加权平均比简单平均在检索任务上能提升5个百分点左右的召回率。当然,现在有更好的方案(比如Sentence-BERT),但Word2Vec加权平均胜在轻量、快、不需要GPU,很多场景够用了。
5. 三者怎么配合:一条完整的实战链路
5.1 选型决策:什么场景用什么
这三个技术不是替代关系,而是各有适用场景。我整理了一张决策表,你对着选就行。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 关键词搜索、精确匹配 | TF-IDF + 余弦 | 快、可解释、无需训练 |
| 短文本去重 | TF-IDF + 余弦 | 字面重合度高,够用 |
| 语义搜索、问答匹配 | Word2Vec + 余弦 | 能捕捉同义词、近义表达 |
| 冷启动、无标注数据 | TF-IDF | 不需要训练,直接算 |
| 大规模语料、有训练时间 | Word2Vec | 语义泛化能力强 |
| 对可解释性要求高 | TF-IDF | 能看出哪些词贡献大 |
我的实际经验是:先用TF-IDF搭baseline,跑通了再上Word2Vec对比。很多时候TF-IDF的效果已经够用,上Word2Vec反而引入训练成本和调参负担。别为了用新技术而用新技术。
5.2 一个完整的检索流程
我把整个流程串一遍,从原始文本到最终排序。
第一步:预处理。分词、去停用词、转小写。中文用jieba或类似工具,英文用nltk。这一步别偷懒,脏数据进去,垃圾结果出来。
第二步:建TF-IDF矩阵。用TfidfVectorizer拟合整个语料,得到文档-词矩阵。注意fit只在训练集上做,测试集用transform,否则会数据泄露。
第三步:训练Word2Vec。用同样的分词结果训练,得到词向量。语料小的话可以加载预训练词向量,效果更稳。
第四步:生成句向量。查询和文档都用TF-IDF加权平均生成句向量。
第五步:算相似度排序。用余弦相似度算查询和所有文档的相似度,取Top-K。
第六步:融合两路结果。把TF-IDF的相似度和Word2Vec的相似度加权融合,比如0.4 * tfidf_sim + 0.6 * w2v_sim。权重靠验证集调。
这套流程我在多个项目里用过,稳定可靠。融合两路结果通常比单用一路好,因为TF-IDF擅长字面匹配,Word2Vec擅长语义匹配,互补。
5.3 效果评估:别只看准确率
评估检索效果,别只看准确率。我一般看三个指标:
- Recall@K:前K个结果里有没有正确答案。K一般取10或20。
- MRR(平均倒数排名):正确答案排得越靠前越好。
- NDCG:考虑排序位置的加权指标,最贴近真实体验。
这三个指标配合看,才能全面反映系统好坏。只看准确率容易漏掉“答案排在第二页”这种问题。
6. 踩过的坑和排查清单
6.1 常见问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 相似度全是0 | 向量未归一化或词表不匹配 | 检查fit/transform是否一致 |
| 相似度全是1 | 向量维度塌缩 | 检查是否所有文档向量相同 |
| 结果和直觉不符 | 停用词没去干净 | 打印Top词看权重 |
| Word2Vec训练慢 | 语料太大或维度太高 | 降维度、加min_count |
| 内存爆掉 | 用了稠密矩阵 | 换scipy稀疏矩阵 |
| 新词查不到 | 词表外词(OOV) | 用subword或预训练向量 |
6.2 三个我踩过的真实坑
坑一:TF-IDF的fit和transform混用。我早期做在线服务,每次来一个查询就重新fit一次,结果每次词表都不一样,相似度算出来乱七八糟。正确做法是训练时fit一次,把vectorizer存下来,线上只transform。这个坑很隐蔽,因为离线测试时不容易发现。
坑二:Word2Vec的随机性。Word2Vec训练有随机初始化,同样的语料跑两次结果不一样。做对比实验时一定要固定随机种子,否则你以为是参数调优带来的提升,其实是随机波动。我一般设seed=42,跑三次取平均。
坑三:中文分词的领域适配。通用分词器在垂直领域经常切错。比如“深度学习框架”可能被切成“深度/学习/框架”,但你要的是“深度学习/框架”。解决办法是加自定义词典,把领域术语提前塞进去。这一步花十分钟,效果提升可能超过调参一整天。
6.3 性能优化的几个技巧
最后分享几个提速技巧。第一,预计算文档向量,别每次查询都重算,存到内存或向量数据库里。第二,用矩阵运算代替循环,前面说过,差几十倍。第三,降维,TF-IDF可以用TruncatedSVD降到几百维,速度提升明显,精度损失很小。第四,缓存高频查询,很多系统里20%的查询占了80%的流量,缓存命中率很高。
提示:如果文档量超过百万级,单机内存放不下所有向量,就该考虑向量数据库了。但那是另一个话题,这里不展开。
这套东西我从头到尾跑过很多遍,从最初手写余弦相似度,到后来用sklearn、gensim,再到自己封装工具类。核心逻辑没变过:好的表示 + 对的度量 + 合理的融合。把这三块吃透,NLP里一大半的检索和匹配问题都能自己搞定。