简介:基于深度学习文本相似度检测系统设计,面向毕业设计、人工智能学习者与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 上单条推理就要几百毫秒。第三是孪生网络,两条共享权重的编码器把两个句子各自编码成向量,再用对比损失拉近同类、推开异类,这正是本项目采用的方案。
选型本质上是在效果、成本和可维护性之间做权衡。如果业务规模不大,几千条 FAQ,BM25 足够;如果对精度要求极高、算力也充足,选预训练模型;如果你的场景是百万级语料、需要考虑线上 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_len |
| preprocess.py | 文本清洗与分词 | 去 HTML、去链接、jieba 分词 |
| dataset.py | 数据管道 | 动态 padding、mask 生成 |
| model.py | 孪生网络定义 | 双向 LSTM + 注意力池化 |
| train.py | 训练入口 | 对比损失、梯度裁剪、保存 checkpoint |
| evaluate.py | 模型评估 | Spearman、阈值 F1、AUC |
| api_server.py | 部署服务 | FastAPI 接口 |
数据流方向是:原始语料从 config 指定的路径进入 preprocess,清洗后得到 token 列表;dataset 把每对句子转成 id 序列和 mask;model 接收 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_k=5): 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_dim | 200 | 词向量维度,小语料用 128,大语料用 300 |
| hidden_dim | 256 | 单向 LSTM 隐层维度,双向后特征维度翻倍 |
| num_layers | 2 | 层数,超过 3 层在中小语料上容易过拟合 |
| dropout | 0.3 | 最后一层 dropout,过拟合严重时加大到 0.5 |
| margin | 1.0 | 对比损失边界,控制负样本拉开的最小距离 |
| max_len | 64 | 最大 token 数,按语料长度分布动态调整 |
| batch_size | 128 | CPU 上可降到 32,GPU 上可提到 256 |
| lr | 3e-4 | AdamW 初始学习率,预训练微调用 1e-5 |
margin 这个参数很多人不理解。对比损失里负样本对的距离至少要超过 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", encoding="utf-8") as f: STOPWORDS = set(f.read().split()) def clean_text(text: str) -> str: text = re.sub(r"<[^>]+>", "", text) # 去掉 HTML 标签 text = re.sub(r"https?://\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 是 padding,1 是 unk,2 是句首,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_freq=2 适合中等规模语料,如果语料只有几千条,建议调成 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_dim=200, hidden_dim=256, num_layers=2, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True, bidirectional=True, dropout=dropout) 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, dim=1).unsqueeze(-1) # [B, L, 1] pooled = torch.sum(out * weight, dim=1) # [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_dim=200 是词向量维度,hidden_dim=256 是单向隐层维度,双向后特征维度为 512。num_layers=2 可以捕获更高层语义组合,但层数继续增加到 4 在中小语料上会明显过拟合,验证集 Spearman 不升反降。dropout 放在向量输出之前,推理阶段自动关闭。
3.4 训练循环:对比损失、梯度裁剪与早停
对比损失的基本思想是:标签为 1 的句对,欧氏距离越小越好;标签为 0 的句对,距离至少要大于 margin。公式分两项,同类的 loss 是距离平方,异类的 loss 是 max(0, margin - dist) 的平方。两个输入共享同一个 encoder,所以模型参数不会加倍。
def contrastive_loss(a_vec, b_vec, label, margin=1.0): dist = torch.norm(a_vec - b_vec, dim=1) # 欧氏距离 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, margin=1.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, margin=margin) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止 LSTM 训练后期梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=3.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, dim=1) 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, device="cpu"): vocab = json.load(open(vocab_path, encoding="utf-8")) model = SiameseNet(len(vocab)) state = torch.load(ckpt_path, map_location=device) 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_len=64, device="cpu"): 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], dtype=torch.long).to(device) a_mask = torch.tensor([a_mask], dtype=torch.long).to(device) b_ids = torch.tensor([b_ids], dtype=torch.long).to(device) b_mask = torch.tensor([b_mask], dtype=torch.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, dim=1).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_version=13, 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 全部置 1,padding 位置也参加了注意力池化,模型被迫把零向量当成有效信息。解决:把 encode_text 抽成公共函数,训练和推理从同一个模块导入,禁止两套逻辑并存。
5.3 正负样本不平衡导致准确率虚高
现象:训练集正负样本比例接近 1:9,验证集准确率 0.93,抽样看预测结果时发现模型几乎把所有句对判成负样本。原因:负样本主导了损失,模型发现“全部输出大距离”就能拿到很低的总损失,根本不需要区分语义差异。解决:采样时把训练集正负比例控制在 1:1.2 到 1:1.5 之间;验证时以 Spearman 和 AUC 为主,准确率只作参考。
5.4 长文本截断,关键信息落在窗口外
现象:一条客服语料是“订单超过 24 小时未支付,系统会自动关闭,如何申请退款”,模型却和“如何关闭订单”打出高相似度。原因:max_len=64 按字数估的,分词后这条语料 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_size=128, shuffle=True, num_workers=4, worker_init_fn=seed_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 都看得过去,分布一偏就翻车。阈值校准、回归验证这些习惯,比模型结构本身更能决定项目能不能交付。希望帮到你。
本文还有配套的精品资源,点击获取