news 2026/10/31 7:38:45

中文文本相似度计算:分词、TF-IDF、余弦与Jaccard

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文文本相似度计算:分词、TF-IDF、余弦与Jaccard

简介:面向自然语言处理与软件工程方向的毕业设计、课程设计学习者,这份文档围绕基于Python的文本相似度计算系统,提供从课题背景、可行性分析到系统设计、实现与实验评估的完整参考。内容覆盖文本清洗与分词、TF-IDF关键词提取、关键词词向量表构建、余弦相似度与Jaccard系数比较、Django前端界面及MySQL数据存储等环节,并给出多组文本数据集上的测试结果与结论展望,便于读者快速把握系统架构、功能模块与论文写作脉络。压缩包内仅含1个docx文件,约749KB,以论文式文档承载系统说明,目录章节清晰,适合对照梳理需求分析、技术选型与实现细节。已有138人学习,可作为文本相似度计算类毕设的选题参考、方案模板和实现思路补充。

1. 相似度 0.93 的两段文字可能毫无关系:分数只是整条链路的末端产物

把两段主题完全不相干的文本丢进词袋模型,删掉停用词、丢掉词序、再做一次 L2 归一化,余弦相似度照样能算出 0.9 以上。这不是算法写错了,而是这个分数本身的出身决定的:它是清洗、分词、去停用词、向量化、归一化五道工序之后残留下来的那点信息的比值。前面每丢一层信息,最后一层就只能在这个残缺的坐标系里做除法,算出多少都不奇怪。

所以想搞懂一个基于 Python 的文本相似度计算系统为什么时准时不准,理解顺序不能反过来:先看清每一层到底留下了什么、扔掉了什么,再看余弦、Jaccard、编辑距离这些度量各自在这个残缺空间里量的是哪一段距离。Django 和 MySQL 那一层只是把这条链包成网页和表,真正决定结果的是下面那几百行算法代码。把这条链倒着拆开,很多看似玄学的分数就能解释得通了。

2. 中文预处理链路:jieba 的分词粒度与停用词表如何决定相似度上限

一段中文进入计算之前,先要过清洗、切词、过滤三道手。很多人把这三步当体力活,随手几行正则就放过去,结果后面无论换成余弦还是 Jaccard,分数都不对劲。原因很直接:相似度是在 token 集合或向量空间里算的,词表一旦切歪,后面所有算法都在一个错误的坐标系里比距离,换算法只是换了个量尺,尺子本身没校准。

2.1 精确模式、全模式与搜索引擎模式到底差在哪

jieba 的切分不是查表那么简单。它先用词典里的词构建一个有向无环图,把句子里所有可能的成词路径都列出来,再跑一遍动态规划,找概率乘积最大的那条路径;词典里查不到的连续汉字串交给 HMM,用 B/M/E/S 四个状态做 Viterbi 解码,猜它是不是一个未登录词。这个机制决定了切分结果不是唯一确定的,它依赖词典内容和 HMM 参数,同一句话换一份词典就能切出完全不同的结果。

对同一个示例串,三种模式的输出差异很明显:

模式对「文本相似度计算」的切分对相似度计算的实际影响
精确模式文本 / 相似度 / 计算token 最干净,靠词典覆盖领域词
全模式文本 / 相似 / 相似度 / 计算大量重叠子串,Jaccard 被虚假共享抬高
搜索引擎模式精确模式结果上再切长词召回高,但短片段会稀释向量权重

全模式最容易被误用。它把「相似」和「相似度」同时列出来,另一段只要含有「相似」两个字的无关文本,交集就被凭空补上一块,Jaccard 立刻虚高。系统默认走精确模式是有道理的:相似度要的是精确率,不是召回率,召回交给后面的候选粗筛去做。

未登录词则是另一个隐形坑。领域术语不在通用词典里,jieba 只能靠 HMM 猜,同一篇文档里这个词可能一半被切开、一半被合起来,两篇同领域的专业文档算出来的相似度反而低于两篇泛泛而谈的通俗文章。我一般会在应用启动时用jieba.load_userdict()加载一份领域词典,词频给中等值、词性随便填,目的是把切分结果锁死。

