☰
文本相似度分析实战:从Jaccard到TF-IDF余弦相似度
2026/9/30 15:46:16 网站建设 项目流程

简介:PDF文档围绕Python文本相似度分析这一主题展开,面向希望入门自然语言处理、实现文档匹配或文本检索的Python开发者。内容以jieba中文分词和gensim相似度计算为主线,先通过jieba.cut对目标文档与测试文档执行分词,再使用corpora.Dictionary建立词袋编号,并利用doc2bow将分词结果转换成词频稀疏向量;随后引入TfidfModel计算TF-IDF权重,借助SparseMatrixSimilarity构建相似度索引,最终通过余弦相似度衡量文本之间的距离,值越接近1代表越相似。文中以8个目标文档和1个测试文档作为完整示例,从分词、编号、语料库制作到相似度排序逐段讲解,还特别指出实际应用中应去除停用词、可结合词形还原等优化方向。实验环境为Anaconda下的Jupyter Notebook,便于读者边看边练。资源仅含1个PDF文件,压缩包大小63KB,轻量便携。目前已有3060人学习,适合作为文本挖掘、信息检索和推荐系统等应用开发的参考。

1. 文本相似度分析不只是“算个相似”:先想清楚业务要什么

我见过很多从零做文本相似度分析的人,包括我自己刚开始,第一反应都是“找两个句子,算出重合度”。等真的拿爬虫抓回来的网页标题、客服工单描述、商品列表去跑,才发现计算结果和业务判断经常对不上:明明说的是同一件事,字面上却只有一两个词相同;看着很像的两段话,语义却差得很远。文本相似度分析这套东西,核心难点反而不在相似度公式本身,而在把“相似”这个概念变成一条可计算的流程:分词、向量化、相似度量、阈值判定。这篇文章给你一条能直接复现的路径,从两个朴素算法写到 TF-IDF 加余弦相似度,再把实际工程里的坑一并讲清楚。适合正在做文本去重、搜索召回、工单聚合,以及数据分析与可视化场景的从业者;新手可以跟着代码跑通,熟手可以直接拿走参数边界和避坑清单。

2. 从两种朴素算法说起:Jaccard 与编辑距离的适用边界

我一般建议不要一上来就上向量模型,先拿最简单的算法跑一遍数据,观察结果和业务的差距,再决定要不要换更重的手段。这一章说的 Jaccard 和编辑距离,属于“字面层”的相似度,速度快、可解释性强,很多去重场景其实用到这一层就够。

2.1 Jaccard:集合重合率怎么算,以及它适合筛掉哪类重复

Jaccard 相似度的定义很直白:两个集合的交集大小除以并集大小,结果落在 0 到 1 之间。落在文本上,就是把两句话分别切成词的集合,然后看有多少词是共有的。这个思路最适合判断“两段文本是不是同一段话被复制粘贴了”这类场景,典型例子是网页正文被不同站点重复采集,或者商品标题里混入了几乎一模一样的文案。

中文数据要先分词再转集合,不然就要用字符级。字符级实现虽然简单,但会把语序信息全部丢干净,比如“苹果”和“果苹”这两个词,字符集合完全相同,Jaccard 会给出 1.0,这显然不符合业务直觉。用 jieba 切成词级集合,能把这个问题缓解不少。

import jieba def jaccard_similarity(text_a: str, text_b: str, use_char: bool = False) -> float: # 字符级:直接把字符串转成字符集合,适合 OCR 之类的快速去重 if use_char: chars_a, chars_b = set(text_a), set(text_b) else: # 词级:先切词,再转成集合,语序信息仍会被丢弃 chars_a, chars_b = set(jieba.lcut(text_a)), set(jieba.lcut(text_b)) # 空集合没有交集概念,直接返回 0,避免除零 if not chars_a or not chars_b: return 0.0 inter = len(chars_a & chars_b) union = len(chars_a | chars_b) return inter / union print(jaccard_similarity("苹果手机屏幕碎了", "iPhone屏幕摔坏了"))

