news 2026/9/15 2:43:53

余弦相似度算法在文本相似度匹配中的原理与Python实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
余弦相似度算法在文本相似度匹配中的原理与Python实现

简介:一个基于Python实现的文本相似度计算小项目,通过简洁代码演示余弦相似度算法在中文文本比较上的应用,适合自然语言处理入门者、算法学习者以及需要快速实现文本匹配功能的前后端开发者使用。项目共4个文件:1个可直接运行的Python主程序、1个Markdown格式的Readme说明、1个License许可证以及1个Git忽略文件,压缩包整体约3KB,结构紧凑。目前已有4207人学习下载。主程序演示了文本相似度计算的完整链路:先通过nltk对输入文本进行分词,再使用sklearn的CountVectorizer构造词袋向量、TfidfVectorizer构造TF-IDF向量,最后借助numpy计算两个向量的点积与范数,从而得到余弦相似度。代码同时给出两种向量化方式的比对结果,注释简洁,便于理解分词、词表构建、向量空间表示等关键概念。通过阅读源码与说明,读者可以快速掌握余弦相似度的原理与实现方式,并将该脚本直接复用于文档去重、信息检索、推荐系统、智能问答等常见场景。

1. 余弦相似度算法在文本相似度匹配中的第一个坑:向量长度

做文本去重时,我碰到过一个很直观的现象:两段摘要明显是同一主题,按字符重合度看却不到一半,用 Jaccard 也低得离谱。后来换成余弦相似度算法,情况立刻变了——它计算的是两个词频向量的夹角余弦,只关心方向,不关心向量模长。也就是说,一篇 50 字的短句和一篇 5000 字的长文,只要词频方向一致,相似度可以是 1。这个特性正是它在文本相似度场景里比编辑距离、Jaccard 更常用的原因。适合刚接触 Python 文本处理的人作为入门案例,也适合在搜索引擎、推荐系统的召回阶段做初筛。

2. 文本向量化:分词、词表与词袋模型的距离

2.1 分词决定了余弦相似度算法的上限

余弦相似度算法本身不直接吃字符串,它吃数值向量。所以第一步是把文本拆成可以计数的单元。中文可以用 jieba,英文可以直接用 nltk.word_tokenize。这里有一个常见的误解:分词不是越细越好。拆成单字会丢掉词组信息,“北京”和“首都”在单字向量里完全不相关;拆成整句又得不到稳定维度。我一般在简单实现里先把标点、换行过滤掉,再用 jieba 的精确模式切词:

import jieba import re def tokenize(text): # 去掉非中英文和数字的字符,避免标点干扰词频统计 text = re.sub(r'[^\w\u4e00-\u9fa5]+', ' ', text) return [w for w in jieba.cut(text) if w.strip()]

这段代码核心是两件事:正则清理字符,jieba 按词典切分。re.sub里的\w对英文和数字有效,\u4e00-\u9fa5覆盖常用汉字,过滤掉标点和特殊符号,否则“。”会被当成一个词进入词表,白白增加维度。分词之后的结果是一个词列表,后续构建词表时需要对所有文档同时统计,不能只对单条文本统计,否则两个文本的向量维度对不上。

2.2 CountVectorizer 与 TfidfVectorizer 的取舍

向量化的常见做法有两种:词袋模型(BoW)和 TF-IDF。BoW 统计词频,实现简单,但“的”“了”这类高频停用词会占据很大的权重;TF-IDF 用词频乘逆文档频率,降低跨文档都出现的词的权重,让“余弦”“相似度”这类有区分度的词主导结果。下面这段代码把两条文本同时向量化:

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer texts = [ "Python 余弦相似度算法计算文本相似度", "使用Python的余弦相似度分析文本" ] bow_vec = CountVectorizer(tokenizer=tokenize) bow_matrix = bow_vec.fit_transform(texts) tfidf_vec = TfidfVectorizer(tokenizer=tokenize) tfidf_matrix = tfidf_vec.fit_transform(texts) print(bow_vec.get_feature_names_out())