2.2 停用词表:删得越干净,分数就越准吗

常见的中文停用词表从几百条到两千条不等,多数是从新闻或网页语料上统计出来的。直接套到医学文献、技术文档或商品评论上,水土不服是常态:该删的虚词没删干净,领域内的实词却被误伤。更麻烦的是删得太狠带来的向量坍塌。

设想两段各十几个字的短文本,去掉停用词后各自只剩两三个 token。这时候只要碰巧重合一个词,Jaccard 就能到 0.33,归一化后余弦能到 0.7 以上,人眼一看却毫无关系。短文本的向量维度本来就低,停用词一删,剩下的信息量撑不起一个有区分度的空间。处理这类输入时,我宁可在短文本上少删一些,也不愿让向量提前坍塌。

反过来说,删得太少,高频虚词会把余弦的方向拉偏,两段八竿子打不着的长文本因为满篇「的、了、是」共享,相似度被强行拉到中等区间。这里有个容易被忽略的事实:TF-IDF 里的 idf 本身就在惩罚高 df 的词,一个词几乎在每篇文档里都出现,它的权重会被压到接近零。停用词表和 idf 在功能上是重叠的,区别在于词袋的 0/1 表示没有 idf,这时停用词表就是唯一的防线。用 TF-IDF 时可以适度放宽容忍度,用纯词袋就必须认真删。

注意:多数通用停用词表里都收着「不、没、无、未」这类否定词。删掉之后,「效果好」和「效果不好」会退化成同一串 token,语义直接反转,而相似度算出来接近 1。我一般会维护一份否定词白名单,让它们绕过停用词过滤,或者在清洗阶段把「不 + 形容词」合并成一个整体 token。

2.3 把清洗、分词、过滤串成一条可复现的管道

链路的每一环都要能被单独测试,否则出了问题只能靠猜。下面这条管道把清洗、切词、过滤拆成三件事,任何一环都可以单独拿出来看中间结果:

import re import unicodedata import jieba # 只保留中文、英文、数字,其余标点、符号、空白统一折叠成一个空格 NOISE = re.compile(r"[^\u4e00-\u9fa5a-zA-Z0-9]+") def load_stopwords(path="stopwords.txt"): with open(path, encoding="utf-8") as f: # 一行一个词,strip 掉行尾换行,空行直接丢弃 return {line.strip() for line in f if line.strip()} def tokenize(text, stopwords, min_len=1): # NFKC 归一化:全角字母数字折成半角,避免「ABC」和「ABC」被当成两个词 text = unicodedata.normalize("NFKC", text).lower() text = NOISE.sub(" ", text) # cut_all=False 走精确模式,cut 返回生成器,不占额外内存 tokens = jieba.cut(text, cut_all=False) return [t for t in tokens if t.strip() # 去掉纯空格片段 and t not in stopwords # set 查找,O(1) and len(t) >= min_len]

NOISE这条正则决定了什么算有效字符,把标点和表情全部换成空格而不是直接删除,是为了防止「你好世界」和「你好,世界」被粘成一个不存在的词。unicodedata.normalize("NFKC", ...)处理全角半角混排,中文文档里粘贴进来的全角数字和英文很常见,不做这步就是两套词。min_len是一把双刃剑:设成 2 能过滤掉大量单字噪声,代价是「猫」「雨」「钱」这类有信息量的单字会被一起丢掉。语料以长文档为主时我倾向设 2,短文本场景老老实实保留 1。

切完之后一定要打印前二十个 token 看一眼。这条管道跑通与否,肉眼三秒钟就能判断,比后面调半天算法参数高效得多。

3. 从 token 到向量:词袋、TF-IDF 与归一化在系统里怎么落地

token 序列本身没法直接比大小,必须映射成向量。这一步的选择直接决定了「相似」这个词在这个系统里的定义:是词有没有出现过,是词出现得有多重要,还是词在空间里的方向。三种表示不是精度高低的关系,是三种不同的语义假设。

3.1 词袋的 0/1 表示和它带来的词表爆炸

词袋模型的做法朴素到极点:把整个语料所有 token 去重排成一列,构成词表,每个文档对应一个和词表等长的向量,某个词出现过填 1,没出现填 0。词表大小等于全语料的去重 token 数,几万到几十万维都很正常,一篇五百词的文档在这个空间里非零元不到五百个,稀疏度 99% 以上。

