☰
Python+Django文本相似度系统:TF-IDF分词与余弦相似度实践
2026/10/27 3:03:15 网站建设 项目流程

简介:面向计算机与自然语言处理方向毕业生及文本分析项目开发者的毕业设计文档,围绕基于Python的文本相似度计算系统展开,可用于信息检索、推荐系统等场景的方案参考与论文写作借鉴。压缩包内共1个docx文件,约749KB,以论文式文档呈现,涵盖摘要、目录、技术选型、系统设计与实验评估等主体内容,便于快速了解整体研究框架。目前已有138人学习。文档具体梳理了文本清洗与分词、TF-IDF关键词提取、词向量构建、余弦相似度及Jaccard系数优化等核心环节,并结合Django、MySQL说明前后端交互与数据存储思路;同时给出可视化界面设计、多组实验测试与结论展望,读者可据此理解文本相似度系统的实现脉络,获取毕业设计章节组织、算法选型与项目复现的参考素材。

1. 一份能跑起来的文本相似度计算系统:它到底解决了谁的什么问题

拿到「基于 Python 的文本相似度计算系统」这个资源包的人,十有八九是被一句需求卡住的:给两段文本,算个相似度,还要有个能点、能演示的界面。这套源码给的就是这个最小闭环——Django 起 Web 层,MySQL 存语料和计算记录,jieba 做中文分词,词袋加 TF-IDF 转向量,余弦相似度出分数,Bootstrap 加 Ajax 把结果怼回页面上。它同时带登录、文本分析、文件分析(txt 上传比对)、新闻管理、用户管理五个模块,是个结构完整的课程设计/毕设底座。适合要交设计的学生和要做内部文档比对原型的工程师;不适合指望它直接扛线上语义检索的人。

2. 环境复原:Django、MySQL、jieba 三件套的安装顺序

2.1 为什么这套技术组合在演示型系统里是合理选择

先说服自己这套栈不是凑数。Django 自带 ORM、模板引擎、表单组件、CSRF 防护和后台管理,登录注册、增删改查、页面渲染这些"跟算法无关但要占一半工期"的活儿基本能靠脚手架抹平。对比 Flask,路由和数据库迁移都要自己拉 SQLAlchemy 和 Flask-Login 补齐,代码量翻倍;对比 Java/JSP 那一套,得先装 JDK、Tomcat、配 web.xml,答辩机器上出问题的概率明显更高。

MySQL 在这里的角色是"语料仓库 + 计算流水账":新闻正文、用户提交的历史文本、每次计算的分数和时间戳,都得落库,不然"新闻管理"和"相似新闻推荐"两个模块没法自洽。jieba 是纯 Python 实现,不依赖 C 编译,pip 一装就能跑,这对要在别人电脑上复现的场景很关键。

真正需要权衡的是分词器和向量化方案。词袋模型(Bag of Words)实现简单、结果可解释,缺点是丢语序、丢语义;如果换成预训练词向量,包体动辄几百 MB,还要下模型文件,答辩现场网络一断就尬住。所以这套源码选词袋加 TF-IDF 加余弦,是在"能跑、能讲清原理、能复现"三者之间取的平衡点。理解这一点,后面调参才知道边界在哪——它的天花板是词面匹配,同义改写("手机"和"移动电话")它是认不出来的。

2.2 依赖清单与版本锁定

不要直接pip install django jieba了事。Django 3.x 和 4.x 在url()与path()、django.conf.urls.url的移除上有硬差异,源码里如果有from django.conf.urls import url,装 4.x 会直接 ImportError。稳妥做法是先写死一份 requirements.txt。

# requirements.txt —— 按源码实际引用锁版本,不要用 latest Django==3.2.20 jieba==0.42.1 numpy==1.24.4 scikit-learn==1.3.2 pymysql==1.1.0 python-dotenv==1.0.0
# 建议在虚拟环境里装,避免污染系统 Python python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

版本说明几句。Django 锁 3.2 是因为它是 LTS,且大量 2020—2023 年间的课程设计源码用的是 3.x 语法;jieba 0.42.1 是长期稳定版,分词行为和词典格式没变过,换新版反而可能踩到词典路径变更;scikit-learn 锁 1.3.x 是因为TfidfVectorizer的token_pattern默认值和cosine_similarity的稀疏矩阵返回类型在更高版本里有细微调整。

