简介:面向中文自然语言处理初学者与Python开发者,这份单文件PDF通过一个可运行的完整案例,讲解如何借助jieba分词与gensim库完成文本相似度分析。资源从语料准备、中文分词、词袋模型构建,讲到TF-IDF建模与基于余弦相似度的文档排序,覆盖了从原始文本到相似度结果输出的完整链路。包体仅63KB,便于作为速查笔记或实验参考;目前已有3060人学习。案例中以8个目标文档和1个测试文档进行对比,详细演示Dictionary、doc2bow、TfidfModel、Similarity等核心接口的用法,并展示了每个词的TF-IDF权重与最终相似度排序,帮助读者直观理解权重计算和向量空间模型的实际效果;同时特意指出未剔除停用词会对结果产生干扰,提醒实战中应补充去停用词、词形还原等预处理步骤。对于希望快速入门文本相似度分析、希望读懂同类项目代码的读者,是内容紧凑且可对照运行的实用参考资料。
1. 文本相似度分析到底在算什么
先把话放这儿:文本相似度分析不是把两串字符拿过来比相等,而是把“接近”这件事变成可计算的距离。数据清洗、评论去重、搜索召回、论文查重、客服工单聚类,底层都在用它。很多人以为这是 NLP 里一个又深又重的方向,实际上用好 Python 的 jieba 和 scikit-learn,不碰任何深度学习框架,就能搭出一条可用的相似度计算链路。这篇文章要做的,是把分词、清洗、向量化、相似度计算到 TopN 筛选的完整操作一步步讲透,并标出哪些参数值得调、哪些坑必须躲。适合 Python 入门到进阶之间的读者,尤其是手上有爬虫数据、正想做文本去重或聚类的开发者。环境不用复杂,按平时写脚本的方式配好 Python 解释器就行,vscode 还是 pycharm 都无所谓,关键是装的库能被同一个解释器识别到。
2. 相似度算法选型:Jaccard、余弦与编辑距离的适用边界
文本相似度没有唯一正确的算法,先选算法,再写代码。因为不同算法对“相似”的定义完全不同——有的只看字符重合,有的看词频权重,有的看语义距离。这一章先把三种最常见算法的计算方式和使用边界讲清楚,后面章节再落到 Python 实现。
2.1 Jaccard 相似度:集合运算为什么适合短文本与关键词去重
Jaccard 相似度把文本切成词的集合,用交集大小除以并集大小,公式是|A ∩ B| / |A ∪ B|。这个算法不看词序、不看词频,只关心重合的词占多大比例。它的优点是实现成本极低,计算速度非常快;缺点是两段文本如果用了同义词但词面完全不同,相似度会直接掉到 0。
实际操作里,Jaccard 很适合商品标题关键词比对、标签系统去重、短信验证码这类短文本场景。比如两条文本“Python 爬虫入门”和“爬虫入门 Python”,分词后得到相同集合,Jaccard 值就是 1.0。但如果应用场景换成一篇文章的段落比对,仅靠词集合就太粗糙了,需要引入词频和权重。
| 算法 | 计算对象 | 是否考虑词序 | 典型场景 | 耗时特点 |
|---|---|---|---|---|
| Jaccard | 词或 n-gram 集合 | 否 | 短文本、关键词去重 | 低 |
| 编辑距离 | 字符序列 | 是 | 拼写纠错、OCR 结果比对 | 随文本长度快速上升 |
| 余弦相似度 | TF-IDF 向量 | 否,依赖 n-gram | 搜索召回、正文查重 | 中,适合批处理 |
| 词向量 | 词嵌入聚合 | 部分 | 语义匹配、问答 | 高,需预训练模型 |
2.2 编辑距离与 Levenshtein:适合错字修正而非整体相似
编辑距离衡量的是把一个字符串变成另一个字符串所需的最少编辑操作次数,操作包括插入、删除和替换。它天然考虑字符顺序,所以对“Python”和“Pythonn”这类拼写差异非常敏感。Levenshtein 距离是它的经典实现,通常用动态规划求解。
但这个算法有两个明显的短板。一是时间复杂度接近 O(m×n),两条几百字的文本做一次计算就非常慢;二是它对“同一件事的不同说法”束手无策。比如“这台手机续航不行”和“电池掉电太快”,字符层面几乎是零重合,编辑距离会给出一个很大的值。因此在文本相似度分析里,编辑距离更适合做辅助手段,比如先通过编辑距离判定是否属于同一商品的不同 SKU 描述,再用余弦相似度做整体判断。
2.3 余弦相似度与 TF-IDF:为什么它是文本相似度分析的默认基线
如果要给“文本相似度分析”选一个默认方案,我会选 TF-IDF 向量化加余弦相似度。所谓向量化,是把每条文本表示成一个向量,每个维度对应一个词。TF-IDF 给每个词赋权重,TF 是词在该文本中的出现频率,IDF 是逆文档频率,用来压制那些在大量文档里都出现的高频词。
余弦相似度计算的是两个向量的夹角余弦值,取值在 -1 到 1 之间,文本相似度场景通常落在 0 到 1。相比 Jaccard,它充分利用了词频信息;相比编辑距离,它对文本长度不敏感,也不会因为个别字符差异导致相似度崩坏。后面第 4 章的完整实现就是围绕这个组合展开的,这也是搜索系统和推荐系统在召回阶段的常用算法之一。
3. 文本预处理与中文分词的实操细节
文本相似度分析里,预处理对结果的影响不亚于算法本身。中文没有天然空格,分词结果的每一个边界都直接影响后续相似度计算。这一章讲三段可复用的预处理代码:分词、清洗、停用词过滤。
3.1 jieba 分词:精确模式与全模式输出的差异
中文文本必须先分词。jieba 是目前最常用的 Python 中文分词库,安装命令是pip install jieba,安装前确认你的 Python 环境里没有重复装到别的解释器上。分词模式常用两种:精确模式cut_all=False和全模式cut_all=True。
import jieba text = "自然语言处理在文本相似度分析中应用广泛" # 精确模式,返回最合理的分词结果,适合相似度计算 print(list(jieba.cut(text, cut_all=False))) # 全模式,把所有可能的词都切出来,词之间有重叠,适合做召回扩展 print(list(jieba.cut(text, cut_all=True)))精确模式下输出接近["自然语言", "处理", "文本", "相似度", "分析", "中", "应用", "广泛"],全模式会额外切出“自然”“语言”“处理在”等重叠片段。相似度计算建议只用精确模式,因为全模式的重复片段会放大某些词在向量里的权重。jieba 还支持jieba.enable_paddle()调用 Paddle 模型,对长句的分词效果更好,但首次运行要加载模型,批处理小规模语料时收益不明显,我一般只在语义匹配场景才开。
3.2 清洗规则:把 URL、数字、标点排除在相似度之外
原始文本里经常混着 URL、数字、特殊符号。这些内容不参与语义表达,却会干扰向量计算。尤其是两条文本都带同一串 URL 时,相似度会被虚高。编程里我一般按下面的顺序清洗:先统一小写,再去掉 URL,再处理数字和符号,最后压缩空白。
import re def clean_text(text): text = text.lower() # 去掉 http/https 开头的链接 text = re.sub(r"https?://\S+|www\.\S+", " ", text) # 数字单独出现的场景保留价值低,替换成空格 text = re.sub(r"\d+", " ", text) # 只保留中文、英文字母和数字,其他符号全部换空格 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", " ", text) # 多个空格合并成一个 text = re.sub(r"\s+", " ", text).strip() return text这里的关键参数是第二和第三个正则。https?://\S+匹配以 http 或 https 开头的链接,\S+吃到第一个空白为止;[^\u4e00-\u9fa5a-zA-Z0-9]是取反字符集,意思是非中文、非英文、非数字的字符全部替换成空格。全角符号如果频繁出现,可以用unicodedata.normalize("NFKC", text)先做规范化,把全角转半角再走上面的流程。
3.3 停用词表:哪些词该删,删除后再分词的差异
停用词指“的、了、在、是、和”这类高频但语义贡献极低的虚词。文本相似度分析对停用词过滤的敏感度比文本分类更高,因为这些词在两条文本里同时出现会显著抬高重合度。实务上先分词,再去停用词,不要反过来做。原因是一个词是否该保留取决于它在句子里的上下文,提前删词会破坏切分边界。
def load_stop_words(path): # 每行一个停用词,编码用 utf-8 with open(path, encoding="utf-8") as f: return set(line.strip() for line in f if line.strip()) stop_words = load_stop_words("stopwords.txt") def filter_stop_words(words, stop_words): # 去掉空字符串和纯空白词 return [w for w in words if w not in stop_words and w.strip()]停用词表建议自己维护,不要直接套用某个 NLP 项目的超大词表,因为领域不同,有些词比如“数据”“系统”在通用词典里不是停用词,但在你的语料里可能高频且无区分度。更精细的做法是加载 jieba 词性标注后只保留名词、动词、形容词,把副词、介词、语气词整体丢弃。这一步能显著提升后面相似度结果的可解释性。
4. 用 TfidfVectorizer 计算余弦相似度的 Python 实现
这一章给出完整可运行的 Python 实现。核心链路是:原始文本 → 清洗 → 分词 → 去停用词 → TF-IDF 向量化 → 余弦相似度矩阵 → 输出 TopN 相似句对。sklearn 和 numpy 负责主要计算,pip install scikit-learn numpy即可,不需要其他重量级依赖。
4.1 语料组织与 TfidfVectorizer 的 6 个常用参数
sklearn 的TfidfVectorizer不负责分词,所以传给它的每条文本必须是已经分词并用空格连接好的字符串。这也是新手最容易犯错的地方:直接把原始中文文本丢进去,得到的是一个按单字切分的向量空间。正确姿势如下。
from sklearn.feature_extraction.text import TfidfVectorizer corpus = [ "自然语言处理是人工智能的重要方向", "自然语言处理在人机对话中很关键", "Python 写爬虫时需要处理文本编码", "物理引擎的碰撞检测依赖向量运算", ] # 模拟第 3 章的预处理链路 corpus_seg = [" ".join(filter_stop_words(jieba.cut(clean_text(t)), stop_words)) for t in corpus] vectorizer = TfidfVectorizer( ngram_range=(1, 2), max_features=5000, sublinear_tf=True, min_df=1 ) tfidf_matrix = vectorizer.fit_transform(corpus_seg)这 6 个参数是实际调参中最常用的组合,逐个说明:
ngram_range=(1, 2)表示向量维度同时包含单个词和相邻两个词构成的短语,对中文这种复合词较多的语言有帮助,代价是特征维度会明显变多。max_features=5000限制特征总数,按词频保留前 5000 个,防止小语料造出几十万维稀疏向量。sublinear_tf=True对词频做1 + log(tf)变换,避免某个词在长文档里重复出现导致权重虚高。min_df=1表示只出现一次的词也保留,如果语料较大可调到 2。stop_words参数可以直接传停用词集合,但我一般在上游显式过滤,因为显式过滤后还能复用分词结果。
4.2 用 cosine_similarity 构建相似度矩阵与 TopN 提取
向量化完成后,调用cosine_similarity计算两两相似度。它接受矩阵后返回一个 n×n 的对称矩阵,第 i 行第 j 列的值就是第 i 条和第 j 条文本的相似度。
import numpy as np from sklearn.metrics.pairwise import cosine_similarity sim_matrix = cosine_similarity(tfidf_matrix) n = len(corpus) results = [] for i in range(n): for j in range(i + 1, n): results.append((i, j, sim_matrix[i][j])) top = sorted(results, key=lambda x: x[2], reverse=True)[:3] for a, b, s in top: print(f"doc {a} <-> doc {b}: {s:.4f}")逻辑说明:上半角遍历只用i < j,对角线上的自身相似度直接用cosine_similarity计算后不需要再用,因为 text 与自己的余铉必然是 1.0。sorted按第三位相似度值降序,取前 3 就是最相似的三对。时间复杂度是 O(n²),语料只有几百条时完全没有压力;如果到万级,就要考虑第 6 章的粗筛方案。
4.3 一个完整的相似度分析脚本
把上面代码整理成函数,输入一个文本列表,输出相似度最高的若干对。
import re import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def clean_text(text): text = text.lower() text = re.sub(r"https?://\S+|www\.\S+", " ", text) text = re.sub(r"\d+", " ", text) text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", " ", text) return re.sub(r"\s+", " ", text).strip() def preprocess(text, stop_words): words = jieba.cut(clean_text(text)) return " ".join(w for w in words if w not in stop_words and w.strip()) def topn_similar(texts, stop_words, topn=5, max_features=5000): corpus = [preprocess(t, stop_words) for t in texts] vec = TfidfVectorizer(ngram_range=(1, 2), max_features=max_features, sublinear_tf=True) tfidf = vec.fit_transform(corpus) sim = cosine_similarity(tfidf) pairs = [] for i in range(len(texts)): for j in range(i + 1, len(texts)): pairs.append((i, j, sim[i][j])) return sorted(pairs, key=lambda x: x[2], reverse=True)[:topn] if __name__ == "__main__": texts = [ "自然语言处理是人工智能的重要方向", "自然语言处理在人机对话中很关键", "Python 写爬虫数据时要处理文本编码", ] for i, j, s in topn_similar(texts, stop_words={"的", "了", "在", "是"}): print(f"文本{i} 与文本{j} 相似度为 {s:.4f}")脚本中preprocess把清洗、分词、去停用词收敛到了一个函数里,方便复用。注意topn不能大于总对数,否则取不满结果;实际项目里可以先判断len(pairs) < topn再截断,避免越界。
5. 参数调优与高频排错:从零向量到 OOV 的五个坑
算法跑通只是第一步。真实语料里会遇到文本被清空、词表外词汇、相似度全部为 NaN 等问题。这一章梳理最常见的五类异常及其排查思路,并给出一个场景化参数速查表。
5.1 全零向量与 NaN:当文本被清空后余弦值如何崩坏
清洗和停用词过滤不当时,某些短文本可能被清理成空字符串。空文本分词后得到空列表,再拼接成空字符串进入TfidfVectorizer,该行就会变成全零向量。全零向量与任何向量做余弦相似度都会产生 NaN,因为公式分母里的向量模长是 0。
处理方案有两种。一是预处理阶段记录日志,统计每次清理后的分词结果数量,这能快速定位是哪一条文档出了问题。二是对确实为空的文本做占位处理,比如统一替换成<EMPTY>标记,在筛选结果时再把这些行过滤掉。
def safe_preprocess(text, stop_words): seg = " ".join(...) return seg if seg.strip() else "<EMPTY>"5.2 中文词向量 OOV 与语料规模不匹配
如果相似度需求从字面匹配升级到语义匹配,很多人会立刻换 Word2Vec,觉得 TF-IDF 过于“笨”。但在领域语料规模不大时,预训练词向量会遇到大量词表外词汇(OOV),比如“熔断机制”“私域运营”这类领域新词在通用词表里根本没有 embedding。此时词向量方案的效果可能反而不如 TF-IDF。
建议的落地顺序是:先用 TF-IDF 加余弦相似度做基线,确认相似度分布是否合理;只有基线明显漏掉同义改写文本时,再引入词向量,且要对 OOV 做单独处理,比如回退到单字向量或直接丢弃该词。短文本、领域词多的场景,TF-IDF 往往更稳。
5.3 高频词干扰与 n-gram 参数取舍
max_features只按词频排序保留前 N 个特征,但高频词不一定是有区分度的词。比如“数据”“分析”在技术文档里可能每条都出现,如果不做过滤,它们会拉近所有包含这些词的文档。处理办法是提高min_df或使用自定义停用词表。这里整理一份参数速查表,你可以按场景直接套。
| 场景 | 分词模式 | ngram_range | 首选算法 | 注意点 |
|---|---|---|---|---|
| 商品标题去重 | 精确模式 | (1, 2) | Jaccard 或余弦 | 先过滤品牌词和虚词 |
| 长文本查重 | 精确模式 | (1, 1) | TF-IDF + 余弦 | 按段落分块后再比对 |
| 评论聚类 | 精确模式 | (1, 2) | 余弦 | 控制 max_features 防稀疏 |
| 语义匹配 | paddle 模式 | (1, 1) | 词向量 | 语料小不要强行上预训练模型 |
最后补充一个低频坑:TfidfVectorizer的默认token_pattern是针对英文设计的,如果你没先用空格连接中文词,它会按单个汉字切分,导致结果完全不可用。遇到相似度矩阵里所有非对角线值都很低时,第一件事检查vectorizer.get_feature_names_out()输出的是词还是字。
6. 工程化落地:大规模文本去重场景的粗筛与精排
最后讲一个实际项目里一定会遇到的问题:数据量过万后,O(n²) 的全量相似度计算会变得不可接受。工程上的通用做法是粗筛加精排——先用廉价方法选出候选对,再用余弦相似度精算排序。
6.1 粗筛层:用倒排索引大幅缩小候选集
粗筛的核心思路是:两条完全不共享任何词的文本,相似度不可能高。先对每条文本的分词结果建倒排索引,词作为 key,文本 ID 列表作为 value;再对出现在同一个词下的文本 ID 做两两组合,生成候选对。只有候选对才进入余弦相似度计算。
from collections import defaultdict def build_candidate_pairs(doc_words, max_skip=100): # doc_words: list of list,每条文本的分词结果 inverted = defaultdict(list) for doc_id, words in enumerate(doc_words): for w in set(words): inverted[w].append(doc_id) candidates = set() for doc_ids in inverted.values(): for i in range(len(doc_ids)): for j in range(i + 1, len(doc_ids)): if doc_ids[j] - doc_ids[i] <= max_skip: candidates.add((doc_ids[i], doc_ids[j])) return candidates这里的max_skip控制候选对的滑动窗口大小。文档 ID 是按原始顺序递增的,如果两个 ID 相差过大,说明它们所在位置跨度很大,先跳过;真正需要计算的是相邻区域内共享同一词的文档。实际应用中,倒排索引通常能把候选集压缩到全量组合的 1% 以下。
6.2 精排阈值怎么标定:小批量抽样画 Precision 曲线
粗筛之后,精排层返回的分数需要定一个阈值,高于阈值才能判定为相似。不要拍脑袋填 0.8。我一般会在 Top 相似结果里随机抽 50 对做人工标注,标出“是否算相似”,然后按不同阈值计算精确率,选择精确率掉到 95% 以下的临界值作为阈值。这种方式成本低,且能适应不同语料的主观尺度。
阈值确定后,把相似句对落库即可。无论是存到 MySQL 里的(id_a, id_b, score)表,还是落到 Parquet 供后续聚类使用,最终效果都取决于你前面每一步的清洗与参数选择。文本相似度分析的价值不在算法炫技,而在把名词变成可调、可验、可靠的加工过程。
本文还有配套的精品资源,点击获取