0/1 表示有个容易被忽略的优点:它天然免疫关键词堆砌。一段话把「相似度」重复二十遍,向量里那个位置还是 1,不会因为重复而抬高分数。换成词频表示,一段刻意堆词的垃圾文本就能骗到高分。缺点同样明显:出现一次和出现十次被一视同仁,而那些真正靠词频区分的场景(比如长文档的主题强度)就丢了信息。

工程上最关键的一条约束是词表顺序必须固化。向量之间的内积是按下标对齐做的,第 37 维在 A 向量里代表「相似度」,在 B 向量里必须还是「相似度」。如果两个文本各自独立构建词表,维度含义错位,算出来的内积毫无意义。正确做法是先在整个语料上 fit 一次,得到固定的词表,之后所有文本都往这个固定坐标系里 transform。

3.2 TF-IDF 的权重公式到底在惩罚什么

TF-IDF 在词袋的基础上给每一维加权重,公式拆开看是两项。词频项衡量这个词在这篇文档里有多重要,逆文档频率项衡量这个词在整个语料里有多稀有。常见的平滑写法是:

idf(t) = ln((1 + N) / (1 + df(t))) + 1 tfidf(t, d) = tf(t, d) * idf(t)

分子分母都加 1 是为了避免 df 为 0 时除零,外面再加 1 是保证一个在所有文档里都出现的词权重不会掉到 0,仍有最小贡献。这条公式的直觉是:一个词在越多文档里出现,它区分文档的能力就越弱。停用词之所以是停用词,本质上就是因为它们的 df 接近 N,idf 被压到最低。

sublinear_tf是另一个值得打开的开关,它把 tf 换成1 + log(tf)。长文档里某个词重复五十次和重复五次,线性 tf 会给出十倍差距,直接把余弦方向拽过去;取对数之后差距被压缩到两倍多,长文档不至于靠堆词取胜。

这里有个反直觉的点:idf 只在构建它的那份语料内部有意义。拿新闻语料训练出来的 idf 去算两篇医学论文,权重分布是错位的,稀有词的判断完全失真。所以词表和 idf 必须和语料一起持久化,换语料就得重建,不能混用。

3.3 归一化与稀疏矩阵:为什么 fit 只能做一次

把上面这些串起来,用现成的向量化器实现,同时注意几个参数的含义:

import joblib from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( tokenizer=lambda toks: toks, # 上游已经切好词,这里只做透传 preprocessor=lambda toks: toks, # 同上,避免再走一遍默认清洗 token_pattern=None, # 关掉默认正则切分,否则会覆盖自定义 tokenizer norm="l2", # 每行做 L2 归一化,余弦退化成纯点积 smooth_idf=True, # 用上面的平滑公式,防止 df=0 除零 sublinear_tf=True, # tf 取 1+log(tf),压制长文档词频 min_df=2, # 只在一篇文档里出现的词多半是噪声,直接不进词表 ) X = vectorizer.fit_transform(corpus_tokens) # 返回 csr 稀疏矩阵,shape=(文档数, 词表大小) joblib.dump(vectorizer, "tfidf.pkl") # 词表 + idf + 参数一起落盘 # 线上只做 transform,永远不再 fit vec = joblib.load("tfidf.pkl") q = vec.transform([query_tokens]) scores = (X @ q.T).toarray().ravel() # 已归一化,内积即余弦

norm="l2"让每一行向量的模长变成 1,两个单位向量的点积就等于它们的夹角余弦,省掉每次比对时再除模长的开销。min_df=2在语料规模不大时能把词表砍掉三分之一以上,副作用是只出现过一次的真关键词也进不来,语料越小这个参数越要谨慎。token_pattern=None不是可选项,某些版本里传了自定义 tokenizer 却保留默认正则,切分会被悄悄覆盖,症状是分词结果和预期完全对不上。

持久化这一条是整个设计里最容易被省掉、也最贵的一步。每次请求都重新 fit 一遍,意味着每个用户进来都要把全语料扫一次,响应时间随文档量线性增长,而得到的词表顺序还可能是随机的。fit只做一次、结果落盘、线上只transform,是这条链路必须守住的边界。

4. 相似度算法层:余弦、Jaccard、编辑距离各自在度量什么

有了向量和 token 集合,最后一层就是把它们换算成一个 0 到 1 之间的数。三种度量对应的其实是三种完全不同的相似观:方向、重合、序列。同一对文本,三种度量给出的排序可能完全相反,混着用而不清楚各自的假设,是分数失真的另一个主要来源。

4.1 余弦相似度为什么只看夹角不看长度

余弦的定义是两个向量的内积除以各自模长,几何意义是夹角。对非负向量来说,值域落在 0 到 1 之间,1 表示方向完全一致,0 表示没有任何共享维度。把长度除掉,本来是为了让一千字的文章和五十字的摘要可以放在一起比:只要主题分布的比例接近,长文档就应当被判为相似。

这个设计在文档级比对上很合适,在查询场景下却会翻车。一段十来个字的查询词,覆盖的词本来就少,向量里非零维度少,和一整篇长文档的夹角天然偏大;文档越长、覆盖词越广,反而越容易被判成和谁都有点像。短查询对长文档的检索,通常要在余弦之外再加一层长度归一化或者换成带饱和函数的打分,否则永远搜不出真正相关的那篇。

顺带提一句余弦和欧氏距离的关系:两个向量都做了 L2 归一化之后,欧氏距离的平方等于2 - 2 * cos。也就是说在这种前提下,按余弦排序和按欧氏距离排序结果完全一致,选哪个只是习惯问题,真正决定结果的是归一化有没有做。

4.2 Jaccard 的集合视角与编辑距离的序列视角

Jaccard 把文本看成词的集合,用交集大小除以并集大小。它完全不关心词出现几次、在什么位置,只关心「有没有出现过」。这个特性使它特别适合查重和标签重合这类场景:一段话把某个词重复多少遍都不影响分数。代价是它对语序和语义完全无感,「我打你」和「你打我」在 Jaccard 眼里一模一样。

编辑距离走的是另一条路。它数的是把一段字符串改成另一段最少需要多少次增删改,是字符级、序列级的度量,对语序和错别字极度敏感,对同义词替换完全无感。短文本、标题比对、拼写纠错这类场景它很强;一旦文本上万字,O(m·n) 的复杂度就会变成性能事故。

三种度量放在一张表里看,边界会比较清楚:

度量输入形态对什么敏感时间复杂度适合的场景
余弦数值向量词的权重分布与方向O(非零元个数)文档级、篇章级比对
Jaccard词集合词是否出现O(min(m, n))查重、标签重合、去重
编辑距离字符序列顺序与字符级差异O(m×n)短文本、标题、错别字

4.3 统一的计算后端与它在 Django 视图里的落点

三种度量签名不同、输入形态不同,直接散在业务代码里会迅速失控。常见做法是抽出一个统一的调度层,让上层只认一个入口:

import numpy as np def cosine(vec_a, vec_b): """入参是两条向量(稀疏或稠密都行),返回 [0,1]""" denom = np.linalg.norm(vec_a) * np.linalg.norm(vec_b) if denom == 0: # 空文本或全被停用词吃光,直接判 0,别让它抛除零 return 0.0 return float(np.dot(vec_a, vec_b) / denom) def jaccard(tokens_a, tokens_b): set_a, set_b = set(tokens_a), set(tokens_b) union = set_a | set_b return 0.0 if not union else len(set_a & set_b) / len(union) def edit_similarity(str_a, str_b): """Levenshtein 距离归一化到 [0,1],滚动数组把空间从 O(mn) 压到 O(n)""" m, n = len(str_a), len(str_b) if m == 0 and n == 0: return 1.0 prev = list(range(n + 1)) # 第 0 行:空串变成前缀所需的插入次数 for i in range(1, m + 1): cur = [i] + [0] * n # 第 0 列:删除 i 个字符 for j in range(1, n + 1): cost = 0 if str_a[i - 1] == str_b[j - 1] else 1 cur[j] = min(prev[j] + 1, # 删除 cur[j - 1] + 1, # 插入 prev[j - 1] + cost) # 替换 prev = cur return 1 - prev[n] / max(m, n) # 除以较长串长度,保证落在 [0,1]

cosine里的除零判断不能省,一段文本全是停用词、或者两个空输入框直接提交,模长就是 0,不判空会直接 500。jaccard用set是对的,重复词本来就不该影响集合度量。edit_similarity归一化时除的是max(m, n)而不是min(m, n)或平均长度,只有除最大长度才能保证结果一定落在 0 到 1 之间,除以最小值会出现负数。

调度层收口之后,Django 的视图只负责取参数、选算法、返回结果,一行业务逻辑都不写:

# views.py from django.http import JsonResponse METRICS = {"cosine": cosine, "jaccard": jaccard, "edit": edit_similarity} def compare(request): if request.method != "POST": return JsonResponse({"error": "method not allowed"}, status=405) text_a, text_b = request.POST["text_a"], request.POST["text_b"] metric = request.POST.get("metric", "cosine") # 和前端下拉框的取值保持一致 func = METRICS.get(metric, cosine) if metric == "edit": score = func(text_a, text_b) # 字符级,直接用原文 else: toks_a, toks_b = tokenize(text_a, STOPWORDS), tokenize(text_b, STOPWORDS) if metric == "jaccard": score = func(toks_a, toks_b) else: # 一次 transform 传两条,保证落在同一个词表坐标系里 vec_a, vec_b = VECTORIZER.transform([toks_a, toks_b]).toarray() score = func(vec_a, vec_b) return JsonResponse({"score": round(score, 4)})

向量化器VECTORIZER和停用词表STOPWORDS都必须是模块级、在应用启动时加载好的单例,不能写在视图函数里。视图里每次 new 一个向量化器,等于每个请求都重新 fit 一遍词表,这就是前面反复强调的那条边界被踩穿的样子。

数据库那一层也有对应的取舍。新闻库里的每篇文档如果每次都现场分词,几万篇的量下响应时间就顶不住了,通常会在入库时把 token 序列一起落表:

CREATE TABLE news ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, content MEDIUMTEXT NOT NULL, tokens TEXT, -- 预处理后的 token,空格分隔,避免每次请求重跑分词 updated_at DATETIME NOT NULL );

