mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2582 字
7 分钟
Sentence-BERT:语义表示的里程碑
2025-02-03

2019 年,Reimers 和 Gurevych 发表了论文《Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks》,解决了 BERT 在语义相似度任务上的一个根本性问题:直接用 BERT 计算两个句子的相似度,需要将它们拼接后送入模型,这在大规模检索场景下计算代价极高(O(n²) 次前向传播)。Sentence-BERT 通过孪生网络(Siamese Network)架构,为每个句子独立生成固定维度的语义向量,使语义搜索和聚类变得高效可行。

Sentence-BERT 是语义表示学习的里程碑,它启发了 SimCSE、E5、BGE 等一系列现代嵌入模型,至今仍是向量检索系统的技术基础。

本文要点#

  • BERT 句子表示的困境:CLS 与平均池化的局限
  • 孪生网络(Siamese Network)架构设计
  • 三种训练目标:分类、回归和三元组损失
  • SNLI 和 MultiNLI 数据集上的训练策略
  • 语义搜索与 FAISS 向量检索
  • sentence-transformers 库的使用方法
  • 从 SimCSE 到 E5、BGE、GTE 的演进历程
  • 在 STS 基准上的性能对比

一、BERT 的句子表示困境#

1.1 直接使用 BERT 的问题#

BERT 虽然在 NLU 任务上表现卓越,但直接用于获取句子表示时存在两个核心问题:

问题一:计算代价过高

计算两个句子的语义相似度时,标准做法是将两个句子拼接后输入 BERT:

[CLS] 句子A [SEP] 句子B [SEP]

要找到与查询最相似的句子,需要对候选集中每个句子都做一次完整的前向传播。对于 10,000 个候选句子,这意味着 10,000 次前向传播——在大规模检索场景下完全不可行。

问题二:表示质量不佳

即使单独提取 BERT 的句子表示(CLS Token 或平均池化),其语义质量也不理想:

# BERT 直接获取句子表示的两种方式
from transformers import BertModel, BertTokenizer
model = BertModel.from_pretrained('bert-base-uncased')
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
inputs = tokenizer("The cat sits on the mat", return_tensors="pt")
outputs = model(**inputs)
# 方式 1:CLS Token
cls_embedding = outputs.last_hidden_state[:, 0, :] # [1, 768]
# 方式 2:平均池化
mean_embedding = outputs.last_hidden_state.mean(dim=1) # [1, 768]

研究表明,BERT 的 CLS 表示和平均池化表示在语义相似度任务上的表现甚至不如 GloVe 词向量的简单平均。

1.2 根本原因分析#

graph TD A["BERT 预训练目标"] --> B["掩码语言模型(MLM)"] A --> C["下一句预测(NSP)"] B --> D["训练 Token 级别表示<br/>非句子级别"] C --> E["NSP 任务过于简单<br/>学不到高质量句子表示"] D --> F["句子表示质量差"] E --> F F --> G["句子相似度甚至<br/>不如 GloVe 平均"] style F fill:#F44336,color:#fff style G fill:#FFCDD2

BERT 的预训练目标是 Token 级别的 MLM,并非针对句子级别的语义表示优化。NSP(Next Sentence Prediction)虽然是句子级别的任务,但过于简单(只是判断两个句子是否相邻),无法学到有意义的语义表示。

二、孪生网络架构#

Sentence-BERT 的核心创新在于使用孪生网络(Siamese Network)架构来微调 BERT,使其能够生成高质量的句子嵌入。

2.1 架构设计#

graph LR subgraph "Sentence-BERT 孪生网络" SA["句子 A"] --> BERT_A["BERT<br/>(共享权重)"] SB["句子 B"] --> BERT_B["BERT<br/>(共享权重)"] BERT_A --> Pool_A["池化层<br/>(Mean/CLS)"] BERT_B --> Pool_B["池化层<br/>(Mean/CLS)"] Pool_A --> Emb_A["嵌入向量 u"] Pool_B --> Emb_B["嵌入向量 v"] Emb_A --> Merge["合并策略"] Emb_B --> Merge Merge --> Output["输出"] end style BERT_A fill:#1976D2,color:#fff style BERT_B fill:#1976D2,color:#fff

