判断两段文字是不是在说同一件事,是搜索排序、查重系统、智能客服等多个场景都绕不开的问题。解决这个问题最经典的思路就是词频向量模型:先把文本切分成词,再统计每个词出现的次数,把文本变成一个数字数组,最后用数学方法比较两个数组的距离或夹角。整个过程不依赖任何复杂的机器学习框架,用基础的数组操作就能实现,非常适合用来理解向量空间模型的本质。

一、词频向量的基本原理:把文字变成数字数组
词频向量模型的核心假设很简单:如果两段文本用词越接近,它们的内容就越接近。要让计算机能比较文字,第一步必须把文字数字化。具体做法是先建立一个统一词表,把所有出现过的词收集起来并编号,然后针对每段文本,统计词表中每个词在这段文本里出现了几次,按词表顺序依次填入数组,得到的数字数组就是这段文本的词频向量。
举个例子,假设词表是["我", "喜欢", "编程", "学习"],那么文本"我喜欢编程"的词频向量就是[1, 1, 1, 0],文本"我喜欢学习编程"的词频向量就是[1, 1, 1, 1]。这样一来,比较两段文本的相似度,就变成了比较两个数组的相似度,问题从语言层面转换到了数学层面。
需要注意的是,中文没有天然的分隔符,需要先分词。下面用Python配合jieba库演示完整的向量化过程:
import jieba
def build_vocab(texts):
"""根据语料构建词表"""
vocab = {}
for text in texts:
for word in jieba.lcut(text):
# 过滤长度为1的单字和空白符,降低噪音
if len(word.strip()) > 1:
vocab[word] = vocab.get(word, 0) + 1
# 按出现次数降序排列,高频词排在前面
sorted_words = sorted(vocab.items(), key=lambda x: x[1], reverse=True)
return {word: idx for idx, (word, _) in enumerate(sorted_words)}
def text_to_vector(text, vocab):
"""把文本转换为词频向量(数组)"""
vector = [0] * len(vocab)
for word in jieba.lcut(text):
if word in vocab:
vector[vocab[word]] += 1
return vector
texts = ["我喜欢编程", "我喜欢学习编程", "今天天气不错"]
vocab = build_vocab(texts)
print(vocab)
# 输出类似:{'喜欢': 0, '编程': 1, '喜欢编程': 2, '学习': 3, '今天': 4, '天气': 5, '不错': 6}
print(text_to_vector("我喜欢编程", vocab))
这段代码里,build_vocab负责建立词与数组下标的映射关系,text_to_vector则负责把任意文本映射成与词表等长的数组。词频向量本质上就是一个定长数组,长度由词表决定,这正是它能放进统一坐标系比较的前提。
二、用余弦相似度计算数组之间的接近程度
有了词频向量之后,如何量化两个数组的相似程度?直接算欧氏距离并不是好选择,因为文本有长有短,长文本的词频数值天然偏大,欧氏距离会被文本长度主导。余弦相似度只关心两个向量方向的夹角,不受向量长度影响,特别适合文本场景。
余弦相似度的公式为:cos(A, B) = (A·B) / (||A|| × ||B||),其中A·B表示两个数组的点积,即对应位置元素相乘后求和;||A||表示向量的模长,即各元素平方和再开方。结果越接近1表示越相似,接近0表示几乎无关。手写实现如下:
import math
def cosine_similarity(vec_a, vec_b):
"""计算两个词频向量数组的余弦相似度"""
# 点积:对应位置相乘再累加
dot_product = sum(a * b for a, b in zip(vec_a, vec_b))
# 各自的模长
norm_a = math.sqrt(sum(a * a for a in vec_a))
norm_b = math.sqrt(sum(b * b for b in vec_b))
if norm_a == 0 or norm_b == 0:
# 空文本或全被过滤的情况,避免除零
return 0.0
return dot_product / (norm_a * norm_b)
vec1 = text_to_vector("我喜欢编程", vocab)
vec2 = text_to_vector("我喜欢学习编程", vocab)
vec3 = text_to_vector("今天天气不错", vocab)
print(cosine_similarity(vec1, vec2)) # 接近1,内容高度相关
print(cosine_similarity(vec1, vec3)) # 接近0,内容几乎无关
运行结果中,前两条文本的相似度会明显偏高,因为它们共享"喜欢"和"编程"两个关键词;而"我喜欢编程"和"今天天气不错"几乎没有共同词,相似度趋近于0。这个结果符合人的直觉判断,说明模型是有效的。
这里有一个容易踩的坑:如果两段文本完全相同,余弦值会等于1;如果其中一段是另一段的简单重复(比如"喜欢编程喜欢编程"),余弦值依然是1,因为词频只按比例放大,方向没变。这在查重场景下可能是个问题,需要结合长度惩罚或改用Jaccard系数补充判断。
三、处理变长文本的进阶技巧与优化方向
基础的词频模型在实际使用中会遇到一些问题。最明显的是"的"、"了"、"是"这类高频虚词会占据词频的主要部分,干扰真正有意义的词的权重。改进方法是采用TF-IDF加权:词频乘以逆文档频率,一个词在越少的文档中出现,它的区分能力越强,权重就越高。
import math
def compute_idf(all_vectors, vocab_size):
"""统计每个词出现在多少个文档中,计算IDF"""
doc_count = len(all_vectors)
idf = [0.0] * vocab_size
for vec in all_vectors:
for i in range(vocab_size):
if vec[i] > 0:
idf[i] += 1
return [math.log((doc_count + 1) / (freq + 1)) + 1 for freq in idf]
def tfidf_vector(vec, idf):
"""把词频向量转换为TF-IDF加权向量"""
return [tf * w for tf, w in zip(vec, idf)]
all_vecs = [text_to_vector(t, vocab) for t in texts]
idf = compute_idf(all_vecs, len(vocab))
weighted = [tfidf_vector(v, idf) for v in all_vecs]
print(cosine_similarity(weighted[0], weighted[1]))
另一个优化方向是稀疏存储。当词表达到几万规模时,每段文本的向量大部分位置都是0,用Python列表存储会浪费大量内存。此时可以改用字典只记录非零位置,或者直接使用scipy的稀疏矩阵,计算点积时只遍历非零项,效率会大幅提升。
最后要认识到词频模型的局限:它完全不理解语义,"电脑"和"计算机"会被当成两个不同的词。如果需要语义层面的相似度,可以考虑word2vec、BERT等向量化方案。但对于入门理解、轻量级查重、关键词匹配这类需求,数组实现的词频向量加余弦相似度依然是性价比最高的方案,代码不到百行就能跑通,也为后续学习更复杂的嵌入模型打下了坚实的概念基础。