☰
BERT文本相似度检测系统:毕设级实现与踩坑记录
2026/10/7 3:31:37 网站建设 项目流程

简介:这套基于BERT的深度学习文本相似度检测系统设计源码,适合作为计算机相关专业毕业设计、课程设计的完整参考项目,也方便NLP初学者理解从数据到模型部署的流程。系统使用Python 3.6.8搭建后端,借助BERT预训练模型完成语义特征提取与相似度计算,并配套MySQL 5.7数据库及Navicat管理工具,整体包含前端界面、后端接口、数据库脚本与说明文档。资源包共389个文件、52.19MB,以Python源码(py/pyc)为核心,辅以JavaScript/CSS/HTML前端资源、gif/png演示素材以及docx/pdf/txt说明文档,SQL文件保存了建库建表数据,目录按源码、数据库、成品归档,结构清晰。目前已有78人学习浏览。通过这套资源,既能直接运行体验完整的前后端联动效果,也可参考项目架构、接口设计和文档撰写毕业设计,对深度学习与Web系统结合的场景有较强的实践指导意义。

1. BERT文本相似度检测:毕设级完整系统到底能给你什么

如果你拿到这套「深度学习文本相似度检测系统」的毕设源码,第一反应多半是找 README、跑 demo、看前端页面长什么样。但更值得先弄清楚的问题不是“代码能不能跑”,而是这个系统凭什么用 BERT,MySQL 在里边是不是可有可无。答案是:BERT 负责语义相似度的实时计算,MySQL 负责把每一次比对的历史记录落库,前端负责把模型能力包装成可操作的页面,三者的取舍直接决定你答辩时能不能把话说圆。这个方向适合两类人:一类是 NLP 毕业设计选了文本相似度、需要完整可复现系统的人;另一类是求职想往 NLP 工程方向靠、需要一个能讲清前后端联调的项目的人。

整套源码里附带前后端、MySQL 建库脚本、说明文档和 LW(论文文档),但拿到手先别急着逐行读代码。我习惯先把系统拆成三个问题:模型怎么算相似度,数据库存什么,前端调什么接口。这三件事想清楚了,后面改阈值、换模型、接真实数据都是顺水推舟的事。

2. 系统拆解:BERT为什么能算相似度,MySQL和前后端各管什么事

2.1 BERT算相似度的两种结构:CLS向量与双塔编码

BERT 本身不是为文本相似度设计的,它训练时用的是掩码语言模型和下一句预测。毕业设计里把它拿来做相似度检测,最常见做法是直接取模型输出的一个固定维度向量来代表整句话,然后算两个向量之间的余弦相似度。这就会牵出一个关键选择:用哪个向量代表句子。

第一种是直接取每个句子送入 BERT 后 [CLS] 位置最后一层的输出。BERT 的 [CLS] 在设计之初就承担了聚合整句语义信息的角色,所以很多教材里默认“拿 CLS 就行”。但实际跑过就会知道,未经过相似度任务微调的 BERT,其 CLS 向量里包含的语义信息并不稳定,直接算余弦相似度会出现大量“看起来该相似却不相似”的情况。

第二种是双塔结构,也叫 Siamese 结构:两个句子分别过同一个 BERT 模型,两个输出向量再进到一个距离计算层。这种方式在 Sentence-BERT 里被验证过效果好很多,代价是训练时要构造句子对数据。毕设系统一般都采用第一种“CLS 或池化向量 + 余弦相似度”的路径,因为它不需要训练数据也能出结果,改造成本低。

这里有一个常被忽略的参数:池化方式。我自己调系统时发现,mean pooling 通常比直接取 CLS 更稳。原因是 CLS 位置只占一个 token 的表示,而 mean pooling 会把整句话所有 token 的向量做平均,受特殊符号影响小。代码里实现时就是拿 attention_mask 过滤掉 padding 位,再对有效 token 向量求均值。

