简介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_df1, # 至少在 1 篇文档里出现才保留 max_df0.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(keylambda x: x[1], reverseTrue) # 跳过下标 0 的自己 return [idx for idx, score in scores[1:k 1]] print(top_k_similar(0, sim_matrix, k2))这个做法胜在简单cosine_similarity 一次算完所有两两组合配合排序就能拿到 TopK。小数据集上完全够用几千篇文档也能扛住。真正的瓶颈出现在几万篇以上做全量两两比较的时候那部分我放在最后一章讲。3.3 四个必调参数ngram_range、min_df、max_df、停用词TfidfVectorizer 参数很多但我实际调参时真正动手的只有四个。下面这张表是起始值的参考具体取值要拿自己的语料跑一遍再定。参数作用起始建议什么时候调整ngram_range是否保留词组合(1, 1) 或 (1, 2)文本较短标题类用 (1, 2) 能捕捉词组min_df过滤低频词1语料很大且低频词全是噪音时调到 2 或 3max_df过滤高频通用词0.9中文语料用 0.5~0.7 能把“我们、进行”压掉stop_words手动指定停用词空列表观察词表后按领域补充不要直接传 english高频词过滤这块最容易翻车。max_df 设成 0.9 的意思是“在 90% 以上的文档里都出现过的词不进入词表”这个阈值在中文语料里通常太松。我有一个写过很多次的配置是 max_df0.6 配合自定义停用词对新闻类和工单类文本都有效。vectorizer TfidfVectorizer( min_df2, max_df0.6, ngram_range(1, 2), stop_words[的, 了, 是, 在, 和, 我们, 进行], token_patternr(?u)\b\w\w\b ) tfidf vectorizer.fit_transform(corpus)注意 token_pattern 这一项因为我们已经把中文分词并用空格拼接过sklearn 的默认正则就能正确按词匹配不需要额外改。如果没做预分词这一项怎么调都救不回来这是顺序问题。4. 文本相似度分析最常见的五个坑现象、原因与排查步骤这一章写的是我在真实数据上踩过、也帮别人排查过的坑。每一条都按“现象、原因、解决”来写你可以拿来做排查清单。4.1 余弦相似度算出 nan零向量没有兜底现象cosine_similarity 的输出里出现一堆 nan打印出来不是数字程序不报错但结果没法用。原因某篇文档分词后没有剩任何有效词或者 min_df 设得太高把它的词全部过滤了导致向量整行为 0。零向量的模是 0余弦相似度分子分母同时为 0sklearn 在计算时会得到 nan。还有一种隐蔽的情况一篇文档全是停用词词表里查不到它。解决向量化之后先检查每行的 L2 范数范数为 0 的文档单独标记在业务里给它一个默认相似度 0不要进余弦计算。import numpy as np norms np.linalg.norm(tfidf.toarray(), axis1) # 范数接近 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, encodingutf-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(flabel{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结果一批模板化重复文本被大量漏掉。排查后发现这些文本虽然主题一致但被拼接了很长的无关后缀把余弦分数拉低了。后来我养成的习惯是拿到新数据先跑一轮正负样本对观察分数分布再定阈值而不是拍脑袋写死一个数。这个习惯帮我避开了很多后期返工希望也能帮到你。本文还有配套的精品资源点击获取