简介:本资源为基于BERT的深度学习文本相似度检测系统毕业设计源码包,面向计算机相关专业学生及NLP入门开发者,可用于毕业设计、课程设计或文本语义匹配实践。系统以Python 3.6.8开发,采用BERT预训练模型完成语义理解与相似度评估,后端结合MySQL 5.7存储数据,前端页面与PyCharm开发环境配套,覆盖搜索引擎、自动问答、推荐系统等典型应用场景。压缩包共389个文件,约52.19MB,包含72个py源码、35个js脚本、15个html页面及18个css样式文件,另有数据库sql、说明文档docx与pdf、模型相关zip及图片资源,前后端与数据库结构完整。项目说明文档记录了开发背景、设计思路、系统架构与运行环境,源码目录便于理解实现机制,数据库文件提供建表与数据基础。目前已有78人学习,适合需要完整赛题方案、排错思路与目录结构参考的读者。
1. 文本相似度检测系统:从 BERT 到前后端落地的完整路径
很多做毕业设计的同学一看到"文本相似度检测"就觉得无非是算个余弦相似度,但真正动手才发现:两个句子长度不一样怎么办?同义词替换后相似度骤降怎么处理?前端怎么把一段文本传给后端模型?MySQL 里到底存什么?这些问题不解决,系统就跑不起来。这个标题指向的是一套完整的工程方案——用 BERT 做语义向量化,配合 Python 后端提供推理接口,前端做交互展示,MySQL 存储检测记录,最终交付一个可演示、可复现的文本相似度检测系统。它适合正在做 NLP 方向毕业设计、需要一套能跑通全流程参考方案的开发者,也适合想理解"模型怎么变成系统"这个跨越过程的同学。接下来我会把选型理由、代码实现、参数配置和踩坑经验逐层拆开讲。
2. BERT 做文本相似度的原理与模型选型:为什么不用 TF-IDF
2.1 从词袋模型到语义向量的本质差异
TF-IDF 和 Word2Vec 是很多人最先接触的方案。TF-IDF 把文本当成词频统计向量,两个句子只要用词不同,哪怕意思一样,相似度也会很低。比如"如何学习编程"和"怎样入门写代码",TF-IDF 算出来几乎不相关。Word2Vec 稍好一些,词级别有语义,但它是静态词向量——"苹果"在"吃苹果"和"苹果发布会"里向量完全一样,句子级别的语义就丢失了。
BERT 的核心优势在于它是动态编码的。同一个词在不同上下文里产生的向量不同,而且 BERT 输出的[CLS]token 向量(或者池化后的句向量)天然携带整句语义。做相似度检测时,把两个句子分别编码成固定维度向量,再算余弦相似度,就能捕捉到"表述不同但含义接近"的情况。
常见做法是取 BERT 最后一层的[CLS]向量做句表示,或者对所有 token 的输出做 mean pooling。实践中 mean pooling 在相似度任务上通常比[CLS]更稳,因为[CLS]在预训练时主要服务于分类任务,未必是句语义的最优摘要。
2.2 模型选型:中文场景下的实际考量
做中文文本相似度,模型选择直接决定效果上限。以下是几种常见方案的实际对比:
| 模型 | 参数量 | 中文效果 | 推理速度(CPU) | 适用场景 |
|---|---|---|---|---|
| bert-base-chinese | 110M | 良好 | 约 200ms/对 | 毕设首选,资料多 |
| bert-base-multilingual-cased | 178M | 一般 | 约 350ms/对 | 需要多语言时 |
| 轻量级蒸馏模型 | 60M左右 | 中等 | 约 80ms/对 | 对速度敏感时 |
| 领域微调后的 BERT | 110M | 优秀 | 约 200ms/对 | 有标注数据时 |
对于毕业设计场景,我一般建议从bert-base-chinese起步。原因很直接:中文预训练充分、HuggingFace 上文档齐全、社区踩坑记录多,出了问题容易搜到答案。如果答辩时被问到"为什么不用更大的模型",可以从参数量与部署成本的平衡角度回答。
注意:不要一上来就追求 SOTA 效果。毕设的核心是展示完整的技术链路和工程能力,模型选型合理、系统跑得通、有对比实验,比盲目堆大模型更有说服力。
2.3 相似度计算方式的选择
拿到两个句向量之后,计算相似度有几种方式:
- 余弦相似度:最常用,对向量长度不敏感,值域 [-1, 1],适合归一化后的向量
- 欧氏距离:值域 [0, +∞),需要额外做归一化映射到 [0,1]
- 曼哈顿距离:对异常维度更鲁棒,但解释性不如余弦
实际工程中余弦相似度是默认选择。代码实现上,先对向量做 L2 归一化,然后点积就是余弦相似度,省去除法运算:
import torch import torch.nn.functional as F from transformers import BertTokenizer, BertModel # 加载预训练模型和分词器 model_name = "bert-base-chinese" tokenizer = BertTokenizer.from_pretrained(model_name) model = BertModel.from_pretrained(model_name) model.eval() # 推理模式,关闭 dropout def get_sentence_embedding(text): """将单句文本编码为固定维度向量,使用 mean pooling""" inputs = tokenizer( text, return_tensors="pt", padding=True, truncation=True, max_length=128 # 超过128个token截断 ) with torch.no_grad(): # 不计算梯度,节省内存 outputs = model(**inputs) # mean pooling:对所有token的输出取平均,排除padding位置 attention_mask = inputs["attention_mask"] token_embeddings = outputs.last_hidden_state # 扩展mask维度以匹配embedding维度 mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() sum_embeddings = torch.sum(token_embeddings * mask_expanded, dim=1) sum_mask = torch.clamp(mask_expanded.sum(dim=1), min=1e-9) sentence_embedding = sum_embeddings / sum_mask # L2归一化,后续点积即余弦相似度 sentence_embedding = F.normalize(sentence_embedding, p=2, dim=1) return sentence_embedding def compute_similarity(text_a, text_b): """计算两段文本的余弦相似度""" emb_a = get_sentence_embedding(text_a) emb_b = get_sentence_embedding(text_b) # 归一化后点积等于余弦相似度 similarity = torch.sum(emb_a * emb_b, dim=1) return similarity.item() # 测试 score = compute_similarity("如何学习编程", "怎样入门写代码") print(f"相似度: {score:.4f}")这段代码的关键点有三个。第一,attention_mask的处理——如果不排除 padding 位置,短句的向量会被大量零向量稀释,导致相似度计算偏差。第二,max_length=128是权衡,太长会拖慢推理,太短会截断信息,中文场景下 128 个 token 大约覆盖 200 多个汉字,对大多数句子够用。第三,F.normalize之后用点积代替余弦公式,减少一次除法运算,批量计算时效率提升明显。
参数调整建议:如果句子普遍较长(比如段落级比对),把max_length提到 256 或 512,但要注意显存占用会平方级增长。如果部署在 CPU 上且对延迟敏感,可以降到 64,牺牲一些长句精度换速度。
3. 后端服务搭建:Flask 接口设计与模型加载策略
3.1 项目结构规划
一个清晰的项目结构能让后续维护和答辩演示都省心。我一般会这样组织:
text_similarity_system/ ├── app.py # Flask 主入口 ├── model/ │ ├── __init__.py │ ├── encoder.py # BERT 编码逻辑 │ └── similarity.py # 相似度计算 ├── db/ │ ├── __init__.py │ ├── models.py # SQLAlchemy 数据模型 │ └── init_db.py # 建表脚本 ├── static/ # 前端静态资源 ├── templates/ # HTML 模板 ├── requirements.txt └── config.py # 配置参数模型加载放在应用启动时完成,不要每次请求都重新加载。BERT 模型加载一次大约需要 2-5 秒(取决于磁盘速度),如果放在请求处理函数里,每次调用都要等这个时间,用户体验极差。
3.2 Flask 接口实现
from flask import Flask, request, jsonify, render_template from model.similarity import compute_similarity from db.models import db, SimilarityRecord import config app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = config.DATABASE_URI app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) @app.route("/") def index(): """渲染前端页面""" return render_template("index.html") @app.route("/api/similarity", methods=["POST"]) def api_similarity(): """文本相似度检测接口""" data = request.get_json() # 参数校验 text_a = data.get("text_a", "").strip() text_b = data.get("text_b", "").strip() if not text_a or not text_b: return jsonify({"code": 400, "msg": "文本不能为空"}), 400 if len(text_a) > 500 or len(text_b) > 500: return jsonify({"code": 400, "msg": "文本长度不能超过500字"}), 400 # 计算相似度 try: score = compute_similarity(text_a, text_b) except Exception as e: return jsonify({"code": 500, "msg": f"计算失败: {str(e)}"}), 500 # 判定等级 if score >= 0.85: level = "高度相似" elif score >= 0.65: level = "中度相似" else: level = "低度相似" # 写入数据库 record = SimilarityRecord( text_a=text_a, text_b=text_b, score=round(score, 4), level=level ) db.session.add(record) db.session.commit() return jsonify({ "code": 200, "data": { "score": round(score, 4), "level": level, "record_id": record.id } }) @app.route("/api/history", methods=["GET"]) def api_history(): """查询历史检测记录""" page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 10, type=int) pagination = SimilarityRecord.query.order_by( SimilarityRecord.created_at.desc() ).paginate(page=page, per_page=per_page, error_out=False) records = [{ "id": r.id, "text_a": r.text_a[:50], # 截断展示 "text_b": r.text_b[:50], "score": r.score, "level": r.level, "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") } for r in pagination.items] return jsonify({ "code": 200, "data": { "records": records, "total": pagination.total, "pages": pagination.pages } }) if __name__ == "__main__": with app.app_context(): db.create_all() # 首次运行自动建表 app.run(host="0.0.0.0", port=5000, debug=False)接口设计上有几个值得注意的决策。第一,相似度阈值 0.85 / 0.65 不是拍脑袋定的,而是根据实际测试数据调整的——BERT 中文模型在语义相近的句对上通常给出 0.8 以上的分数,而完全不相关的句子一般在 0.3-0.5 之间。第二,文本长度限制 500 字是防止恶意长文本拖垮服务,同时也和前端输入框的maxlength属性保持一致。第三,历史记录接口做了分页,避免一次性返回大量数据导致前端卡顿。
3.3 数据库表设计
from flask_sqlalchemy import SQLAlchemy from datetime import datetime db = SQLAlchemy() class SimilarityRecord(db.Model): """相似度检测记录表""" __tablename__ = "similarity_record" id = db.Column(db.Integer, primary_key=True, autoincrement=True) text_a = db.Column(db.Text, nullable=False, comment="文本A") text_b = db.Column(db.Text, nullable=False, comment="文本B") score = db.Column(db.Float, nullable=False, comment="相似度分数") level = db.Column(db.String(20), nullable=False, comment="相似等级") created_at = db.Column(db.DateTime, default=datetime.now, comment="创建时间") def __repr__(self): return f"<Record {self.id}: {self.score}>"表结构保持简单,只存必要字段。text类型用Text而不是String(255),因为用户可能输入较长文本。created_at用datetime.now而不是datetime.utcnow,因为毕设演示通常面向国内用户,本地时间更直观。
注意:MySQL 建库时字符集务必用
utf8mb4,否则遇到 emoji 或生僻字会报错。建库语句:CREATE DATABASE similarity_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
4. 前端交互与联调:从输入框到结果展示的完整链路
4.1 页面结构与交互逻辑
前端不需要太复杂,核心是一个输入区、一个结果展示区、一个历史记录列表。用原生 HTML + CSS + JavaScript 就够了,不必引入 Vue 或 React——毕设答辩时老师更关心功能是否完整,而不是用了什么框架。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>文本相似度检测系统</title> <style> .container { max-width: 800px; margin: 40px auto; padding: 0 20px; } .input-group { margin-bottom: 16px; } textarea { width: 100%; height: 100px; padding: 10px; border: 1px solid #ddd; border-radius: 6px; resize: vertical; } .btn { padding: 10px 32px; background: #4a6cf7; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; } .btn:disabled { background: #aaa; cursor: not-allowed; } .result { margin-top: 24px; padding: 20px; background: #f5f7ff; border-radius: 8px; display: none; } .score { font-size: 36px; font-weight: bold; color: #4a6cf7; } .level { font-size: 18px; margin-top: 8px; } .history { margin-top: 40px; } .history table { width: 100%; border-collapse: collapse; } .history th, .history td { padding: 10px; border-bottom: 1px solid #eee; text-align: left; font-size: 14px; } </style> </head> <body> <div class="container"> <h1>文本相似度检测</h1> <div class="input-group"> <textarea id="textA" placeholder="请输入文本A" maxlength="500"></textarea> </div> <div class="input-group"> <textarea id="textB" placeholder="请输入文本B" maxlength="500"></textarea> </div> <button class="btn" id="submitBtn" onclick="checkSimilarity()">开始检测</button> <div class="result" id="result"> <div class="score" id="scoreValue">--</div> <div class="level" id="levelValue">--</div> </div> <div class="history"> <h2>历史记录</h2> <table> <thead> <tr><th>文本A</th><th>文本B</th><th>相似度</th><th>等级</th><th>时间</th></tr> </thead> <tbody id="historyBody"></tbody> </table> </div> </div> <script> async function checkSimilarity() { const textA = document.getElementById("textA").value.trim(); const textB = document.getElementById("textB").value.trim(); if (!textA || !textB) { alert("请填写两段文本"); return; } const btn = document.getElementById("submitBtn"); btn.disabled = true; btn.textContent = "检测中..."; try { const resp = await fetch("/api/similarity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text_a: textA, text_b: textB }) }); const data = await resp.json(); if (data.code === 200) { document.getElementById("result").style.display = "block"; document.getElementById("scoreValue").textContent = (data.data.score * 100).toFixed(1) + "%"; document.getElementById("levelValue").textContent = data.data.level; loadHistory(); // 刷新历史记录 } else { alert(data.msg || "检测失败"); } } catch (err) { alert("网络错误,请稍后重试"); } finally { btn.disabled = false; btn.textContent = "开始检测"; } } async function loadHistory() { const resp = await fetch("/api/history?page=1&per_page=10"); const data = await resp.json(); if (data.code !== 200) return; const tbody = document.getElementById("historyBody"); tbody.innerHTML = data.data.records.map(r => ` <tr> <td>${r.text_a}</td> <td>${r.text_b}</td> <td>${(r.score * 100).toFixed(1)}%</td> <td>${r.level}</td> <td>${r.created_at}</td> </tr> `).join(""); } // 页面加载时拉取历史记录 loadHistory(); </script> </body> </html>前端逻辑很直白:用户输入两段文本,点击按钮后 POST 到后端接口,拿到相似度分数和等级后展示,同时刷新历史记录表格。按钮在请求期间禁用,防止重复提交。maxlength="500"和后端校验保持一致,避免用户输入超长文本后被后端拒绝却不知道为什么。
4.2 联调时最容易卡住的三个点
第一个是跨域问题。如果前端用 Live Server 跑在 5500 端口,后端跑在 5000 端口,浏览器会拦截请求。解决办法有两种:一是后端加 CORS 头,二是把前端页面直接放在 Flask 的templates目录下由 Flask 渲染。毕设场景我推荐第二种,省去跨域配置的麻烦。
第二个是 JSON 解析失败。Flask 的request.get_json()在请求头没有Content-Type: application/json时会返回None,然后.get()就报错。前端fetch里必须显式设置这个头,或者后端用request.get_json(force=True)强制解析。
第三个是模型首次推理特别慢。BERT 模型第一次前向传播时需要初始化一些计算图结构,可能比后续推理慢 3-5 倍。解决办法是在应用启动后先跑一次预热推理:
# 在 app.run 之前执行一次预热 with app.app_context(): db.create_all() # 预热模型 _ = compute_similarity("预热文本", "预热文本") print("模型预热完成")5. 避坑与排查:那些让系统跑不起来的细节
5.1 模型加载报 OOM 或显存不足
现象:启动 Flask 时抛出RuntimeError: CUDA out of memory,或者进程被系统 kill。
原因:BERT-base 模型加载到 GPU 大约占 400-500MB 显存,如果同时有其他进程占用,或者批量推理时 batch size 设得太大,就会溢出。
解决:毕设场景直接用 CPU 推理即可,model.to("cpu")显式指定。CPU 单次推理约 200ms,对演示完全够用。如果必须用 GPU,把 batch size 设为 1,并在推理后用torch.cuda.empty_cache()释放缓存。
5.2 相似度分数普遍偏高或偏低
现象:任意两句话算出来都在 0.7 以上,或者明明意思相近却只有 0.4。
原因:前者通常是没用 mean pooling 而是直接取了[CLS]向量,后者可能是max_length设得太小导致长句被截断,或者没有做 L2 归一化。
解决:统一用 mean pooling + L2 归一化。如果分数整体偏高,检查是否在model.eval()模式下推理——训练模式下的 dropout 会让向量随机波动。如果分数整体偏低,把max_length从 128 提到 256 试试。
5.3 MySQL 连接超时导致接口 500
现象:系统跑了一段时间后,接口开始报OperationalError: (2006, 'MySQL server has gone away')。
原因:MySQL 默认wait_timeout是 8 小时,连接池里的空闲连接被服务端断开,但客户端不知道,继续用旧连接就报错。
解决:在 SQLAlchemy 配置里加pool_pre_ping=True,每次从连接池取连接前先 ping 一下,失效就重建:
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { "pool_pre_ping": True, "pool_recycle": 3600, # 1小时回收一次 "pool_size": 5, "max_overflow": 10 }5.4 中文输入导致数据库乱码
现象:历史记录里中文显示成???或乱码。
原因:建库时没指定utf8mb4,或者连接字符串没加charset参数。
解决:连接字符串写成mysql+pymysql://user:pass@host/dbname?charset=utf8mb4,同时确认建库语句用了CHARACTER SET utf8mb4。
5.5 前端请求一直 pending 最后超时
现象:点击检测按钮后转圈很久,最后报网络错误。
原因:Flask 默认单线程处理请求,如果模型推理阻塞了主线程,后续请求全部排队。开发模式下app.run()默认threaded=True,但如果用了 gunicorn 且 worker 数为 1,同样会阻塞。
解决:开发时确认app.run(threaded=True);部署时 gunicorn 至少开 2 个 worker,或者用--threads 4参数。更彻底的做法是把模型推理放到独立进程,通过队列通信,但毕设场景没必要做到这个程度。
6. 进阶技巧:让相似度检测更准的几个实用手段
6.1 用对比学习微调提升领域效果
预训练 BERT 在通用语料上表现不错,但如果你的毕设场景是特定领域(比如法律文书、医疗问答),通用模型的相似度判断可能不够准。这时候可以用对比学习做微调:构造正样本对(语义相同)和负样本对(语义不同),用 triplet loss 或 cosine embedding loss 训练。
from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup # 假设 train_dataloader 返回 (anchor, positive, negative) 三元组 optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=100, num_training_steps=1000 ) model.train() for epoch in range(3): for batch in train_dataloader: anchor_emb = get_sentence_embedding(batch["anchor"]) pos_emb = get_sentence_embedding(batch["positive"]) neg_emb = get_sentence_embedding(batch["negative"]) # triplet loss: 让 anchor 与 positive 更近,与 negative 更远 pos_sim = torch.sum(anchor_emb * pos_emb, dim=1) neg_sim = torch.sum(anchor_emb * neg_emb, dim=1) loss = torch.clamp(neg_sim - pos_sim + 0.3, min=0).mean() loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()关键参数:学习率用 2e-5(BERT 微调的标准起点),margin 设 0.3(表示正负样本相似度至少差 0.3),训练轮数 2-3 轮即可,太多会过拟合。微调数据不需要太多,每个类别几百对就能看到明显提升。
6.2 缓存高频查询结果
如果系统会被反复查询相同的文本对,加一层缓存能显著降低响应时间。用 Python 内置的functools.lru_cache最简单:
from functools import lru_cache @lru_cache(maxsize=1000) def cached_similarity(text_a, text_b): """带缓存的相似度计算,相同输入直接返回缓存结果""" return compute_similarity(text_a, text_b)maxsize=1000表示缓存最近 1000 组结果。注意这个装饰器要求参数可哈希,字符串天然满足。如果文本很长,缓存会占内存,可以改用 Redis 做外部缓存并设置过期时间。
6.3 批量推理提升吞吐
当需要一次性比对多组文本时,逐对计算效率很低。把文本对拼成 batch 一起推理,GPU 上能快 5-10 倍:
def batch_similarity(pairs, batch_size=16): """批量计算文本对相似度""" results = [] for i in range(0, len(pairs), batch_size): batch = pairs[i:i+batch_size] texts_a = [p[0] for p in batch] texts_b = [p[1] for p in batch] # 分别编码两组文本 emb_a = get_sentence_embedding_batch(texts_a) emb_b = get_sentence_embedding_batch(texts_b) # 逐对计算余弦相似度 sims = torch.sum(emb_a * emb_b, dim=1) results.extend(sims.tolist()) return resultsbatch_size根据显存调整,CPU 上建议 8-16,GPU 上可以到 64 甚至 128。注意get_sentence_embedding_batch需要对整个 batch 做 padding,确保所有序列长度一致。
6.4 一个容易被忽略的验证方法
答辩前一定要做一轮人工验证:准备 20-30 组文本对,人工标注"相似/不相似",然后跑系统看准确率。如果准确率低于 80%,检查是不是阈值设得不合理,或者模型选型有问题。这个验证过程本身也是答辩时的加分项——说明你不仅跑通了系统,还做了效果评估。
我自己的习惯是:每次改完模型或参数,先跑这 30 组验证集,确认没有退化再继续。这个习惯帮我省了很多"改了一个地方结果整体效果崩了却不知道"的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取