数据库驱动这里有个高频翻车点:源码如果写的mysqlclient,在 Windows 上要先装 Visual C++ Build Tools 才能编译,很多人卡在这一步就放弃了。替代方案是用纯 Python 的pymysql,然后在项目的__init__.py里伪装一下:

# 项目包下的 __init__.py —— 让 Django 以为装的是 mysqlclient import pymysql pymysql.install_as_MySQLdb()

install_as_MySQLdb()的作用是把pymysql注册成MySQLdb模块名,Django 的 MySQL 后端找的就是这个名字。代价是少数极端场景(比如需要mysqlclient特有的 C 扩展特性)不兼容,但文本相似度系统用不到那些。

2.3 建库与 settings.py 的关键四项

先建库再改配置,顺序反了会多排查半小时。字符集必须是utf8mb4而不是utf8——后者在 MySQL 里其实是三字节的残缺 UTF-8,存 emoji 或者部分生僻汉字会直接报Incorrect string value。

-- 建库语句:字符集和排序规则两个都要写 CREATE DATABASE text_sim DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 单独建个应用账号,别用 root 跑项目 CREATE USER 'sim_user'@'localhost' IDENTIFIED BY 'ChangeMe_2024'; GRANT ALL PRIVILEGES ON text_sim.* TO 'sim_user'@'localhost'; FLUSH PRIVILEGES;
# settings.py 中需要改动的片段 DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'text_sim', 'USER': 'sim_user', 'PASSWORD': 'ChangeMe_2024', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', # 必须,和建库保持一致 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, } } LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_TZ = False # 用本地时间,避免模板里日期差 8 小时 STATIC_URL = '/static/' STATICFILES_DIRS = [BASE_DIR / 'static']

USE_TZ = False这一项值得单独说。源码里的模型大概率是DateTimeField(auto_now_add=True)直接往模板里渲染,开着USE_TZ会存 UTC,页面上显示的时间比实际早八小时,答辩时被问一句"这个记录时间怎么不对"就很难看。要么关掉,要么在模板里加|date:"Y-m-d H:i"配合localtime过滤器,二选一,别混着来。

2.4 迁移、建管理员、启动

# 1. 生成并执行迁移 python manage.py makemigrations python manage.py migrate # 2. 创建后台管理员(用于录入新闻语料) python manage.py createsuperuser # 3. 启动开发服务器 python manage.py runserver 0.0.0.0:8000

makemigrations只对注册在INSTALLED_APPS里的 app 生效,如果源码的 app 目录名和你INSTALLED_APPS里写的不一致(常见于解压后多套了一层文件夹),这一步会提示 "No changes detected",表现就是数据库里一张业务表都没建出来,页面一访问就报Table 'text_sim.xxx' doesn't exist。遇到这个先查INSTALLED_APPS里每一项的模块路径能不能被import。

runserver 0.0.0.0:8000而不是默认的127.0.0.1:8000,是为了让同一局域网内的其他设备(比如答辩时投屏用的另一台机器)能访问。这时候必须同步把ALLOWED_HOSTS = ['*']或具体 IP 加进去,否则 Django 会返回 400 Bad Request,而且报错信息很不显眼,容易被误判成"服务没起来"。

启动后第一件事不是点页面,是先访问/admin把新闻语料录几条进去。文件分析模块的"匹配最佳新闻文本"功能依赖库里有数据,空库状态下点计算,返回的永远是空结果,会让人误以为代码坏了。

3. 预处理链路:从原始文本到可比向量

3.1 清洗、分词、停用词,这三步为什么不能合并写

很多人图省事,把正则清洗、jieba 分词、停用词过滤塞进一个函数里递归处理,结果调参时完全不知道是哪一步把关键信息吃掉了。分开写,每步的输入输出都能打日志,出问题能定位。

清洗阶段要处理的脏东西比想象中多:全角标点、零宽字符、连续空白、HTML 残留标签、URL。这里用正则统一处理,注意re.sub的顺序——先去标签再去标点,否则<p>被拆成p之后就没法识别了。