这段代码逻辑上就两步:jieba.lcut 把句子切成词的列表,set 去重后取交集与并集。返回的分数反映的是“两句话在词面上的重合程度”。注意它完全不在意词的顺序,所以“我看书”和“书看我”会得到很高的相似度,这个特性决定了它只适合粗筛,不适合做语义判断。

词级 Jaccard 对“苹果手机屏幕碎了”和“iPhone屏幕摔坏了”这类语义相同但用词不同的数据,分数会很低,因为交集只有“屏幕”一个词。这是它的固有边界,不是参数能调好的问题。实际操作里我会把标点和空白字符在分词前先清掉,否则“屏幕碎了”和“屏幕碎了。”会被当成两个不同的词条,白白拉低相似度。

2.2 编辑距离:适合短文本模糊匹配,不适合长文档

编辑距离也叫 Levenshtein 距离,定义是一个字符串最少经过多少次“增、删、改”操作才能变成另一个字符串。它和 Jaccard 的视角完全不同:Jaccard 看的是“有哪些词相同”,编辑距离看的是“整体上差了多少步”。

这个算法用在短文本上非常顺手。比如 OCR 识别出来的标题经常带一两个错字,“华为P30 Pro”被识别成“华为P3O Pro”,编辑距离只有 1,一算就知道是同一件东西。再比如地址字段匹配、Excel 里两列客户名的模糊对齐,都是它的舒适区。

def edit_distance(s1: str, s2: str) -> int: # dp[i][j] 表示 s1 前 i 个字符到 s2 前 j 个字符的最小编辑次数 m, n = len(s1), len(s2) dp = [[0] * (n + 1) for _ in range(m + 1)] # 边界:空串变成任意串的距离等于串长 for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if s1[i - 1] == s2[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min( dp[i - 1][j], # 删除 s1 的一个字符 dp[i][j - 1], # 在 s1 中插入一个字符 dp[i - 1][j - 1] # 替换 s1 的一个字符 ) return dp[m][n] def edit_similarity(s1: str, s2: str) -> float: # 把距离归一化到 0~1,距离越大相似度越低 max_len = max(len(s1), len(s2)) if max_len == 0: return 1.0 return 1 - edit_distance(s1, s2) / max_len print(edit_similarity("苹果手机屏幕碎了", "iPhone屏幕摔坏了"))

代码里的二维数组 dp 是核心,转移方程就三行,对应删除、插入、替换三种操作。这个实现是教科书标准版,适合理解,但性能一般,时间复杂度是 O(m n)。两三百字的短文本没问题,一旦文档到了几千字,计算量会快速膨胀,我在实际项目里基本只拿它处理标题、地址、编号这些短字段。

还要提醒一句:编辑距离同样不懂语义。“苹果手机坏了”和“手机无法开机”描述的是同一件事,编辑距离却很大。所以在业务里它和 Jaccard 一样,只能当“字面模糊匹配”的工具,真要处理同义词改写,得靠后面的向量化方案。

3. 用 TF-IDF 加余弦相似度跑通两两文本对比:参数怎么调才不翻车

上一章的两种算法都卡在“字面重合”上。想往前走一步,通常的做法是把文本转成向量,再用余弦相似度衡量方向上的接近程度。这套方案里,TF-IDF 是最容易落地的一个,不需要训练模型,几十行代码就能跑起来。

3.1 为什么是 TF-IDF 而不是纯词频

最直觉的向量化方式是词频,也就是统计每个词在文档里出现了多少次。但词频有个明显问题:文档越长,每个词出现的绝对次数就越高;像“的、了、是、在”这类停用词,几乎每篇文档都有,会把真正的主题词淹没掉。

TF-IDF 在词频基础上乘了一个逆文档频率。一个词在越少的文档里出现,它的权重越高,这意味着它越能代表当前这篇文档的主题。常见词的逆文档频率接近 0,权重自然被压下来。sklearn 的 TfidfVectorizer 默认实现还带平滑,避免文档数或词频为 0 时出现除零。

