ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从BERT提取句向量:池化策略、批量推理与whitening避坑指南

从BERT提取句向量:池化策略、批量推理与whitening避坑指南

简介:这份资源围绕BERT预训练模型的实际调用展开,面向具备一定深度学习基础、希望将预训练模型落地到文本表示任务的学习者与开发者,解决句向量与词向量生成流程不清晰、配置与调用方式不明确的问题。压缩包共3个文件,均为Python脚本,整体约4KB,涵盖模型配置、Keras层实现以及句向量与词向量提取逻辑,结构轻量,便于快速阅读与二次修改。资源重点在于通过配置脚本指定预训练模型权重与词汇表路径,再借助提取脚本分别生成句子级和词级向量:句子向量可用于相似度计算与文本分类,词向量可服务于词性标注、词义消歧等任务。目前已有1127人学习下载,适合作为自然语言处理入门到进阶的实践参考,帮助读者理解BERT输出如何转化为可用的文本特征表示。

1. 从 BERT 里把向量抠出来:为什么你拿到的句向量总是不对味

很多人第一次用 BERT 生成句向量,代码跑通了,向量也出来了,但一拿去算相似度就发现不对劲——明明语义完全不同的两句话,余弦相似度却高得离谱。这不是玄学,是 BERT 的原始输出本来就不适合直接当句向量用。BERT 预训练模型输出的[CLS]向量和 token 级词向量,在未经微调或池化策略处理的情况下,各向异性严重,向量分布集中在一个狭窄的锥形区域里,导致任意两句话的相似度都偏高。这个标题要解决的核心问题就是:怎么从 BERT 预训练模型里,稳定、可复现地拿到有区分度的句向量和词向量。适合谁看?做语义检索、文本聚类、去重、RAG 召回、相似度匹配的工程师,以及需要把 BERT 模型实操落地的 NLP 方向从业者。下面从选型、代码、参数、避坑一路讲透。

2. 选对模型和池化策略:句向量质量的分水岭

2.1 BERT 原始输出为什么不能直接当句向量

BERT 的预训练目标决定了它的输出特性。它做的是掩码语言模型和下一句预测,训练过程中并没有显式优化「整句话的向量表示应该彼此远离」。结果就是每个 token 的输出向量都偏向同一个方向,[CLS]向量也不例外。你拿[CLS]直接算余弦相似度,会发现所有句对的分数都挤在 0.8 到 0.99 之间,区分度极低。

常见做法有三种应对策略。第一种是换用 Sentence-BERT 架构的模型,比如shibing624/text2vec-base-chinese或BAAI/bge-base-zh-v1.5,这些模型在 BERT 基础上做了对比学习微调,句向量质量直接可用。第二种是继续用原始 BERT,但改用 mean pooling 或 last-token pooling 替代[CLS]。第三种是加一层 whitening 变换,把向量分布「白化」后再用。

我一般会先看场景:如果只是做实验验证,用原始 BERT + mean pooling 就够了;如果要上生产做检索,直接换 Sentence-BERT 系列模型,省掉大量调参时间。RoBERTa 中文预训练模型在同样任务上通常比 BERT 略好,因为训练数据更多、训练更充分,但差距没有想象中大,选哪个更多取决于你的领域数据匹配度。

2.2 用 transformers 加载 BERT 并生成词向量的最小代码

先跑通词向量提取,这是理解句向量生成的基础。以下代码用 HuggingFace transformers 加载bert-base-chinese,提取每个 token 的向量。

from transformers import BertTokenizer, BertModel import torch # 加载预训练模型和分词器 model_name = "bert-base-chinese" tokenizer = BertTokenizer.from_pretrained(model_name) model = BertModel.from_pretrained(model_name) model.eval() # 切换到推理模式,关闭 dropout # 输入文本 text = "自然语言处理很有趣" inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=128) # 前向传播,不计算梯度 with torch.no_grad(): outputs = model(**inputs) # last_hidden_state 形状: (batch_size, seq_len, hidden_size) token_embeddings = outputs.last_hidden_state # 去掉 [CLS] 和 [SEP],取实际 token 的向量 input_ids = inputs["input_ids"][0] tokens = tokenizer.convert_ids_to_tokens(input_ids) print("token 列表:", tokens) print("词向量维度:", token_embeddings.shape)

逻辑说明:last_hidden_state是 BERT 最后一层的隐藏状态,每个 token 对应一个 768 维向量(base 模型)。model.eval()必须加,否则 dropout 会让每次推理结果不一致。torch.no_grad()减少显存占用并加速推理。

参数说明:max_length=128对短文本足够,长文本建议 256 或 512,但注意 BERT 最大位置编码是 512。padding=True在批量推理时对齐长度,单条推理可以不加。truncation=True防止超长输入报错。

2.3 三种池化策略的对比与选择

拿到 token 级向量后,需要聚合成一个句向量。三种主流策略:

策略做法优点缺点
CLS pooling取[CLS]位置向量简单,部分微调模型效果好原始 BERT 上区分度差
Mean pooling对所有 token 向量取平均稳定,通用性好受 padding 影响需 mask
Max pooling对每个维度取最大值保留显著特征对噪声敏感

Mean pooling 是最常用的 baseline。关键细节是要用 attention_mask 排除 padding token,否则短句会被 padding 拉低质量。

import torch.nn.functional as F def mean_pooling(token_embeddings, attention_mask): # token_embeddings: (batch, seq_len, hidden) # attention_mask: (batch, seq_len) input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() # 只对真实 token 求和,再除以真实 token 数量 sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, dim=1) sum_mask = torch.clamp(input_mask_expanded.sum(dim=1), min=1e-9) return sum_embeddings / sum_mask with torch.no_grad(): outputs = model(**inputs) sentence_embedding = mean_pooling(outputs.last_hidden_state, inputs["attention_mask"]) # 归一化后可直接算余弦相似度 sentence_embedding = F.normalize(sentence_embedding, p=2, dim=1) print("句向量维度:", sentence_embedding.shape)

这段代码的核心在attention_mask的扩展和clamp防零除。min=1e-9是防止全 padding 的极端情况。归一化后余弦相似度等价于点积,检索时可以直接用 FAISS 做内积搜索。

3. 批量生成与向量落盘:从单条到万级语料的工程化

3.1 批量推理的显存控制与 DataLoader 写法

单条推理跑通后,下一步是处理几千到几十万条语料。直接 for 循环逐条跑太慢,批量推理要注意显存。常见做法是用 PyTorch DataLoader 配合动态 batch,或者按 token 总数控制 batch size。

from torch.utils.data import DataLoader, Dataset class TextDataset(Dataset): def __init__(self, texts, tokenizer, max_length=128): self.texts = texts self.tokenizer = tokenizer self.max_length = max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): return self.texts[idx] def collate_fn(batch_texts): return tokenizer( batch_texts, return_tensors="pt", padding=True, truncation=True, max_length=128 ) # 假设 texts 是十万条文本列表 dataset = TextDataset(texts, tokenizer) loader = DataLoader(dataset, batch_size=64, collate_fn=collate_fn, shuffle=False) all_embeddings = [] model.eval() with torch.no_grad(): for batch in loader: batch = {k: v.to("cuda") for k, v in batch.items()} outputs = model(**batch) emb = mean_pooling(outputs.last_hidden_state, batch["attention_mask"]) emb = F.normalize(emb, p=2, dim=1) all_embeddings.append(emb.cpu()) all_embeddings = torch.cat(all_embeddings, dim=0) print("全部向量形状:", all_embeddings.shape)

逻辑说明:collate_fn负责把一批文本转成模型输入,padding=True保证同批内对齐。shuffle=False是为了保持向量顺序和原始文本一一对应,方便后续检索映射回原文。

参数说明:batch_size=64在 8GB 显存上跑 base 模型、max_length=128 基本安全。如果显存不够,优先降 batch_size 而不是 max_length,因为截断会丢信息。用torch.cuda.amp.autocast()可以进一步省显存,但要注意半精度可能带来微小数值差异。

3.2 向量存成 FAISS 索引并验证召回

向量算完要落盘才能用。小规模直接存 numpy,大规模上 FAISS。

import faiss import numpy as np # 转成 float32 numpy emb_np = all_embeddings.numpy().astype("float32") dim = emb_np.shape[1] # 构建内积索引(向量已归一化,内积等价余弦) index = faiss.IndexFlatIP(dim) index.add(emb_np) print("索引中向量数:", index.ntotal) # 查询示例 query = "自然语言处理" query_inputs = tokenizer(query, return_tensors="pt", padding=True, truncation=True, max_length=128) with torch.no_grad(): query_out = model(**query_inputs) query_emb = mean_pooling(query_out.last_hidden_state, query_inputs["attention_mask"]) query_emb = F.normalize(query_emb, p=2, dim=1).numpy().astype("float32") scores, indices = index.search(query_emb, k=5) for score, idx in zip(scores[0], indices[0]): print(f"相似度: {score:.4f} | 文本: {texts[idx]}")

逻辑说明:IndexFlatIP做精确内积搜索,适合百万级以下。超过百万考虑IndexIVFFlat或 HNSW。归一化后内积就是余弦相似度,分数范围 -1 到 1。

参数说明:k=5是返回 top5。如果召回结果明显不相关,先检查向量是否归一化、模型是否 eval 模式、padding 是否被正确 mask。这三个是最高频的翻车点。

3.3 用 Sentence-BERT 替代原始 BERT 的迁移成本

如果发现原始 BERT + mean pooling 效果不达预期,换 Sentence-BERT 系列是最省事的路径。代码改动极小:

from sentence_transformers import SentenceTransformer # 换模型即可,接口更简洁 sbert = SentenceTransformer("BAAI/bge-base-zh-v1.5") embeddings = sbert.encode(texts, batch_size=64, normalize_embeddings=True) print("句向量形状:", embeddings.shape)

normalize_embeddings=True直接输出归一化向量,省掉手动处理。batch_size同样根据显存调。迁移成本主要在模型下载和推理速度上,bge-base 比 bert-base 略慢,但句向量质量提升明显,尤其在检索任务上。如果领域垂直度高,还可以在自有数据上做对比学习微调,但那是另一个话题了。

4. 避坑与排查:句向量生成中最容易翻车的五个点

4.1 相似度全都偏高,区分度差

现象:任意两句话余弦相似度都在 0.85 以上,排序几乎随机。

原因:原始 BERT 的[CLS]向量各向异性,或者 mean pooling 时没排除 padding。

解决:改用 mean pooling + attention_mask,或直接换 Sentence-BERT 模型。如果坚持用原始 BERT,加 whitening 变换:对向量矩阵做 PCA,除以标准差后再降维。

4.2 批量推理结果和单条推理不一致

现象:同一条文本,单独跑和放在 batch 里跑,向量不一样。

原因:model.eval()没加,dropout 在作祟;或者 padding 策略不同导致 attention_mask 变化。

解决:推理前必须model.eval(),并用torch.no_grad()。批量推理时确保 padding 和 mask 逻辑与单条一致。

4.3 长文本截断后语义丢失

现象:超过 512 token 的文本,向量只反映了前 512 个 token 的语义。

原因:BERT 位置编码上限 512,truncation=True直接截断。

解决:长文本先分段,每段生成向量后再聚合(平均或加权)。或者换用支持长文本的模型如 Longformer。常见做法是滑动窗口切分,窗口间重叠 64 个 token,最后对窗口向量取平均。

4.4 中文分词和 tokenizer 不匹配

现象:某些中文词被拆成奇怪的子词,词向量语义不完整。

原因:bert-base-chinese的 tokenizer 是按字切分的,不是按词。RoBERTa 中文预训练模型的 tokenizer 对中文处理略有不同。

解决:这是 BERT 中文模型的固有特性,不是 bug。如果对词级粒度要求高,考虑用词向量模型如 word2vec 或腾讯词向量做补充。句向量层面影响不大,因为 mean pooling 会平滑掉。

4.5 FAISS 索引召回结果与预期不符

现象:明明库里有相似文本,FAISS 却召回不出来。

原因:向量没归一化就用了IndexFlatIP,或者归一化后用了 L2 索引。

解决:归一化向量配内积索引,未归一化配 L2 索引。两者混用是高频错误。另外检查index.ntotal是否等于文本数量,确认没有漏加。

5. 进阶技巧:用 whitening 让原始 BERT 的句向量起死回生

如果你手头只有原始 BERT 预训练模型,不想换模型,whitening 是最值得试的补救方案。原理是对 BERT 输出的向量矩阵做白化变换,让各维度去相关、方差归一,从而缓解各向异性。操作步骤:先用一批语料(几万条即可)生成 mean pooling 向量,计算均值和协方差矩阵,做特征值分解得到变换矩阵,之后所有向量都乘这个矩阵。

import numpy as np def compute_whitening_params(embeddings): # embeddings: (N, dim) numpy array mu = embeddings.mean(axis=0, keepdims=True) cov = np.cov(embeddings, rowvar=False) # 特征值分解 u, s, vh = np.linalg.svd(cov) # 变换矩阵:去相关 + 方差归一 W = np.dot(u, np.diag(1.0 / np.sqrt(s + 1e-6))) return mu, W def apply_whitening(embeddings, mu, W): return np.dot(embeddings - mu, W) # 用训练集语料算参数 train_embs = all_embeddings.numpy() mu, W = compute_whitening_params(train_embs) # 应用到所有向量 whitened = apply_whitening(train_embs, mu, W) # 再归一化 whitened = whitened / np.linalg.norm(whitened, axis=1, keepdims=True)

关键参数:1e-6是防止特征值为零导致除零。mu和W必须用同一批语料计算并保存,推理时复用,不能每次重新算。whitening 后相似度分布会明显拉开,原来挤在 0.9 以上的分数会散开到 0.3 到 0.9 之间,区分度提升肉眼可见。

验证方法:准备 100 对标注好「相似/不相似」的句对,分别用原始向量和 whitening 后向量算相似度,看 AUC 或准确率变化。我自己的血泪经验是,whitening 在短文本上提升明显,长文本上提升有限,因为长文本本身语义更分散。另外 whitening 参数对语料领域敏感,跨领域迁移时最好重新算一遍。这个方案不值得做的情况也有:如果你已经能用 Sentence-BERT,就别折腾 whitening 了,直接换模型更省心。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进