拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于BERT的句子相似度计算:从加载到微调的完整指南

基于BERT的句子相似度计算:从加载到微调的完整指南 简介这份资源面向自然语言处理初学者与需要快速验证文本相似度方案的开发者围绕如何用 PyTorch 与 Transformers 直接加载 BERT 预训练模型完成句子相似度计算展开。包内共 4 个文件包含 1 个 py 脚本、1 个 bin 模型权重、1 个 json 配置和 1 个 txt 词表压缩包约 365.84MB其中脚本负责加载模型与计算余弦相似度权重与配置支撑中文 BERT 模型直接推理词表用于文本分词编码。资源选用 chinese-bert-wwm-ext 中文预训练模型可帮助读者理解如何将两个句子编码为向量并度量语义接近程度适用于问答匹配、文档检索等场景。目前已有 1641 人学习下载适合希望跳过繁琐训练、直接上手 BERT 相似度计算的读者参考。1. 用 torch transformers 直接加载 BERT 算句子相似度一条最少依赖的落地路径线上要判断两句话是不是一个意思很多人第一反应是调个云端接口但真到了内网、离线、批量几万条的场景接口这条路就走不通了。这时候最省事的做法是用torch加transformers直接把 BERT 预训练模型拉起来在本地算句子相似度。它不需要你从头训练也不需要标注数据加载权重就能跑特别适合做语义去重、FAQ 匹配、检索召回粗排这类活。我见过不少团队一上来就上双塔、上向量库结果发现连最基本的单句相似度都没跑通参数也没调对最后绕了一大圈。这篇就按「先跑通、再调对、再避坑」的顺序把这条路径讲清楚新手能照着复现熟手能对着参数和边界做取舍。2. 先搞清楚 BERT 算相似度到底在算什么2.1 为什么不是直接拿两个句向量做余弦很多人以为 BERT 算相似度就是「把两句话各编码成一个向量然后算余弦」这个理解只对了一半。原生 BERT 的[CLS]向量是在预训练阶段为下一句预测任务服务的它并没有被专门训练成「语义相似度」的度量空间。也就是说你直接拿两个[CLS]向量算余弦结果往往不稳定语义相近的句子可能分数不高语义无关的句子反而偶尔冒高分。这不是模型坏了而是它没被这样用过。常见做法有两种。第一种是句对输入把两句话拼成[CLS] A [SEP] B [SEP]送进模型取[CLS]输出接一个分类头输出 0 到 1 的相似概率。这种方式精度高但每条句对都要过一次模型批量大时慢。第二种是双塔各自编码取[CLS]或对 token 向量做 mean pooling再算余弦。这种方式可以预先算好句向量检索时只做向量比对快但精度依赖 pooling 策略和是否做过微调。选哪种取决于你的场景。离线批量比对、句对数量在几千以内用句对输入更稳要做实时检索、候选上万双塔加向量索引更合适。我一般会先用句对输入把效果基线跑出来再决定要不要换双塔。2.2 加载模型前必须确认的三件事在写代码之前有三件事没确认后面一定翻车。第一模型权重从哪来。transformers默认会去在线仓库拉内网环境必须提前把权重目录准备好用本地路径加载。第二分词器和模型必须配套。你用一个中文 BERT 的分词器去配一个英文 BERT 的权重token 对不上输出全是噪声。第三torch和transformers的版本要能对上。老版本transformers配新torch或者反过来经常在加载时直接报错。下面这段是最小加载示例先确认环境能跑通import torch from transformers import BertTokenizer, BertModel # 本地权重目录内含 config.json、pytorch_model.bin、vocab.txt model_dir ./bert-base-chinese # 分词器和模型必须来自同一个目录 tokenizer BertTokenizer.from_pretrained(model_dir) model BertModel.from_pretrained(model_dir) # 推理模式关掉 dropout model.eval() text 今天天气不错 inputs tokenizer(text, return_tensorspt, paddingTrue, truncationTrue, max_length128) with torch.no_grad(): outputs model(**inputs) # last_hidden_state 形状为 [batch, seq_len, hidden] cls_vector outputs.last_hidden_state[:, 0, :] print(cls_vector.shape)这段代码的逻辑是分词器把文本转成input_ids和attention_mask模型输出每个 token 的隐状态取第 0 位就是[CLS]。参数上max_length128对短句足够长文本要按实际长度调但注意 BERT 上限是 512。paddingTrue在单句时无所谓批量时必须开否则张量形状对不齐。torch.no_grad()一定要加否则显存会随着推理不断累积。跑通这一步说明环境和权重都没问题。接下来才是相似度怎么算。3. 两种相似度计算路径句对分类和双塔向量3.1 句对输入精度优先的最小实现句对输入的核心是把两句话拼成一个序列让模型在编码时就能看到两边的交互信息。transformers的分词器支持直接传两段文本它会自动插入[SEP]。import torch import torch.nn.functional as F from transformers import BertTokenizer, BertForSequenceClassification model_dir ./bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_dir) # 用带分类头的模型num_labels2 对应不相似/相似 model BertForSequenceClassification.from_pretrained(model_dir, num_labels2) model.eval() def pair_similarity(sent_a, sent_b): inputs tokenizer( sent_a, sent_b, return_tensorspt, paddingTrue, truncationTrue, max_length256 ) with torch.no_grad(): logits model(**inputs).logits # softmax 转概率取相似类 probs F.softmax(logits, dim-1) return probs[0, 1].item() score pair_similarity(如何重置密码, 密码忘了怎么办) print(score)这里有个关键点BertForSequenceClassification加载的是预训练权重分类头是随机初始化的。也就是说如果你不做微调这个分数本身没有经过相似度训练输出接近随机。所以句对输入要真正可用必须用相似度标注数据微调分类头。没有标注数据时这条路只能作为结构验证不能直接上线。参数上max_length256是句对场景的常见值因为两句话拼起来会变长。num_labels2是二分类如果你用回归方式做相似度可以改成num_labels1输出一个分数但损失函数要相应换成 MSE。3.2 双塔向量批量场景的工程做法双塔的思路是每句话独立编码得到固定长度向量再用余弦或点积算相似度。这样句向量可以缓存检索时只做矩阵运算。import torch import torch.nn.functional as F from transformers import BertTokenizer, BertModel model_dir ./bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_dir) model BertModel.from_pretrained(model_dir) model.eval() def encode(texts, batch_size32): vectors [] for i in range(0, len(texts), batch_size): batch texts[i:i batch_size] inputs tokenizer( batch, return_tensorspt, paddingTrue, truncationTrue, max_length128 ) with torch.no_grad(): outputs model(**inputs) # mean pooling比直接取 CLS 更稳 mask inputs[attention_mask].unsqueeze(-1).float() summed (outputs.last_hidden_state * mask).sum(dim1) counts mask.sum(dim1).clamp(min1e-9) vectors.append(summed / counts) return torch.cat(vectors, dim0) def cosine_sim(vec_a, vec_b): return F.cosine_similarity(vec_a, vec_b, dim-1) texts [如何重置密码, 密码忘了怎么办, 今天天气不错] vecs encode(texts) print(cosine_sim(vecs[0:1], vecs[1:2]).item()) print(cosine_sim(vecs[0:1], vecs[2:3]).item())这段代码里mean pooling 比直接取[CLS]更稳因为它把整句的 token 信息都平均进来减少了[CLS]未针对相似度训练带来的偏差。attention_mask的作用是排除 padding 位置否则 padding 的向量会拉低均值。clamp(min1e-9)是防止某句全被截断后除零。参数上batch_size32在显存 8G 左右比较安全长文本要往下调。max_length128对短句够用超过 256 的文本建议先做截断或分段。双塔的缺点是两句话没有交互精度通常低于微调后的句对模型但胜在速度和可缓存。3.3 两种路径怎么选一张对比表维度句对输入双塔向量精度高有交互中依赖 pooling 和微调速度慢每对都要过模型快向量可预计算显存高序列长低单句编码是否需微调需要否则分数无意义可先跑基线微调后更好适用场景离线精排、句对量小检索召回、候选量大我一般会先用双塔跑一个基线看看语义相近的句子分数是否明显高于无关句。如果区分度不够再考虑用句对加微调。没有标注数据时双塔加 mean pooling 是性价比最高的起点。4. 避坑与排查加载和计算中最容易翻车的五件事4.1 现象加载模型时报「Connection error」或卡住原因from_pretrained默认会去在线仓库检查更新内网或断网环境直接失败。解决把权重目录下载好加载时传本地路径并且加local_files_onlyTrue。如果目录里缺config.json或vocab.txt也会报错检查文件是否齐全。model BertModel.from_pretrained(model_dir, local_files_onlyTrue)4.2 现象相似度分数全是 0.99 或全是一样原因句对分类头没微调softmax 输出接近均匀或塌缩或者双塔用了未训练的[CLS]向量区分度低。解决句对路径必须微调后再用双塔路径换成 mean pooling并检查分词器是否和模型配套。如果所有句子向量几乎相同多半是模型加载错了比如用了一个随机初始化的权重。4.3 现象显存越跑越高最后 OOM原因推理时没加torch.no_grad()计算图被保留或者 batch_size 太大序列太长。解决推理代码统一包在with torch.no_grad():里。批量时按显存调batch_size长文本先截断。如果还是不够可以开torch.cuda.empty_cache()但根本办法是减小 batch。4.4 现象中文句子被切成单字相似度不准原因用了英文 BERT 的分词器中文被按字符切分语义单元丢失。解决确认vocab.txt是中文 BERT 的分词器加载路径和模型一致。可以用tokenizer.tokenize(如何重置密码)看一眼输出正常应该是词或子词不是一堆单字。4.5 现象两条语义相同的句子分数却很低原因原生 BERT 的向量空间不是为相似度对齐的尤其是[CLS]。另外句子长度差异大时mean pooling 也会受影响。解决优先用句对加微调没有标注数据时可以尝试对句向量做白化或标准化再算余弦。另一个实用技巧是同时算两个方向的分数取平均减少位置偏差。5. 把相似度算准的一个进阶技巧用句对微调把双塔拉起来如果你手上有一批「相似/不相似」的句对哪怕只有几百条也可以把双塔的效果拉上一个台阶。做法是用句对分类模型做微调然后把编码器部分抽出来当双塔用。这样既保留了双塔的推理速度又让向量空间向相似度任务对齐。from transformers import BertTokenizer, BertForSequenceClassification import torch from torch.utils.data import DataLoader, Dataset class PairDataset(Dataset): def __init__(self, pairs, labels, tokenizer, max_len128): self.pairs pairs self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.pairs) def __getitem__(self, idx): a, b self.pairs[idx] enc self.tokenizer( a, b, paddingmax_length, truncationTrue, max_lengthself.max_len, return_tensorspt ) return { input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long) } tokenizer BertTokenizer.from_pretrained(./bert-base-chinese) model BertForSequenceClassification.from_pretrained(./bert-base-chinese, num_labels2) pairs [(如何重置密码, 密码忘了怎么办), (如何重置密码, 今天天气不错)] labels [1, 0] dataset PairDataset(pairs, labels, tokenizer) loader DataLoader(dataset, batch_size8, shuffleTrue) optimizer torch.optim.AdamW(model.parameters(), lr2e-5) model.train() for epoch in range(3): for batch in loader: outputs model(**batch) loss outputs.loss loss.backward() optimizer.step() optimizer.zero_grad() print(fepoch {epoch}, loss {loss.item():.4f})微调完成后取model.bert作为编码器按双塔方式编码句子再算余弦。这样得到的向量比原生[CLS]更贴近相似度任务。参数上lr2e-5是 BERT 微调的常见起点太大容易破坏预训练权重太小收敛慢。batch_size8在句对场景比较稳显存够可以加到 16。训练轮数 3 到 5 轮通常够再多容易过拟合。验证时准备一批没参与训练的句对看相似句和不相似句的分数分布是否拉开。如果重叠严重说明数据太少或学习率不对。我自己的习惯是每次微调后都固定跑一组「正例、负例、边界例」的分数记下来做对比避免凭感觉判断模型变好还是变坏。这套流程跑顺之后再换 RoBERTa 或其他中文预训练模型只需要改模型目录和分词器结构不用动。希望帮到你。本文还有配套的精品资源点击获取
返回列表