这里的fit_transform分两步:fit建立词表,transform把每条文本映射成向量。bow_matrix是一个稀疏矩阵,行对应文本,列对应词表中的词。直接打印会看到(0, 2) 1这样的坐标形式,这是 scipy 稀疏矩阵的存储方式,不是 bug。后面的余弦相似度计算需要先.toarray()转成密集数组,或者用能处理稀疏矩阵的sklearn.metrics.pairwise.cosine_similarity。实际项目中我通常优先选 TF-IDF,因为它对高频无意义词的抑制是自动的,不需要手工维护太细的停用词表。

提示:CountVectorizer默认的token_pattern只匹配字母数字,中文会被整个丢掉,所以必须显式传入tokenizer参数。

维度CountVectorizerTfidfVectorizer
权重含义词频词频 × 逆文档频率
跨文档常见词权重偏高自动压低
适用场景关键词出现次数本身有意义文本相似度、检索排序
输出矩阵稀疏矩阵稀疏矩阵

如果拿两个独立训练的向量去算相似度,维度不同直接报错。工程里常见做法是拿全量语料先fit一次词表,再把新来的文本只做transform。这个“先 fit 后 transform”的顺序经常被忽略。新手容易对每条文本单独调用fit_transform,结果是每条文本的向量维度完全不同,np.dot会抛出 shape mismatch。所以要保存一个tfidf_vec对象,新文本进来时调用tfidf_vec.transform([new_text]),词表保持训练时的样子。

3. Python 实现余弦相似度核心算法:从 numpy 到源码解析

3.1 用 numpy 手写余弦相似度计算

给定两个向量 A 和 B,余弦相似度公式是(A·B) / (|A| × |B|)。使用 numpy 可以这样写:

import numpy as np def cosine_similarity(vec_a, vec_b): dot_product = np.dot(vec_a, vec_b) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) return dot_product / (norm_a * norm_b)

np.dot计算逐个元素乘积再求和,np.linalg.norm计算向量欧几里得范数,也就是向量的长度。这个函数的隐含假设是norm_anorm_b都不为 0。如果某个文本全被过滤成空字符串,对应的向量全零,np.linalg.norm返回 0,分母会变成 0,得到nan。在实际的simpe_text_cosine_similarity.py这类简单实现中,通常会在函数入口判断一下:

if norm_a == 0 or norm_b == 0: return 0.0

返回 0 表示其中一个文本没有有效特征,和任何文本都不相似。这在代码逻辑上说得通,但如果你面对的是一批短文本,空向量比例可能很高,需要留意 0 值的数量,否则最终相似度矩阵会有一大片 0,看不出真实差异。

3.2 稀疏矩阵与密集数组的转换陷阱

CountVectorizer返回的bow_matrixscipy.sparse.csr_matrix,直接传入np.dot时,numpy 可能会把它当成对象数组处理,结果不是期望的数值。常见做法是.toarray()转成二维数组,再取第一行。下面是完整示例:

from sklearn.metrics.pairwise import cosine_similarity vec1 = tfidf_matrix.toarray()[0] vec2 = tfidf_matrix.toarray()[1] print(cosine_similarity([vec1], [vec2])[0][0])

这里我用了 sklearn 内置的余弦相似度函数,它内部对稀疏矩阵做了优化,不会真的转成 dense。cosine_similarity接收二维数组,每行是一个文本向量,返回值是二维相似度矩阵。很多人在这段代码上踩坑:直接传vec1vec2两个一维数组,返回值变成标量或者形状不一致。必须用[vec1]包一层,让它变成1 × n的矩阵。

这两种写法效果一样,但性能差别明显。几万条文本做两两相似度时,.toarray()会把稀疏矩阵膨胀成巨大的 dense 矩阵,内存直接翻车。所以生产代码里我会直接用cosine_similarity(tfidf_matrix),它内部用稀疏矩阵乘法实现,只计算非零位置的乘积,速度能快一个数量级。