注意这里存的是 token,不是向量。向量维度随词表变化,词表一重建,库里存的向量全部作废,而 token 是词表无关的中间产物,换向量化器、换 idf 都能直接复用。这个决定看起来不起眼,却决定了以后换算法时是要重建整张表还是只重跑一遍计算。

5. 排查与避坑:五个让相似度结果失真的真实场景

分数不对的时候,人的第一反应总是去调算法参数,而绝大多数问题出在算法之前。下面五条是按「现象 → 原因 → 解决」整理出来的高频翻车记录,排在前面的比排在后面的更常见。

5.1 预处理阶段最常见的三个坑

现象:两段主题完全不同、都超过五百字的长文本,余弦相似度稳定在 0.85 以上,怎么看都不合理。原因:停用词表没加载成功。文件路径写错、编码不是 UTF-8、或者换行符是 Windows 格式导致每行末尾带着\r,strip()之前集合里存的其实是「的\r」,过滤时一个都匹配不上,满篇虚词全部进入向量。解决:在启动日志里打印停用词表的条数,跑一条固定样例文本,断言它的 token 数明显少于总字数;读取时统一用encoding="utf-8"并逐行strip()。

现象:同一段文本连续算两次,分数差了零点几。原因:向量化器在每个请求里都重新 fit,词表顺序随机变化,两次请求的向量维度根本对不齐;或者多进程部署时,自定义词典只在主进程加载过,fork 出来的 worker 用的是残缺词典。解决:向量化器和词典在应用启动时初始化一次并持久化,视图里只做 transform;多进程部署时把初始化放在 worker 启动钩子里,而不是模块导入之后才懒加载。

现象:两篇同领域的专业文档相似度只有 0.3,反倒是一篇专业文档和一篇泛泛而谈的科普文能到 0.7。原因:领域术语不在通用词典里,被 HMM 猜着切,一篇文档切成了「卷积/神经/网络」,另一篇切成了「卷积神经/网络」,本该重合的词一个都没对上。解决:维护领域词典,用jieba.load_userdict()在启动时加载,词频给中等值把切分锁死;同时把领域词典里的词加进停用词保护名单,防止被误删。

