ARTICLE DETAIL

资讯详情

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

BERT句向量生成实战:池化策略、微调与语义检索避坑指南

BERT句向量生成实战:池化策略、微调与语义检索避坑指南 简介这份资源面向自然语言处理初学者与需要快速落地文本向量化的开发者围绕如何调用BERT预训练模型生成句向量与词向量展开帮助解决文本语义表示与下游任务特征提取的问题。压缩包共3个文件均为Python脚本整体约4KB其中配置脚本负责指定模型权重与词汇表路径向量提取脚本分别实现句子级与词级向量生成另含模型结构相关代码便于按模块理解与二次修改。资源已有1127人学习下载说明其在入门实践中具有一定参考价值。读者可借此掌握以[CLS]标记提取整句语义、按位置获取单词上下文向量的具体做法并将所得向量用于文本分类、情感分析、相似度计算或问答系统等场景同时理解PyTorch或TensorFlow环境下transformers库的调用方式为后续研究预训练模型提供可复用的脚本基础。1. 用 BERT 预训练模型生成句向量为什么你跑出来的向量“没那味儿”很多团队第一次把 BERT 接进检索或聚类流程时都会经历同一个场景模型加载成功、forward不报错、向量也拿到了但拿去做相似度匹配结果和关键词匹配差不多甚至更差。问题往往不在 BERT 本身而在于“用哪一层输出、怎么池化、要不要微调”这三件事没定清楚。这个标题讲的就是把预训练 BERT 变成可用的句向量或词向量词向量取隐层状态句向量靠池化策略或 Sentence-BERT 这类结构最终服务于语义检索、去重、聚类、召回粗排。适合已经会调 HuggingFacetransformers、但向量质量一直上不去的工程师也适合想从 TF-IDF 迁移到语义向量的搜索团队。2. 词向量与句向量BERT 的哪一层输出才是你要的2.1 词向量不是查表而是上下文相关的隐状态Word2Vec、GloVe 这类静态词向量一个词只有一个向量“苹果”在“吃苹果”和“苹果发布会”里完全一样。BERT 的输出是每个 token 在每个位置上的隐状态同一个词在不同句子里向量不同这就是常说的 contextual embedding。取词向量的常见做法是拿last_hidden_state再按 tokenizer 的offset_mapping把 subword 合并回原词。注意 BERT 用的是 WordPiece一个中文词可能被切成多个 token直接取第一个 subword 会丢信息常见做法是对同一词的所有 subword 取平均。from transformers import BertTokenizer, BertModel import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertModel.from_pretrained(bert-base-chinese) model.eval() text 苹果发布了新款手机 inputs tokenizer(text, return_tensorspt, return_offsets_mappingTrue) offsets inputs.pop(offset_mapping)[0] # 每个 token 对应原文的字符区间 with torch.no_grad(): outputs model(**inputs) hidden outputs.last_hidden_state[0] # [seq_len, hidden_size] # 按字符区间把 subword 合并成词向量 word_vecs {} for idx, (start, end) in enumerate(offsets.tolist()): if start end: # 特殊符号 [CLS] [SEP] 跳过 continue word text[start:end] word_vecs.setdefault(word, []).append(hidden[idx]) word_vecs {w: torch.stack(v).mean(0) for w, v in word_vecs.items()} print(word_vecs[苹果].shape) # torch.Size([768])逻辑说明offset_mapping是 tokenizer 给出的字符级映射用它把 subword 还原成原词是最稳的方式。参数上return_offsets_mappingTrue必须开否则拿不到区间model.eval()和torch.no_grad()一起用避免 dropout 干扰和显存浪费。bert-base-chinese的 hidden_size 是 768如果你的下游任务对维度敏感可以在池化后再接一层线性降维。2.2 句向量的四种池化策略与选型句向量不是 BERT 原生输出的东西得自己“造”。常见四种做法策略做法适用场景注意点CLS 池化取[CLS]位置向量分类微调后的模型原生 BERT 的 CLS 未针对相似度训练效果一般平均池化对所有 token 向量取均值通用、稳定要排除 padding否则被 0 拉偏最大池化逐维取最大值关键词敏感任务对噪声敏感加权池化按 IDF 或 attention 权重加权检索场景实现复杂收益不稳定我一般先用平均池化做基线再决定要不要上 Sentence-BERT。原生 BERT 的句向量在语义相似度任务上表现平平这是有论文验证过的未经对比学习的 BERT句向量甚至不如 GloVe 平均。所以如果你的场景是语义检索直接上text2vec-base-chinese或paraphrase-multilingual-MiniLM这类已经用对比学习训过的模型比在原生 BERT 上折腾池化划算得多。def mean_pooling(hidden, attention_mask): # hidden: [batch, seq_len, dim] mask attention_mask.unsqueeze(-1).float() # [batch, seq_len, 1] summed (hidden * mask).sum(dim1) # 屏蔽 padding counts mask.sum(dim1).clamp(min1e-9) return summed / counts inputs tokenizer([今天天气不错, 今天阳光很好], return_tensorspt, paddingTrue, truncationTrue, max_length128) with torch.no_grad(): out model(**inputs) emb mean_pooling(out.last_hidden_state, inputs[attention_mask]) # 归一化后再算余弦相似度 emb torch.nn.functional.normalize(emb, p2, dim1) print((emb[0] emb[1]).item())参数说明paddingTrue配合attention_mask是必须的否则 batch 内短句会被 padding 污染。max_length128对多数中文句子够用长文档要截断或分段。归一化后余弦相似度退化成点积省一次计算检索时常用。2.3 微调与否决定向量质量的分水岭如果你的语料是法律、医疗、工单这类垂直领域原生 BERT 的向量空间和你的业务语义有偏差。这时候两条路一是用 Sentence-BERT 的对比学习框架在自己的句对上微调二是用text2vec这类已经在大规模中文语料上训过的模型直接推理。前者成本高但上限高后者上手快。判断标准很简单拿 100 对业务句对做人工标注算一下原生模型的相似度排序和人工排序的 Spearman 相关系数低于 0.5 就考虑微调。3. 从零跑通句向量生成环境、代码与批量推理3.1 环境准备与模型选择pip install transformers torch sentence-transformers # 国内下载慢可以设镜像 export HF_ENDPOINThttps://hf-mirror.com模型选择上中文场景我一般按这个顺序试shibing624/text2vec-base-chinese轻量、快、BAAI/bge-base-zh-v1.5检索强、bert-base-chinese只做基线对比。sentence-transformers库封装了池化和归一化直接model.encode()就能出句向量比自己手写池化省事。from sentence_transformers import SentenceTransformer model SentenceTransformer(shibing624/text2vec-base-chinese) sentences [如何重置密码, 忘记密码怎么办, 今天天气如何] emb model.encode(sentences, normalize_embeddingsTrue, batch_size32) print(emb.shape) # (3, 768)normalize_embeddingsTrue让输出直接是单位向量后续算余弦相似度就是点积。batch_size根据显存调768 维模型在 8G 显存上跑 64 没问题。3.2 批量推理与显存控制生产环境里句子是流式来的不能一次全加载。常见做法是分块 DataLoader同时用torch.cuda.amp做半精度推理。from torch.utils.data import DataLoader def encode_in_batches(model, texts, batch_size64, max_length128): model.eval() all_emb [] for i in range(0, len(texts), batch_size): batch texts[i:ibatch_size] with torch.no_grad(), torch.cuda.amp.autocast(): emb model.encode(batch, normalize_embeddingsTrue, batch_sizelen(batch), show_progress_barFalse) all_emb.append(emb) import numpy as np return np.vstack(all_emb)逻辑说明autocast在支持 Tensor Core 的卡上能提速 30% 以上精度损失对检索任务可忽略。np.vstack把分块结果拼回矩阵方便后续写 FAISS 或 Milvus。注意model.encode内部已经做了 tokenize 和池化不要再手动传attention_mask。3.3 向量落库与相似度检索拿到向量后小规模万级直接用 numpy 算余弦大规模上 FAISS。import faiss import numpy as np dim 768 index faiss.IndexFlatIP(dim) # 内积索引配合归一化向量等价余弦 index.add(emb.astype(float32)) query model.encode([密码忘了], normalize_embeddingsTrue).astype(float32) scores, ids index.search(query, k3) print(ids, scores)参数说明IndexFlatIP是精确检索数据量超过百万考虑IndexIVFFlat并调nlist。归一化向量用内积索引省去每次算模长。如果向量没归一化得用IndexFlatL2并做距离到相似度的转换。4. 避坑与排查句向量质量翻车的五个血泪现场4.1 现象相似句得分 0.99不相似句也 0.95原因用了原生bert-base-chinese的 CLS 向量没做对比学习向量空间各向异性严重所有向量挤在一个锥形区域里余弦相似度普遍偏高。解决换text2vec或bge这类对比学习模型或者自己用MultipleNegativesRankingLoss微调。判断方法随机抽 100 对无关句子算相似度均值超过 0.7 就是各向异性问题。4.2 现象短句和长句相似度算出来偏低原因平均池化时没排除 padding或者长句被max_length截断丢了关键信息。解决确认attention_mask传对了长文档按 256 或 512 分段后分别编码再聚合。我一般对长文本用“分段编码 平均”而不是直接截断召回率能差 10 个点。4.3 现象GPU 显存爆了batch_size 调到 1 还是 OOM原因max_length设太大或者 tokenizer 没开truncation遇到超长文本直接撑爆。解决tokenizer(..., truncationTrue, max_length128)必须加paddingmax_length比paddingTrue更耗显存动态 padding 更省。另外检查是不是在no_grad外面跑了推理梯度图会占大量显存。4.4 现象同一句话两次编码结果不一样原因模型没设eval()dropout 还在生效。解决推理前model.eval()并用torch.no_grad()包住。如果是sentence-transformersmodel.encode内部已经处理但自己手写 forward 时容易忘。4.5 现象中文词向量里“的”“了”这类停用词向量模长特别大原因BERT 的隐状态里高频词的向量范数普遍偏大直接算相似度会被这些词主导。解决池化前对每个 token 向量做 L2 归一化或者用 IDF 加权。检索场景里我一般对 query 和 doc 都做归一化再算相似度能压掉一部分高频词干扰。5. 进阶技巧用对比学习把句向量调到业务可用原生 BERT 的句向量在垂直领域不够用这是共识。真正让向量“有那味儿”的一步是用业务句对做对比学习微调。核心思路是让语义相近的句子在向量空间里靠近不相关的推远。sentence-transformers提供了MultipleNegativesRankingLoss训练时一个 batch 内其他样本自动当负例不需要人工构造负样本。from sentence_transformers import SentenceTransformer, InputExample, losses from torch.utils.data import DataLoader model SentenceTransformer(shibing624/text2vec-base-chinese) # 每条是 (query, 正例) 句对负例由 batch 内其他样本充当 train_examples [ InputExample(texts[如何重置密码, 密码忘了怎么找回]), InputExample(texts[订单怎么取消, 我想退掉刚下的单]), # ... 至少几千条业务句对 ] train_dataloader DataLoader(train_examples, shuffleTrue, batch_size32) train_loss losses.MultipleNegativesRankingLoss(model) model.fit( train_objectives[(train_dataloader, train_loss)], epochs3, warmup_steps100, output_path./finetuned-bert-vec, optimizer_params{lr: 2e-5}, )参数说明batch_size32意味着每个 query 有 31 个负例batch 越大负例越多效果通常越好但显存也涨。lr2e-5是 BERT 微调的经典值太大容易灾难性遗忘。epochs3对几千条数据够用多了会过拟合。训练完用model.encode直接出向量和之前接口一致。验证微调有没有效果别只看 loss。我一般做两件事一是拿 200 对人工标注的句对算微调前后相似度排序和人工排序的 Spearman 相关系数二是拿业务 query 跑一次召回看 Top-10 里相关文档的占比。相关系数提升 0.1 以上、召回率提升 5 个点以上这次微调就值了。如果没提升先检查句对质量——很多团队栽在“正例其实不相似”上标注规范比模型选择重要得多。一个我踩过的坑微调时用了太多“字面相似但语义不同”的句对比如“苹果手机”和“苹果价格”模型学出来还是字面匹配。后来改成按业务意图标注同一意图下的不同说法才算正例向量质量才真正上来。这个方向值不值得投入取决于你的业务有没有稳定的句对来源如果没有先用现成的bge或text2vec顶着别硬训。希望帮到你。本文还有配套的精品资源点击获取
返回列表