
简介这份资源是一套基于Python的文本相似度计算系统毕业设计文档面向计算机、人工智能等相关专业的学生尤其适合正在准备自然语言处理方向毕业设计或课程论文的读者。文档完整呈现了从需求分析到系统实现的流程先对文本进行清洗与分词再利用TF-IDF提取关键词并构建词向量最后通过余弦相似度算法计算文本间相似度同时结合Django完成界面展示并讨论了系统在信息检索、推荐系统等场景的应用价值。包体为单个docx文件共1个文件大小749KB内含中英文摘要、目录、正文、参考文献等部分结构完整便于直接查阅和排版参考。目前已有138人学习下载。作为一篇完整的毕业设计论文读者可从中快速获取系统模块划分、核心算法实现细节以及整体技术选型思路也可借鉴其章节组织方式与实验评估写法用于自身论文撰写或项目方案设计。1. 文本相似度计算系统不只是“分词余弦”那么简单把“基于 Python 的文本相似度计算系统源码数据库.docx”翻译成落地语言核心其实就三件事文本相似度算法、可复用的 Python 源码、以及把文档和结果管理起来的数据库。单独写几个相似度函数很容易难的是把它做成一个能持续跑批、结果可查、阈值可调的系统。一套完整的源码加数据库方案最典型的场景是内部文档查重、客服问答匹配、商品标题归一、论文/公告去重这几类。一个反直觉的点是这类系统上线后最先出问题的往往不是算法本身而是预处理不一致、存储设计不合理和阈值拍脑袋。所以我会把算法选型、源码结构、数据库设计放到同等位置来讲你按这套路径搭出来的东西才敢拿去跑真实数据。2. 先把算法层定下来三类相似度算法的选型与源码实现2.1 用 TF-IDF 加权余弦作为主力算法最小实现与参数说明做中英文混合的文本相似度最稳的主力算法是 TF-IDF 向量 余弦相似度而不是直接对两个字符串做字符重叠。原因是它能缓解“高频无意义词”对相似度分数的干扰。比如技术文档里总是出现“系统”“模块”“功能”这类词如果只按词频统计相似度两篇完全不同主题的文章也能算出很高的分。TF-IDF 会给这些高频词压低权重让真正的关键词主导分数。下面是最小可用的实现我把它拆成清洗、分词、建 TF-IDF 向量、算余弦四步# -*- coding: utf-8 -*- import math import re from collections import Counter import jieba STOPWORDS set([ 的, 了, 在, 是, 我, 有, 和, 就, 不, 人, 都, 一, 一个, 上, 也, 很, 到, 说, 要, 去, 你, 会, 着, 没有, 看, 好, 自己, 这, 那, 与 ]) def clean_text(text: str) - str: # 统一大小写去掉 URL/数字串/多余空白避免它们干扰中文分词 text text.lower() text re.sub(rhttps?://\S, , text) text re.sub(r\d, , text) text re.sub(r\s, , text) return text.strip() def tokenize(text: str) - list: # jieba.lcut 返回词列表过滤空串和停用词 words jieba.lcut(clean_text(text)) return [w for w in words if w.strip() and w not in STOPWORDS] def build_tfidf_vectors(docs_text: list) - list: # 每个文档先分词再统计词频和文档频率 docs_tokens [tokenize(doc) for doc in docs_text] doc_freq Counter() for tokens in docs_tokens: for w in set(tokens): doc_freq[w] 1 n_docs len(docs_tokens) vectors [] for tokens in docs_tokens: tf Counter(tokens) total len(tokens) vec {} for w, cnt in tf.items(): # 1 平滑防止低频词 idf 过大也避免除零 idf math.log((n_docs 1) / (doc_freq[w] 1)) 1 vec[w] (cnt / total) * idf # L2 归一化让不同长度文本的向量模长一致余弦才能比较 norm math.sqrt(sum(v * v for v in vec.values())) if norm 0: vec {k: v / norm for k, v in vec.items()} vectors.append(vec) return vectors def cosine_similarity(vec_a: dict, vec_b: dict) - float: common set(vec_a.keys()) set(vec_b.keys()) dot sum(vec_a[w] * vec_b[w] for w in common) # 向量已归一化分母恒为 1这里保留计算是为了可读性 norm_a math.sqrt(sum(v * v for v in vec_a.values())) norm_b math.sqrt(sum(v * v for v in vec_b.values())) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b)这段代码有四个值得说的点。第一STOPWORDS是通用停用词表但真实业务里一定要追加领域停用词比如做系统文档去重“系统”“模块”这类词同样要加进去否则相似度虚高。第二idf 平滑用log((N1)/(df1)) 1这样至少保留词本身的权重而不是让出现次数极少的词拿到离谱的高权重。第三L2 归一化放在建向量阶段而不是余弦计算阶段避免每对比一对文本都重复计算模长。第四clean_text里把数字直接替换成空格这是很多中文相似度系统翻车的细节同一篇文档把“2024 年”改成“2024年”分词结果完全不同去掉数字反而让文本特征更稳定。# 使用示例 docs [ 基于 Python 的文本相似度计算系统设计与实现, 基于 Python 的文本查重系统源码, 基于 Java 的电商库存管理系统 ] vecs build_tfidf_vectors(docs) print(cosine_similarity(vecs[0], vecs[1])) # 预期较高 print(cosine_similarity(vecs[0], vecs[2])) # 预期较低使用示例里的第二种情况能直接测出停用词和领域词的影响。“系统”“基于”这类词如果权重没有被压低第一和第三篇的相似度就会虚高到 0.7 以上而实际主题完全不同。跑通最小实现后下一步建议对齐参数STOPWORDS需要按业务语料扩充clean_text里保留小数点还是整数规则要统一L2 归一化后算出的分数范围才好跟后面的阈值体系对接。2.2 编辑距离兜底处理短文本Levenshtein 的实现技巧TF-IDF 余弦在 100 字以上的中长文本里表现稳定但一旦比对对象是标题、型号、人名、地址这种 10~30 个字符的短文本它就开始不稳定。原因很简单短文本分词后可能只剩三五个词向量稀疏余弦相似度要么接近 1 要么接近 0中间没有过渡带。这种场景我一般直接用编辑距离做兜底核心指标变成“最少编辑几次能从一个字符串变成另一个”。def edit_distance(a: str, b: str) - int: 经典二维 DP 实现返回 a 到 b 的最小编辑次数 m, n len(a), len(b) 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): cost 0 if a[i - 1] b[j - 1] else 1 dp[i][j] min( dp[i - 1][j] 1, # 删除 a[i] dp[i][j - 1] 1, # 插入 b[j] dp[i - 1][j - 1] cost # 替换或保持 ) return dp[m][n] def edit_similarity(a: str, b: str) - float: dist edit_distance(a, b) max_len max(len(a), len(b)) if max_len 0: return 1.0 return 1 - dist / max_len这段 DP 实现的时间复杂度是 O(mn)空间复杂度 O(mn)。如果后续要处理几万条短文本两两对比空间会扛不住常见做法是改成两行滚动数组把空间降到 O(min(m,n))。实际用的时候edit_similarity返回 1.0 表示完全相同0.8 以上可以认为是轻微改写的标题比如“Python文本相似度系统”和“Python文本相似度计算系统”之间就落在这个区间。它跟余弦算法不是替代关系是互补关系。我在系统里通常会做一层路由比对字段来自标题或型号时走编辑距离来自正文时走 TF-IDF 余弦。这个路由逻辑不能省否则短文本用余弦算出的分数波动很大长文本用编辑距离算又慢又不准。2.3 同义词和语义匹配的补充词向量平均法TF-IDF 和编辑距离都解决不了同义词问题。“文本相似度”和“文档相似度”在词面上没有交集TF-IDF 算出来分数可能只有 0.3但语义上它俩说的是同一件事。这个问题光靠词典替换解决不干净我一般会在系统里预留一个词向量层作为补充不替换主力算法只作为一个可选分数通道。def sentence_vector(words: list, word_vectors: dict, dim: int): 把词向量平均成句向量返回 None 表示没有命中任何一个词 vec [0.0] * dim hit_count 0 for w in words: if w in word_vectors: v word_vectors[w] vec [vec[i] v[i] for i in range(dim)] hit_count 1 if hit_count 0: return None # 平均后做 L2 归一化避免句子长度影响向量模长 vec [x / hit_count for x in vec] norm math.sqrt(sum(x * x for x in vec)) if norm 0: vec [x / norm for x in vec] return vec def vector_cosine(a: list, b: list) - float: dot sum(x * y for x, y in zip(a, b)) norm_a math.sqrt(sum(x * x for x in a)) norm_b math.sqrt(sum(x * x for x in b)) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b)常见做法是加载一个预训练的中文词向量文件把每个词的向量读进字典然后对分词结果做平均。这个方案的优点是实现成本低不需要 GPU 也不需要训练缺点是“平均”会让高频但语义弱的词把句向量拉偏效果往往不如带 IDF 加权的平均。所以在真正投入生产前我建议先拿一批带标签的文本对做对比实验如果语义分数和人工判断的相关性一般就不要把词向量分数作为主判据只作为 TF-IDF 分数偏低时的参考。三套算法都跑通以后系统的算法层就算立住了。接下来最容易被忽略的是源码怎么组织算法函数只是系统的一小部分剩下的预处理、调度、存储、日志才是工作量的大头。3. 系统源码怎么组织模块划分与数据流设计3.1 最小源码目录每个文件的职责边界有了算法函数很多人会顺手把它们全塞进一个main.py这是后面一切维护噩梦的起点。一个文本相似度计算系统哪怕只有几千行代码也应该按职责拆成独立模块。下面是我常用的最小目录结构你可以直接照搬text_similarity_system/ ├── config.py # 全局配置停用词路径、阈值、数据库地址 ├── preprocess.py # 清洗、分词、去停用词 ├── features.py # 构建 TF-IDF 向量、词向量句向量 ├── similarity.py # 余弦相似度、编辑距离、路由选择 ├── storage.py # 数据库连接、建表、读写结果 ├── pipeline.py # 核心调度文档入库 - 比对 - 结果落库 └── batch_task.py # 批量任务入口支持命令行调用每个文件只做一件事preprocess.py不碰数据库storage.py不参与算法计算similarity.py只管输入向量输出分数。这样分的好处是换数据库、换分词器、换阈值时改动都限制在单文件内不会牵一发动全身。3.2 文本预处理的顺序一个藏了很多坑的环节预处理看似简单其实是整个系统里翻车概率最高的环节。我踩过的坑集中在编码、空白字符、全角半角、词条缓存这几处。下面这条处理链是我在多个项目里逐步补出来的# preprocess.py 中的核心函数 import unicodedata def normalize_text(raw_text: str) - str: # 第一步NFKC 统一全角半角、兼容字符 text unicodedata.normalize(NFKC, raw_text) # 第二步统一换行和制表符 text text.replace(\r\n, \n).replace(\r, \n) text text.replace(\t, ) # 第三步去掉控制字符保留中文、英文、数字、常用标点 text .join(ch for ch in text if not unicodedata.category(ch).startswith(C)) return text为什么要先做 NFKC 再做清洗因为中文文本里经常混着全角括号、全角逗号、全角数字如果不归一同一篇文档在不同阶段导出的版本会计算出偏低的相似度。控制字符这一点更容易被忽略从网页复制下来的文本可能带有零宽字符这类字符不可见却会让分词结果多出空串或异常 token。清洗完成后才进入分词和去停用词流程分词结果要缓存到数据库不要每次对比都重跑分词。这个缓存是后面性能优化的关键。3.3 核心调度逻辑从入参到落库的完整链路pipeline.py里的主流程我用伪代码拆给你看# pipeline.py def process_document(doc_id: int, doc_text: str): # 1. 规范化 分词 norm_text normalize_text(doc_text) tokens tokenize(norm_text) # 2. 构建当前文档向量 vec build_vector_from_tokens(tokens, global_idf) # 3. 存入语料表tokens 和 vector 一起存后续对比不再重新分词 doc_key storage.insert_document(doc_id, norm_text, tokens, vec) # 4. 从库里读出候选向量计算相似度 candidates storage.load_all_vectors_except(doc_key) results [] for cand_key, cand_vec in candidates: score cosine_similarity(vec, cand_vec) results.append((cand_key, score)) # 5. 按分数降序写入结果表 results.sort(keylambda x: x[1], reverseTrue) storage.save_task_result(doc_key, results[:TOP_N]) return results[:TOP_N]第 3 步存tokens和第 2 步构建global_idf是这串流程里最容易被忽略的两个设计。tokens缓存的意义在于语料库新增文档时旧文档不需要重新分词只需要用缓存的 token 更新词频统计即可。global_idf也不应该每次跑批都全量重建而是建一个独立表存每个词的 idf 值新增文档后增量更新避免每次比对都要把所有文档重新算一遍向量。异常兜底也要安排在调度层而不是算法层。单条文档清洗后变成空文本比如全是表情符号或控制字符这种情况直接跳过并记录日志不要让整个跑批中断。数据库写入失败时先留内存里的结果等连接恢复再重试而不是立刻抛异常退出。4. 数据库怎么设计语料存储、结果落库和增量索引4.1 表结构设计语料表、结果表、任务表文本相似度系统的数据库设计核心是三个字可追溯。你算完一批相似度过两周要复盘某条结果怎么来的数据库里必须能查出来。围绕这个目标我一般建三张表语料表存参与比对的文档结果表存每一次比对产生的分数任务表存跑批记录。-- 语料表每一行是一条参与相似度计算的文本 CREATE TABLE IF NOT EXISTS corpus ( doc_id INTEGER PRIMARY KEY AUTOINCREMENT, doc_name TEXT NOT NULL, content TEXT NOT NULL, tokens TEXT, -- 分词结果JSON 数组字符串 vector TEXT, -- 归一化向量JSON 字典字符串 created_at TEXT DEFAULT (datetime(now, localtime)) ); -- 结果表记录每对文本的相似度分数 CREATE TABLE IF NOT EXISTS similarity_result ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, src_doc_id INTEGER NOT NULL, dst_doc_id INTEGER NOT NULL, score REAL NOT NULL, matched_at TEXT DEFAULT (datetime(now, localtime)), UNIQUE(task_id, src_doc_id, dst_doc_id) ); -- 跑批任务表记录每次全量比对或增量比对的上下文 CREATE TABLE IF NOT EXISTS similarity_task ( task_id INTEGER PRIMARY KEY AUTOINCREMENT, task_name TEXT, algorithm TEXT, -- 使用的算法标识如 tfidf_cos / edit_dist threshold REAL, -- 本次任务使用的阈值 total_pairs INTEGER, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE INDEX IF NOT EXISTS idx_result_task_score ON similarity_result(task_id, score DESC); CREATE INDEX IF NOT EXISTS idx_result_src_dst ON similarity_result(src_doc_id, dst_doc_id);语料表里tokens和vector都用 JSON 字符串存储而不是用 BLOB。原因有两个一是 JSON 可以直接在数据库客户端里查出来看方便排查问题二是 Python 侧序列化和反序列化都只需要json.dumps和json.loads跨语言迁移也友好。代价是字段会膨胀但文本相似度系统核心瓶颈通常不在磁盘而在比对计算这点冗余可以接受。4.2 向量与相似度结果的持久化读写函数怎么写向量存储是容易被做成“黑匣子”的地方很多方案把向量序列化后直接塞进 BLOB导致线上无法排查“为什么这篇和那篇算出 0.8”。我的做法是把向量存成 JSON 字典键是词值是归一化后的权重。读取时先转回 dict再参与余弦计算。# storage.py 中的向量读写示例 import json import sqlite3 def insert_document(conn, doc_name, content, tokens, vector) - int: cur conn.execute( INSERT INTO corpus(doc_name, content, tokens, vector) VALUES (?, ?, ?, ?), (doc_name, content, json.dumps(tokens, ensure_asciiFalse), json.dumps(vector, ensure_asciiFalse)) ) conn.commit() return cur.lastrowid def load_all_vectors(conn): rows conn.execute(SELECT doc_id, vector FROM corpus).fetchall() result [] for doc_id, vector_json in rows: try: vec json.loads(vector_json) except json.JSONDecodeError: continue result.append((doc_id, vec)) return result写入时用ensure_asciiFalse这样库里存的是中文原文而不是\uXXXX转义直接查数据库就能读懂。读取时做JSONDecodeError兜底历史脏数据不会让整个跑批挂掉。这个兜底在真实环境里非常常见早期版本可能导入过不完整的数据如果不在读取层容错后面每次全量跑批都会在同一行数据上崩掉。4.3 典型查询与批量写入别一条条 insert结果表的写入是跑批里耗时大户。如果两两配对产生 10 万条结果用 Python 循环逐条INSERTSQLite 在普通机械硬盘上可能要跑几分钟。executemany是必须用上的优化项。# 批量写入相似度结果 def save_results_batch(conn, task_id, results): rows [] for src_id, dst_id, score in results: rows.append((task_id, src_id, dst_id, round(score, 4))) conn.executemany( INSERT OR REPLACE INTO similarity_result (task_id, src_doc_id, dst_doc_id, score) VALUES (?, ?, ?, ?), rows ) conn.commit()round(score, 4)这一步不是可有可无。相似度分数保留 4 位小数足够业务判断还让结果表可读性更好索引体积更小。INSERT OR REPLACE依赖表结构里的UNIQUE(task_id, src_doc_id, dst_doc_id)约束重复跑同一任务不会产生脏数据。查询端最常用的 SQL 是按任务查 TopNSELECT dst_doc_id, score FROM similarity_result WHERE task_id ? ORDER BY score DESC LIMIT 10;这条查询能走idx_result_task_score复合索引5 万条结果以内的查询响应时间基本在毫秒级。注意不要写SELECT *只取需要的字段否则大字段content 全文会被一起加载拖慢查询。5. 文本相似度系统的常见问题与避坑指南5.1 同一份文本两次计算相似度分数不稳定现象上午跑批 A 文档和 B 文档相似度 0.86下午重跑变成 0.79代码没改、数据没改结果变了。 原因分词结果不稳定。最常见的是环境里加载的自定义词典不同或者STOPWORDS集合被后续代码修改甚至 Python 进程里 jieba 的缓存路径变了导致同一句话切出的词不完全一致。 解决把分词结果写入语料表tokens字段后续比对一律读取缓存的 tokens不重新分词。只有新增语料或停用词表主动变更时才重建 tokens。这样即使底层分词器升级已入库文档的特征也不会漂移。5.2 只有几行字的标题相似度算出来比正文还高现象两条完全不相干的商品标题比如“华为智能手环”和“华为智能手表”余弦相似度超过 0.8人工判断完全不能接受。 原因短文本分词后只有两三个词如果共用“华为”“智能”这两个词向量重叠比例非常高余弦相似度天然虚高。 解决长度小于 30 个字符的文本路由到编辑距离算法而不是 TF-IDF 余弦。编辑距离对字符串字面差异更敏感能区分“智能手环”和“智能手表”这种局部替换。系统里要维护一个route_by_length的判断函数长度阈值可以配在config.py里方便不同业务调整。5.3 文档量到 5 万条以后跑批速度断崖式下跌现象1 万条文档两两比对还能在十几分钟内跑完5 万条直接跑到深夜也跑不完日志显示 CPU 占用很低但任务一直不结束。 原因全量两两比对的时间复杂度是 O(n²)5 万条会产生 12.5 亿次余弦计算就算每次计算只有 1 毫秒也要 34 小时以上。 解决先粗筛再精排。用倒排索引或 SimHash 先圈定候选集只对候选做余弦。常用做法是维护一个词到文档 ID 的倒排索引查询时取 query 中多个关键词对应的文档 ID 并集交集数量低于阈值就跳过。粗筛阶段把候选集从 5 万降到几百再跑精确余弦跑批时间可以缩短一个数量级。5.4 跑批时提示 database is locked现象SQLite 跑批过程中频繁报database is locked有时是写入时有时是读取时。 原因SQLite 同一时刻只允许一个写连接。跑批程序使用了多个线程每个线程各开一个连接写操作互相争锁。也有可能是另一个程序开着数据库客户端没关闭事务。 解决跑批进程只保留一个写连接所有写入走同一个连接串行执行。SQLite 不适合高并发写入如果业务是多个服务同时写入结果表建议切换到 MySQL 或 PostgreSQL存储层接口独立替换时改storage.py即可。5.5 阈值拍脑袋定成 0.7线上误报漏报一起出现现象系统上线后设阈值 0.7人工抽查发现明显重复的文档没报出来不相关的文档反而报成疑似重复。 原因0.7 这个数字没有经过业务样本验证。不同算法、不同分词器、不同停用词表下相似度分数的分布完全不同。TF-IDF 余弦在技术文档上的 0.7跟编辑距离在标题上的 0.7物理含义完全不一样。 解决构造 30~50 对带人工标签的文本对扫描阈值并画出对应关系选使 F1 最高的那个值。这是最后一章要展开讲的校准流程也是我从多次血泪经验里总结出最值得做的一件事。6. 上线前别忘了这一步用人工标注集校准阈值与召回6.1 构造最小验证集从小处着眼的效果确认拿系统里两种典型文本各抽 15 对人工标注“是否为重复”1 表示重复0 表示不重复。标注集合覆盖三种情况明显重复、明显不重复、似像非像的边界案例。每对文本记录系统算出的分数保存为 JSON后面跑阈值扫描直接用。[ {a: Python文本相似度系统设计与实现, b: Python文本相似度计算系统开发, label: 1, score: 0.0}, {a: 库存管理系统需求分析, b: 电商平台订单模块设计, label: 0, score: 0.0} ]6.2 扫描阈值用召回率和精确率决定最终参数def scan_threshold(pairs, label_keylabel, score_keyscore): best_t, best_f1 0.0, 0.0 # 从 0.3 到 0.9 按 0.01 步长扫描 for t in [i / 100 for i in range(30, 90)]: tp fp fn tn 0 for p in pairs: pred 1 if p[score_key] t else 0 label p[label_key] if pred 1 and label 1: tp 1 elif pred 1 and label 0: fp 1 elif pred 0 and label 1: fn 1 else: tn 1 precision tp / (tp fp) if tp fp 0 else 0 recall tp / (tp fn) if tp fn 0 else 0 f1 2 * precision * recall / (precision recall) if precision recall 0 else 0 if f1 best_f1: best_f1, best_t f1, t return best_t, best_f1扫描结果是表格里的几个代表值实际报告里我会取完整曲线看拐点阈值精确率召回率F10.550.780.930.850.620.860.870.860.700.940.730.820.800.970.530.69看这个表格0.62 才是当前语料下的最优值而不是拍脑袋的 0.7。0.7 的精确率更高但召回率掉到 0.73意味着接近三成的重复文档会漏网。如果业务宁可多查不可漏查阈值可以降到 0.55配合人工复核清单使用。6.3 阈值配置和增量迭代的收尾习惯校准出来的阈值不要写死在代码里放进config.py数据库任务表里也要记录每个任务实际使用的阈值。这样以后如果语料分布变了比如新增了一批客服话术导致分数整体偏移你可以对比历史任务定位是哪一版语料引入的漂移。我现在的习惯是任何一次阈值调整或算法参数调整都会重跑这个几十对的标注集半小时不到但能明确告诉我改动到底有没有用。线上误报多了先看分数分布再调阈值而不是凭感觉改代码。这算是被线上事故教育出来的习惯希望帮到你。本文还有配套的精品资源点击获取