
简介基于深度学习文本相似度检测系统设计面向毕业设计、人工智能学习者与Python开发者。系统以BERT模型为核心实现欧氏距离、余弦相似度、曼哈顿距离等相似度算法并覆盖文件管理创建文件夹、指定目录上传、批量删除、下载、收藏和文本查重模块支持Word格式、粘贴上传、批量上传及文件管理模块关联检测输出检测报告是完整可运行的项目源码。包体共396个文件约65.86MB包含72个Python源码与82个pyc编译文件、13个docx文档、15个html及35个js、18个css等前端资源另附75个gif动图用于演示交互效果以及pdf、sql等辅助材料目录结构清晰便于二次开发与毕业设计答辩展示。目前已有292人学习下载适合需要快速参考前端界面实现、算法集成与文件管理逻辑的开发者可从中获取前后端完整实现、相似度对比策略和文档撰写思路。1. 用 Python 做深度学习文本相似度检测系统先搞清它能省下什么网上 Python 源码包不少能直接跑通中文深度学习的却没几个。用 Python 搭深度学习文本相似度检测系统最怕的不是模型选复杂而是训练完了才发现评估指标虚高、上线没法用。这套基于 PyTorch 的文本相似度检测工程采用孪生 BiLSTM 加注意力池化做语义匹配数据层用公开问答对和同义句语料构建训练集模型层把句子映射为向量再算相似度。它能直接用在智能客服 FAQ 匹配、内容去重、文献关联检索等场景也适合作为人工智能方向毕业设计的完整框架。内容覆盖预处理、训练、部署全套流程并附参数说明和踩坑记录。具备 Python 基础按章节顺序跑完就能得到一套可演示、可二次开发的系统。2. 模型与数据设计先定义相似度再决定怎么训练2.1 三种相似度建模路径的取舍文本相似度在工程上主要有三条路线。第一是传统词面匹配BM25 或编辑距离优点是快、可解释缺点是遇到同义词和语序变化就失灵比如“如何修改支付密码”和“支付密码怎么重置”在编辑距离上差异很大语义却是同一件事。第二是预训练模型句向量用 BERT 的 [CLS] 向量或专用句向量模型做余弦相似度效果好但成本高12 层 Transformer 在 CPU 上单条推理就要几百毫秒。第三是孪生网络两条共享权重的编码器把两个句子各自编码成向量再用对比损失拉近同类、推开异类这正是本项目采用的方案。选型本质上是在效果、成本和可维护性之间做权衡。如果业务规模不大几千条 FAQBM25 足够如果对精度要求极高、算力也充足选预训练模型如果你的场景是百万级语料、需要考虑线上 CPU 推理孪生 BiLSTM 是性价比最高的落点。我把三者的差异整理成一张表方便对照。方案语义能力训练成本推理速度适用场景BM25 / 编辑距离弱无极快精确匹配、初筛召回BERT 句向量强高慢小规模高精度检索孪生 BiLSTM Attention中上中快大规模 FAQ、离线批量去重提示项目里默认跑的是孪生 BiLSTM如果你手头有 GPU 也可以把 TextEncoder 换成 HuggingFace 的 BERT训练代码的接口不需要大改。2.2 工程模块划分与数据流我把整个系统切成六个模块每个模块只负责一件事方便出问题时快速定位。config 放全局超参数preprocess 处理清洗和分词dataset 构造训练对model 定义编码器train 控制训练循环infer 和 api_server 负责对外服务。工程目录结构如下。文件作用关键点config.py超参数统一管理embedding 维度、batch、学习率、max_lenpreprocess.py文本清洗与分词去 HTML、去链接、jieba 分词dataset.py数据管道动态 padding、mask 生成model.py孪生网络定义双向 LSTM 注意力池化train.py训练入口对比损失、梯度裁剪、保存 checkpointevaluate.py模型评估Spearman、阈值 F1、AUCapi_server.py部署服务FastAPI 接口数据流方向是原始语料从 config 指定的路径进入 preprocess清洗后得到 token 列表dataset 把每对句子转成 id 序列和 maskmodel 接收 id 和 mask 输出句向量train 用对比损失反传更新权重评估通过后infer 负责单条或批量打分最后 api_server 把打分逻辑包装成 HTTP 接口。这条链路上任何一步的处理规则不一致都会在推理阶段暴露成奇怪的高分或低分。2.3 训练句对的构造与正负样本设计孪生网络训练靠的是句对所以数据质量取决于句对怎么构造。正样本好办同一标准问法的不同改写、同一意图的相似表达都是正样本。负样本的构造却大有讲究如果直接用随机句子模型很快学会“句子长得不一样就不相似”召回率会很低。常见做法是用 BM25 先召回一批和原句字面相似但意思不同的句子作为困难负样本逼着模型去学语义层面的差异。from rank_bm25 import BM25Okapi # 假设 corpus_tokenized 是全部语料的分词结果 bm25 BM25Okapi(corpus_tokenized) def hard_negative_query(query_tokens, top_k5): scores bm25.get_scores(query_tokens) idx np.argsort(scores)[::-1] # 第 1 名往往是原文自身从第 2 名开始取 candidates [corpus[i] for i in idx[1: top_k 1]] return candidates逻辑说明先把整个语料切词并构建 BM25 索引对每个标准问题检索出字面相似度最高的候选句从中挑选意思不同但词面接近的句子作为负样本。这样可以防止模型走捷径只靠字符重合度判断。参数 top_k 是取几个候选项目里默认 5语料更大时可以放宽到 10。训练时的正负比例我建议控制在 1:1.2 到 1:1.5。负样本太少模型倾向全判正负样本太多模型又倾向全判负。这里有一个容易犯的错误有人为了让数据集“均衡”硬凑正样本把同一句话复制两遍当正样本结果模型学到了“完全相同才算相似”得不偿失。2.4 关键超参数表与默认设置config.py 里的超参数直接影响模型表现我把默认值和调整思路列成表格。参数默认值作用与调整建议embed_dim200词向量维度小语料用 128大语料用 300hidden_dim256单向 LSTM 隐层维度双向后特征维度翻倍num_layers2层数超过 3 层在中小语料上容易过拟合dropout0.3最后一层 dropout过拟合严重时加大到 0.5margin1.0对比损失边界控制负样本拉开的最小距离max_len64最大 token 数按语料长度分布动态调整batch_size128CPU 上可降到 32GPU 上可提到 256lr3e-4AdamW 初始学习率预训练微调用 1e-5margin 这个参数很多人不理解。对比损失里负样本对的距离至少要超过 margin 才算分类正确margin 越大模型对负样本的要求越苛刻推远距离的难度也越高。项目默认 1.0观察验证集 Spearman 不再上升时可以先调 margin 而不是学加层数。max_len 的设定原则是覆盖 95% 语料长度先跑一段统计代码看分词后 token 数的分位点再回填到 config。3. 核心实现从清洗语料到训练对比损失模型3.1 文本清洗与分词别让停用词背锅清洗规则我保留三条主线去掉 HTML 标签和 URL合并多余空白对全角字符做归一化用 jieba 做中文分词。这里最需要提醒的是停用词很多通用停用词表包含“不”“没”一旦过滤掉否定句的语义直接翻转。我的做法是训练阶段默认不删除否定词和程度副词只过滤“的、了、吗、呢”这类纯语气词。import re import jieba STOPWORDS set() with open(data/stopwords.txt, encodingutf-8) as f: STOPWORDS set(f.read().split()) def clean_text(text: str) - str: text re.sub(r[^], , text) # 去掉 HTML 标签 text re.sub(rhttps?://\S, , text) # 去掉 URL text re.sub(r\s, , text) # 合并空白 return text.strip() def tokenize(text: str) - list: tokens list(jieba.cut(clean_text(text))) # 不在这里过滤“不/没”避免否定语义丢失 return [t for t in tokens if t.strip() and t not in STOPWORDS]逻辑说明clean_text 分三步处理原始文本前两行处理网页和链接噪声最后一行把多个空格合成一个。tokenize 在 jieba 分词后过滤空白 token 和停用词但保留否定词。参数上停用词表文件位置放在 data 目录下替换成自己的词表时保持一行一个词即可。3.2 构建词表并编码成 id 序列分词之后要建立词表我做了三个特殊标记0 是 padding1 是 unk2 是句首3 是句尾。词表只保留训练语料中出现次数不少于 2 的词低频噪声词统一映射到 unk。编码时每个句子截断到 max_len - 2两端补上句首和句尾标记并同时生成 mask。from collections import Counter def build_vocab(token_lists: list, min_freq: int 2) - dict: counter Counter() for tokens in token_lists: counter.update(tokens) vocab {pad: 0, unk: 1, sos: 2, eos: 3} for word, freq in counter.items(): if freq min_freq: vocab[word] len(vocab) return vocab def encode_text(tokens: list, vocab: dict, max_len: int 64): 把 token 列表转成 id 序列并生成对应的 mask ids [vocab.get(t, 1) for t in tokens[:max_len - 2]] ids [2] ids [3] # 加句首和句尾标记 mask [1] * len(ids) pad_len max_len - len(ids) if pad_len 0: ids [0] * pad_len mask [0] * pad_len else: ids ids[:max_len] mask mask[:max_len] return ids, mask逻辑说明build_vocab 统计全量分词结果按词频过滤后按出现顺序编号encode_text 把超长句子截断再补标记padding 位置在 mask 中置 0。参数 min_freq2 适合中等规模语料如果语料只有几千条建议调成 1否则大量低频但有意义的词会变成 unk。3.3 孪生 BiLSTM 编码器与注意力池化编码器是整个系统的核心。双向 LSTM 把句子编码成每个 token 的上下文表示然后经过注意力池化每个位置通过线性层算出一个得分padding 位置被压成负无穷softmax 后得到权重再用权重对全部位置加权求和。这种设计比直接取最后一步隐状态更稳因为关键信息不一定出现在句尾。import torch import torch.nn as nn class TextEncoder(nn.Module): def __init__(self, vocab_size, embed_dim200, hidden_dim256, num_layers2, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.attention nn.Linear(hidden_dim * 2, 1) self.dropout nn.Dropout(dropout) def forward(self, input_ids, mask): emb self.embedding(input_ids) # [B, L, D] out, _ self.lstm(emb) # [B, L, 2H] score self.attention(out).squeeze(-1) # [B, L] score score.masked_fill(mask 0, -1e9) # 忽略 padding weight torch.softmax(score, dim1).unsqueeze(-1) # [B, L, 1] pooled torch.sum(out * weight, dim1) # [B, 2H] return self.dropout(pooled) class SiameseNet(nn.Module): def __init__(self, vocab_size, **encoder_kwargs): super().__init__() self.encoder TextEncoder(vocab_size, **encoder_kwargs) def forward(self, a_ids, a_mask, b_ids, b_mask): a_vec self.encoder(a_ids, a_mask) b_vec self.encoder(b_ids, b_mask) return a_vec, b_vec参数说明embed_dim200 是词向量维度hidden_dim256 是单向隐层维度双向后特征维度为 512。num_layers2 可以捕获更高层语义组合但层数继续增加到 4 在中小语料上会明显过拟合验证集 Spearman 不升反降。dropout 放在向量输出之前推理阶段自动关闭。3.4 训练循环对比损失、梯度裁剪与早停对比损失的基本思想是标签为 1 的句对欧氏距离越小越好标签为 0 的句对距离至少要大于 margin。公式分两项同类的 loss 是距离平方异类的 loss 是 max(0, margin - dist) 的平方。两个输入共享同一个 encoder所以模型参数不会加倍。def contrastive_loss(a_vec, b_vec, label, margin1.0): dist torch.norm(a_vec - b_vec, dim1) # 欧氏距离 loss_same label * dist.pow(2) loss_diff (1 - label) * torch.relu(margin - dist).pow(2) return (loss_same loss_diff).mean()训练脚本核心片段如下优化器用 AdamW学习率初始 3e-4并且加了梯度裁剪。import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, margin1.0): model.train() total_loss 0.0 for batch in loader: a_ids, a_mask batch[a_ids], batch[a_mask] b_ids, b_mask batch[b_ids], batch[b_mask] label batch[label] a_vec, b_vec model(a_ids, a_mask, b_ids, b_mask) loss contrastive_loss(a_vec, b_vec, label, marginmargin) optimizer.zero_grad() loss.backward() # 梯度裁剪防止 LSTM 训练后期梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm3.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)逻辑说明每个 batch 同时传入 A 句和 B 句的 id、mask模型输出两个句向量后计算对比损失。clip_grad_norm_ 把参数梯度范数限制在 3.0 以内不做的后果是训练到第 20 个 epoch 左右 loss 突然变成 nan。早停建议以验证集 Spearman 为准连续 3 个 epoch 不上升就停止并保存最优 checkpoint。3.5 验证阶段为什么准确率不能当唯一标准训练过程看 loss 并不直观因为对比损失的绝对值和 margin 强相关。我在验证集上主要看两个指标Spearman 相关系数衡量打分排序能力阈值 F1 衡量二分类效果。这两个指标对类别比例不敏感更能反映真实水平。from scipy.stats import spearmanr def evaluate_similarity(model, loader): model.eval() preds, labels [], [] with torch.no_grad(): for batch in loader: a_vec, b_vec model(batch[a_ids], batch[a_mask], batch[b_ids], batch[b_mask]) cos torch.cosine_similarity(a_vec, b_vec, dim1) score (cos 1) / 2 # 从 [-1,1] 映射到 [0,1] preds.extend(score.tolist()) labels.extend(batch[label].tolist()) rho, _ spearmanr(preds, labels) return rho, preds, labels提示训练时用欧氏距离验证时用余弦相似度这是孪生网络项目里的常见组合。余弦对向量模长不敏感分数分布更稳定。4. 部署与推理把模型变成可调用的检测服务4.1 加载 checkpoint 的三处易错点训练完成保存的是 model.state_dict()。加载时有三处经常翻车第一vocab_size 必须和训练时一致否则 embedding 层维度对不上直接报错第二保存的 checkpoint 里如果包含优化器状态、epoch 等字段加载模型时要先取 model 键第三多卡训练的 state_dict 会带 module. 前缀单卡推理前必须去掉。import json import torch def load_model(vocab_path, ckpt_path, devicecpu): vocab json.load(open(vocab_path, encodingutf-8)) model SiameseNet(len(vocab)) state torch.load(ckpt_path, map_locationdevice) state_dict state[model] # 去掉 DataParallel 产生的前缀 state_dict {k.replace(module., ): v for k, v in state_dict.items()} model.load_state_dict(state_dict) model.to(device).eval() return model, vocab逻辑说明加载时先把 checkpoint 里的模型权重取出来再用 replace 去掉多卡前缀最后 load_state_dict。注意 model.eval() 必须调用否则 dropout 和注意力池化仍处于训练态推理分数会不稳定。推理时文本处理规则必须和训练完全一致这是最常见的隐蔽问题。def predict_pair(model, vocab, text_a, text_b, max_len64, devicecpu): tokens_a tokenize(text_a) tokens_b tokenize(text_b) a_ids, a_mask encode_text(tokens_a, vocab, max_len) b_ids, b_mask encode_text(tokens_b, vocab, max_len) a_ids torch.tensor([a_ids], dtypetorch.long).to(device) a_mask torch.tensor([a_mask], dtypetorch.long).to(device) b_ids torch.tensor([b_ids], dtypetorch.long).to(device) b_mask torch.tensor([b_mask], dtypetorch.long).to(device) with torch.no_grad(): a_vec, b_vec model(a_ids, a_mask, b_ids, b_mask) cos torch.cosine_similarity(a_vec, b_vec, dim1).item() return (cos 1) / 2提示如果不确定预处理是否一致拿训练集里一条样本跑 predict分数接近 1 说明链路通畅接近 0.5 说明某个环节变形了。4.2 用 FastAPI 暴露相似度接口接口层我采用 FastAPI自带 OpenAPI 文档调试方便。单条接口接收 text_a 和 text_b返回 0 到 1 的分数。from fastapi import FastAPI app FastAPI(title文本相似度检测服务) model, vocab load_model(checkpoints/vocab.json, checkpoints/best.pt) app.post(/similarity) def similarity(payload: dict): a payload.get(text_a, ) b payload.get(text_b, ) score predict_pair(model, vocab, a, b) return {score: round(score, 4), text_a: a, text_b: b}启动命令如下。uvicorn api_server:app --host 0.0.0.0 --port 8000用 curl 验证接口。curl -X POST http://127.0.0.1:8000/similarity \ -H Content-Type: application/json \ -d {text_a: 如何修改支付密码, text_b: 支付密码怎么重置}返回示例。{score: 0.9312, text_a: 如何修改支付密码, text_b: 支付密码怎么重置}score 贴近 1说明这两个问法在语义层面被识别为同一意图。如果分数在 0.4 左右回到预处理环节逐条核对重点看分词、词表、padding 三处。4.3 CPU 上的推理提速与模型导出如果要在 CPU 上跑批量检测PyTorch 的原始模型有优化空间。最简单的做法是转成 ONNX推理时用 onnxruntime 执行通常能快 20% 到 40%。torch.onnx.export( model, (dummy_a_ids, dummy_a_mask, dummy_b_ids, dummy_b_mask), similarity.onnx, opset_version13, input_names[a_ids, a_mask, b_ids, b_mask], output_names[a_vec, b_vec], dynamic_axes{a_ids: {0: batch}, a_mask: {0: batch}} )导出时把 batch 维度声明为动态轴这样同一份 onnx 文件既能单条推理也能批量推理。实际部署时对单条请求仍建议用 PyTorch 推理避免每次请求都走 ONNX 会话创建的额外开销批量离线任务再用 onnxruntime。5. 避坑指南五条实测过的文本相似度翻车记录文本相似度项目里最难受的不是训练不起来而是训练看起来一切正常评测指标也漂亮一到线上就露馅。下面五条都是我在这个项目里真实遇到并修掉的坑按频次排序。5.1 停用词过滤过猛否定语义被过滤没了现象模型在“我不喜欢这款手机”和“我喜欢这款手机”上打出了 0.95 的高分。原因停用词表里包含“不”和“没”分词后“我不喜欢”只剩“喜欢”两个句子在模型眼里几乎没有区别。解决停用词表只保留无语义贡献的词否定词和程度副词必须保留改完停用词后立刻跑一组包含否定词的回归用例。# 回归用例必须全过 test_cases [ (我不喜欢这款手机, 我喜欢这款手机, 0.2), (如何修改支付密码, 支付密码怎么重置, 0.9), ]这类问题特别隐蔽因为训练集里如果恰好没有否定句对模型永远不会暴露这个缺陷直到线上用户输入“不喜欢”才炸。5.2 训练和推理阶段 padding 不一致现象训练时 loss 正常下降验证集也不错线上推理却对短句对打出异常高分很多完全不相关的短句都接近 0.9。原因训练代码里 mask 是随 batch 动态生成的推理代码里重写了一遍 encode 函数把 mask 全部置 1padding 位置也参加了注意力池化模型被迫把零向量当成有效信息。解决把 encode_text 抽成公共函数训练和推理从同一个模块导入禁止两套逻辑并存。5.3 正负样本不平衡导致准确率虚高现象训练集正负样本比例接近 1:9验证集准确率 0.93抽样看预测结果时发现模型几乎把所有句对判成负样本。原因负样本主导了损失模型发现“全部输出大距离”就能拿到很低的总损失根本不需要区分语义差异。解决采样时把训练集正负比例控制在 1:1.2 到 1:1.5 之间验证时以 Spearman 和 AUC 为主准确率只作参考。5.4 长文本截断关键信息落在窗口外现象一条客服语料是“订单超过 24 小时未支付系统会自动关闭如何申请退款”模型却和“如何关闭订单”打出高相似度。原因max_len64 按字数估的分词后这条语料 token 数超过 64尾部“如何申请退款”被截掉留下的是关于关闭订单的上下文描述。解决先统计语料 token 长度分布把 max_len 调到覆盖 95% 样本对超长样本采用保留头部和尾部的截断策略而不是只留开头。def truncate_tokens(tokens, max_len): if len(tokens) max_len: return tokens # 保留前 70% 和后 30%关键问句往往在句尾 head tokens[: int(max_len * 0.7)] tail tokens[-int(max_len * 0.3):] return head tail这个截断方法不一定适合所有场景但至少比只留开头更能保住句尾的疑问词。5.5 多卡训练破坏随机种子结果不可复现现象相同参数连跑两次最好的两个模型 F1 相差 4 个点而且无法判断哪次是偶然。原因DataLoader 的 num_workers 多进程采样时随机种子没有在 worker 里重置每个 epoch 拿到的样本顺序都不一样。解决在 DataLoader 里传 worker_init_fn 固定进程内种子同时把 torch、numpy、random 三处随机种子全部固定。def seed_worker(worker_id): worker_seed torch.initial_seed() % 2 ** 32 numpy.random.seed(worker_seed) random.seed(worker_seed) loader DataLoader(dataset, batch_size128, shuffleTrue, num_workers4, worker_init_fnseed_worker)如果做了这些仍然无法复现把 num_workers 设成 0 做一次对照实验排除数据加载顺序的影响。6. 进阶垂直领域迁移与阈值校准6.1 垂直领域语料的二次训练如果要做法律文本检索或医疗问答匹配直接用通用语料训练的模型效果会明显衰减因为领域词汇分布差异太大。常见做法是在通用模型基础上做二次训练也就是把领域语料清洗后继续训练 5 到 10 个 epoch学习率降到 1e-4。同时给 jieba 加领域词典让“合同解除权”“不可抗力条款”这类复合词被切成整体。jieba.add_word(合同解除权) jieba.add_word(不可抗力条款)领域词表规模大时可以整理成一行一个词的 user_dict 文件在 preprocess.py 中一次性加载。这一步对相似度指标的提升往往比更换模型结构更直接因为分词质量的改善会让词表上下文更加准确。6.2 阈值校准从验证集到上线的最后一步模型输出的分数分布在 0.35 到 0.95 之间0.5 不一定是最佳切割点。校准原理很简单在验证集上遍历候选阈值计算每个阈值下的 F1 并取最优值。import numpy as np from sklearn.metrics import f1_score def find_best_threshold(scores, labels): best_t, best_f1 0.5, 0.0 for t in np.arange(0.4, 0.95, 0.01): pred (np.array(scores) t).astype(int) f1 f1_score(labels, pred) if f1 best_f1: best_t, best_f1 t, f1 return best_t, best_f1参数说明步长 0.01 足够细范围根据分数的实际分布调整。如果最优阈值落在搜索区间端点说明分布偏移严重要回头查样本质量而不是继续调阈值。线上阈值应该定期重算因为真实查询分布会随着业务运营发生变化。6.3 最后说点习惯这套系统前后调过三个版本。第一版纯 BiLSTM 不接注意力长句召回差第二版加了注意力又栽在停用词上直到后来每次版本更新都强制跑一遍回归用例把“训练同一条样本的 score 和验证集表现”当作验收红线才真正稳下来。从那以后我每次调整数据或模型都会先写一条最小验证命令在改动前记录基线 score改动后立刻对比差超过 0.02 就回头查。文本相似度最骗人的地方就是单个 case 都看得过去分布一偏就翻车。阈值校准、回归验证这些习惯比模型结构本身更能决定项目能不能交付。希望帮到你。本文还有配套的精品资源点击获取