# preprocess.py —— 分三步,每步可单独测试 import re import jieba def clean_text(raw: str) -> str: """清洗:去 HTML 标签、URL、非中英文数字字符,统一全角半角""" if not raw: return "" text = re.sub(r'<[^>]+>', ' ', raw) # 去 HTML 标签 text = re.sub(r'https?://\S+|www\.\S+', ' ', text) # 去 URL text = re.sub(r'[\u3000\xa0]', ' ', text) # 全角空格、不间断空格 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text) # 只保留中英文数字 return re.sub(r'\s+', ' ', text).strip() # 压缩连续空白 def cut_words(text: str) -> list: """分词:中文用精确模式,英文单词原样保留""" # cut_all=False 是精确模式,不会产生重叠词,适合相似度计算 return [w.strip().lower() for w in jieba.cut(text, cut_all=False) if w.strip()] def load_stopwords(path: str) -> set: """停用词表读成 set,查找 O(1),列表是 O(n)""" with open(path, encoding='utf-8') as f: # 用 set 而不是 list,几千词的表在循环里差距很明显 return {line.strip() for line in f if line.strip()} def preprocess(raw: str, stopwords: set) -> list: tokens = cut_words(clean_text(raw)) # 过滤停用词 + 过滤单字(单字噪音大,"的""了"之外还有很多无意义单字) return [t for t in tokens if t not in stopwords and len(t) > 1]

逐段说明。clean_text里[\u4e00-\u9fa5]是常用汉字区,a-zA-Z0-9保留英文和数字,其余全部替换成空格而不是直接删除——直接删除会把Python文本粘成Python文本一个整体,jieba 就切不开了。cut_all=False用精确模式,全模式会产生"中华/华人/人民"这类重叠词,词频统计直接失真。

load_stopwords返回set不是洁癖。假设停用词表 2000 词、一篇文档 500 个 token,用 list 做in判断是 100 万次字符串比较,用 set 是 500 次哈希查找,批量处理几百篇文档时差距是秒级的。

len(t) > 1这个过滤看似粗暴但有效。中文单字在词袋模型里几乎全是噪音,"的""了""在"已经被停用词表干掉,剩下的"人""事""物"这类单字带不来区分度,反而会拉高所有文本的余弦相似度。英文场景要放宽到len(t) > 1会误杀 "I" "a",不过那种短词本来也该进停用词表。

3.2 词袋到 TF-IDF:权重为什么必须算,归一化为什么不能省

词袋模型只数词频,问题在于"的"这种词在所有文档里都高频,但它没有任何区分能力。TF-IDF 的思路是给每个词乘一个逆文档频率:出现在越多文档里的词,权重越低。

# vectorize.py —— 训练一次,向量化多次 from sklearn.feature_extraction.text import TfidfVectorizer def build_vectorizer(corpus_tokens: list) -> TfidfVectorizer: """corpus_tokens 是 [[词, 词, ...], [...]] 形式""" # 把 token 列表拼回空格分隔的字符串,配合 token_pattern 直接按空格切 corpus = [' '.join(tokens) for tokens in corpus_tokens] vectorizer = TfidfVectorizer( token_pattern=r'(?u)\b\w+\b', # 已分好词,这里只做简单切分 min_df=2, # 至少在 2 篇文档出现过,滤掉只出现一次的专有名词噪音 max_df=0.85, # 出现在 85% 以上文档的词视为无区分度,直接丢弃 sublinear_tf=True, # 用 1+log(tf) 替代 tf,抑制超高频词 norm='l2', # L2 归一化,余弦相似度必需 ) vectorizer.fit(corpus) return vectorizer

min_df=2和max_df=0.85这两个参数在小语料上尤其重要。语料只有几十条新闻时,min_df设 1 会让词表膨胀到几万维,每个向量极度稀疏,余弦相似度的数值会普遍偏低且不稳定。max_df=0.85是自动版的停用词过滤,效果比手工维护停用词表还稳。

norm='l2'是余弦相似度的前置条件,但它做的事很多人没意识到:它把所有向量拉到单位长度,此时余弦相似度就退化成点积,计算量减半。如果不做归一化,长文本因为词多、模长大,跟任何文本的点积都会偏大,相似度普遍虚高——这是"为什么我的系统什么文本都比出 0.9"的最常见原因。

最需要警惕的是词表一致性。TfidfVectorizer的fit会建立一份vocabulary_(词到列下标的映射),transform时如果遇到没见过的词直接忽略。所以正确姿势是:用全部语料fit一次,把 vectorizer 存下来,之后所有查询文本都只transform,绝不能再fit一次。两次fit出来的词表顺序不同,同一个词落在不同列上,算出来的相似度毫无意义,而且不会报错,属于典型的"结果不对但程序不崩"。

落地做法是把 vectorizer 和它产出的矩阵一起序列化:

# 训练阶段:一次性完成,存盘 import pickle from sklearn.metrics.pairwise import cosine_similarity corpus_matrix = vectorizer.transform(corpus) # 注意:用 transform 不是 fit_transform with open('model/tfidf.pkl', 'wb') as f: pickle.dump({'vectorizer': vectorizer, 'matrix': corpus_matrix}, f) # 查询阶段:加载,只 transform 查询文本 with open('model/tfidf.pkl', 'rb') as f: bundle = pickle.load(f) query_vec = bundle['vectorizer'].transform([' '.join(query_tokens)]) scores = cosine_similarity(query_vec, bundle['matrix'])[0] # 一维数组,长度=语料条数 top_idx = scores.argsort()[::-1][:5] # 取 Top5

cosine_similarity(A, B)返回的是len(A) × len(B)的矩阵,[0]取第一行拿到"这条查询对所有语料"的分数数组。argsort()[::-1][:5]是降序取前五的惯用写法,注意argsort默认升序,忘了[::-1]就会拿到最不相似的五个,而且分数是合法的,不看数值根本发现不了。

3.3 挂到 Django 的 service 层,别写进 view

视图函数里塞算法逻辑是这类源码最普遍的毛病:一个views.py八百行,改个分词参数要翻半天。合理做法是单独一个services/similarity.py,视图只负责取参数、调服务、返回 JSON。

# services/similarity.py from django.core.cache import cache def compare_two_texts(text_a: str, text_b: str, metric: str = 'cosine') -> dict: """两段文本直接比对,走临时向量化,不依赖语料库""" sw = cache.get_or_set('stopwords', lambda: load_stopwords('data/stopwords.txt'), 3600) ta, tb = preprocess(text_a, sw), preprocess(text_b, sw) if not ta or not tb: return {'ok': False, 'msg': '有效词为空,请检查输入'} # 两段文本临时拟合一个小词表,只用于这一次比对 vec = TfidfVectorizer(token_pattern=r'(?u)\b\w+\b', norm='l2') mat = vec.fit_transform([' '.join(ta), ' '.join(tb)]) score = cosine_similarity(mat[0], mat[1])[0][0] return {'ok': True, 'score': round(float(score), 4), 'metric': metric, 'tokens_a': len(ta), 'tokens_b': len(tb)}
# views.py —— 视图只做三件事:收参、调服务、回 JSON from django.http import JsonResponse from django.views.decorators.http import require_POST import json @require_POST def api_compare(request): try: payload = json.loads(request.body.decode('utf-8')) except json.JSONDecodeError: return JsonResponse({'ok': False, 'msg': '请求体不是合法 JSON'}, status=400) result = compare_two_texts(payload.get('a', ''), payload.get('b', ''), payload.get('metric', 'cosine')) return JsonResponse(result)

参数说明几个点。@require_POST挡住 GET 请求,避免有人直接在地址栏敲接口导致 405 之外的一堆怪错。json.loads(request.body)是处理 Ajax 传 JSON 的标准写法,如果前端用的是FormData,就得改成request.POST.get(...),两者混用会拿到空值。cache.get_or_set缓存停用词表,因为每次请求都读一遍几千行的文件是纯浪费 IO,缓存一小时足够。

前端 Ajax 那一侧必须带 CSRF token,否则一律 403:

// 从 cookie 里取 csrftoken,POST 时塞进 header function getCookie(name) { const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); return v ? v.pop() : ''; } fetch('/api/compare/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken')}, body: JSON.stringify({a: textA, b: textB, metric: 'cosine'}) }).then(r => r.json()).then(data => renderScore(data));

Django 认的 header 名是X-CSRFToken(中间带 CSRF 四个大写字母),写成X-CSRF-Token或X-Csrf-Token都会 403。这个细节在跨框架迁移时最容易翻车,因为不同框架对 header 名的处理策略不一样。

renderScore(data)里做可视化,最简单的方案是画个横向进度条,宽度按分数百分比设,别上图表库——为一个数字引 ECharts 会让首屏加载多几百 KB,得不偿失。分数展示固定两位小数,0.83比0.8347261...可读性好得多,也避免了"你这小数点后七位是怎么算出来的"这种追问。

4. 相似度算法的选型边界:余弦、Jaccard、编辑距离各自管什么

4.1 余弦相似度:默认选项的成立条件和失效场景

余弦相似度衡量两个向量在方向上的夹角,公式是点积除以模长乘积。它的核心优势是对长度不敏感:一段 50 字的摘要和一篇 5000 字的正文,只要用词分布接近,余弦值就高。这个特性正好匹配"新闻标题 vs 新闻正文"这类长短悬殊的比对场景。

手写实现不难,但生产代码建议直接用库:

# 方式一:numpy 手写,便于理解公式 import numpy as np def cosine_manual(a: np.ndarray, b: np.ndarray) -> float: na, nb = np.linalg.norm(a), np.linalg.norm(b) if na == 0 or nb == 0: # 空向量必须短路,否则除零得到 nan return 0.0 return float(np.dot(a, b) / (na * nb)) # 方式二:sklearn,自动处理稀疏矩阵,语料大时内存友好 from sklearn.metrics.pairwise import cosine_similarity score = cosine_similarity(query_vec, corpus_matrix)[0]

手写版里那个na == 0 or nb == 0的判断不是防御性编程洁癖。预处理把所有词都过滤掉的情况真实存在——用户输入"的了呢吗"这种纯虚词,分词后全被停用词干掉,向量是零向量,np.linalg.norm返回 0,除零得到nan,nan传到前端toFixed(2)会显示NaN,看起来像系统崩了。

余弦相似度的失效场景要提前心里有数。第一,短文本虚高:两个只有三五个词的短句,只要共享一个实词,余弦值就可能到 0.6 以上。第二,词面不匹配就归零:"手机"和"移动电话"在词袋里是两个完全无关的词,余弦值是 0,但它们语义上是一回事。这是词袋模型的硬边界,靠调参救不回来,只能换语义向量方案。

4.2 Jaccard 与编辑距离:短文本和近似重复的分工

三种算法各有各的适用面,选错了不是精度差一点,是结果完全不可用。

# Jaccard:集合交并比,只关心"词有没有出现",不看词频 def jaccard_similarity(tokens_a: list, tokens_b: list) -> float: sa, sb = set(tokens_a), set(tokens_b) union = sa | sb if not union: return 0.0 return len(sa & sb) / len(union) # 编辑距离:衡量"改几个字符能变成另一个",对字面重复极度敏感 def edit_distance(a: str, b: str) -> int: """标准 Levenshtein 动态规划,空间压缩到两行""" if len(a) < len(b): a, b = b, a prev = list(range(len(b) + 1)) for i, ca in enumerate(a, 1): cur = [i] for j, cb in enumerate(b, 1): cur.append(min(prev[j] + 1, # 删除 cur[j-1] + 1, # 插入 prev[j-1] + (ca != cb))) # 替换 prev = cur return prev[-1] # 归一化到 0~1,方便和余弦分数放在一起比较 def edit_similarity(a: str, b: str) -> float: m = max(len(a), len(b)) return 1.0 if m == 0 else 1 - edit_distance(a, b) / m

jaccard_similarity用集合运算,天然去重,所以它对"同一句话重复三遍"和"说一遍"给出相同结果。这个特性在去重场景(判断两篇新闻是不是转载)是优点,在语义相似度场景是缺点。另外它对集合大小极度敏感:一段 200 词的长文和一段 10 词的短句,交集最多 10,并集至少 200,Jaccard 上限只有 0.05,看起来像"完全不相关"。

edit_distance的空间压缩版本用两行滚动数组,时间复杂度 O(mn) 不可避免。不要对整篇长文做编辑距离——两篇 5000 字的文章要算 2500 万次单元格,Python 循环下要几秒。它的正确用法是先做长度预筛(长度差超过阈值直接判为不相似),再对短文本或标题做精确比对。

三种算法横向对比:

算法计算依据适用文本长度时间复杂度对词频敏感典型场景
余弦相似度向量夹角中长文本O(词汇表维度)是(TF-IDF 加权)新闻推荐、文档检索
Jaccard集合交并比短文本、关键词集O(词数)否标签比对、转载去重
编辑距离字符级差异短文本(百字内)O(m×n)否错别字检测、标题近似
欧氏 / 曼哈顿距离向量空间距离中长文本同余弦是需要绝对距离值时

表格最后一行的欧氏和曼哈顿,本质上和余弦在同一个向量空间里工作,区别是它们对模长敏感。如果两段文本长度差异大,欧氏距离会被长度主导,反而不如余弦稳定。源码里把四种都实现了让用户下拉选择,这个设计在教学上合理,实际使用时默认给余弦、文件比对时切 Jaccard 是最稳的组合。

4.3 分数怎么读:0.8 不等于"一样"

新手最容易犯的错是拿一个固定阈值卡所有场景。0.8 在长文本比对里已经是高度相似,在两三个词的短句比对里可能只是共享了一个"系统"。

实操上按文本长度分档给参考:

文本长度弱相关相关高度相似说明
10 字以内< 0.300.30 ~ 0.60> 0.60共享一个实词就会跳高,只能做粗筛
10 ~ 100 字< 0.250.25 ~ 0.55> 0.55本系统的主战场,阈值相对稳定
100 字以上< 0.150.15 ~ 0.40> 0.40长文本词表大,余弦天然偏低

这套数值不是标准答案,是用几十对标注样本跑出来的经验区间。想让它变成你自己的数,就在库里挑 30 对"已知相关"和 30 对"已知无关"的文本,跑一遍记下分数分布,取两组的交界处当阈值。这个过程半小时能做完,比拍脑袋定 0.8 靠谱得多,而且答辩时被问"阈值怎么来的"有话说。

5. 避坑与排查:五条踩过的坑,按现象倒推原因

5.1 相似度恒为 0 或数值乱跳:词表被拟合了两次

现象:两段肉眼可见高度相似的文本,算出来 0.0;或者同一对文本连点两次计算,分数不一样。

原因:查询文本走的是fit_transform而不是transform,每次请求都重建一份词表,列下标和语料矩阵对不上。两个维度不同的矩阵做cosine_similarity本该报错,但如果两边维度恰好相同(比如都是 500 维),就会静默算出一个错误结果。

解决:全局只fit一次,产物 pickle 落盘,视图里只加载和transform。加一行断言当保险:assert query_vec.shape[1] == corpus_matrix.shape[1],维度不匹配立刻炸出来,比拿到错误分数强。

5.2 服务器启动慢十几秒,内存还翻倍

现象:runserver敲下去要等很久才打印 "Starting development server",机器风扇狂转。

原因:jieba 首次调用cut时要加载内置词典(约几百 MB 内存峰值,加载完回落到几十 MB)。开发服务器默认开了自动重载(autoreload),会起两个进程,词典加载两遍。

解决:启动时预热一次词典,把加载开销前置到启动阶段而不是首个请求。用python manage.py runserver --noreload可以避免双进程,但改了代码要手动重启,开发期不推荐常开,只在演示时用。另外别在模块顶层直接jieba.cut大文件,那会把加载开销压到 import 阶段,migrate命令都会变慢。

# apps.py 的 ready() 里预热,启动即加载,避免首个请求超时 import jieba def ready(self): jieba.initialize() # 显式触发词典加载

5.3 停用词表读了但没生效

现象:明明把"的""了"写进了 stopwords.txt,分词结果里还是有。

原因:三种可能。一是文件编码是 GBK,用encoding='utf-8'读会抛UnicodeDecodeError,但如果外面套了try/except吞掉异常,就变成静默返回空集合;二是文件带 BOM(Windows 记事本另存为 UTF-8 时的默认行为),第一行变成\ufeff的,只有第一个词匹配不上;三是strip()只去了首尾空白没去\r,Windows 换行符残留。

解决:读文件统一用encoding='utf-8-sig',能自动吃掉 BOM;不要用裸except吞异常;读完打印len(stopwords)和repr(list(stopwords)[:5])目检一次,\r、\ufeff这类不可见字符在repr下无所遁形。

5.4 上传 txt 文件后后端拿不到内容

现象:文件分析模块点"选择文件"选了 txt,提交后request.FILES是空的,或者读出来全是乱码。

原因:两种情况。第一,<form>标签漏了enctype="multipart/form-data",浏览器会把文件当成普通表单字段编码,后端只能拿到文件名。第二,文件本身是 GBK 编码,用f.read().decode('utf-8')直接抛异常。

解决:表单必须写成<form method="post" enctype="multipart/form-data">。解码用chardet探测或者按顺序试编码:

raw = request.FILES['file'].read() for enc in ('utf-8', 'gb18030', 'utf-16'): try: text = raw.decode(enc) break except UnicodeDecodeError: continue else: return JsonResponse({'ok': False, 'msg': '无法识别文件编码'})

gb18030而不是gbk,因为前者是超集,能覆盖更多生僻字。另外要在视图里限制文件大小(if request.FILES['file'].size > 2 * 1024 * 1024: return ...),不然有人传个几十 MB 的日志进来,预处理直接卡死。

5.5 长文本入库被截断,相似度越算越怪

现象:新闻正文超过几百字,存进去只剩前半截,后续比对结果全部偏低。

原因:模型字段用了models.CharField(max_length=255)。MySQL 在非严格模式下会静默截断,不报错,问题藏得很深。

解决:正文、分词结果这类不定长内容一律用models.TextField(),它能存 64KB 以上(MySQL 的 TEXT 类型上限)。同时把sql_mode设为STRICT_TRANS_TABLES(前面 settings 里已经加了),让超长写入直接报错而不是静默截断。已经截断的脏数据要用脚本重跑一遍原始文本,光改字段类型救不回来。

6. 文件分析模块的进阶用法:把"算个分"变成可验证的批量评测

单条比对的结果只能当演示,真要证明这个系统"算得准",得有量化指标。我的做法是在services下加一个评测脚本,用人工标注的文本对做基准集,输出准确率、召回率、F1,再扫一遍阈值看曲线拐点在哪。

# eval_similarity.py —— 跑基准集,输出 P/R/F1 import json from services.similarity import compare_two_texts def evaluate(pairs_path: str, threshold: float = 0.45): """pairs.json 格式:[{"a": "...", "b": "...", "label": 1}, ...]""" with open(pairs_path, encoding='utf-8') as f: pairs = json.load(f) tp = fp = fn = tn = 0 for p in pairs: score = compare_two_texts(p['a'], p['b'])['score'] pred = 1 if score >= threshold else 0 if pred == 1 and p['label'] == 1: tp += 1 elif pred == 1 and p['label'] == 0: fp += 1 elif pred == 0 and p['label'] == 1: fn += 1 else: tn += 1 precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 return {'threshold': threshold, 'precision': round(precision, 4), 'recall': round(recall, 4), 'f1': round(f1, 4)} # 阈值扫描:0.2 到 0.8 每 0.05 一档,找 F1 最高的那个 best = max((evaluate('data/pairs.json', t) for t in [0.2 + i * 0.05 for i in range(13)]), key=lambda r: r['f1']) print(best)

逻辑上有两点值得说。基准集必须正负样本均衡,如果 100 对里 90 对是"相关",那么把所有文本都判为相关就能拿到 0.9 的准确率——所以看 F1 不看准确率。compare_two_texts内部对每对文本临时拟合词表,避免了语料库变动导致的分数漂移,评测结果可复现。跑一轮 100 对文本大约几秒,完全可以在答辩前跑完,把 F1 数值写进 PPT。

阈值扫描的结果通常会告诉你一个反直觉的事实:最优阈值和文本长度强相关,混在一起的基准集上 F1 最优值往往只有 0.7 出头。想再往上走,就得按长度分桶分别定阈值,或者上语义向量——那就是另一个工程了,别在这个资源包的框架里硬改。

长文本比对还有个实用技巧:整篇 5000 字的文章直接算余弦,会被大量中性内容稀释,分数普遍偏低。按段落切成 300 字左右的块,对每块算分,取 Top-K 的均值而不是全篇最大值——取最大值容易被一段引用文本误导,取均值又太平滑,Top-3 均值是个折中点。

带邮箱后缀的文本、纯数字编号的段落要先在预处理里剔掉,这些内容会把词表撑大,还会让不同新闻之间产生虚假的共享词。我在clean_text里加了re.sub(r'\S+@\S+', ' ', text)和re.sub(r'\b\d{6,}\b', ' ', text)两条,删掉之后基准集的 F1 提了两个点。

从那以后我每次拿到这类源码包,第一件事不是点开页面看界面,而是先在services目录里补一个评测脚本,把 F1 拉出来当基线,改任何参数都重跑一遍对比。没有基线的调参全是玄学,有了数字,改min_df从 2 到 3 到底有没有用,一目了然。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询