2.2 前后端与MySQL的职责边界:模型不做持久化,库只存记录

这个系统的架构层级并不复杂,但职责边界必须清楚。文本相似度推理本身是无状态的:输入两个句子,BERT 前向传播一次,输出一个 0 到 1 之间的分数,这个过程不需要查数据库。所以把 BERT 推理逻辑放在后端服务里,提供 HTTP 接口给前端调用,这是最顺的组装方式。

MySQL 在这里不是用来存模型参数的。BERT 的权重文件通常是几百 MB 的本地文件,不应该也不适合放进数据库。MySQL 存的是业务数据:哪两个句子在什么时间被比对过、分数是多少、当时用的阈值是什么、用户是谁。这些数据是答辩时最值钱的东西,因为可以导出统计结果,比如“本系统共处理了 1500 次比对请求,平均响应时间 800ms”。

另外一个容易踩的认知误区是:把 BERT 推理结果缓存到数据库里。常见做法是每次请求都实时推理,除非你有明确的句子对重复出现场景。毕设系统规模不大,实时推理完全可以承受,别为了“优化”把缓存逻辑做复杂。

前端在这个系统里的任务是:提供两个输入框、一个提交按钮、一个结果展示区域。它通过 AJAX 请求把两个句子 POST 给后端,后端返回 score、label、耗时等字段,前端把结果渲染出来。整个链路里数据和逻辑的边界就是“模型在后端,记录在 MySQL,展示在前端”。

2.3 为什么不用TF-IDF和Word2Vec:语义相似度的分水岭

如果你在答辩时被问“为什么要用深度学习而不是传统方法”,这个问题必须在选型阶段就准备好。TF-IDF 配合余弦相似度只能衡量字面重合程度,它会把文本拆成词后按词频加权,两个句子只要用词不同,哪怕语义完全相同,得分也会很低。

举个实际例子:“这个价格真便宜”和“这个价位很实惠”在语义上是同一个意思,但 TF-IDF 的向量空间里,除了“这个”以外没有任何一个词是重合的,算出来相似度可能只有 0.2 不到。Word2Vec 能解决一部分问题,因为它把词映射成了向量,可以用来算词之间的相似度,但它是静态的:一个词只有一个向量,无法根据上下文动态调整。多义词“苹果”在“苹果很好吃”和“苹果发布新手机”里应该指向不同语义,Word2Vec 做不到。

BERT 是上下文相关的,同一个词在不同句子里得到的向量不一样。这是它和 Word2Vec 最本质的区别,也是一个很好的答辩切入点。所以 BERT 在相似度任务上的优势不是“深度学习四个字听起来高级”,而是它能理解语义层面的相似,而不是停留在字符层面的匹配。

3. 从零跑通:Python环境、MySQL建表和BERT权重准备

3.1 先把环境钉死:Python版本、依赖清单与安装命令

很多时候代码跑不起来,不是因为代码有问题,而是环境版本太新。BERT 相关的依赖对 Python 版本有隐性要求,尤其是 torch 在较新版本上的编译支持。我一般会把 Python 环境锁定在 3.8 到 3.10 之间,不要一上来就装最新的 3.12。python 安装教程里总劝你勾选 Add Python to PATH,这一步做对了后面能少很多事。

推荐用虚拟环境隔离项目,避免污染系统 Python。下面是一套可以直接执行的安装命令:

python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install --upgrade pip pip install torch --index-url https://download.pytorch.org/whl/cpu pip install transformers==4.30.2 pip install flask pymysql numpy

这里把 torch 单独安装,并且指定了 CPU 版本。很多人安装 numpy 时会遇到版本冲突,常见原因是系统中已经存在一个旧版 numpy,而 transformers 对它有过高要求。建议在虚拟环境里全新安装,不要用手动下载的包去覆盖。transformers 指定 4.30.2 是稳妥选择,新版 API 变动较大,很多旧教程代码在新版下会报错。