两个句子分别通过共享权重的 BERT 模型和池化层,得到固定维度的嵌入向量 u 和 v。由于权重共享,语义相似的句子会被映射到向量空间中相近的位置。

2.2 池化策略#

Sentence-BERT 支持三种池化策略:

  1. CLS 池化:直接取 CLS Token 的输出
  2. 平均池化(MEAN):对所有 Token 的输出取平均(默认推荐)
  3. 最大池化(MAX):对所有 Token 的输出取最大值

实验表明,平均池化在大多数任务上表现最佳。

2.3 合并策略#

根据不同任务,u 和 v 的合并方式不同:

# 分类任务的合并方式
combined = torch.cat([u, v, |u - v|], dim=1) # |u-v| 是逐元素绝对差
# 回归任务的相似度计算
similarity = cosine_similarity(u, v) # 或点积
# 三元组损失的距离计算
distance = euclidean_distance(u, v)

三、三种训练目标#

Sentence-BERT 根据不同任务设计了三种训练目标:

3.1 分类目标(Classification Objective)#

用于 NLI(自然语言推理)等分类任务:

# 分类目标
# 输入:句子对 (A, B) + 标签(蕴含/矛盾/中立)
# 合并向量:[u, v, |u-v|]
# 输出:3 类分类
combined = torch.cat([u, v, torch.abs(u - v)], dim=1) # [batch, 3*dim]
logits = classifier(combined) # [batch, 3]
loss = CrossEntropyLoss(logits, labels)

3.2 回归目标(Regression Objective)#

用于 STS(语义文本相似度)等回归任务:

# 回归目标
# 输入:句子对 (A, B) + 相似度分数(0-5)
# 计算 cosine similarity
sim = cosine_similarity(u, v) # [-1, 1]
# 缩放到 [0, 5] 范围
pred = sim * 2.5 + 2.5
loss = MSELoss(pred, gold_score)

3.3 三元组损失(Triplet Objective)#

用于学习相对相似度排序:

# 三元组损失
# 输入:锚点句 a, 正样本 p, 负样本 n
# 目标:使 a 与 p 的距离小于 a 与 n 的距离 + margin
dist_pos = euclidean_distance(emb_a, emb_p)
dist_neg = euclidean_distance(emb_a, emb_n)
loss = max(0, dist_pos - dist_neg + margin)
graph LR A["锚点句 (Anchor)<br/>一只猫坐在垫子上"] --> |"距离近"| P["正样本 (Positive)<br/>小猫趴在地毯上"] A --> |"距离远"| N["负样本 (Negative)<br/>今天天气很好"] P --> |"margin"| Margin["margin > 0"] N --> Margin style P fill:#4CAF50,color:#fff style N fill:#F44336,color:#fff style A fill:#1976D2,color:#fff

四、训练数据与流程#

Sentence-BERT 主要使用 SNLI 和 MultiNLI 数据集进行训练:

数据集句子对数标签类型来源
SNLI570K蕴含/矛盾/中立图像描述
MultiNLI433K蕴含/矛盾/中立多领域文本
STS Benchmark8,6280-5 相似度分数新闻标题

4.1 训练流程#

flowchart TD A["加载预训练 BERT"] --> B["构建孪生网络"] B --> C["选择训练目标"] C --> D["分类目标(SNLI/MultiNLI)"] C --> E["回归目标(STS Benchmark)"] C --> F["三元组损失(WikiAnswers)"] D --> G["微调 BERT"] E --> G F --> G G --> H["评估:STS Benchmark"] H --> I["最优模型"] style G fill:#FF9800,color:#fff style I fill:#4CAF50,color:#fff

关键发现:在 SNLI + MultiNLI 上用分类目标训练的模型,在 STS 任务上表现最好——即使训练数据不是相似度标注的,孪生网络的对比学习也能学到高质量的语义表示。

五、语义搜索与 FAISS#