py3-cosine-similiarity-master这类小项目中,通常把分词、向量化、相似度计算拆成三个函数。Readme.md里会写调用流程,.gitignore排除虚拟环境。一个可复用的做法是把cosine_similarity封装成脚本入口,接收两个文本路径或者命令行参数:

python simpe_text_cosine_similarity.py "文本A" "文本B"

脚本内部依次调用tokenizeTfidfVectorizercosine_similarity,最后输出0.87这样的浮点数。这个实现的关键不是函数多复杂,而是数据格式在每一步都要对齐:分词返回列表,向量化返回稀疏矩阵,相似度函数接收二维矩阵。只要这一步不统一,后续所有封装都会报错。

4. 停用词、词干提取与相似度阈值的工程调优

4.1 预处理对余弦相似度算法结果的影响

在基础实现跑通后,相似度数字往往不符合直觉。比如“我用Python写爬虫”和“我爬虫用Python写”词频完全一样,余弦相似度是 1,这正确。但“Python写爬虫”和“用Python做数据分析”可能只有 0.2,因为共享词太少。这时候第一反应不是调算法,而是检查预处理。停用词表能显著改变结果:

stop_words = {'的', '了', '和', '与', '在', '是', '对', '中', '就', '都'} def tokenize_with_stopwords(text): text = re.sub(r'[^\w\u4e00-\u9fa5]+', ' ', text) return [w for w in jieba.cut(text) if w.strip() and w not in stop_words]

加上停用词之后,无意义的“的”“了”不再进入词表,向量维度缩小,和主题相关的词权重相对变大。但要小心,停用词不一定都是虚词,在特定领域“计算”“使用”这种高频词可能也不该参与相似度。工程上我用的是先看词频 Top 30,再人工决定哪些词加入停用表。盲目套用网上停用词表会把领域关键词删掉。

词干提取对英文有意义,对中文一般不用。中文不存在runningrun的形态变化,但英文文本可以加PorterStemmer。如果语料是中英混合,我通常会单独处理:中文走 jieba,英文走word_tokenizeSnowballStemmer,最后把两个词列表拼起来。代码里要小心nltk.download('punkt')这个网络初始化步骤,在离线环境会直接报错,需要提前把 punkt 包放进 nltk_data。

4.2 相似度阈值怎么定才不算瞎调

阈值是文本相似度项目里最容易被问到的参数。0.8 以上才算相似?不一定。取决于向量化方式和文本长度。文本越长,非零维度越多,余弦值普遍偏小。我做过一个短标题去重任务,TF-IDF 向量的相似度分布集中在 0.1~0.4,0.5 以上明显是同一商品的变体。另一个长文档分类任务,同类文档相似度也只有 0.4。所以正确的做法是先跑一批样本,把相似度分布画出来,再根据任务选择阈值。下面这个表格是常见的经验范围:

场景向量化方式经验阈值
短文本去重TF-IDF0.8 以上
新闻标题聚类BoW0.6 以上
长文档相似TF-IDF0.4 以上
推荐召回TF-IDF0.3 以上可入候选

这个表不是标准答案,它只是说明不同场景下同一套余弦相似度算法输出的分布完全不同。调阈值时至少要看一个批次的正负样本分布,而不是拍脑袋定 0.8。如果分布图显示两个类别都堆在 0.3 附近,就说明特征工程有问题,改阈值没有意义。

4.3 相似度计算失败时优先排查的三个点

遇到nan先查零向量,遇到维度不一致查 fit 和 transform 是否分开。遇到相似度全部为 0 则查分词是否成功,特别是中文文本没有传tokenizer参数时,CountVectorizer默认正则匹配不出中文,词表为空,结果自然全零。这三个点覆盖了我在这个简单实现里遇到的绝大多数问题。排查时在分词函数后加一行print(tokens),比反复计算相似度直观得多。

5. 余弦相似度算法批量比较文本的向量化进阶写法

5.1 一次调用算出所有文本两两相似度

当文本数量从 2 条变成 2000 条,仍然用双重循环调用cosine_similarity会非常慢,而且代码难看。正确做法是直接把整个tfidf_matrix传给cosine_similarity,一次得到 N×N 矩阵:

from sklearn.metrics.pairwise import cosine_similarity import numpy as np # tfidf_matrix 的形状是 (n_samples, n_features) sim_matrix = cosine_similarity(tfidf_matrix) print(sim_matrix.shape) # 把对角线置 0,方便找最大相似度 np.fill_diagonal(sim_matrix, 0) for i, row in enumerate(sim_matrix): j = np.argmax(row) print(f"sample {i} 最相似的是 sample {j},相似度 {row[j]:.3f}")

这里cosine_similarity会自动处理稀疏矩阵,内部用矩阵乘法计算,避免了 Python 层循环。np.fill_diagonal把每个文本和自身的相似度 1 清零,否则argmax永远指向自己。输出时用 f-string 格式化相似度保留三位小数,方便快速检查阈值。

这个写法的计算量级是 O(n²),2000 条文本生成 400 万个数值,内存大约 32MB,尚可接受。如果文本量上到几万,sim_matrix就会膨胀到几十 GB,这时候要用 minhash 或 SimHash 做候选召回,再用余弦相似度精排。这是另一个话题,但你要清楚余弦相似度算法的边界:它能处理中等规模两两比较,不适合海量全连接。

5.2 用 Top-K 相似结果验证阈值是否合理

批量算完相似度后,我通常会看每个样本的最相似 Top3 分布。如果绝大多数样本的最相似相似度都低于 0.2,说明语料本身区分度差,或者预处理过于激进。反之,如果所有样本最相似度都接近 1,说明文本几乎重复,任务可能不需要相似度模型。随着文本数据积累,这个验证步骤应该固化成脚本的一部分,每次换语料都跑一遍。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 2:43:08

Chrome插件MV3工程化实战:端侧AI与跨进程通信优化

1. 这不是“加个弹窗”的时代了:一个真实插件工程师的日常我去年接手过一个需求:给某电商比价平台的 Chrome 插件增加“智能比价摘要”功能——不是简单抓取价格,而是要实时分析商品详情页的图文、用户评论、参数表格,生成一段带可…

作者头像 李华
网站建设 2026/9/15 2:43:05

Tabby终端美化与深度配置:从安装到插件打造的跨平台终端工作台

说实话,我一度对Windows上找一款好用的终端这件事已经不抱什么希望了。系统自带的cmd和PowerShell Console,界面停留在上个时代,连个标签页都要靠第三方工具硬撑;Windows Terminal虽然底子不错,但默认配置那个丑样子&a…

作者头像 李华
网站建设 2026/9/15 2:42:34

超级碗前夕霸屏北美:追觅的品牌跃迁营销拆解

超级碗前夕,打开纽约时代广场的直播画面,或者刷几轮TikTok、YouTube,你会发现一个高频出现的名字——Dreame追觅。扫地机器人、洗地机、高速吹风机的广告轮番出现,从户外巨幕到手机信息流,密集到什么程度?我…

作者头像 李华
网站建设 2026/9/15 2:41:48

御剑Web目录扫描工具实战:原理、配置与WAF绕过技巧

市面上叫“御剑”的Web目录扫描工具确实流传很广,很多做Web渗透测试或者站点运维的朋友都听说过。这个工具的核心价值在于快速发现Web应用中的隐藏目录和敏感文件,在授权测试和信息收集阶段能帮上大忙。不过很多网上下载的所谓“珍藏版”包里往往夹杂着各…

作者头像 李华
网站建设 2026/9/15 2:41:23

Excel函数入门:5个高频函数搞定办公数据匹配、统计与清洗

做了这么多年办公软件培训,我经常被问到同一个问题:“Excel到底学什么最值钱?”我的答案一直很稳定——先把函数吃透。真正值钱的Office能力,从来不是会插入个图表、会做个漂亮表格,而是能用Excel函数把重复劳动变成自…

作者头像 李华
网站建设 2026/9/15 2:41:08

工业自动化GEO优化服务商选型指南:5类画像与合同避坑要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华