
简介面向Python初学者的文本相似度分析实操讲解围绕jieba中文分词与gensim库构建TF-IDF模型展开。文中通过8个目标文档与1个测试文档的具体案例逐步演示分词、制作词袋、构建语料库、计算TF-IDF向量及余弦相似度的完整流程并给出相似度排序与结果解读适合希望掌握NLP基础文本匹配任务的开发者参考。资源为单个PDF文档共1个文件、大小63KB内容精炼且便于按步骤阅读和对照实践。已有3060人学习。文档从环境说明开始包含完整示例代码、运行输出和相似度排序结果重点解释了Dictionary、doc2bow、TfidfModel与Similarity等关键对象的使用方法能够帮助读者快速理解文本向量化与相似度计算的实现技巧以及实际应用中去除停用词、词形还原等注意事项。1. 文本相似度分析用 8 个短文档解释我喜欢上海的小吃应该匹配谁做搜索排序、推荐系统或者文档去重时最常遇到的问题就是用户输入一句话怎么和一堆既定文档计算相近程度。直接比字符重叠中文几乎行不通比如上海好吃的在哪里和上海好玩的在哪里只差一个词但语义完全不同如果只统计词频又会被上海这种高频词带偏导致每个跟上海有关的文档都被抬到一个很高的分数上。这篇文章就用一个最小例子8 个目标文档加 1 个测试文档完整跑通 jieba 分词、gensim 词袋模型、TF-IDF 加权和稀疏矩阵相似度这整条链路并演示去掉停用词之后相似度排序会发生什么变化。适合刚接触 Python NLP 的工程师也适合想快速搭一个文本相似度服务的同学参照。2. 中文分词与词袋模型jieba 和 Dictionary 的正确打开方式文本相似度计算的第一步是把连续字符串切成有意义的词。中文没有空格边界分词器的切分质量直接决定后续模型的输入这里先用 jieba 把问题简化。2.1 jieba 三种分词模式为什么精确模式是默认选择jieba 的cut方法默认使用精确模式适合文本分析因为切出来的词数量最少后续 TF-IDF 算出来更稳定。全模式会把所有能成词的组合都捞出来比如遇到北京大学生会同时切出北京北京大学大学大学生这在给搜索引擎建索引时有用但用于相似度计算会让一个实体被拆成多个维度稀释权重。搜索引擎模式则是全模式的补充会进一步把长词切成更细的片段同样会增加向量中的噪声适合检索召回而不是相似度排序。import jieba doc_test 我喜欢上海的小吃 print(list(jieba.cut(doc_test))) # 精确模式 print(list(jieba.cut(doc_test, cut_allTrue))) # 全模式代码逻辑很简单jieba.cut返回一个生成器必须用list()转成列表才能看到完整分词结果。精确模式下doc_test 被切成[我, 喜欢, 上海, 的, 小吃]这个结果和后面 gensim 的 Dictionary 是直接对接的。如果使用全模式同一个句子在不同 jieba 版本下可能会混入额外组合词导致词袋维度波动所以本项目的所有实验都保留默认精确模式。2.2 用 Dictionary 把分词结果转成稀疏向量gensim 的corpora.Dictionary负责把所有文档里出现的词映射到一个整数 id这个映射决定向量空间的坐标系。先汇总 8 个目标文档并统一分词from gensim import corpora doc0 我不喜欢上海 doc1 上海是一个好地方 doc2 北京是一个好地方 doc3 上海好吃的在哪里 doc4 上海好玩的在哪里 doc5 上海是好地方 doc6 上海路和上海人 doc7 喜欢小吃 all_doc [doc0, doc1, doc2, doc3, doc4, doc5, doc6, doc7] # 列表推导式一次完成分词比手写 for 循环更简洁 all_doc_list [list(jieba.cut(doc)) for doc in all_doc] dictionary corpora.Dictionary(all_doc_list) print(dictionary.token2id)输出中可以看到上海: 0、喜欢: 2、小吃: 17这样的映射。关键点在于测试文档必须复用同一个dictionary否则两边的词 id 对不上相似度计算就是错的。token2id是词到编号的字典dictionary.keys()就是特征空间的维度。接着把测试文档也转成词袋向量doc_test_list list(jieba.cut(我喜欢上海的小吃)) doc_test_vec dictionary.doc2bow(doc_test_list) print(doc_test_vec)输出为[(0, 1), (2, 1), (3, 1), (12, 1), (17, 1)]这里的每个元素都是词编号词频二元组比如(0, 1)表示上海在测试文档中出现 1 次。doc2bow会忽略没有匹配到词典的词所以如果测试文档里出现训练语料中没见过的词它会被静默丢弃。下表列出 doc_test 各个词的编号和频次词词典编号在 doc_test 中频次上海01喜欢21我31的121小吃171稀疏向量的好处是不出现的词不需要存 0极大节省内存。这一步做完8 个目标文档的语料库corpus也通过同样的dictionary.doc2bow(doc)生成后续 TF-IDF 模型直接基于这个语料库计算。3. TF-IDF 加权用模型把上海的干扰降下来词袋模型把文本变成了词频向量但直接比较词频向量有一个明显问题高频通用词会主导相似度。这一章把 TF-IDF 的作用讲清楚并给出可运行的 gensim 实现。3.1 为什么词频向量不能直接比较IDF 才是区分度来源在上一章的语料里上海出现在 doc0、doc1、doc3、doc4、doc5、doc6 共 6 个文档中而小吃只出现在 doc7 一个文档中。如果直接算余弦相似度测试文档我喜欢上海的小吃和 doc0我不喜欢上海会因为有三个公共词而排得很靠前甚至超过 doc7喜欢小吃。但从语义上讲喜欢小吃显然更接近用户意图。TF-IDF 的核心思想就是一个词在越少的文档里出现它对当前文档的区分能力越强权重应该越高。gensim 的TfidfModel默认使用ln(1 N / (1 df))作为 IDF 计算公式其中 N 是语料库文档总数df 是包含该词的文档数。不需要自己实现公式但要看得懂权重输出from gensim import models # corpus 是第 2 章生成的 8 个 doc2bow 列表 tfidf models.TfidfModel(corpus) test_tfidf_vec tfidf[doc_test_vec] print(test_tfidf_vec)输出如下[(0, 0.08112725037593049), (2, 0.3909393754390612), (3, 0.5864090631585919), (12, 0.3909393754390612), (17, 0.5864090631585919)]逻辑说明编号 0 是上海权重只有 0.081因为它在语料库中太常见编号 17 是小吃权重 0.586因为只出现在 doc7 里编号 3 是我权重 0.586原因是我在语料库中只出现在 doc0 和 doc_test 里。注意这里我的权重高是语料库规模太小导致的实际项目中需要靠停用词表清洗。下表汇总了这三个词的分布差异词语料库中出现文档数IDF 效果对相似度的影响上海6很低贡献极小容易制造假相关喜欢2中等正常贡献小吃1很高关键区分词强烈拉近 doc73.2 SparseMatrixSimilarity用稀疏矩阵批量算余弦相似度得到测试文档的 TF-IDF 向量后还需要和 8 篇目标文档的 TF-IDF 向量逐一计算余弦相似度。gensim 的similarities模块提供了两种索引本项目用的是SparseMatrixSimilarity它适合文档数量不大、向量稀疏的场景。from gensim import similarities index similarities.SparseMatrixSimilarity( tfidf[corpus], num_featureslen(dictionary) ) sim index[tfidf[doc_test_vec]] print(sim)这里tfidf[corpus]是把语料库整体转成 TF-IDF 向量列表等价于[tfidf[doc] for doc in corpus]。num_features必须等于词典大小也就是len(dictionary)否则 gensim 会按截断后的维度计算结果会整体偏差。sim是一个 numpy 数组下标 0 到 7 依次对应 doc0 到 doc7。运行结果如下[0.54680777 0.01055349 0. 0.17724207 0.17724207 0.01354522 0.01279765 0.70477605]下标 7 对应 doc7喜欢小吃相似度 0.7048 最高下标 0 对应 doc0我不喜欢上海0.5468 次之下标 2 是 doc2北京是一个好地方相似度为 0说明两个文档没有任何可匹配的语义维度。这个结果与 TF-IDF 权重是一致的doc7 的喜欢和小吃权重都很高doc0 共享的上海权重太低所以最终分数被拉开。使用降序排序即可得到最相似的文档编号sorted(enumerate(sim), keylambda item: -item[1])输出是[(7, 0.70477605), (0, 0.54680777), (3, 0.17724207), (4, 0.17724207), ...]这个排序直接回答了开头的问题测试文档最匹配 doc7。4. 完整复现与停用词过滤从 0.70 的结果反推优化方向前三章拆开了每一个环节这里把它们串成一个可直接运行的脚本再演示停用词过滤如何影响结果最后列出最常见的几个参数坑。4.1 一段可直接运行的完整脚本把分词、词典、语料库、TF-IDF、索引和排序全部放进一个脚本方便在 Jupyter Notebook 或命令行里直接验证import jieba from gensim import corpora, models, similarities docs [ 我不喜欢上海, 上海是一个好地方, 北京是一个好地方, 上海好吃的在哪里, 上海好玩的在哪里, 上海是好地方, 上海路和上海人, 喜欢小吃, ] doc_test 我喜欢上海的小吃 all_doc_list [list(jieba.cut(doc)) for doc in docs] doc_test_list list(jieba.cut(doc_test)) dictionary corpora.Dictionary(all_doc_list) corpus [dictionary.doc2bow(doc) for doc in all_doc_list] doc_test_vec dictionary.doc2bow(doc_test_list) tfidf models.TfidfModel(corpus) index similarities.SparseMatrixSimilarity( tfidf[corpus], num_featureslen(dictionary) ) sim index[tfidf[doc_test_vec]] for i, s in sorted(enumerate(sim), keylambda x: -x[1]): print(fdoc{i}: {s:.4f} {docs[i]})代码逻辑说明corpus必须一次性构建完整列表因为TfidfModel需要看到全部文档的统计信息来计算 IDF逐篇调用tfidf[doc]拿不到全局分布。SparseMatrixSimilarity的第一个参数tfidf[corpus]传入的是一个惰性迭代器gensim 会边遍历边建立索引不会额外占用一份完整副本。输出排序结果与原始项目一致doc7 排第一doc2 排在最后。到这里一个最基本的文本相似度分析已经可以交付。4.2 停用词过滤最小改动下的相似度排序变化项目正文特别提示没有剔除停用词。实际项目中的我是这类词如果不处理它们会占据词典维度并影响 IDF 计算。下面做一个最小实验只过滤的和我两个词stop_words {的, 我} def tokenize_without_stopwords(text): return [w for w in jieba.cut(text) if w not in stop_words] all_doc_list [tokenize_without_stopwords(doc) for doc in docs] doc_test_list tokenize_without_stopwords(doc_test)过滤后doc_test 的分词从[我, 喜欢, 上海, 的, 小吃]变成[喜欢, 上海, 小吃]词袋向量里少了编号 3 和 12 两个维度。重新运行模型后doc7 的相似度会更高因为 doc_test 只保留了喜欢小吃两个有效词而这两个词和 doc7 完全重合doc0 则只保留不喜欢上海中的喜欢和上海匹配优势下降。下表是排序趋势的前三名对比排序不过滤停用词过滤我的之后1doc7 喜欢小吃doc7 喜欢小吃2doc0 我不喜欢上海doc0 不喜欢上海3doc3/doc4 上海好吃的/好玩的doc3/doc4 上海好吃的/好玩的注意过滤停用词后排序前三名没有变化但分值差异更大。在更大的语料库里停用词会改变更多高频词的 IDF 分布排序结果可能完全反转所以这一步不是可选项。4.3 常见参数与报错排查现象原因处理方式KeyError: word xx not in dictionary测试文档包含训练语料中没出现过的词doc2bow不会报错但后续手工处理词典时容易触发统一用dictionary.doc2bow不要自己查 id相似度结果全是 0 或整体偏低num_features设置小于len(dictionary)导致向量被截断显式设置num_featureslen(dictionary)内存占用过高文档数过多SparseMatrixSimilarity依然以稀疏矩阵形式驻留内存改用similarities.MatrixSimilarity或对语料分块建索引结果区分度差所有文档分数接近没有做停用词过滤或TfidfModel没有被实际作用到索引上检查index使用的是否是tfidf[corpus]而非原始corpus这里最容易被忽略的是最后一行有些写法会先tfidf_corpus list(tfidf[corpus])再传入SparseMatrixSimilarity如果手滑传成corpus那么索引就是基于词频而非 TF-IDF 权重结果里上海会变得异常重要。5. 验证与延伸TF-IDF 相似度结果只解决一半问题相似度排序出来之后不要直接上线先做一次反向验证再考虑在更大语料上换成主题模型。5.1 用 doc_test 的 TF-IDF 权重验证排序合理性回到第 3 章打印出的test_tfidf_vec里面权重最高的是编号 3 的我和编号 17 的小吃都是 0.586编号 0 的上海只有 0.081。但 doc_test 的原始分词里我出现的频率和小吃一样为什么最终 doc7 能排第一因为语料库中我只在 doc0 里出现而喜欢和小吃共同出现在 doc7 里所以小吃和喜欢一起把 doc7 推高。这里有一个通用验证方法把测试文档的 TF-IDF 向量按权重从高到低排列观察排在最前面的词是否真的能代表文档主题。如果排在最前面的是的我是这类词说明停用词表没有生效后续所有相似度结果都不可信。5.2 当语料规模变大从 TF-IDF 向量到 LSI / LDA 主题向量SparseMatrixSimilarity在几十个到几千个文档时表现很好但如果文档量到十万级每个文档的 TF-IDF 向量仍然落在原始词空间里索引规模会快速增长。一个常见做法是先做 LSI 降维再用MatrixSimilarityfrom gensim import models, similarities lsi models.LsiModel(tfidf[corpus], num_topics200) lsi_index similarities.MatrixSimilarity(lsi[tfidf[corpus]]) lsi_sim lsi_index[lsi[tfidf[doc_test_vec]]]代码逻辑说明LsiModel的输入应为 TF-IDF 向量这样降维时保留的是词的权重信息而非简单词频。num_topics决定压缩后的主题数量通常取 100 到 300太小会丢失语义太大会让向量重新变得稀疏。LSI 训练完成后lsi[tfidf[corpus]]把每个文档映射到 200 维主题空间MatrixSimilarity在这个低维空间上计算余弦相似度性能和精度在中等规模语料上比直接使用原词空间好不少。如果文档主题非常明显也可以换成 LDA但 LDA 训练更慢更适合离线批量分析。无论哪种模型都必须和 TF-IDF 共用同一个dictionary、同一份停用词表否则向量空间不对齐相似度结果没有可比性。清洗文本时还可以考虑过滤低频词比如dictionary.filter_extremes(no_below2, no_above0.5)去掉只出现一次的词和出现在一半以上文档中的词这一步在真实数据里往往比调num_topics更有效。本文还有配套的精品资源点击获取