这一步对中文还有一个绕不开的前置操作:分词。中文没有天然的空格分隔,如果直接把原始字符串传给 TfidfVectorizer,它会按默认正则把一整段连续中文当成一个 token,得到的向量完全没有意义。所以标准流程是先用 jieba 切词,再用空格拼回去,让 TF-IDF 按词切分。

3.2 最小可运行代码:jieba 加 TfidfVectorizer 加 cosine_similarity

先把依赖装齐,这一步对应很多人卡住的 python 安装 sklearn 库的过程。建议在虚拟环境里执行,避免把包装到系统 Python 里导致 import 时找不到。

pip install jieba scikit-learn

然后是最小可运行的完整脚本。我习惯把分词单独封装成一个函数,这样后面换分词器、加自定义词表,只需要改一处。

import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def zh_cut(text: str) -> str: # 中文先切词再用空格拼接,这是 sklearn 能正确处理中文的前提 return " ".join(jieba.lcut(text)) docs = [ "Python实现简单的文本相似度分析操作详解", "用Python做文本相似度计算", "文本相似度分析的工程落地笔记", "今天天气不错适合出门" ] corpus = [zh_cut(d) for d in docs] vectorizer = TfidfVectorizer( min_df=1, # 至少在 1 篇文档里出现才保留 max_df=0.9, # 超过 90% 文档都含有的词视为通用词,过滤 ngram_range=(1, 2) # 同时保留单个词和相邻两词组合 ) tfidf = vectorizer.fit_transform(corpus) sim_matrix = cosine_similarity(tfidf) print(sim_matrix)

运行后你会得到一个 4x4 的相似度矩阵,第 i 行第 j 列的值表示第 i 篇和第 j 篇文档的相似度,对角线必然是 1.0。我的习惯是先打印矩阵,看一眼整体分布,再去思考阈值设多少,而不是直接跳到某个具体业务结果。

如果只想看某篇文档最相似的前几篇,可以写一个小函数。排序时注意把自己排除掉,日常业务里很少需要“一篇文档和自己比”。

def top_k_similar(target_idx: int, sim_matrix, k: int = 3) -> list: # 枚举目标行所有相似度,按分数从高到低排序 scores = list(enumerate(sim_matrix[target_idx])) scores.sort(key=lambda x: x[1], reverse=True) # 跳过下标 0 的自己 return [idx for idx, score in scores[1:k + 1]] print(top_k_similar(0, sim_matrix, k=2))

这个做法胜在简单:cosine_similarity 一次算完所有两两组合,配合排序就能拿到 TopK。小数据集上完全够用,几千篇文档也能扛住。真正的瓶颈出现在几万篇以上做全量两两比较的时候,那部分我放在最后一章讲。

3.3 四个必调参数:ngram_range、min_df、max_df、停用词

TfidfVectorizer 参数很多,但我实际调参时真正动手的只有四个。下面这张表是起始值的参考,具体取值要拿自己的语料跑一遍再定。

参数作用起始建议什么时候调整
ngram_range是否保留词组合(1, 1) 或 (1, 2)文本较短(标题类)用 (1, 2) 能捕捉词组
min_df过滤低频词1语料很大且低频词全是噪音时调到 2 或 3
max_df过滤高频通用词0.9中文语料用 0.5~0.7 能把“我们、进行”压掉
stop_words手动指定停用词空列表观察词表后按领域补充,不要直接传 "english"

高频词过滤这块最容易翻车。max_df 设成 0.9 的意思是“在 90% 以上的文档里都出现过的词不进入词表”,这个阈值在中文语料里通常太松。我有一个写过很多次的配置是 max_df=0.6 配合自定义停用词,对新闻类和工单类文本都有效。

vectorizer = TfidfVectorizer( min_df=2, max_df=0.6, ngram_range=(1, 2), stop_words=["的", "了", "是", "在", "和", "我们", "进行"], token_pattern=r"(?u)\b\w\w+\b" ) tfidf = vectorizer.fit_transform(corpus)