Sentence-BERT 最广泛的应用场景是语义搜索。通过预计算所有文档的嵌入向量,检索时只需计算查询向量与候选向量的余弦相似度。

5.1 基本流程#

from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
# 1. 加载模型
model = SentenceTransformer('all-MiniLM-L6-v2')
# 2. 编码文档库
documents = [
"Python is a popular programming language",
"Machine learning models require large datasets",
"The weather is sunny today",
# ... 数百万条文档
]
doc_embeddings = model.encode(documents) # [N, 384]
# 3. 构建 FAISS 索引
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatIP(dimension) # 内积索引
faiss.normalize_L2(doc_embeddings) # L2 归一化(使内积 = cosine sim)
index.add(doc_embeddings.astype('float32'))
# 4. 搜索
query = "What programming languages are popular?"
query_embedding = model.encode([query])
faiss.normalize_L2(query_embedding)
scores, indices = index.search(query_embedding.astype('float32'), k=5)
# 5. 返回结果
for i, (score, idx) in enumerate(zip(scores[0], indices[0])):
print(f"Rank {i+1}: {documents[idx]} (score: {score:.4f})")

5.2 FAISS 索引选择#

索引类型适合规模搜索精度内存占用
IndexFlatIP<100K精确搜索
IndexIVFFlat100K-10M近似(可调)
IndexIVFPQ>10M近似(可调)
IndexHNSW任意高精度

六、语义表示的演进#

Sentence-BERT 开创了句子嵌入的学习范式,此后出现了多个重要的后续工作:

timeline title 语义表示模型演进 2019 : Sentence-BERT : 孪生网络 + NLI 对比学习 2020 : SBERT-WK : Word Knowledge 增强表示 2021 : SimCSE : 无监督 Dropout 增强<br/>有监督 NLI 对 2022 : GTR / E5 : 预训练 + 精调两阶段<br/>多任务统一 2023 : BGE : 指令感知嵌入<br/>多语言支持 2023 : GTE : 多阶段对比学习<br/>长文本支持 2024 : E5-Mistral : LLM 作为嵌入模型

6.1 SimCSE:Dropout 增强#

SimCSE(Simple Contrastive Learning of Sentence Embeddings)是一个巧妙的改进:

  • 无监督版本:同一个句子通过两次不同的 Dropout 路径,得到两个”正样本”
  • 有监督版本:使用 NLI 数据的蕴含句作为正样本,矛盾句作为困难负样本
# SimCSE 无监督训练的核心思想
# 同一个句子通过两次不同的 Dropout
sentence = "A cat sits on the mat"
emb1 = model(sentence, dropout_mask_1) # 正样本对 1
emb2 = model(sentence, dropout_mask_2) # 正样本对 2
# 两个嵌入应该相似(正样本对)

6.2 E5:预训练 + 精调两阶段#

E5(EmbEddings from bidirectional Encoder rEpresentations)引入了两阶段训练:

  1. 预训练阶段:使用大规模弱监督文本对(Reddit 问答、StackExchange 等)
  2. 精调阶段:使用高质量标注数据(NLI、STS 等)

6.3 BGE:指令感知嵌入#

BGE(BAAI General Embedding)的关键创新是指令感知(Instruction-aware)嵌入:

# BGE 的查询格式
query_instruction = "Represent this sentence for searching relevant passages: "
query_embedding = model.encode(query_instruction + query)
doc_embedding = model.encode(document) # 文档不需要指令前缀
# 这样同一个查询可以有不同指令,适配不同任务

6.4 现代 LLM 作为嵌入模型#

最新的趋势是直接使用 LLM(如 Mistral-7B)作为嵌入模型,通过在最后几层添加池化得到句子表示。E5-Mistral 和 NV-Embed 是这一方向的代表工作。

七、性能对比#

7.1 STS Benchmark 上的表现#