安装完后建议运行一次 python -c "import torch, transformers; print(torch.version, transformers.version)" 确认版本。这一步能排查 80% 的导入失败问题。

3.2 数据库设计:比对记录表结构与MySQL配置要点

MySQL 在这个系统里的核心表只有一张,叫相似度比对记录表。表设计不需要复杂,但字段类型、字符集、索引这几个点要一次到位,否则后面写进中文数据就会遇到乱码或者排序问题。

CREATE DATABASE sim_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE sim_system; CREATE TABLE t_similarity_record ( id INT AUTO_INCREMENT PRIMARY KEY, text_a VARCHAR(500) NOT NULL COMMENT '待比对文本A', text_b VARCHAR(500) NOT NULL COMMENT '待比对文本B', sim_score DECIMAL(5,4) NOT NULL COMMENT 'BERT计算出的余弦相似度', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '比对时间', KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文本相似度比对记录表';

这里有两个细节值得说明。第一,字符集必须用 utf8mb4,而不是 utf8,因为 MySQL 的 utf8 实际只支持最多 3 字节的字符,遇到生僻字或 emoji 会报错或乱码。第二,sim_score 用 DECIMAL(5,4),表示最多 5 位数字、小数占 4 位,可以存 0.0000 到 9.9999,余弦相似度的范围在 -1 到 1 之间,完全够用。

MySQL 配置方面,最常见的坑是安装完成后 root 密码没记住、或者服务启动失败。mysql 5.7 与 8.0 在安装流程上差异较大,如果照着网上的 mysql 安装教程操作,先确认教程对应的版本是否和你本地一致。数据库启动后,用 mysql -u root -p 登录,执行上面的 SQL 即可。

3.3 加载BERT前的自检脚本:模型、分词器、设备一次确认

transformers 库首次加载模型时会从 Hugging Face 下载权重文件,如果网络不稳定或者模型名称写错,会卡在下载阶段很久。稳妥做法是先把模型下载到本地目录,然后用 local_files_only 参数强制从本地加载。这样后面每次启动都不会有网络依赖。

from transformers import AutoTokenizer, AutoModel model_dir = "./bert-base-chinese-local" # 首次运行去掉 local_files_only,让它自动下载到 model_dir tokenizer = AutoTokenizer.from_pretrained(model_dir, local_files_only=True) model = AutoModel.from_pretrained(model_dir, local_files_only=True) model.eval() print(f"模型已加载,参数量约 {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")

参数说明:local_files_only=True 表示只读取本地文件,不发起任何网络请求;如果目录为空会直接报错,这样能立刻发现问题而不是卡住。model.eval() 把模型切换到推理模式,它会关闭 dropout 等训练专用行为,这一点很多人会忘记,导致同样的输入两次推理结果不一致,看着像玄学,其实就是没切 eval 模式。

这段脚本跑通后,BERT 这块的黑匣子就算打开了。设备方面,CPU 也能跑,只是单次推理慢一些;如果机器有 NVIDIA 显卡,可以加一行 model.to("cuda"),但要注意把输入张量也移到同一设备。

4. 把检测做成接口:后端推理、前端页面与MySQL落库的完整链路

4.1 模型推理封装:向量计算与余弦相似度的参数设置

在写接口之前,先把推理逻辑封装成一个独立函数,输入两个字符串,输出一个浮点数。这一步做得好,后面接 Flask 接口就只是加一层网络协议的问题。

import torch import torch.nn.functional as F from transformers import AutoTokenizer, AutoModel model_dir = "./bert-base-chinese-local" tokenizer = AutoTokenizer.from_pretrained(model_dir, local_files_only=True) model = AutoModel.from_pretrained(model_dir, local_files_only=True) model.eval() def encode_text(text: str): encoded = tokenizer( text, max_length=128, padding="max_length", truncation=True, return_tensors="pt" ) return encoded def mean_pooling(token_embeddings, attention_mask): input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, dim=1) sum_mask = torch.clamp(input_mask_expanded.sum(dim=1), min=1e-9) return sum_embeddings / sum_mask def compute_similarity(text_a: str, text_b: str) -> float: with torch.no_grad(): enc_a = encode_text(text_a) enc_b = encode_text(text_b) out_a = model(**enc_a) out_b = model(**enc_b) emb_a = mean_pooling(out_a.last_hidden_state, enc_a["attention_mask"]) emb_b = mean_pooling(out_b.last_hidden_state, enc_b["attention_mask"]) cos_score = F.cosine_similarity(emb_a, emb_b, dim=-1) return round(float(cos_score[0]), 4)