注意 token_pattern 这一项:因为我们已经把中文分词并用空格拼接过,sklearn 的默认正则就能正确按词匹配,不需要额外改。如果没做预分词,这一项怎么调都救不回来,这是顺序问题。

4. 文本相似度分析最常见的五个坑:现象、原因与排查步骤

这一章写的是我在真实数据上踩过、也帮别人排查过的坑。每一条都按“现象、原因、解决”来写,你可以拿来做排查清单。

4.1 余弦相似度算出 nan:零向量没有兜底

现象:cosine_similarity 的输出里出现一堆 nan,打印出来不是数字,程序不报错但结果没法用。

原因:某篇文档分词后没有剩任何有效词,或者 min_df 设得太高把它的词全部过滤了,导致向量整行为 0。零向量的模是 0,余弦相似度分子分母同时为 0,sklearn 在计算时会得到 nan。还有一种隐蔽的情况:一篇文档全是停用词,词表里查不到它。

解决:向量化之后先检查每行的 L2 范数,范数为 0 的文档单独标记,在业务里给它一个默认相似度 0,不要进余弦计算。

import numpy as np norms = np.linalg.norm(tfidf.toarray(), axis=1) # 范数接近 0 的文档向量视为无效,相似度直接置 0 valid_mask = norms > 1e-8

加了这个保护之后,矩阵里就不会再出现 nan。顺便也提醒一句:这类空文档在项目初期就该被清洗掉,否则后面每一步都要为它写特判。

4.2 jieba 把专业词切碎,相似度被系统性低估

现象:两篇包含同一个专业术语的文档,相似度反而低于预期。比如“量化交易策略”被切成“量化”和“交易策略”,“ChatGPT”被切成“Chat”和“GPT”,同一个词在两篇文档里没能对齐。

原因:jieba 的默认词典是通用语料训练的,对行业黑话、新词、品牌名不友好。这个问题在金融、医疗、编程领域尤其明显。

解决:分词前先注册自定义词表。最直接的方式是调用 jieba.add_word,也可以加载一个自定义词典文件,每行一个词。

提示:自定义词必须在第一次分词前加载,否则 jieba 的缓存会绕过新词,你改了也白改。

import jieba jieba.add_word("量化交易") jieba.add_word("ChatGPT") jieba.add_word("文本相似度分析")

加完词之后要重新跑分词,词表也会变化,所以最好把“加载词表、分词、向量化”写成一个完整流程,避免在调试时漏掉某一步。

4.3 不预分词就把中文原始字符串喂给 TfidfVectorizer

现象:词表里出现一长串连续中文,vectorizer 的词汇表维度小得离谱,相似度矩阵全都很接近或差异很小。

原因:TfidfVectorizer 的默认 token_pattern 是按英文习惯设计的,对没有空格的连续中文会把整段话当做一个 token,等于完全没切词。网上很多 python 教程拿英文语料演示,照搬到中文数据上几乎必现这个现象。

解决:中文数据必须“先分词、再空格拼接”,把切好的词用空格隔开之后,sklearn 才能按词处理。

text = "Python实现简单的文本相似度分析操作详解" tokenized_text = " ".join(jieba.lcut(text)) # 输出形如:Python 实现 简单 的 文本 相似度 分析 操作 详解

这一步是中文文本相似度分析的分水岭,做对了后面的流程和英文处理完全一致;没做对,后面调多少参数都救不回来。

4.4 Windows 下读文件直接崩溃:编码问题

现象:open("texts.csv").read() 抛出 UnicodeDecodeError,程序在读取阶段就挂了,根本走不到相似度计算。

原因:Windows 默认的文本编码是 gbk,而大多数爬虫、导出工具生成的文件是 utf-8。open 函数不指定 encoding 时,会按系统默认编码去解 utf-8 文件,中文必然解码失败。

