
简介面向毕业设计、人工智能课程设计及文本相似度检测应用开发者这份资源以Python实现了一个基于深度学习的文本相似度检测系统。系统采用BERT模型并结合欧氏距离、余弦相似度、曼哈顿距离等多种度量算法同时配套完整的文件管理模块与文本查重模块覆盖文件夹创建、文件上传、批量删除下载、搜索收藏以及多方式文本查重和检测报告输出等实用功能。资源包共包含396个文件大小约65.86MB以Python源码72个py文件及编译缓存82个pyc为核心辅以前端界面所需的js、css、html文件以及docx说明文档、sql数据库脚本和示例媒体文件整体结构清晰便于理解系统全貌。目前已有292人学习下载适合需要完整毕业设计源码作为参考或进行二次开发的读者。获取后可直接导入项目结合自带数据库与前端页面快速运行并通过源码研读学习BERT文本向量化与相似度计算的工程实现细节。1. 文本相似度检测到底在检测什么一篇文章说清任务边界与模型选型很多第一次接触“python基于深度学习文本相似度检测系统”这个标题的人会下意识以为它是个聊天机器人或者搜索引擎。其实它的核心任务非常窄给定两段文本输出一个分数表示它们在语义上有多像。这个“像”不是字面重复而是意思接近比如“怎么退货”和“退款流程是什么”在字面上几乎没有共同词但语义上高度相关。这种能力在论文查重、客服工单归类、问答匹配、舆情去重这些场景里都有实实在在的需求也是深度学习入门项目里性价比很高的一个方向——数据好构造、模型不需要太大、单卡甚至 CPU 就能训练。这个标题对应的是一套完整的课程设计或毕业设计工程包里面通常包含 Python 源码、数据集、训练脚本和设计文档。本文不依赖某个特定压缩包内容而是把这套系统背后最常见、最可靠的实现路径拆开讲透数据怎么构造、模型怎么选、训练怎么做、阈值怎么调、部署时哪些环节最容易翻车。适合的人群是那些已经会写 Python、装好了 PyTorch 或 TensorFlow但第一次想完整跑通一个“输入文本→输出相似度分数”的从业者。读完你可以自己复现一版并且知道每个参数动了之后会发生什么。2. 任务建模与数据构造先搞清楚模型要学什么2.1 相似度检测的两种建模方式句对回归与向量召回深度学习做文本相似度业内最基本的建模只有两条路。第一条是句对分类/回归把两段文本拼接成一个输入交给模型输出一个 0 到 1 之间的分数。这种方式精度高但每次比较都要过一次模型速度慢适合候选集小的场景。第二条是孪生网络Siamese Network把句子分别编码成向量再用余弦相似度或欧氏距离计算接近程度。这种方式可以预先给全量文本建好向量索引查询时只做向量计算速度快适合召回场景。标题里的“检测系统”没有说明是离线批量检测还是在线实时检测。我的习惯是一开始就两边都留口子训练阶段用孪生网络结构做表征学习推理阶段同时支持“逐对打分”和“向量批量比对”两种模式。这样无论对方后续提出什么需求都不用重构模型。至于损失函数常见做法是用对比学习里的 InfoNCE 或者余弦相似度 MSE 回归损失。如果是课程设计MSE 回归最容易讲清楚且调参直观如果追求效果对比学习更值得写进论文。2.2 数据从哪来公开数据集、自造数据与负样本策略文本相似度检测系统的训练数据最常见的三个来源是LCQMC中文问句匹配、STS-B语义文本相似度基准、以及自己从业务日志里挖出来的“标题-正文”或“问题-答案”对。LCQMC 适合中文场景包含 26 万对标注好的问句对标签是 0 或 1直接拿来训练二分类模型非常顺手。STS-B 是英文场景标注分数是 1 到 5 的连续值适合回归模型。如果手上没有现成数据自己构造是常见且可行的方案。比如拿一批商品标题用“同一商品的不同写法”作为正样本用“不同商品”作为负样本。还有一种低成本做法从百科或新闻里抽句子把原句和它的改写句作为正样本随机拼接两句话作为负样本。负样本的难度直接影响模型上限——如果负样本都是完全不相干的两句话模型学到的判别边界会非常粗糙真实场景里的负样本往往是“看起来有点像但意思不同”比如“苹果发布了新手机”和“苹果发布了新平板”。import random import json def build_pairs(sentences, pos_ratio0.5): 从句子列表构造(文本1, 文本2, 标签)三元组。 pos_ratio控制正样本比例实际训练时建议正负样本接近1:1。 pairs [] for i, s in enumerate(sentences): # 正样本同义改写对。这里用自身做模拟实际场景应替换为真实改写句。 pairs.append((s, s, 1)) # 负样本随机抽一句不同的话 neg_idx random.choice([j for j in range(len(sentences)) if j ! i]) pairs.append((s, sentences[neg_idx], 0)) return pairs # 示例10条句子生成20对样本 demo_sentences [如何申请退款, 退款怎么操作, 订单取消了还能退吗, 怎么联系客服, 客服电话是多少, 发货多久能到, 物流一直没有更新, 修改收货地址, 发票怎么开, 可以开发票吗] train_pairs build_pairs(demo_sentences[:8]) print(json.dumps(train_pairs[:4], ensure_asciiFalse, indent2))这段代码展示的是最基础的数据构造思路。逻辑上正样本和负样本各占一半避免类别不均衡导致模型全部预测为多数类。真实使用中负样本不能这样随机抽要按“batch 内随机采样”做成困难负样本给模型制造一些“需要细看才能区分”的例子训练出来的向量才更有区分度。参数说明里pos_ratio是最先要调的。新场景下我一般从 0.5 起步如果模型把几乎所有样本都判成正样本把这个值降到 0.3 看看如果判负严重升到 0.7。另外正样本不能用同一句话否则模型会直接学会“字符串相同就是相似”这种没用的特征对语义相似的改写句毫无判别力。正确做法是用同义句改写哪怕用简单的词典替换都比用原句强。2.3 预处理细节分词、截断长度与 Embedding 初始化文本进入模型之前需要确定几个全局参数。最大序列长度很重要LCQMC 这类短文本数据集绝大多数句子长度在 20 到 40 个字符之间max_len64已经足够如果处理的是商品评论或长文档建议设到 128 或 256但要注意显存占用会线性上升。分词方面中文场景常用 jieba 或哈工大 LTP但要小心分词错误会直接影响语义编码质量尤其是“深度学习”被切成“深度”和“学习”后语义已经被破坏了。Embedding 层建议使用预训练词向量初始化而不是随机初始化。常见做法是用腾讯中文词向量或 word2vec 预训练向量将词表外的词初始化为很小的随机值。如果你用的是 BERT 这类预训练语言模型就不需要额外分词和词向量了直接用它的 Tokenizer 即可。这两种路线的本质区别是词向量 BiLSTM 适合做课程设计和理解原理BERT 适合实际业务和追求精度上限。标题是“设计”所以下文主要以可解释性更强的 BiLSTM Attention 为主路线展开同时会在进阶章给出 BERT 替换方案。import jieba def preprocess_text(text, max_len64, use_jiebaTrue): 对文本做分词、截断、padding返回token id列表。 max_len设太大训练慢设太小丢信息短文本场景64是安全值。 if use_jieba: tokens list(jieba.cut(text)) else: tokens list(text) # 截断超过max_len的部分直接丢弃保留前max_len-2个位置给特殊符号 tokens tokens[:max_len - 2] # 这里模拟词表映射实际工程中用vocab字典做token2id token_ids [hash(t) % 5000 for t in tokens] # padding不够长度补0 token_ids [0] * (max_len - len(token_ids)) return token_ids sample 如何申请退款 print(preprocess_text(sample))这里的hash(t) % 5000只是演示词表映射逻辑真实工程里必须用固定的vocab字典否则训练和推理时相同词的 id 会对不上。截断方式上头部截断和尾部截断对语义保留的影响不同——对问句类文本尾部往往承载核心意图建议保留头尾、截中间但这个操作会引入额外复杂度入门阶段直接保留前 64 个字符也够用。预处理这一层最容易出现的问题就是训练和推理逻辑不一致后面避坑章会专门展开。3. 核心模型搭建从 BiLSTM 到注意力机制3.1 为什么选 BiLSTM Attention 而不是直接上 BERT深度学习文本相似度模型的选型逻辑本质上是在“效果”和“可解释性”之间做权衡。BERT 效果确实好但显存占用大、训练慢、部署体积动辄 400MB。对于一个检测系统设计项目BiLSTM Attention 是教科书级别的标准方案能够捕捉上下文语义、参数量小、在 CPU 上也能跑推理而且论文里可以画结构图讲清楚每一层的作用。这不代表 BiLSTM 是“低端替代品”在短文本匹配场景中经过良好训练的 BiLSTM 可以逼近 BERT 八成以上的效果但训练和部署成本只有它的十分之一。具体结构上输入经过 Embedding 层得到词向量序列然后送入双向 LSTM前向和后向的隐状态拼接后得到每个位置的表征。Attention 层的作用是给句子中信息量大的词更高的权重——比如“退款”这个词比“怎么”重要得多。最后把注意力加权后的向量作为句子的语义表征。孪生网络就是两个共享权重的这种编码器分别编码句子 A 和句子 B得到两个向量后算相似度。3.2 模型完整代码共享权重的孪生编码器import torch import torch.nn as nn import torch.nn.functional as F class SimilarityNet(nn.Module): 孪生 BiLSTM Attention 文本相似度模型。 共享权重意味着两句文本走的是同一套编码器保证向量空间一致。 def __init__(self, vocab_size, embedding_dim128, hidden_dim128, num_layers2, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) # 注意力权重参数将双向隐状态映射为标量权重 self.attention_w nn.Linear(hidden_dim * 2, 1, biasFalse) self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len) embedded self.dropout(self.embedding(x)) # (batch, seq_len, embedding_dim) lstm_out, _ self.lstm(embedded) # (batch, seq_len, hidden_dim*2) # 注意力得分非线性softmax归一化 attn_scores torch.tanh(lstm_out) attn_weights F.softmax(self.attention_w(attn_scores), dim1) # (batch, seq_len, 1) # 加权求和得到句向量 sentence_vec torch.sum(attn_weights * lstm_out, dim1) # (batch, hidden_dim*2) return sentence_vec def cosine_similarity(vec_a, vec_b): 计算两个句向量的余弦相似度输出范围[-1, 1] return F.cosine_similarity(vec_a, vec_b, dim1) if __name__ __main__: net SimilarityNet(vocab_size5000) fake_a torch.randint(1, 5000, (4, 64)) # batch4, seq_len64 fake_b torch.randint(1, 5000, (4, 64)) vec_a net(fake_a) vec_b net(fake_b) sims cosine_similarity(vec_a, vec_b) print(输出相似度分数:, sims)这段代码是模型的核心。重点解释三个设计选择第一padding_idx0让 Embedding 层对 padding 位置不产生梯度更新避免无效字符干扰训练。第二num_layers2是效果与训练稳定性的折中——层数太少学不到复杂语义层数超过 2 在数据量不足时容易过拟合且训练时间翻倍。第三attention_w先经过tanh激活再做 softmax让注意力分布更均匀避免模型只盯住某一两个词。如果训练时发现损失震荡严重可以尝试把dropout从 0.3 降到 0.1如果过拟合升高到 0.5。hidden_dim是 128 还是 256 主要看数据量——LCQMC 级别26 万对用 128 足够了几百万对可以上 256。3.3 损失函数选择对比损失与回归损失的实际差异孪生网络训练有两种主流损失函数。第一种是ContrastiveLoss它要求正样本对的距离尽可能小负样本对的距离尽可能大公式是L (1-y)*d^2 y*max(margin-d, 0)^2。这个损失适合“只关心相对远近”的场景输出的余弦相似度天然在 [-1, 1] 区间。第二种是直接对余弦相似度做 MSE 回归训练目标就是逼近标注分数比如 STS-B 数据集的 1 到 5 分。这个适合需要输出绝对分数的场景。实际项目中我一般优先尝试ContrastiveLoss。原因是它只要求正负样本拉开距离不要求模型把相似度校准到某个绝对值上训练难度更低。当精度需求高的时候再切到余弦相似度 MSE然后配合温度系数把分数分布拉开。温度系数是一个重要的调参项它本质上是把相似度除以一个标量再进 softmax温度越低分布越尖锐模型越有把握温度太高所有样本都会挤到 0.5 附近难以区分。class ContrastiveLoss(nn.Module): def __init__(self, margin0.5): 对比损失。 margin控制负样本对的最小距离调大则负样本区分更严但过大收敛变慢。 super().__init__() self.margin margin def forward(self, sims, labels): # sims: 相似度得分(越大越相似), labels: 1正样本 / 0负样本 # 将相似度转为距离相似度越高距离越近 distances 1 - sims # 正样本损失距离的平方负样本损失max(margin-distance, 0)的平方 losses labels * distances ** 2 (1 - labels) * torch.clamp(self.margin - distances, min0) ** 2 return torch.mean(losses) # 使用示例 labels torch.tensor([1.0, 0.0, 1.0, 0.0]) pred_sims torch.tensor([0.9, 0.8, 0.7, 0.4]) criterion ContrastiveLoss(margin0.5) print(损失值:, criterion(pred_sims, labels).item())这个自定义损失函数的逻辑很清晰对正样本对距离1-sim越小越好所以损失是距离平方对负样本对模型希望距离大于margin没有达到就会产生损失超过了就不管。margin的值建议在 0.3 到 0.8 之间尝试。太小会导致负样本对在向量空间里靠得太近影响区分度太大则模型对所有负样本都“用力过猛”训练震荡。如果你发现验证集的准确率上不去优先检查margin而不是模型结构。3.4 训练循环与验证指标一个可以完整跑通的脚本import torch.optim as optim from torch.utils.data import DataLoader, Dataset class PairDataset(Dataset): 把(文本A id序列, 文本B id序列, 标签)包装成Dataset def __init__(self, data): self.data data def __len__(self): return len(self.data) def __getitem__(self, idx): a_ids, b_ids, label self.data[idx] return torch.tensor(a_ids), torch.tensor(b_ids), torch.tensor(label, dtypetorch.float) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for a, b, label in loader: a, b, label a.to(device), b.to(device), label.to(device) vec_a, vec_b model(a), model(b) sims cosine_similarity(vec_a, vec_b) loss criterion(sims, label) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) def evaluate(model, loader, device, threshold0.5): 验证函数给定阈值判断相似度大于阈值即为正样本。 阈值的选取直接影响准确率后面会专门讲如何调。 model.eval() correct, total 0, 0 with torch.no_grad(): for a, b, label in loader: a, b, label a.to(device), b.to(device), label.to(device) vec_a, vec_b model(a), model(b) sims cosine_similarity(vec_a, vec_b) preds (sims threshold).float() correct (preds label).sum().item() total label.size(0) return correct / total # 模拟数据50条样本每条64维token序列 import random fake_data [(random.sample(range(1, 5000), 64), random.sample(range(1, 5000), 64), random.randint(0, 1)) for _ in range(50)] dataset PairDataset(fake_data) loader DataLoader(dataset, batch_size16, shuffleTrue) model SimilarityNet(vocab_size5000) optimizer optim.Adam(model.parameters(), lr0.001) criterion ContrastiveLoss(margin0.5) for epoch in range(3): loss train_one_epoch(model, loader, optimizer, criterion, devicecpu) acc evaluate(model, loader, devicecpu, threshold0.5) print(fepoch {epoch1}: loss{loss:.4f}, acc{acc:.4f})batch_size 和 learning rate 是这里最关键的两个参数。LCQMC 数据集上batch_size32到64是合理区间太小会让梯度噪声大太大则内存吃紧。学习率0.001是 Adam 的常用默认值但如果 loss 在 2 到 3 个 epoch 后不降可以降到0.0003再试。验证时threshold0.5只是初始值真正的阈值要在验证集上搜索下一章详细说。4. 相似度分数计算与阈值调优从向量到可用的判定规则4.1 余弦相似度为什么够用给模型输出做“后校准”模型训练完成后输出的是两个句向量之间的余弦相似度值域理论上是 [-1, 1]。但实际训练样本中正样本对的相似度通常集中在 0.6 到 0.9负样本对在 0.1 到 0.4 之间分布是有重叠的。直接拿 0.5 当阈值分类结果取决于分布偏移的方向。这不算模型的问题而是校准问题。常见的后处理手段有两种一是温度缩放把相似度除以一个温度参数后再归一化拉大分数差距二是归一化到 [0, 1] 区间方便业务方设定直观阈值。我一般会先做一次相似度分布可视化把验证集里所有正样本对和负样本对的相似度分别画直方图看分界线在哪。如果正负样本分布重叠面积很大说明模型还没训练好调阈值救不了如果只是有小部分重叠那阈值搜索就有意义了。4.2 阈值搜索方法遍历验证集找最优切分点def search_best_threshold(sims, labels, step0.01): 在验证集上搜索最佳阈值。 返回能在F1分数上取得最优表现的阈值点。 best_thresh, best_f1 0.5, 0.0 for thresh in [i * step for i in range(0, 101)]: # 0.00到1.00 preds (sims thresh).float() tp ((preds 1) (labels 1)).sum().item() fp ((preds 1) (labels 0)).sum().item() fn ((preds 0) (labels 1)).sum().item() precision tp / (tp fp) if tp fp 0 else 0 recall tp / (tp fn) if tp fn 0 else 0 f1 2 * precision * recall / (precision recall) if precision recall 0 else 0 if f1 best_f1: best_f1, best_thresh f1, thresh return best_thresh, best_f1 # 模拟结果前50个来自正样本对后50个来自负样本对 sims torch.cat([torch.rand(50) * 0.4 0.6, torch.rand(50) * 0.5 0.1]) labels torch.cat([torch.ones(50), torch.zeros(50)]) best_thresh, best_f1 search_best_threshold(sims, labels) print(f最佳阈值: {best_thresh:.2f}, 最佳F1: {best_f1:.4f})搜索阈值时从 0.0 到 1.0 每隔 0.01 试一次用验证集的 F1 分数做判据而不是准确率。因为如果正负样本不平衡准确率会被多数类带偏F1 更全面地反映判别质量。注意这段代码的前提是标签 1 为正样本、0 为负样本如果你做的回归分数是 0 到 5需要先把标签二值化——比如大于等于 3.5 视为相似否则阈值搜索没有意义。4.3 相似度分数怎么用链路设计比单点阈值重要实践中文本相似度检测系统不会只输出一个分数就完事而是要把分数接入业务链路。比如在客服工单系统中通常设定一个主阈值和一个容差区间相似度大于 0.82 直接判定为重复工单在 0.65 到 0.82 之间标记为“疑似重复”进入人工确认队列低于 0.65 认为是新工单。这个双阈值设计能显著减少误判带来的业务损失。另一个常见做法是 top-k 召回不设硬阈值而是对每一条新文本从存量库中取相似度最高的 k 条返回由后续规则或人工决定最终是否命中。这种方式在查重系统中更常见。我见过很多系统只追求模型指标而忽略链路设计上线后效果远不如预期。根本原因是模型输出的是“相似程度”而非“是否重复”后者是业务判断结果必须结合业务容忍度去定策略。5. 文本相似度检测系统踩坑实录五个高频问题与排查路径5.1 训练 loss 正常下降但验证 accuracy 不涨现象训练集上的损失每次迭代都在降模型看起来在学东西但验证集的准确率始终在 55% 左右晃荡几乎没有上升趋势。原因这是典型的过拟合信号到得太晚。更隐蔽的原因可能是负样本构造太简单模型只需要记住“有共同词就是相似”这种表层特征就能在训练集上刷低损失到了验证集遇到意思相近但字面不同的样本就失效。LCQMC 这类数据集里有一批“字面重叠但语义不同”的难负样本如果训练数据里没有加入这类样本模型会过度自信。解决首先检查训练集正负样本比例负样本要保证和正样本数量大致持平。其次把难度加上去——用 TF-IDF 选出与正样本关键词重合度高的文本作为负样本强迫模型关注语义而不是词面重叠。最后降低 batch size 到 32提高梯度噪声有时候反而能跳出局部最优。5.2 推理时结果与训练时差异巨大现象训练时模型在验证集上准确率 90%部署到推理脚本后发现同一对文本输出的相似度分数差了很多阈值判断结果完全变了。原因训练和推理时文本预处理逻辑不一致。最常见的两个坑训练时用了 jieba 分词推理时直接按字符切分或者训练时max_len64做了截断推理时却传入了完整的超长文本。Embedding 层接受的输入长度和 token 分布一旦发生变化语义向量表征就会有偏差。解决建立一条统一的预处理 pipeline把分词、截断、padding 封装成同一个函数训练脚本和推理脚本都调用它。同时写一个简单回归测试拿训练时的输入和推理时的输入做对比确保输出一致。这段逻辑看起来简单但几乎所有实际项目都遇到过。最典型的场景是换机器或换环境后jieba 词典版本不同导致同一句话切分结果不同进而模型输出不一致。5.3 模型对长文本失效短文本正常现象验证集上的样本大多在 20 到 50 个字符效果良好。但输入一段 200 字的商品评论时相似度分数几乎全部趋近于 0.5模型失去区分能力。原因训练数据里长文本比例太低模型没见过这么长的序列。BiLSTM 对长序列的遗忘问题本来就存在再加上max_len64的截断直接把长文本后半段全丢了。如果后半段恰好承载了核心语义结果自然崩坏。解决三种手段并行最好。一是数据层面把长文本按语义切分成多个片段每个片段单独编码后再做平均池化二是模型层面把 LSTM 层数降到 1hidden 维度升到 256缓解长序列下的梯度传播问题三是字符截断策略不要简单保留前 64 个字符改用“保头保尾、中间截断”的策略。很多情况保尾比保头更重要因为中文里结论性语句往往出现在句末。5.4 相似度分数整体偏高阈值怎么调都区分不开现象训练完成后正样本对相似度平均 0.85负样本对平均 0.72两个分布严重重叠。即便把阈值从 0.5 调到 0.8f1 分数还是很低。原因这是对比损失没有生效的典型症状。如果 margin 设得太大比如 1.0模型在训练初期发现所有负样本都难以拉开梯度主导方向是“加大距离”但正样本距离也被拉大最终导致两个类别的向量都挤在很小的角度范围内。另一个原因可能是 embedding 层没有用预训练向量初始化模型在随机空间里很难学到语义结构。解决先把 margin 降回 0.5 甚至 0.3看分布是否开始分离。同时检查负样本是否真的“够难”——如果负样本都是完全无关的句子模型很容易把相似度拉开到 0.9 以上但遇到稍难的负样本就失效。用随机负样本 困难负样本混合策略通常要让困难负样本占比达到 30% 才够。分布重叠严重的另一个快速排查手段是看训练集 loss 是否降到 0.1 以下如果还在 0.5 附近说明模型尚未收敛阈值调优没有意义。5.5 显存溢出或训练速度过慢现象训练到一半弹出CUDA out of memory或者每轮迭代耗时比预期长几十倍。原因显存溢出常是 batch size 设得过大或序列长度超过训练预期。训练速度过慢则可能因为num_layers3搭配双向 LSTM计算量成倍增长在 CPU 上尤其明显。还有容易忽略的是 padding 策略——如果 batch 内样本长度差距悬殊且没有使用 pack_padded_sequence大量无效计算跑在 padding 位置上。解决batch size 从 16 开始试num_layers2对于短文本已经是上限CPU 训练时 embedding_dim 降到 64。事后排查也不能省用torch.cuda.max_memory_allocated()查看峰值显存确认瓶颈在数据维度还是模型维度。如果用了变长序列一定要配合 pack_padded_sequence否则每个 batch 都要吃掉最大长度对应的显存浪费极其严重。6. 把模型封装成检测接口推理脚本、批量验证与发布前自检推理阶段和训练阶段是完全不同的工程问题。训练时你关心 loss 和 accuracy推理时关心延迟、吞吐和易用性。常见的做法是把模型封装成一个类加载权重后暴露predict(text_a, text_b)和predict_batch(text_pairs)两个方法。前者适合单次查询后者适合离线批量检测。封装时要把预处理也包进去确保调用方拿原始字符串就能得到结果不需要关心 token 化细节。class SimilarityDetector: 封装训练好的模型对外提供文本相似度检测能力。 所有预处理都在内部完成调用方只需传字符串。 def __init__(self, model_path, vocab_path, max_len64, devicecpu): # 加载词表和模型权重device控制跑CPU还是GPU self.vocab self._load_vocab(vocab_path) self.max_len max_len self.device device self.model SimilarityNet(vocab_sizelen(self.vocab)) self.model.load_state_dict(torch.load(model_path, map_locationdevice)) self.model.to(device) self.model.eval() def _load_vocab(self, vocab_path): vocab {} with open(vocab_path, r, encodingutf-8) as f: for idx, line in enumerate(f): vocab[line.strip()] idx return vocab def _text_to_ids(self, text): # 统一分词、截断、padding与训练时保持完全一致 tokens list(jieba.cut(text))[:self.max_len - 2] ids [self.vocab.get(t, 1) for t in tokens] # 1是UNK的id ids [0] * (self.max_len - len(ids)) return ids def predict(self, text_a, text_b): ids_a torch.tensor([self._text_to_ids(text_a)]) ids_b torch.tensor([self._text_to_ids(text_b)]) with torch.no_grad(): vec_a self.model(ids_a.to(self.device)) vec_b self.model(ids_b.to(self.device)) return cosine_similarity(vec_a, vec_b).item() def predict_batch(self, pairs, batch_size64): outputs [] for i in range(0, len(pairs), batch_size): batch pairs[i:i batch_size] ids_a torch.tensor([self._text_to_ids(p[0]) for p in batch]) ids_b torch.tensor([self._text_to_ids(p[1]) for p in batch]) with torch.no_grad(): vec_a self.model(ids_a.to(self.device)) vec_b self.model(ids_b.to(self.device)) outputs.extend(cosine_similarity(vec_a, vec_b).tolist()) return outputs封装有两个必须注意的点。第一model.eval()必须调用否则 dropout 层在推理时仍然生效导致同一对文本每次预测结果都有微小抖动这在线上是不可接受的。第二_text_to_ids里max_len、分词方式、padding 策略必须和训练时完全一致建议在发布前用 10 条训练集样本跑一遍比对脚本确认推理输出和训练验证时输出的误差在1e-6以下。另外batch_size用于批量检测时控制显存占用GPU 上 64 是个安全值CPU 上可以放到 128。发布前自检时我会准备三类测试用例第一类是典型的正样本对比如“怎么退款”和“退款流程是什么”期望输出在 0.7 以上第二类是典型的负样本对比如“怎么退款”和“今天天气怎么样”期望输出在 0.3 以下第三类是边界样本比如“苹果发布了新手机”和“苹果发布了新平板”这类样本答案不绝对但输出不应该超过 0.8。如果第三类样本的分数比第一类还高说明模型对相似概念和相同实体的区分能力不足上线前需要补充该类样本继续训练。最后一个实务建议是输出格式不要只给一个 float。实际业务方需要知道这个分数是否可信我通常会让predict额外返回“相似度 是否超过阈值 阈值来源版本”三个信息。这样当业务方质疑某个判断不准确时可以快速定位到底是阈值策略问题还是模型问题。这个习惯帮我解决过无数次排查纠纷——相似度检测系统上线后绝大多数争议不是模型不对而是阈值和场景不匹配。希望这篇笔记能帮你在动手前把数据、模型、阈值、封装四条链路想清楚真正把“设计”做到可落地的程度。希望帮到你。本文还有配套的精品资源点击获取