尧图精选

中文文本相似度分析实战:jieba分词与gensim TF-IDF全流程

🕒 发布时间:2026/10/2 5:04:10 📁 来源:尧图网络
简介在自然语言处理中文本相似度是文档去重、问答匹配和内容推荐等任务的基础一份 PDF 技术笔记以 Python 为环境详解文本相似度分析的完整实现思路适合 NLP 入门者、数据分析师以及需要做文档匹配的开发者参考。内容围绕 jieba 分词和 gensim 库展开覆盖中文分词、词典构建、词袋模型、TF-IDF 权重计算、余弦相似度等关键知识点并通过 8 个目标文档与测试文档的对比示例展示从分词、构建语料库、计算向量到输出相似度排序的完整流程每一步都配有代码片段与计算结果便于读者按步骤复现和验证。文档同时分析了 Dictionary、doc2bow、TfidfModel、SparseMatrixSimilarity 等 API 的用法解释了词频与逆文档频率的含义并提示去除停用词等预处理注意事项有助于规避常见误区。资源共 1 个 PDF 文件约 63KB轻量易携带目前已有 3060 人学习下载。阅读后可以掌握文本相似度分析的基本方法并迁移到文档分类、信息检索、推荐系统等实际场景。1. 文本相似度分析这套 jieba gensim 流程能直接解决你的文档匹配需求做 Python 文本相似度分析最怕的不是算法不懂而是装了一堆库却不知道每一步在干什么。这篇笔记拆解的是一个完整的可运行案例8 个目标文档加 1 个测试文档用 jieba 做中文分词用 gensim 的 TF-IDF 模型做向量化最后用稀疏矩阵相似度算出测试文档与每个目标文档的相似度排名。整个过程在 Jupyter Notebook 里逐段跑通适合刚接触 NLP 的开发者照着复现也适合做文档聚类、简易检索或重复内容检测的从业者参考。读完你不仅能跑通代码还能知道为什么 doc7 相似度最高、doc2 相似度为零以及实际项目中必须补上的停用词过滤这一步。2. 分词与词袋模型先把中文句子拆成机器能算的数字2.1 为什么中文分词必须用 jieba而不是直接 split英文文本按空格切分即可但中文句子没有天然分隔符。“我喜欢上海的小吃”这句话如果用str.split()按空格切整个句子会变成一个不可拆分的整体后续任何词频统计、向量化都无从谈起。jieba 的作用就是把连续的汉字序列切分成有意义的词语单元——我、喜欢、上海、的、小吃——这样每个词才能作为特征参与相似度计算。import jieba doc0 我不喜欢上海 doc1 上海是一个好地方 doc2 北京是一个好地方 doc3 上海好吃的在哪里 doc4 上海好玩的在哪里 doc5 上海是好地方 doc6 上海路和上海人 doc7 喜欢小吃 doc_test 我喜欢上海的小吃 all_doc [doc0, doc1, doc2, doc3, doc4, doc5, doc6, doc7]这段代码把 8 个目标文档放进一个列表all_doc测试文档单独存放。注意这里没有做任何预处理标点符号、停用词都没处理这是为了先把主流程跑通后面的避坑章节会专门讲不处理的后果。接着对每个文档分词all_doc_list [] for doc in all_doc: doc_list [word for word in jieba.cut(doc)] all_doc_list.append(doc_list) print(all_doc_list)jieba.cut(doc)返回一个可迭代的生成器用列表推导式把分词结果收集成列表。这里用的是 jieba 默认的精确模式它能把句子最合理地切开适合文本分析场景。分词结果如下[[我, 不, 喜欢, 上海], [上海, 是, 一个, 好, 地方], ... [喜欢, 小吃]]可以看到上海是一个好地方被切成了 5 个词上海路和上海人被切成了[上海, 路, 和, 上海, 人]其中“上海”出现了两次这个信息后续会体现在词袋模型的词频上。2.2 Dictionary 和 doc2bow从词到数字 ID 的映射机制分词完成后文本仍然是字符串无法直接计算。gensim 的corpora.Dictionary类负责给每个词分配一个整数 IDdoc2bow方法则把分词后的文档转换成词袋向量。from gensim import corpora, models, similarities dictionary corpora.Dictionary(all_doc_list) print(dictionary.keys()) print(dictionary.token2id)dictionary.keys()返回所有词的 ID 集合这里一共有 18 个词ID 从 0 到 17。dictionary.token2id是一个字典展示了词与 ID 的对应关系比如上海: 0、喜欢: 2、小吃: 17。这一步非常关键——词袋模型本质上就是用词的 ID 和频次来表示文档而不是直接拿字符串比较。corpus [dictionary.doc2bow(doc) for doc in all_doc_list] print(corpus)doc2bow接收一个分词后的词列表返回一个稀疏向量向量中每个元素是(词ID, 频次)的二元组。比如第一个文档分词后是[我, 不, 喜欢, 上海]对应词袋向量为[(0, 1), (1, 1), (2, 1), (3, 1)]意思是“上海”ID0出现 1 次、“不”ID1出现 1 次以此类推。再看 doc6分词结果是[上海, 路, 和, 上海, 人]对应向量为[(0, 2), (14, 1), (15, 1), (16, 1)]“上海”出现两次所以频次是 2。测试文档同样处理doc_test_list [word for word in jieba.cut(doc_test)] doc_test_vec dictionary.doc2bow(doc_test_list) print(doc_test_list) print(doc_test_vec)输出为[我, 喜欢, 上海, 的, 小吃] [(0, 1), (2, 1), (3, 1), (12, 1), (17, 1)]这里必须注意一个细节doc_test_vec中每个词的 ID 必须与dictionary中对应词的 ID 一致因为后续相似度计算要求测试文档和目标文档在同一个向量空间内。如果测试文档里出现了词典中不存在的词比如“美食”doc2bow会直接忽略它这个词的信息就丢了。这是词袋模型的一个天然局限后文避坑部分会展开。2.3 语料库的结构与稀疏向量的含义语料库corpus是一个包含 8 个稀疏向量的列表每个向量代表一个目标文档。所谓“稀疏”是因为词典里有 18 个词但每个文档只包含其中 5 个左右的词绝大部分位置是 0用(ID, 频次)二元组可以大幅节省内存。# 示例doc2 的向量表示 print(corpus[2]) # 输出 [(4, 1), (5, 1), (6, 1), (7, 1), (8, 1)]这个向量对应北京是一个好地方的 5 个词一个ID4、地方ID5、好ID6、是ID7、北京ID8。稀疏向量只记录非零位置所以在文档数多、词典规模大的场景下这种表示方式效率远高于稠密向量。提示corpus在这里扮演的是“参考语料库”的角色所有后续的 TF-IDF 统计和相似度计算都以它为基准。实际项目中这个语料库就是你的目标文档集合比如一批待检索的商品描述、历史工单或新闻稿件。3. TF-IDF 建模与相似度计算权重怎么算、相似度怎么比3.1 TF-IDF 模型的核心逻辑为什么“上海”不如“小吃”重要原始词袋模型只统计词频但它有个明显的缺陷像“上海”这种在多个文档中都出现的词频次高但区分度低“小吃”这种只在少数文档出现的词频次低但辨识度高。TF-IDF 就是用来修正这个问题的。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)]把这里的 TF-IDF 值和原始词袋向量对比看词袋里“上海”ID0频次是 1“我”ID3频次也是 1但 TF-IDF 值分别是 0.081 和 0.586差距近 7 倍。原因在于8 个文档里有 6 个都提到“上海”IDF 很低而“我”只出现在 doc0 和测试文档中IDF 高所以“我”的区分度远高于“上海”。TF-IDF 的数学定义是TF * IDF其中 TF 是词在文档中的频次这里归一化过IDF 是log(总文档数 / 包含该词的文档数)。IDF 的作用就是惩罚常见词、奖励罕见词。理解这一点就能解释为什么下面的相似度计算结果与直觉一致。3.2 稀疏矩阵相似度余弦相似度与 num_features 参数有了 TF-IDF 向量下一步就是计算测试文档与每个目标文档的相似度。gensim 提供了SparseMatrixSimilarity类它会在内部对所有目标文档的 TF-IDF 向量构建索引然后计算查询向量与每个索引向量的余弦相似度。index similarities.SparseMatrixSimilarity(tfidf[corpus], num_featureslen(dictionary.keys())) sim index[tfidf[doc_test_vec]] print(sim)输出为array([0.54680777, 0.01055349, 0. , 0.17724207, 0.17724207, 0.01354522, 0.01279765, 0.70477605], dtypefloat32)这里有两个关键点。第一tfidf[corpus]是对所有目标文档做 TF-IDF 转换得到 8 个 TF-IDF 向量第二num_featureslen(dictionary.keys())指定向量空间的维度也就是词典大小这里为 18。如果漏掉这个参数或者维度小于实际词典大小索引构建会直接报错或者产生错误的相似度计算。余弦相似度的公式是A·B / (|A| × |B|)结果值域在 -1 到 1 之间越接近 1 表示两个向量方向越一致。这里的结果数组对应 8 个目标文档的相似度doc7 是 0.7048 最高doc2 是 0.0 最低。3.3 排序输出用 sorted 按相似度倒序排列裸的相似度数组不好读需要把文档编号和相似度值绑定后排序sorted_sim sorted(enumerate(sim), keylambda item: -item[1]) print(sorted_sim)输出为[(7, 0.70477605), (0, 0.54680777), (3, 0.17724207), (4, 0.17724207), (5, 0.013545224), (6, 0.01279765), (1, 0.010553493), (2, 0.0)]enumerate(sim)把数组下标和值配对keylambda item: -item[1]表示按相似度值从大到小排序。结果一目了然测试文档“我喜欢上海的小吃”与 doc7“喜欢小吃”最相似因为两者都包含“喜欢”和“小吃”这两个高权重的词与 doc0“我不喜欢上海”次之共享“我”“喜欢”“上海”与 doc2“北京是一个好地方”完全无共同词相似度为 0。提示tfidf[doc_test_vec]每次计算时都会实时转换如果测试文档不止一篇可以把它转成列表传入index[]gensim 支持批量查询返回的是一个二维数组每行对应一个查询文档的相似度结果。4. 避坑与常见问题停用词、重复词和零相似度的隐藏陷阱4.1 现象doc3 和 doc4 的相似度虚高doc1 反而很低跑完上面的代码很多人会疑惑测试文档是“我喜欢上海的小吃”doc3“上海好吃的在哪里”和 doc4“上海好玩的在哪里”算出的相似度都是 0.177而 doc1“上海是一个好地方”只有 0.0105。从语感上看doc1 与测试文档的语义关联并不弱于 doc3。原因出在停用词“的”和“在”上。doc3 和 doc4 与测试文档共享“上海”“的”两个词其中“的”在词典里是一个高频停用词IDF 值很低但它在 doc3 和测试文档里都存在所以贡献了相似度。doc1 与测试文档只共享“上海”“是”“好”“地方”中的部分词——实际上是“上海”一个词“是”在测试文档没有“好”和“地方”也没有因此相似度被拉得很低。解决方法是引入停用词表在分词后过滤掉“的、是、在、一个”等无实际意义的词stop_words set([的, 是, 在, 一个, 和, 不]) def tokenize_with_stopwords(text): return [word for word in jieba.cut(text) if word not in stop_words] all_doc_list [tokenize_with_stopwords(doc) for doc in all_doc] doc_test_list tokenize_with_stopwords(doc_test)过滤后doc3 与测试文档的共享词只剩下“上海”“的”和“在”不再参与计算相似度会明显下降doc1 的相对排名也会回升。这就是原文特意声明“没有剔除停用词”的用意——实际项目中这一步必不可少。4.2 现象doc6“上海路和上海人”的相似度只有 0.0127doc6 包含“上海”两次“上海路和上海人”中“上海”出现两次测试文档也包含“上海”理论上相似度应该不低但结果只有 0.0127接近垫底。原因有两层。第一“上海”虽然频次为 2但在 TF-IDF 中它的 IDF 极低8 个文档 6 个都含“上海”所以 TF-IDF 值被压得很低。第二doc6 的其他词“路”“人”“和”与测试文档毫无交集这些词反而分摊了向量的模长导致余弦相似度的分子很小、分母很大。这个案例说明TF-IDF 对高频词的抑制非常激进在主题区分度弱的短文本里它可能让核心词被淹没。如果业务场景里“上海”这种词恰好是重要实体可以考虑不套 TF-IDF直接用词袋向量计算 Jaccard 相似度或者改用 TF-IDF 的变体。4.3 现象doc2 与测试文档相似度严格为零doc2“北京是一个好地方”与测试文档没有任何共同词所以相似度精确为 0。这看起来符合直觉但隐藏着一个语义鸿沟——测试文档说的是“我喜欢上海的小吃”doc2 说的是“北京是一个好地方”两者在语义上都属于“城市 评价”的范畴但词袋模型完全看不到这层关系。这个局限不是 bug而是词袋模型的基本假设文档相似度仅取决于字面共现词。如果你的业务需要对同义词、上位词做语义匹配就需要升级到 LSI 或 LDA 主题模型甚至用词向量如 Word2Vec做语义相似度。gensim 同时支持这些模型切换成本不高。4.4 现象修改词典后忘记同步num_features索引构建报错把停了停用词后的dictionary传入SparseMatrixSimilarity时如果不更新num_featuresgensim 会报维度不匹配的错误或者更隐蔽的——不报错但计算结果异常。# 正确做法每次修改词典后重新获取特征数 num_features len(dictionary.keys()) index similarities.SparseMatrixSimilarity(tfidf[corpus], num_featuresnum_features)num_features必须大于等于语料库中所有向量的最大词 ID 加 1。比如停用词过滤后词典大小为 15但你把num_features设为 18计算结果虽然能跑出来但会引入额外的零维度余弦相似度的分母被虚增所有相似度值都会失真。这一点在调试时很难察觉建议用一个断言检查max_id max(max(vec, keylambda x: x[0])[0] for vec in corpus) assert max_id num_features, fnum_features {num_features} 小于最大词ID {max_id}4.5 现象jieba 分词结果里出现单个字干扰相似度计算在默认精确模式下比如遇到人名、专有名词或新词jieba 可能切出单字。比如“上海路和上海人”中的“路”和“人”在相似度计算中都会作为特征词参与稀释有效词的权重。解决方法是过滤长度为 1 的词但要注意“我”“不”这种有语义的单字词需结合停用词表决定def tokenize_filter(text): return [word for word in jieba.cut(text) if len(word.strip()) 1 or word in [我, 你, 他, 不, 很]]这个策略属于工程妥协——纯按长度过滤会把“我”这种高频代词丢掉但保留它们又可能引入噪声。实际项目中建议先按长度过滤再做停用词过滤最后人工检查一遍剩余词表。低成本但有效能显著提升短文本相似度的稳定性。5. 进阶从 demo 到可用服务的四个升级点5.1 停用词表要结合业务场景自建不要直接抄网上的通用表原文在开头特意说“没有剔除停用词”这是为了教学简化但真实项目必须处理。通用停用词表如百度停用词表、哈工大停用词表覆盖面广但未必贴合你的业务语境。比如在电商场景里“包邮”“正品”“特价”这类营销词在所有商品描述里都高频出现它们对区分商品品类几乎没有帮助应该加入业务停用词表。我一般会把停用词过滤做成一个可复用的预处理函数放在独立模块里import jieba STOP_WORDS set() with open(stopwords_cn.txt, encodingutf-8) as f: for line in f: word line.strip() if word: STOP_WORDS.add(word) # 业务自定义停用词 BIZ_STOP_WORDS {包邮, 正品, 特价, 官方, 正版} STOP_WORDS.update(BIZ_STOP_WORDS) def clean_tokenize(text): return [w for w in jieba.cut(text) if w not in STOP_WORDS and len(w.strip()) 1]停用词表的构建是个迭代过程先跑一遍模型把相似度排名中经常出现但明显无关的词加入停用词表再跑一遍验证效果。通常两三轮就能让结果稳定下来。5.2 相似度计算换成 BM25短文本效果往往更好TF-IDF 在短文本场景里有个问题词频普遍为 1TF 部分几乎没有区分度整个模型退化成 IDF 排序。BM25 是 TF-IDF 的改进版引入了文档长度归一化和饱和词频控制在短文本相似度、搜索引擎相关性排序场景里表现更稳定。gensim 里切换 BM25 很简单from gensim.summarization.bm25 import BM25 # 注意gensim 4.x 已移除 summarization 模块需使用 bm25 单独的安装包 # pip install gensim3.8.3 或引入第三方 bm25 实现 bm25 BM25([doc_list for doc_list in all_doc_list]) scores bm25.get_scores(doc_test_list)不过要留意 gensim 4.x 之后summarization模块被移除了旧代码会直接报ImportError。如果项目从 gensim 3.x 升级到 4.x需要把 BM25 逻辑改成独立实现或者停用 gensim 版本。这也提醒我们生产环境锁版本很重要我一般在requirements.txt里钉死gensim4.3.2避免上游接口变动踩坑。5.3 文档量过万时用Similarity类替代SparseMatrixSimilaritySparseMatrixSimilarity把整个语料库的稀疏向量一次性加载到内存文档数不大时没问题但文档数过万、词典规模达到十万级别时内存占用会非常可观。gensim 提供了分片索引Similarity类它能把索引分块存储到磁盘查询时逐块加载比对。from gensim.similarities import Similarity # 指定索引存储路径和分片大小 index Similarity(./index_cache, tfidf[corpus], num_featureslen(dictionary), shardsize32768) sim index[tfidf[doc_test_vec]]shardsize是每个分片的向量数量默认 32768。分片过多会降低查询速度分片过大会占用太多内存需要结合服务器配置调整。这个类还有index.save()和Similarity.load()方法索引构建一次后可以持久化复用避免每次查询都重新构建。如果文档量继续膨胀到百万级就要考虑用 Elasticsearch 的more_like_this查询或者 Faiss 做向量检索gensim 这套流程就不太够用了。但理解当前这套词袋 → TF-IDF → 余弦相似度的链路对理解那些检索系统的原理很有帮助。5.4 从相似度排名到可解释性输出共享词和词权重对业务方来说光给一个相似度分数没有说服力最好把“为什么相似”也展示出来。我习惯把命中的文档、相似度、共享词和各自权重一并输出def explain_similarity(query_vec, doc_id, dictionary, tfidf_model): doc_names {0: doc0, 1: doc1, 2: doc2, 3: doc3, 4: doc4, 5: doc5, 6: doc6, 7: doc7} query_weights dict(tfidf_model[query_vec]) doc_vector tfidf_model[corpus[doc_id]] doc_weights dict(doc_vector) common_ids set(query_weights.keys()) set(doc_weights.keys()) shared [] for wid in common_ids: word dictionary[wid] shared.append((word, query_weights[wid], doc_weights[wid])) print(f文档 {doc_names[doc_id]} 相似度最高) print(f共享词及权重词, 查询权重, 文档权重: {shared})这段代码把相似度从“黑匣子”变成可解释的结果尤其在调试阶段非常有用。你会发现某些相似度虚高的案例往往是因为共享词集中在“的”“了”“在”这类停用词上这时就能反推停用词表是否漏词。从那以后我每次做文本相似度分析都会强制在预处理阶段先输出一份词频 Top 50 的清单人工扫一遍再决定要不要调停用词表——词袋模型很依赖这一步调好它就是“好用”调不好就是“玄学”。希望这篇拆解能帮你在自己的数据上少走几趟弯路。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →