简介:围绕基于Python的文本相似度计算系统整理的毕业设计文档,面向计算机、软件工程等专业准备课程设计或毕业设计的学生,以及希望了解文本相似度计算系统整体实现思路的自学者。文档以自然语言处理中的文本相似度计算为主线,串联文本清洗、分词、关键词提取、词向量构建、余弦相似度计算与可视化展示等环节,并涉及Python、Django、JSP/Java及数据库等技术栈的选型说明。压缩包共1个docx文件,约749KB,属于论文型文档,包含摘要、目录、绪论、可行性分析及系统实现等章节,便于读者把握毕业设计的章节组织与写作结构。已有138人学习,可作为选题参考、系统设计框架和论文撰写模板;其中对TF-IDF、Word2Vec/GloVe、Jaccard等方法的讨论,也可为算法选型与功能模块划分提供思路,帮助梳理从需求分析到实验评估的完整链路。
1. 从「两段文本到底有多像」出发:这个 Django 系统解决的是什么问题
很多人第一次接触文本相似度计算系统,场景都很朴素:搜索框里敲一句话,想知道库里哪篇稿子跟它最像。这份基于 Python 的毕业设计项目就是围绕这个动作展开的——前端两个输入框,后端 jieba 分词加余弦相似度,算完把分数和可视化结果回填到页面上。它谈不上高深,但把 Django 的请求流转、MySQL 的存储、NLP 的预处理流水线完整串了一遍,正好适合想拿一个能跑起来的中文文本处理 Web 项目练手的人。这里先排一个雷:论文关键词里混进了 jsp、java,翻遍代码目录找不到一行 Java,实际技术栈是 Django 3.x + MySQL + jieba + numpy,别被那几个词带跑偏。系统定位是"文本分析工具站",不是搜索引擎,理解这一点,后面看模块边界会顺很多。
2. 技术栈与目录结构:Django + MySQL + jieba 这套组合怎么落地
拿到一个毕设项目,我一般先不看论文,先看requirements.txt和目录树。论文里可以写得很理想,代码里有什么才是真的。这个项目的技术选型属于典型的"够用且好交付":Python 负责算法,Django 负责 Web 层,MySQL 负责持久化,Bootstrap 负责把页面撑起来,不需要前端工程化那一套。
2.1 为什么是 Django 而不是 Flask
这个系统的交互模式非常固定:用户填两个文本框 → 提交 → 后端算 → 渲染结果页。Django 自带的 Form 组件、CSRF 中间件、模板引擎和 ORM,正好覆盖这条链路上的大部分样板代码。用 Flask 也能做,但你得自己拼 WTForms、自己处理 CSRF token、自己写数据库连接池,对一个以算法为主要工作量、Web 只是壳子的项目来说,属于给自己找活。
Django 在这里还有两个隐性收益。一是admin后台开箱即用,新闻管理、用户管理这两个模块几乎不用写 CRUD 页面,注册个 ModelAdmin 就能增删改查;二是settings.py里的数据库配置切 SQLite 和 MySQL 只改几行,本地调试不用被迫先装 MySQL 服务。常见做法是本地先用 SQLite 跑通流程,部署前再切 MySQL,毕业答辩演示阶段能省掉一堆环境问题。
代价也有:Django 的 MTV 分层对新手来说概念多,views.py容易写成几百行的面条代码。这个项目里作者的处理方式是把算法抽到独立的nlp包里,views.py只负责收参数、调函数、拼上下文,这是我认为整份代码里最值得学的一处结构设计。
2.2 目录结构:把算法代码和 Web 代码分开放
实际代码的组织大致是这样,核心思路是core(业务)和nlp(算法)解耦:
text_similarity/ ├── manage.py ├── requirements.txt ├── similarity/ # 项目配置包 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── core/ # 主业务 app │ ├── models.py # 文章、计算记录、用户扩展 │ ├── views.py # 请求入口,只做编排 │ ├── forms.py # 表单校验 │ ├── urls.py │ └── admin.py ├── nlp/ # 算法层,不依赖 Django │ ├── preprocess.py # 清洗、分词、去停用词 │ ├── vectorizer.py # 词袋 / TF-IDF 向量化 │ └── metrics.py # 余弦、Jaccard、编辑距离 ├── templates/ │ ├── base.html │ ├── index.html │ └── result.html ├── static/ │ ├── css/ │ └── js/ └── data/ ├── stopwords.txt # 停用词表 └── user_dict.txt # jieba 自定义词典这个划分的价值在于:nlp包里没有任何import django,你可以单独写个脚本python -m nlp.metrics直接验证算法对不对,不用起 Web 服务。二次开发时想换算法,只动nlp目录,视图层一行不用改。我带过的几个同学里,凡是把算法逻辑直接写进views.py的,后期加一个相似度算法就要复制粘贴一整段视图函数,维护起来非常难受。
2.3 数据表设计:三张核心表撑起整个系统
models.py里真正干活的是三张表,其余都是附属:
from django.db import models from django.contrib.auth.models import User class Article(models.Model): """库内文本,文件分析和新闻推荐都从这里取语料""" title = models.CharField(max_length=200, verbose_name='标题') content = models.TextField(verbose_name='正文') source = models.CharField(max_length=50, blank=True, verbose_name='来源') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'article' indexes = [models.Index(fields=['created_at'])] class CalcRecord(models.Model): """每次计算的留痕,方便做历史记录和结果复核""" input_text = models.TextField(verbose_name='输入文本') target_text = models.TextField(verbose_name='对比文本') algorithm = models.CharField(max_length=20, default='cosine') score = models.FloatField(verbose_name='相似度') user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'calc_record' ordering = ['-created_at'] class KeywordVector(models.Model): """关键词词向量缓存,避免每次请求都重新训练""" article = models.ForeignKey(Article, on_delete=models.CASCADE) vector = models.TextField(verbose_name='序列化后的向量') updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'keyword_vector'三张表的分工很明确:Article存语料,CalcRecord存计算历史,KeywordVector存向量缓存。这里有个设计取舍值得说:vector字段用的是TextField存 JSON 字符串,而不是 MySQL 的 JSON 类型或单独的向量表。原因是 Django 3.x 对 MySQL JSONField 的支持在部分旧版本驱动上不稳定,序列化成字符串最省事,代价是查询时没法直接在数据库层做向量运算——不过这个系统的向量计算本来就在 Python 内存里做,问题不大。
提示:
CalcRecord里同时存了input_text和target_text两份原文,文本长的时候表会膨胀得很快。生产环境建议改成存article_id关联,或者只保留前 500 字做摘要。
2.4 依赖清单与本地环境搭建
requirements.txt大致这几行,版本号不用卡太死,Django 3.2 和 4.x 在这个项目上都能跑:
Django==3.2.18 jieba==0.42.1 numpy==1.24.3 scikit-learn==1.2.2 PyMySQL==1.0.3 python-Levenshtein==0.20.9python-Levenshtein是编辑距离的加速实现,纯 Python 版本在大文本上慢一个数量级,能装就装。scikit-learn这里主要用它的TfidfVectorizer和cosine_similarity,比手写省事,但如果只想跑通基础功能,手写余弦也就十行代码。
MySQL 驱动的接入有个固定写法,放在similarity/__init__.py里:
import pymysql pymysql.install_as_MySQLdb()然后在settings.py配数据库:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'text_sim', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': {'charset': 'utf8mb4'}, # 中文和 emoji 都靠它 } }utf8mb4这个字符集是血泪经验,utf8在 MySQL 里只占 3 字节,遇到 emoji 或生僻字直接报错,而且报错信息往往指向插入语句而不是字符集本身,排查起来很绕。建库时一起指定CREATE DATABASE text_sim DEFAULT CHARSET utf8mb4;,能避免后面 90% 的乱码问题。
初始化流程就是标准三步:python manage.py makemigrations→migrate→runserver。如果要用 admin 后台,再createsuperuser建个账号。
3. 核心流水线拆解:从原始文本到余弦相似度分数
这是整个项目的主干,也是答辩时最容易被追问的部分。流水线可以概括成四步:清洗 → 分词去停用词 → 向量化 → 算相似度。每一步都有它存在的理由,缺一步结果就会明显跑偏。
3.1 文本清洗:正则先做第一道过滤
原始文本里什么都有:HTML 标签、连续空格、全角半角混排、无意义的数字串。清洗不追求完美,目标是去掉对相似度计算有干扰的噪声。
import re import unicodedata def clean_text(text: str) -> str: """基础清洗:去标签、统一全半角、压缩空白""" if not text: return '' # 去 HTML 标签,文件上传和网页抓取的语料常见 text = re.sub(r'<[^>]+>', '', text) # 统一转半角,中文标点保留 text = unicodedata.normalize('NFKC', text) # 去掉控制字符和不可见符号 text = re.sub(r'[\x00-\x1f\x7f]', '', text) # 只保留中文、英文、数字和常见标点 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。!?;:、\s]', '', text) # 连续空白压成一个空格 text = re.sub(r'\s+', ' ', text) return text.strip()逐条说下理由。NFKC归一化是处理全角字母数字的,ABC和ABC在相似度眼里应该是同一个东西,不清掉会白白拉低分数。\u4e00-\u9fa5是 CJK 统一汉字的主区间,覆盖常用汉字足够。最后一步压缩空白,很多人会漏,导致分词结果里出现大量为空格的 token,白白占向量维度。
有个细节要提醒:不要把标点全删。中文分句靠标点,全删掉之后句子粘连,jieba 的 HMM 模式容易切出错误的长词。保留,。!?这几类就够。
3.2 分词与停用词:jieba 加载词典的那些参数
中文分词是这个项目最依赖外部库的环节。jieba 默认词典覆盖广,但在特定领域词上会切错,所以项目里带了自定义词典:
import jieba import os from django.conf import settings # 模块级只加载一次,别放到函数里反复读盘 jieba.set_dictionary(os.path.join(settings.BASE_DIR, 'data', 'dict.txt.big')) jieba.load_userdict(os.path.join(settings.BASE_DIR, 'data', 'user_dict.txt')) def load_stopwords(path: str) -> set: """停用词读成 set,查找是 O(1),列表是 O(n)""" with open(path, encoding='utf-8') as f: return {line.strip() for line in f if line.strip()} STOPWORDS = load_stopwords(os.path.join(settings.BASE_DIR, 'data', 'stopwords.txt')) def tokenize(text: str, use_stopwords: bool = True) -> list: """ cut_for_search 比 cut 更适合相似度场景: 长词会额外切出子词,召回率更高 """ tokens = jieba.cut_for_search(text) result = [] for tok in tokens: tok = tok.strip() if not tok: continue if use_stopwords and tok in STOPWORDS: continue if len(tok) == 1 and not tok.isalnum(): continue # 单个标点不要 result.append(tok.lower()) # 英文统一小写 return result三个参数选择解释一下。cut_for_search是搜索引擎模式,对"中华人民共和国"这种长词会切出"中华""人民""共和"等子词,好处是两段文本只要共享子串就能被捕捉到,坏处是向量维度变多。做短文本相似度用它是合适的,做长文档分类反而建议用精确模式cut。
jieba.set_dictionary换成大词典能显著提升覆盖率,但首次加载要 1~2 秒,所以必须放在模块级别,不能放进视图函数里——每次请求 1 秒,页面直接卡死。加载失败是部署时的高频坑,BASE_DIR拼路径在测试环境正常,到服务器上因为工作目录不同就找不到文件,后面避坑章节会专门讲。
停用词表这个项目用的是哈工大停用词表的常见版本,几千条。有人会纠结要不要加"的、了、是"之外的词,我的建议是先跑通,用几组对照文本看分数合不合理,不合理再往里加,别一上来就塞一万条,容易把有效信息也过滤掉。
3.3 特征向量构建:词袋 vs TF-IDF
论文里写的是词袋模型,实际代码里两种都实现了。词袋的问题是高频词权重过高,"的""是"这类词即使去过停用词,剩下的高频词也会主导向量方向,导致任意两段中文文本的相似度都虚高。TF-IDF 用逆文档频率压制高频词,效果稳定得多。
先看词袋版本,理解原理用:
def build_bow(texts: list) -> tuple: """ 词袋:所有文本的 token 集合做词表,按词频填数 返回 (矩阵, 词表) """ vocab = sorted({tok for toks in texts for tok in toks}) word2idx = {w: i for i, w in enumerate(vocab)} matrix = [] for toks in texts: vec = [0] * len(vocab) for tok in toks: vec[word2idx[tok]] += 1 matrix.append(vec) return matrix, vocab然后是 TF-IDF 版本,生产环境推荐这个:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def tfidf_similarity(text_a: str, text_b: str) -> float: """TF-IDF + 余弦,两段文本直接算""" # tokenizer 换成我们自己的分词函数,保证和预处理一致 vectorizer = TfidfVectorizer( tokenizer=lambda x: tokenize(x), token_pattern=None, # 指定了 tokenizer 必须关掉默认正则 lowercase=False, use_idf=True, smooth_idf=True, ) tfidf_matrix = vectorizer.fit_transform([text_a, text_b]) sim = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:2]) return float(sim[0][0])token_pattern=None这行是个经典坑。TfidfVectorizer默认用(?u)\b\w\w+\b做分词,你传了自定义tokenizer之后如果不关掉它,某些 sklearn 版本会直接报错或者静默忽略你的分词器,结果就是中文全被当成一个词。我见过不止一次有人卡在这里,分数永远是 0 或 1。
smooth_idf=True是加一平滑,防止某个词只在一篇文档出现时 IDF 除零。两段文本的场景下 IDF 意义有限(只有两个文档),做库内检索时它的价值才真正体现出来。
3.4 三种相似度算法的统一接口
系统实现了余弦、Jaccard、编辑距离三种,视图层通过一个参数切换。用一个字典做分发,比写 if-elif 链干净:
import math from Levenshtein import distance as lev_distance def cosine_sim(vec_a: list, vec_b: list) -> float: """手写余弦,注意零向量保护""" dot = sum(a * b for a, b in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(a * a for a in vec_a)) norm_b = math.sqrt(sum(b * b for b in vec_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) def jaccard_sim(tokens_a: list, tokens_b: list) -> float: """集合交并比,对词序不敏感,短文本效果好""" set_a, set_b = set(tokens_a), set(tokens_b) if not set_a and not set_b: return 0.0 return len(set_a & set_b) / len(set_a | set_b) def edit_sim(text_a: str, text_b: str) -> float: """编辑距离归一化到 0~1,长文本会很慢,慎用""" max_len = max(len(text_a), len(text_b)) if max_len == 0: return 1.0 return 1 - lev_distance(text_a, text_b) / max_len ALGO_MAP = { 'cosine': lambda a, b: cosine_sim(*build_bow([tokenize(a), tokenize(b)])[0]), 'jaccard': lambda a, b: jaccard_sim(tokenize(a), tokenize(b)), 'edit': edit_sim, } def calc_similarity(text_a: str, text_b: str, algo: str = 'cosine') -> float: algo = algo if algo in ALGO_MAP else 'cosine' return round(ALGO_MAP[algo](text_a, text_b), 4)三种算法的适用场景差异很大,答辩时被问到要能答上来:余弦适合词频分布比较、对长度不敏感;Jaccard 只看词集合是否有交集,短文本、关键词匹配场景更快;编辑距离看字符级差异,适合拼写纠错、短标题比对,但它对词序极其敏感,长文本上 O(n²) 的复杂度会直接拖垮请求。
注意:
edit_sim里对超过 5000 字的文本要加长度截断或直接禁用。python-Levenshtein用 C 实现,但 5000×5000 的矩阵计算依然要几百毫秒,两段三万字的文章就能把 worker 卡住。
3.5 结果写回与可视化数据格式
算完分数之后,前端要的不只是一个 float。结果页通常要展示三样东西:分数、分词后的关键词对比、三种算法的分数对比柱状图。视图层组装成 JSON 交给模板:
def build_result_payload(text_a, text_b): tokens_a, tokens_b = tokenize(text_a), tokenize(text_b) scores = {name: fn(text_a, text_b) for name, fn in ALGO_MAP.items()} return { 'scores': scores, 'best_algo': max(scores, key=scores.get), 'common_words': list(set(tokens_a) & set(tokens_b))[:20], 'len_a': len(tokens_a), 'len_b': len(tokens_b), }common_words取交集前 20 个,在页面上高亮展示,用户能直观看出"为什么这两个文本被判为相似"。这个字段对答辩演示特别有用,比只给一个 0.83 的分数有说服力得多。柱状图用的是 ECharts,模板里{{ payload|json_script:"payload" }}把数据安全地传到 JS,比在<script>里直接拼字符串安全,能避免引号转义问题。
4. 前后端串联:表单、Ajax 与文件分析模块怎么跑通
算法写对了只是开始,真正让系统"能用"的是请求链路。这一章按一次用户点击的走向,把视图、表单、模板、静态资源串起来。
4.1 路由与视图:一次计算请求的完整走向
core/urls.py的路由表不长,主入口就几个:
from django.urls import path from . import views urlpatterns = [ path('', views.index, name='index'), path('calc/text/', views.calc_text, name='calc_text'), path('calc/file/', views.calc_file, name='calc_file'), path('history/', views.history, name='history'), ]对应的视图函数保持"薄",只做三件事:取参数、调算法、返回:
from django.shortcuts import render from django.http import JsonResponse from django.views.decorators.http import require_POST from .forms import TextSimilarityForm from nlp.preprocess import tokenize from nlp.metrics import calc_similarity from .views_helper import build_result_payload def index(request): """首页,渲染表单和历史记录""" form = TextSimilarityForm() return render(request, 'index.html', {'form': form}) @require_POST def calc_text(request): form = TextSimilarityForm(request.POST) if not form.is_valid(): return JsonResponse({'ok': False, 'errors': form.errors}, status=400) text_a = form.cleaned_data['text_a'] text_b = form.cleaned_data['text_b'] algo = form.cleaned_data.get('algorithm') or 'cosine' payload = build_result_payload(text_a, text_b) score = calc_similarity(text_a, text_b, algo) # 关键:入参长度限制,防止超长文本拖垮线程 return JsonResponse({ 'ok': True, 'score': score, 'algo': algo, **payload, })@require_POST这个装饰器值得单独说。相似度计算是纯计算型操作,用 GET 会让浏览器缓存、爬虫抓取都变成无意义的重复计算,加上它之后非 POST 请求直接返回 405,省事又安全。
4.2 表单类与 CSRF 校验
forms.py是参数校验的第一道关:
from django import forms ALGO_CHOICES = [ ('cosine', '余弦相似度'), ('jaccard', 'Jaccard 相似度'), ('edit', '编辑距离'), ] class TextSimilarityForm(forms.Form): text_a = forms.CharField( label='文本 A', widget=forms.Textarea(attrs={ 'class': 'form-control', 'rows': 6, 'placeholder': '输入第一段文本…', }), max_length=20000, ) text_b = forms.CharField( label='文本 B', widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6}), max_length=20000, ) algorithm = forms.ChoiceField( choices=ALGO_CHOICES, initial='cosine', widget=forms.Select(attrs={'class': 'form-select'}), ) def clean_text_a(self): val = self.cleaned_data['text_a'].strip() if len(val) < 2: raise forms.ValidationError('文本 A 至少需要 2 个字符') return valmax_length=20000是保护措施,不是功能限制。没有它,用户粘贴一篇十万字的论文进来,cut_for_search加词袋构建就能让单个请求跑好几秒,几个并发直接把开发服务器堵死。clean_text_a做最小长度校验也是同理,空文本进算法会得到 NaN,不如在表单层就拦掉。
CSRF 这块 Django 默认开启,模板里<form>内必须加{% csrf_token %}。用 Ajax 提交时要么从 cookie 读csrftoken塞进 header,要么在页面里渲染{% csrf_token %}后用 JS 取值。项目里用的是后者,static/js/calc.js里能看到:
function getCookie(name) { const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); return v ? v.pop() : ''; } async function submitCalc(formData) { const resp = await fetch('/calc/text/', { method: 'POST', headers: { 'X-CSRFToken': getCookie('csrftoken') }, body: formData, }); if (!resp.ok) { const err = await resp.json(); alert('参数有误:' + JSON.stringify(err.errors)); return null; } return resp.json(); }X-CSRFToken这个 header 名是 Django 约定的,写错成X-CSRF-Token会被中间件直接 403,而且报错页面只说 CSRF 校验失败,不说 header 名错了,第一次踩基本都要查一阵。
4.3 Ajax 异步提交与结果渲染
页面不刷新直接出结果,体验比表单整体提交好很多。返回的 JSON 里scores是三种算法的字典,前端把它塞进 ECharts:
function renderChart(payload) { const chart = echarts.init(document.getElementById('scoreChart')); const names = Object.keys(payload.scores); const values = names.map(n => (payload.scores[n] * 100).toFixed(1)); chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: names.map(n => ({cosine: '余弦', jaccard: 'Jaccard', edit: '编辑距离'}[n])) }, yAxis: { type: 'value', max: 100, name: '相似度(%)' }, series: [{ type: 'bar', data: values, itemStyle: { color: '#4e79a7' }, label: { show: true, position: 'top', formatter: '{c}%' }, }], }); }图表容器别在隐藏状态下初始化。如果图表所在的 tab 默认是display: none,ECharts 初始化时拿到的宽高是 0,切过去之后是一片空白。常见做法是切 tab 的时候调一次chart.resize(),或者初始化时给容器写死height: 320px。
4.4 文件分析模块:txt 上传与库内最佳匹配
文本分析是"两段输入互相比",文件分析是"上传一个 txt,和库里的文章逐一比,返回最像的那篇"。这是全系统里最贴近真实使用场景的功能:
import os from django.conf import settings from django.core.files.storage import default_storage from .models import Article def calc_file(request): upload = request.FILES.get('file') if not upload or not upload.name.endswith('.txt'): return JsonResponse({'ok': False, 'msg': '只支持 txt 文件'}, status=400) # 显式按 utf-8 解码,失败再退回 gbk,中文 txt 的一大半是这个原因 raw = upload.read() try: content = raw.decode('utf-8') except UnicodeDecodeError: content = raw.decode('gbk', errors='ignore') # 只对比最近 200 篇,避免全表扫描 candidates = Article.objects.order_by('-created_at')[:200] best, best_score = None, 0.0 for art in candidates: s = calc_similarity(content, art.content, 'cosine') if s > best_score: best, best_score = art, s return JsonResponse({ 'ok': True, 'best_title': best.title if best else None, 'best_score': round(best_score, 4), 'candidates': candidates.count(), })candidates[:200]这个截断是性能上的妥协。全表几千篇文章逐对比,每次余弦都要重建词袋,请求时间会飙升到十几秒,浏览器早就超时了。真实系统会把向量预先算好存进KeywordVector表,请求时只做矩阵乘法,这个改造后面二次开发章节会讲。
编码回落那段是必要的。Windows 上保存的 txt 大量是 GBK 编码,直接decode('utf-8')会抛UnicodeDecodeError,用户看到的是 500 错误页。先试 utf-8 再退 gbk,用errors='ignore'兜底,虽然可能丢了几个字符,但至少功能可用。
4.5 新闻管理与用户模块的边界
论文里列了"新闻管理、个人信息、用户管理"三个模块,实际代码里新闻管理主要靠 Django admin 注册一个 ModelAdmin:
from django.contrib import admin from .models import Article, CalcRecord @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): list_display = ('title', 'source', 'created_at') search_fields = ('title',) list_filter = ('source',) @admin.register(CalcRecord) class CalcRecordAdmin(admin.ModelAdmin): list_display = ('algorithm', 'score', 'created_at') readonly_fields = ('created_at',)用户模块直接复用了django.contrib.auth,没有自己写登录注册,settings.py里加LOGIN_URL和@login_required装饰器就够。这里要提醒一句:论文里说"个人信息"是独立模块,代码里其实就是一个改密码页面,答辩时被问到模块边界,如实回答"复用了 auth 框架"比硬编一套说辞更稳。
CalcRecord的 admin 里created_at设成readonly_fields,不然 Django 会把它渲染成可编辑的日期控件,用户改了还能保存,时间戳就失去意义了。
5. 避坑与排查:分词、编码、相似度数值这几处最容易翻车
这一章是我在实际跑这个项目和帮同学调类似系统时,重复遇到最多的问题。每条按"现象 → 原因 → 解决"写,照着对号入座能省很多时间。
5.1 现象:上传中文 txt 读出来全是乱码,或者直接 500
原因:文件编码不是 UTF-8。Windows 记事本默认保存为 ANSI(中文系统下就是 GBK),而 Python 的open()或decode()默认按 UTF-8 处理,遇到 GBK 字节序列就抛UnicodeDecodeError。Django 的UploadedFile.read()返回的是原始字节,不做编码探测。
解决:分成两步走。第一步在视图里做编码回落,先 utf-8 后 gbk,代码见 4.4 节。第二步建议引入chardet做显式探测,对混合来源的文件更稳:
import chardet def smart_decode(raw: bytes) -> str: guess = chardet.detect(raw[:4096]) # 只探前 4KB 就够 enc = guess['encoding'] or 'utf-8' try: return raw.decode(enc) except (UnicodeDecodeError, LookupError): return raw.decode('utf-8', errors='ignore')另外 MySQL 那边的连接字符集也要检查,OPTIONS: {'charset': 'utf8mb4'}缺了的话,存进去的中文可能在数据库里就已经是乱码,读出来怎么设都对不回来。
5.2 现象:任意两段中文算出来的相似度都在 0.9 以上,或全是 0
原因:两个极端。全高是因为词袋没做去停用词或者没做归一化,高频虚词主导了向量方向;全是 0 大概率是TfidfVectorizer的token_pattern没关掉,分词器被忽略,中文被当成一整串字符处理,两段文本没有任何共享 token。
解决:先做二分排查。写个最小脚本打印分词结果和向量,一眼就能看出是哪一环断了:
toks = tokenize("今天天气很好,适合出去走走") print(toks) # 期望看到 ['今天', '天气', '很好', ...] print(len(set(toks))) # 期望几十,如果是 1 说明分词没生效分词正常再看向量。TfidfVectorizer初始化时务必加tokenizer=tokenize, token_pattern=None。如果用的是手写词袋,检查build_bow里两段文本是否共用同一个word2idx——分别建词表再算余弦,维度对不上,结果毫无意义。
5.3 现象:本地跑得好好的,部署到服务器上启动就报找不到停用词表
原因:停用词表路径用的是相对路径,或者用os.getcwd()拼的。Django 开发服务器启动目录和 gunicorn/uwsgi 的工作目录经常不一致,相对路径就失效了。另一种情况是文件没被打包进部署产物,比如.gitignore里误加了data/。
解决:一律用settings.BASE_DIR拼绝对路径,BASE_DIR是 Django 启动时算好的项目根目录,跟工作目录无关。停用词表加载失败时不要静默返回空集合,加一行日志或者直接raise,让它早失败:
import os from django.conf import settings STOPWORD_PATH = os.path.join(settings.BASE_DIR, 'data', 'stopwords.txt') if not os.path.exists(STOPWORD_PATH): raise FileNotFoundError(f'停用词表缺失: {STOPWORD_PATH}')部署前习惯性ls一下服务器上的data/目录,比出事之后再翻日志快得多。
5.4 现象:用户提交两篇长文,页面转圈十几秒最后 502
原因:Django 默认的runserver是单线程的,一个请求把 CPU 占满,其他请求全部排队。加上编辑距离算法对长度的平方级敏感,两篇五千字文本用编辑距离能跑好几秒。
解决:三层措施一起上。第一层在表单层限制max_length,见 4.2 节。第二层在算法分发时对长度做保护:
def calc_similarity(text_a, text_b, algo='cosine'): total = len(text_a) + len(text_b) if algo == 'edit' and total > 3000: algo = 'cosine' # 超长自动降级,别让用户干等 return round(ALGO_MAP[algo](text_a, text_b), 4)第三层是部署时用 gunicorn 多 worker,gunicorn similarity.wsgi:application -w 4 -k gthread --threads 2,把并发能力提上来。真正的性能解法是把计算挪到异步任务队列,但对一个毕设体量的项目来说,长度限制加算法降级已经足够。
5.5 现象:并发压测时数据库报Too many connections
原因:settings.py里CONN_MAX_AGE设得太长,或者根本没有配连接复用,每个请求新建一条连接,Django 默认CONN_MAX_AGE=0是请求结束就关,但如果视图里有长事务或忘了close_old_connections,连接会堆积。MySQL 默认max_connections是 151,压测几百并发立刻打满。
解决:在settings.py里给DATABASES加CONN_MAX_AGE: 60让连接复用,同时确认视图里没有把Article.objects.all()这类大查询放在循环里。CalcRecord这种高频写入的表,建议把created_at建成索引,避免每次插入都全表扫。本地调试环境直接用 SQLite 反而省心,并发问题在 SQLite 上基本不存在。
6. 二次开发:把词袋换成 TF-IDF、接入批量文件与缓存
跑通之后,这个项目最值得改的地方有两处,改完它的实用性和"毕设感"会同时上一个档次。
6.1 词袋换 TF-IDF:改三处就够
第一步,nlp/vectorizer.py里新增基于 sklearn 的实现,保持函数签名和词袋版本一致,视图层无感知:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity from .preprocess import tokenize def tfidf_pair_sim(text_a: str, text_b: str) -> float: vec = TfidfVectorizer( tokenizer=tokenize, token_pattern=None, lowercase=False, smooth_idf=True, ) mat = vec.fit_transform([text_a, text_b]) return float(cosine_similarity(mat[0:1], mat[1:2])[0][0])第二步,nlp/metrics.py的ALGO_MAP里把cosine的实现替换掉。第三步,跑一组对照测试:拿两篇主题相同、用词不同的文章,词袋版通常给 0.5 左右,TF-IDF 版会到 0.7 以上,差异能直观看到。如果两个版本结果几乎一样,回去检查token_pattern是否真的关掉了。
6.2 批量对比与向量缓存:让文件分析真正能用
原始实现的文件分析是"上传一个文件,和最近 200 篇逐一算",每次都要重新分词重新向量化。改造思路是把向量预先算好落库,请求时只做矩阵乘法。KeywordVector表已经预留了,只需要在文章保存时触发一次更新:
from django.db.models.signals import post_save from django.dispatch import receiver from django.db import transaction from .models import Article, KeywordVector from nlp.preprocess import tokenize import json @receiver(post_save, sender=Article) def refresh_vector(sender, instance, **kwargs): tokens = tokenize(instance.content) # 词频字典存 JSON,比存完整的稀疏矩阵更省空间 freq = {} for t in tokens: freq[t] = freq.get(t, 0) + 1 KeywordVector.objects.update_or_create( article=instance, defaults={'vector': json.dumps(freq, ensure_ascii=False)}, )然后用倒排思路查询:先取上传文本的 token 集合,只在vector字段里包含这些词的文章中做精算。文章量上千之后,这个优化能把文件分析的响应时间从十几秒压到几百毫秒。验证方法是准备 20 篇已知相似关系的文章,改造前后各跑一遍,看返回的 Top1 是否一致、耗时是否下降。
一个我踩过的坑:信号函数里如果直接调用transaction.on_commit之前的查询,可能读到未提交的数据。批量导入文章时建议用bulk_create后手动触发一次全量刷新,而不是依赖post_save,否则几千条数据会触发几千次信号,慢得让你怀疑人生。从那以后我每次加post_save信号,都先在导入脚本里把信号临时 disconnect,导完再 connect 回来。
这套改造做完,系统就从"能跑的演示"变成"能用的工具"了。希望帮到你。
本文还有配套的精品资源,点击获取