解决:读文件时显式传入编码参数,我一般统一用 utf-8。如果源文件可能是其他编码,先用二进制方式读入,再用 chardet 检测。

with open("texts.csv", encoding="utf-8") as f: lines = f.readlines()

顺手说一个关联场景:做完相似度分析后想把结果写入 Excel,同理要指定编码,写成 utf-8-sig 可以避免 Excel 打开 CSV 时中文乱码。这个细节我在导出结果时被坑过一次,之后写文件就再没漏过。

4.5 相似度高不等于业务要的“重复”

现象:两篇文章都在讲 Python 语法,余弦相似度给到 0.8,业务方却说这俩完全不重复,不该被合并。

原因:TF-IDF 余弦衡量的是词汇分布层面的主题相关,不是“同一篇内容”。同一主题、不同来源、不同观点的文章,词袋高度重合,分数自然高。

解决:把业务目标拆成两级处理。第一级用向量相似度做候选召回,圈定“主题可能相关”的集合;第二级用规则精排,比如标题是否完全一致、正文长度比是否在合理范围、关键数字和实体是否重合。只靠一个阈值做全量判定,几乎必然会在某个方向误伤。

这个坑不在于算法选错,而在于没有把“相似”的定义锚定在业务语言上。去重任务要的是“内容等价”,搜索召回要的是“主题相关”,两者对相似度的解释完全不同。

5. 上线前的阈值校验法:用人工样本对代替拍脑袋

5.1 构造正负样本对,把阈值标定出来

很多教程讲完相似度计算就结束了,实际项目里真正难的是“阈值定多少”。我的习惯是构造一批正负样本对:正样本对是业务上认定重复或相关的文本对,负样本对是肯定不相关的文本对。每类至少 50 条,覆盖长度差异、同义词改写、标点变化这些真实情况。

pairs = [ # (文本A, 文本B, 期望标签:1 相关 / 0 不相关) ("苹果手机屏幕碎了", "iPhone屏幕摔坏了", 1), ("Python实现简单的文本相似度分析", "用Python做文本相似度计算", 1), ("苹果手机屏幕碎了", "今天天气不错适合出门", 0), ("Python实现简单的文本相似度分析", "股票量化交易策略回测", 0), ] for a, b, label in pairs: ca = " ".join(jieba.lcut(a)) cb = " ".join(jieba.lcut(b)) vec_a = vectorizer.transform([ca]) vec_b = vectorizer.transform([cb]) score = cosine_similarity(vec_a, vec_b)[0][0] print(f"label={label}, score={score:.3f}, text_a={a}, text_b={b}")

跑完这一组,你会看到正负样本的分数分布。正常情况下正样本集中在高位,负样本集中在中低位,中间有一段重叠区。阈值就选在重叠区里“误伤负样本最少”的位置,而不是凭感觉写个 0.8。业务如果更怕漏报,阈值可以适当下调;更怕误报,则上调。

5.2 数据量大时先粗筛,再精算

最后补一个工程技巧:几万篇文档做全量两两比较,复杂度是 O(n²),哪怕 TF-IDF 向量是稀疏的,计算量也会很快失控。我一般会先做粗筛,再对候选集做精算。

粗筛的常见做法是先用字符级 Jaccard 或者文本长度比过滤一遍:两篇文档长度比超过 2 的,或者字符级 Jaccard 低于 0.3 的,直接跳过,不进入向量计算。这样可以把真正需要精算的文本对压缩到很小的比例。到了几十万篇的规模,再考虑用向量检索库做近似最近邻,但那个前提依然是先把本方案跑通。

有次我把阈值定在 0.85,结果一批模板化重复文本被大量漏掉。排查后发现这些文本虽然主题一致,但被拼接了很长的无关后缀,把余弦分数拉低了。后来我养成的习惯是:拿到新数据先跑一轮正负样本对,观察分数分布,再定阈值,而不是拍脑袋写死一个数。这个习惯帮我避开了很多后期返工,希望也能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询