模型STS12STS13STS14STS15STS16STS-BSICK-R平均
BERT CLS29.042.041.057.058.047.043.045.3
BERT MEAN31.044.045.059.060.049.045.047.6
SBERT (NLI)64.072.071.076.074.078.073.072.6
SBERT (NLI+STS)67.074.073.079.076.081.076.075.1
SimCSE (unsup)68.076.075.080.077.082.077.076.4
E5-base72.080.078.084.080.085.079.079.7
BGE-base73.081.079.085.081.086.080.080.7

可以看到,从 BERT 直接使用到 Sentence-BERT 有约 25 分的巨大提升,而后续的 SimCSE、E5、BGE 又进一步提升了约 5-8 分。

常见问题 FAQ#

7.1 Q1: 为什么 BERT 的句子表示质量不如 GloVe 平均?#

BERT 的预训练目标是 Token 级别的 MLM,不是句子级别的语义表示。各层的表示呈现出”各向异性”(anisotropic),即大部分向量集中在向量空间的一个狭窄区域,导致即使语义不同的句子也被映射到相近的位置。Sentence-BERT 通过对比学习缓解了这个问题。

7.2 Q2: sentence-transformers 库如何选择模型?#

选择取决于场景:

  • 通用语义搜索all-MiniLM-L6-v2(快速)或 all-mpnet-base-v2(精度更高)
  • 多语言paraphrase-multilingual-MiniLM-L12-v2
  • 中文shibing624/text2vec-base-chinese 或 BGE 系列的中文模型
  • 最新最优:BAAI/bge-large-en-v1.5 或 intfloat/e5-large-v2

7.3 Q3: 三元组损失中的 margin 如何选择?#

margin 通常设为 1.0。太小的 margin 会导致正负样本距离不够分开,太大的 margin 会导致训练困难(模型难以满足约束)。在实际应用中,1.0 是一个经过验证的良好默认值。

7.4 Q4: FAISS 搜索和暴力搜索的精度差距大吗?#

对于 IndexFlatIP(暴力搜索),精度是 100%。对于近似索引(IVFFlat、IVFPQ、HNSW),精度取决于参数设置。通常 nprobe=10-20 时,recall@10 可以达到 95% 以上,同时搜索速度快 10-100 倍。

7.5 Q5: Sentence-BERT 还适用于现代 LLM 时代吗?#

Sentence-BERT 的范式(预训练到对比学习微调到向量检索)仍然是向量检索系统的基础。现代的改进主要是用更强的预训练模型(如 RoBERTa、DeBERTa 甚至 Mistral-7B)替换 BERT backbone,训练方法的核心思想没有根本变化。

7.6 Q6: 如何处理长文档的语义搜索?#

Sentence-BERT 默认最大长度为 128-256 Token。对于长文档,常见策略有:

  1. 分段编码:将文档分成段落,分别编码后取平均或最大池化
  2. 层次化方法:先编码段落,再用 Cross-Encoder 精排
  3. 长文本模型:使用支持 8K+ Token 的嵌入模型(如 jina-embeddings-v2)

小结#

Sentence-BERT 的核心贡献可以总结为:

  1. 孪生网络架构:解决了 BERT 句子对计算 O(n²) 的效率问题
  2. 三种训练目标:分类、回归、三元组损失,覆盖了不同类型的语义任务
  3. NLI 数据集的有效利用:证明分类任务也能训练出高质量句子嵌入
  4. sentence-transformers 生态:降低了语义搜索的技术门槛

从 Sentence-BERT 到 SimCSE、E5、BGE、GTE,语义表示的质量持续提升,但核心范式——对比学习 + 预训练微调——始终未变。Sentence-BERT 的思想已经深入到现代 RAG 系统、搜索引擎、推荐系统的底层架构中。

对于想要构建语义搜索系统的读者,建议:

  1. 使用 sentence-transformers 快速搭建原型
  2. 根据数据特点选择合适的预训练模型
  3. 在领域数据上进行微调以获得最佳效果
  4. 使用 FAISS 等向量数据库实现高效检索

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Sentence-BERT:语义表示的里程碑
https://blog.souloss.com/posts/machine-learning/llm-paper-history/sentencebert-semantic-representation/
作者
Souloss
发布于
2025-02-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时