这段代码有几个参数是实践出来的,不是默认值就能用。max_length=128 对短文本比对足够,超过 128 的部分会被截断,如果做长文档比对可以调到 256,但推理时间几乎翻倍。padding="max_length" 会把所有句子都补到 128 个 token,好处是 batch 内形状统一,坏处是短句子浪费计算。truncation=True 必须和 max_length 同时使用,否则超长文本会报错。

emb_a 和 emb_b 的 shape 都是 (1, 768),768 是 BERT-base 的隐藏层维度。mean_pooling 利用 attention_mask 屏蔽 padding 位,避免无效 token 拉低向量的平均语义,这是比直接取 CLS 更稳的池化方法。

4.2 接口与落库:POST请求里同时完成打分和记录

推理函数写好后,用 Flask 包一层 HTTP 接口。模型加载必须放在模块级别,也就是只加载一次。如果写进请求处理函数里,每个请求都会重新读权重文件,响应时间会从几百毫秒恶化到几十秒。

from flask import Flask, request, jsonify import pymysql app = Flask(__name__) def save_record(text_a: str, text_b: str, score: float): conn = pymysql.connect( host="localhost", user="root", password="123456", database="sim_system", charset="utf8mb4" ) try: with conn.cursor() as cursor: sql = "INSERT INTO t_similarity_record (text_a, text_b, sim_score) VALUES (%s, %s, %s)" cursor.execute(sql, (text_a, text_b, score)) conn.commit() finally: conn.close() @app.route("/api/similarity", methods=["POST"]) def similarity(): data = request.get_json(force=True) text_a = data.get("text_a", "").strip() text_b = data.get("text_b", "").strip() if not text_a or not text_b: return jsonify({"error": "text_a and text_b cannot be empty"}), 400 score = compute_similarity(text_a, text_b) threshold = float(data.get("threshold", 0.7)) label = "相似" if score >= threshold else "不相似" save_record(text_a, text_b, score) return jsonify({ "text_a": text_a, "text_b": text_b, "score": score, "label": label, "threshold": threshold }) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)

request.get_json(force=True) 会强制把请求体按 JSON 解析,即使请求头没写 Content-Type。这么做是为了兼容前端的 fetch 调用。threshold 允许前端传入,这比写死在代码里更灵活,方便后面做阈值对比实验。

save_record 每次请求都新建连接,在毕设量级下没有问题,没必要引入连接池。注意 pymysql.connect 里的 charset="utf8mb4" 必须和建表时的字符集一致,否则中文文本写进库后可能仍是乱码。conn.commit() 在 execute 之后调用,忘记提交会导致数据查不到,这个问题经常被漏掉。

启动后可以先在命令行里用 curl 验证接口:

curl -X POST http://127.0.0.1:5000/api/similarity \ -H "Content-Type: application/json" \ -d '{"text_a":"这个价格真便宜","text_b":"这个价位很实惠"}'

返回的 JSON 里包含 score、label、threshold 三个字段,前端直接拿这些字段渲染即可。

4.3 前端联调:两个输入框、一个按钮、三分区展示结果