5.2 计算与工程阶段的两个坑

现象:文本短的时候接口正常,一贴长文档就超时或内存飙高。原因:选了编辑距离这条路径。O(m·n) 在万字级文本上是上亿次循环,滚动数组虽然把空间压到 O(n),时间还是顶不住;再加上每次请求重新构建向量,两个问题叠在一起。解决:编辑距离只对短文本(比如标题、短句,长度设一个几百字的上限)开放,超长自动降级到余弦;长文本比对走预计算向量加一次矩阵乘法。

现象:中文文本结果正常,中英混排或者带全角符号的文本分数明显偏低。原因:清洗正则把英文和数字一起删了,或者全角字母数字没做归一化,ABC和ABC被当成两个不同的 token;还有大小写没折平,Python和python各占一维。解决:清洗之前先做 NFKC 归一化,正则里显式保留a-zA-Z0-9,统一.lower()折平大小写。这套处理对纯中文无损,对混排是必需的。

提示:这五条里有四条的根因都在预处理,而预处理的中间结果最容易被忽略。加一条调试接口,把两个输入文本的 token 列表和各自权重最高的十个词直接打出来,绝大多数「分数不对劲」看一眼就能定位,比对着公式推半天快得多。

6. 取舍:从能跑通到能改得动,中间隔着几个决定

算法部分加起来不到三百行,真正让这类系统难以维护的,是几个看起来和算法

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 7:38:07

CentOS7源码编译安装Nginx全攻略

1. 源码安装Nginx的必要性与优势在CentOS7环境下通过源码编译安装Nginx,相比直接使用yum安装预编译版本,能够带来诸多实际好处。最直接的优势是可以完全自定义Nginx的功能模块,根据实际业务需求裁剪或增强功能。比如在需要国密算法支持时&…

作者头像 李华
网站建设 2026/10/31 7:35:56

Windows USB虚拟打印机端口:从设备枚举到批量部署与排障

1. 先弄清楚 USB 虚拟打印机端口到底是什么很多人第一次撞上这个问题,是在打印机已经插上、驱动也装完、电源灯也亮了,点了打印却半点动静都没有的时候。打开“打印机属性—端口”,下拉框里空着,或者只有 LPT1、COM1、FILE: 这些看…

作者头像 李华
网站建设 2026/10/31 7:35:50

Vue异步组件原理与最佳实践指南

1. 异步组件的本质与核心价值异步组件是前端性能优化的重要手段,它允许我们将组件代码拆分成独立的包(chunk),在真正需要时才加载。这种机制与传统的同步加载有着本质区别:同步组件会在应用初始化时一次性加载所有代码…

作者头像 李华
网站建设 2026/10/31 7:35:18

Oracle基金交易平台数据库设计:从表空间到PL/SQL事务

简介:《oracle项目实战》是一份围绕开放式基金交易平台数据库设计展开的实战文档,面向正在学习Oracle、准备完成数据库课程设计或项目实训的初、中级读者。内容从银行基金业务的需求描述入手,梳理后台基金管理、客户账户管理、交易审核以及前…

作者头像 李华
网站建设 2026/10/31 7:34:55

Oracle改SGA异常:pfile应急启动与spfile回写恢复指南

简介:围绕 Oracle SGA 参数调整后实例无法正常启动这一常见故障,这份 docx 文档给出了应急处理与恢复思路,面向需要处理内存参数配置失误的数据库管理员、运维人员和备考面试者。包内仅含 1 个 docx 文件,压缩包约 14KB&#xff0…

作者头像 李华
网站建设 2026/10/31 7:34:55

SQL Server 2022安装与配置全指南

1. SQL Server 2022安装前的系统准备在开始安装SQL Server 2022之前,我们需要确保系统环境满足基本要求。根据微软官方文档,SQL Server 2022需要Windows 10 1607及以上版本或Windows Server 2016及以上版本。建议系统内存至少4GB(生产环境建议…

作者头像 李华