前端尽量用纯 HTML 加原生 JavaScript,不引框架,这样项目在任意浏览器打开都能跑,教师机、机房电脑都能演示,避免答辩现场因为没装依赖翻车。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>文本相似度检测系统</title> </head> <body> <div> <textarea id="text_a" placeholder="输入第一段文本"></textarea> <textarea id="text_b" placeholder="输入第二段文本"></textarea> <button id="btn_submit">开始比对</button> <div id="result"></div> </div> <script> document.getElementById("btn_submit").addEventListener("click", async () => { const textA = document.getElementById("text_a").value.trim(); const textB = document.getElementById("text_b").value.trim(); if (!textA || !textB) { document.getElementById("result").innerText = "两个文本框都要填写"; return; } const resp = await fetch("http://127.0.0.1:5000/api/similarity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text_a: textA, text_b: textB }) }); const data = await resp.json(); document.getElementById("result").innerText = "相似度得分:" + data.score + ",判定结果:" + data.label; }); </script> </body> </html>

fetch 请求默认是异步的,用了 async/await 后代码更接近顺序执行。这里的 URL 写死为 127.0.0.1:5000,适用于前后端都在本机开发的情况。如果前端页面和后端不在同一个域名下,浏览器会触发跨域限制,此时最简单做法是在 Flask 里加 CORS 响应头,不是改前端。

text_a 和 text_b 在发送前做 trim 去首尾空格,避免用户手滑多打一个空格导致相似度无意义地下降。这个细节看起来小,实际演示时很关键,一段文本末尾多个空格,得分可能从 0.85 掉到 0.8。

5. 踩坑记录:BERT部署和毕设答辩前最容易翻车的五个点

5.1 首次推理卡到怀疑人生:速度与内存的双重失控

现象:接口首次调用时等了十几秒才返回,第二次也并没有快多少。用 CPU 跑一段 100 字的文本,单次推理耗时超过 3 秒。

原因:很多人把 max_length 设成了 512,并且没有开 torch.no_grad()。BERT 的 self-attention 计算复杂度是 O(n²),序列长度从 128 涨到 512,耗时不是翻 4 倍而是接近 16 倍。另一个常见原因是模型每次请求都重新加载了。

解决:把 max_length 压到 64 或 128;推理代码包在 with torch.no_grad() 里,关闭梯度计算;模型加载移到模块顶层,只初始化一次。做完这三步,CPU 上单次推理通常能压到 1 秒以内。

5.2 中文句子被割成乱码:分词器与预训练权重不配套

现象:英文文本相似度正常,中文文本算出来的分数完全不对,甚至分词后出现 [UNK]。

原因:使用了 bert-base-uncased 这个英文模型来处理中文。英文分词器压根不认识中文字符,也没有对应的词表,所有汉字都被映射成未知字符。

解决:无条件选择 bert-base-chinese,或者改用 chinese-roberta-wwm-ext 之类的中文预训练模型。这里的血泪经验是:不要在代码里把模型名称写成缩写,比如写 bert-chinese 会直接报错;模型名必须和 Hugging Face 上的完整名称一致。模型加载后,也可以用 tokenizer.tokenize("你好") 快速验证:如果输出成了 [UNK],赶紧换分词器。

5.3 相似度普遍0.99:输出层与池化方式选错了

现象:任意两段文本,哪怕完全不相关,算出的相似度都在 0.98 以上,系统失去区分度。

原因:直接把 model 的 pooler_output 用来相似度计算。BERT 在预训练时会优化 Next Sentence Prediction 任务,pooler_output 是专门为二分类设计的一层变换,它输出的向量会被压缩到一个高相似度的空间里。另一个常见失误是没做归一化,直接用原始向量点积当相似度。

解决:改用 last_hidden_state 配合 mean_pooling,也就是第 4.1 节里的写法。验证方法很简单:拿“今天天气很好”和“今天天气很糟糕”去测,如果得分还是 0.99,就说明向量提取路径有问题。正常模型这两句话的相似度应该在 0.8 到 0.9 之间。

5.4 页面中文乱码:MySQL编码与Flask响应头没对齐

现象:前端页面显示中文正常,但存入 MySQL 后通过命令行查询变成问号;或者 Flask 返回的 JSON 中中文显示成 \uXXXX。

原因:第一类是因为 MySQL 连接时没有指定 charset="utf8mb4",第二类是因为 Flask 的 jsonify 默认使用 ensure_ascii=True,把中文转成了 ASCII 转义序列。

解决:pymysql.connect 中显式写 charset="utf8mb4";如果想让返回结果直接显示中文,可以在 Flask 配置里设置 app.config["JSON_AS_ASCII"] = False。建表时 DEFAULT CHARSET 也必须是 utf8mb4,三个地方保持一致,乱码才能根治。

5.5 显存不够也想跑:CPU推理的降级方案

现象:在 8GB 显存的显卡上加载模型,推理几个请求后报 CUDA out of memory。

原因:模型默认以 float32 精度加载,BERT-base 参数量约 110M,仅权重就占 440MB,加上中间激活值和 attention 矩阵,长文本下显存占用快速膨胀。

解决:一是把 batch_size 降为 1,二是改用半精度推理。这相当于裁剪模型精度,算力允许时可以用。实际验证时发现,float16 推理的相似度结果和 float32 的差异通常只有千分之一,不影响判定结果。如果机器连 CPU 都吃力,就把项目定位成“可演示的完整系统”,答辩时重点讲架构设计,而不是压榨性能。

6. 把作品做成能答辩的项目:阈值标定与微调实验设计

6.1 用LCQMC思路造自己的微调数据集

原始 BERT 直接算余弦相似度,效果是“能区分完全不相关和高度相似”,但对“有点相关但不同义”的句子判断不稳定。想要在答辩时有亮点,可以做一个轻量级微调:构造正负样本对,把文本相似度转成二分类任务。

from transformers import BertForSequenceClassification, Trainer, TrainingArguments model_cls = BertForSequenceClassification.from_pretrained( "./bert-base-chinese-local", num_labels=2 ) training_args = TrainingArguments( output_dir="./checkpoints", num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=16, learning_rate=2e-5, weight_decay=0.01, logging_steps=50, save_strategy="epoch" )

learning_rate=2e-5 是 BERT 微调的经典默认值,不要改成 1e-3,那会让预训练权重在几步之内被毁掉。weight_decay=0.01 起到类似 L2 正则化的作用,防止在小数据集上过拟合。如果训练集不到 2000 条,把 num_train_epochs 降到 2,并且 batch_size 调到 8。

6.2 怎么向导师证明“调过参”:阈值对比实验

答辩最怕被问“阈值 0.7 是怎么来的”“准确率多少”。最扎实的回应是拿出一张阈值对比表,证明自己做了实验而不是拍脑袋。构造 50 条“语义相似/不相似”的测试样本,统计不同阈值下的判定结果。

阈值准确率召回率F1
0.50待填入待填入待填入
0.60待填入待填入待填入
0.70待填入待填入待填入
0.80待填入待填入待填入

把这张表填进 LW 的实验章节里,比写一百行代码更能说明你会做效果评估。计算脚本可以用 Python 写一个简单的循环遍历测试集,每一对样本经过 compute_similarity 得到分数,再和阈值做比较。

我做这个方向时最后悔的一件事,就是把阈值写死在代码里,答辩现场被问“阈值为什么是 0.7”时只能说“感觉”。后来花了半天做了这组对比实验,才知道自己用的 0.7 并不是 F1 最高的点。如果你也正在做这套系统,建议从第一天起就保留好每一版实验结果,哪怕只是记录一个分数、一条命令、一次手动标注,这些碎片在写论文时都是后悔药。希望这篇笔记能帮你少走几段弯路。

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

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

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

立即咨询