Embedding模型演进:从Encoder-only到LLM-based的技术变革
深度拆解Transformer-based Embedding模型的两代演进:Encoder-only时代、LLM-based崛起
引言:为什么需要文本Embedding?
在深入技术演进之前,先理解一个核心问题:为什么需要把文本转换为向量?
现实场景
想象你是一个招聘平台的工程师,需要实现"找到与岗位需求最匹配的简历":
岗位需求:"招聘5年Java后端工程师,熟悉Spring Boot和微服务架构"
简历库:10万份简历
挑战:如何快速找到最相关的简历?
传统方法的困境
| 方法 | 问题 |
|---|---|
| 关键词匹配 | "Java"无法匹配"Java开发","微服务"无法匹配"分布式系统" |
| TF-IDF | 无法理解语义,"银行工作"和"金融经验"无法关联 |
| Word2Vec | 词级别向量,无法表达句子/段落的整体语义 |
Embedding模型的解决方案
岗位需求 → [0.23, 0.56, -0.12, ..., 0.89] (向量)
↓ 计算相似度
简历A → [0.25, 0.54, -0.10, ..., 0.87] (余弦相似度 = 0.92)
简历B → [0.01, 0.89, 0.67, ..., -0.23] (余弦相似度 = 0.31)
结果:简历A更匹配
核心能力
- ✅ 语义理解:"Java后端"≈"Server端开发"
- ✅ 上下文感知:"苹果公司" ≠ "苹果水果"
- ✅ 高效检索:向量相似度计算(毫秒级)
第一代:Encoder-only时代(2018-2021)
1.1 奠基者:BERT(2018)
背景
2018年,Google发布BERT(Bidirectional Encoder Representations from Transformers),开启了Transformer-based Embedding的时代。
架构特点
输入文本: "我是一名Java工程师"
┌──────────────────────┐
│ [CLS] Token │ 特殊标记
└──────────────────────┘
↓
┌──────────────────────┐
│ Transformer Encoder │
│ (双向注意力) │
│ × 12层 (BERT-base) │
└──────────────────────┘
↓
每个Token都有向量表示
[CLS]: [0.23, 0.56, ...] ← 用于分类任务
"我": [0.12, 0.45, ...]
"是": [0.34, 0.67, ...]
...
核心设计
| 特性 | 说明 | 影响 |
|---|---|---|
| 双向注意力 | 每个词可以看到前后所有词 | 理解上下文能力强 |
| [CLS] Token | 句首特殊token,用于分类 | 传统做法:用[CLS]表示整句 |
| 预训练任务 | MLM(遮盖词预测)+ NSP(下句预测) | 学到丰富的语言知识 |
问题:BERT不适合做Embedding?
BERT最初是为分类任务设计的,用于Embedding有三个问题:
-
[CLS] Token不够优
# BERT原生用法 sentence_vector = bert_output['last_hidden_state'][:, 0, :] # 取[CLS] # 问题:[CLS]只在NSP任务中训练,不是为句子相似度设计 -
无法直接计算句子相似度
# 比较两个句子需要拼接后再过BERT input = "[CLS] 句子A [SEP] 句子B [SEP]" similarity = bert(input) # 慢!每对都要过一次 -
推理效率低
比较10万份简历 vs 1个查询: - BERT:需要过BERT模型 10万次 - 预计算向量:只需10万次向量相似度计算(快1000倍)
1.2 突破者:Sentence-BERT(2019)
核心创新:Siamese网络 + Mean Pooling
句子A: "Java工程师" 句子B: "Python开发"
↓ ↓
┌──────────────┐ ┌──────────────┐
│ BERT Encoder │ │ BERT Encoder │ 共享权重
└──────────────┘ └──────────────┘
↓ ↓
Mean Pooling (平均所有token) Mean Pooling
↓ ↓
[0.23, 0.56, ...] [0.21, 0.58, ...]
↓ ↓
└────────── 计算相似度 ──────────┘
Cosine(A, B)
↓
0.95 (相似)
训练策略:对比学习
# 训练数据:(句子A,句子B,标签)
正例: ("Java工程师", "后端开发", 1.0) # 语义相似
负例: ("Java工程师", "前端设计", 0.2) # 语义不相似
# 损失函数:让相似句子的向量距离近,不相似的远
loss = triplet_loss(anchor, positive, negative)
效果提升
| 指标 | BERT (CLS) | Sentence-BERT | 提升 |
|---|---|---|---|
| STS-B相关性 | 0.73 | 0.85 | +16% |
| 推理速度 (10万对) | 65小时 | 5秒 | 46800倍 |
为什么更快?关键在于"预计算 + 重用"
场景:1个查询 vs 10万份简历
┌─────────────────────────────────────────┐
│ BERT原生方法(每次都要过BERT) │
├─────────────────────────────────────────┤
│ 用户查询:"招聘Java工程师" │
│ ↓ │
│ for 每份简历 in 10万份: │
│ input = "[CLS]查询[SEP]简历[SEP]" │
│ score = BERT(input) # 100ms/次 │
│ │
│ 总耗时:10万 × 100ms = 10,000秒 │
│ = 2.78小时 ❌ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Sentence-BERT(预计算 + 快速匹配) │
├─────────────────────────────────────────┤
│ [离线阶段 - 只需做一次] │
│ for 每份简历 in 10万份: │
│ resume_vec = BERT(简历) # 预计算 │
│ 存储到向量数据库 │
│ │
│ [在线阶段 - 用户查询时] │
│ 用户查询:"招聘Java工程师" │
│ ↓ │
│ query_vec = BERT(查询) # 100ms │
│ ↓ │
│ for 每个简历向量 in 10万个: │
│ score = cosine(query_vec, resume_vec) │
│ # 0.05ms/次(向量点积) │
│ │
│ 在线耗时:100ms + (10万 × 0.05ms) │
│ = 100ms + 5秒 = 5.1秒 ✅ │
└─────────────────────────────────────────┘
速度提升:10,000秒 / 5.1秒 = 1960倍!
核心理解
❓ 疑问:"Sentence-BERT对比A和B,不也需要先把A和B都转成向量吗?这不是2次BERT调用,比BERT的1次更慢吗?"
✅ 答案:是的!如果只比较一对句子(A vs B),Sentence-BERT确实更慢。
但实际场景是:
| 场景 | BERT | Sentence-BERT | 谁更快? |
|---|---|---|---|
| 一次性比较(A vs B) | 1次调用 | 2次调用 | BERT更快 |
| 一对多比较(1查询 vs 10万文档) | 10万次调用 | 预计算10万次 + 在线1次 | Sentence-BERT快2000倍 |
关键点:
- 预计算可重用:10万份简历的向量只需计算一次(离线阶段)
- 向量比较极快:余弦相似度是简单点积,比BERT推理快10000倍
- 真实场景都是一对多:搜索引擎、推荐系统、简历匹配,都是1个查询匹配大量文档
1.3 工业化:BGE/E5/GTE系列(2023)
2023年,智源研究院、微软等机构发布了新一代Encoder模型(BGE、E5、GTE),在Sentence-BERT基础上进行了工程化优化。核心改进包括:
主要改进
- 数据规模:从百万级扩展到亿级训练样本
- 硬负例挖掘:使用排名靠前但不相关的文档作为负样本,提升区分能力
- 指令优化:支持任务前缀(如"为这个查询生成向量:"),增强任务适应性
- 多语言支持:针对中英文场景深度优化
关键点:架构未变,仍是BERT + Mean Pooling,提升主要来自训练数据和方法
| 模型 | 参数量 | C-MTEB分数 | 发布时间 |
|---|---|---|---|
| Sentence-BERT | 110M | 62.3 | 2019 |
| BGE-large-zh-v1.5 | 326M | 69.8 | 2023 |
第二代:LLM-based Embedding崛起(2024-至今)
3.1 突破:Instruction-tuned LLM Embedding
代表模型
| 模型 | 基座 | 参数量 | 发布时间 | 特点 |
|---|---|---|---|---|
| E5-Mistral-7B | Mistral-7B | 7B | 2024.01 | 首个基于LLM的Embedding |
| BGE-M3 | XLM-RoBERTa | 568M | 2024.02 | 多语言、多粒度 |
| NV-Embed-v1 | Mistral-7B | 7B | 2024.05 | MTEB榜首 |
| Jina-v2 | Jina-v1 | 137M | 2024.06 | 长文本(8K) |
| Voyage-2 | 未公开 | - | 2024.08 | 商业模型,效果顶级 |
3.2 架构演进:从Encoder到Decoder
架构对比
┌────────────────────────────────────────────────────────┐
│ Encoder-only (BERT, BGE) │
├────────────────────────────────────────────────────────┤
│ 输入: "Java工程师" │
│ ↓ │
│ Transformer Encoder (双向注意力) │
│ - 每个Token可以看到所有Token │
│ - 12-24层 │
│ ↓ │
│ Mean Pooling → 句子向量 │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ Decoder-only (GPT, LLaMA-based Embedding) │
├────────────────────────────────────────────────────────┤
│ 输入: "Instruct: 将以下句子转为向量\nQuery: Java工程师" │
│ ↓ │
│ Transformer Decoder (单向/因果注意力) │
│ - 每个Token只能看到前面的Token │
│ - 32-80层 (更深!) │
│ ↓ │
│ 取最后Token / Mean Pooling → 句子向量 │
└────────────────────────────────────────────────────────┘
关键差异:不只是注意力方向!
很多人认为Decoder和Encoder只有"单向vs双向注意力"的区别,但实际上差异是全方位的:
| 维度 | Encoder (BERT) | Decoder (LLM-based) | 影响 |
|---|---|---|---|
| 注意力机制 | 双向(每个token能看到全部) | 单向/因果(只能看前文) | Encoder更适合理解,Decoder需补偿 |
| 预训练目标 | MLM(遮盖词预测) | 自回归(预测下一个词) | 决定了训练难度和能力边界 |
| 语义理解深度 | 较好(~67分) | 更强(~70分) | 核心差异:数据、参数、训练共同作用 |
| 位置编码 | 绝对位置(Learned) | RoPE相对位置 | Decoder更容易扩展到长文本 |
详细解析:核心差异拆解
差异1:注意力机制
# Encoder:双向注意力(Bidirectional)
输入: "Java 工程师 在 公司"
处理"工程师"时:
可以看到: [Java] [工程师] [在] [公司] # 前后都能看
注意力权重: [0.4, 0.0, 0.2, 0.1]
# Decoder:单向注意力(Causal/Unidirectional)
输入: "Java 工程师 在 公司"
处理"工程师"时:
只能看到: [Java] [工程师] # 只能看前面
注意力权重: [0.6, 0.0, 0.0, 0.0] # 后面都被mask掉
差异2:语义理解深度(核心优势)
这是Decoder做好Embedding的根本原因。Decoder通过更多参数、更多数据、更难的训练目标,获得了更深的语义理解能力。
三大支柱:参数、数据、训练难度
| 维度 | Encoder (BERT) | Decoder (LLM) | 差距 | 影响 |
|---|---|---|---|---|
| 参数量 | 110M-340M | 7B-70B | 20-200倍 | 更强的语义记忆和表达能力 |
| 预训练数据 | 16GB | 1-2TB | 100-1000倍 | 见过更多语言模式和知识 |
| 训练Tokens | 30亿 | 1-2万亿 | 300倍 | 更充分的学习 |
| 模型深度 | 12-24层 | 32-80层 | 2-3倍 | 更深层的抽象能力 |
为什么这些差异能转化为更好的Embedding?
1. 更多参数 → 更强记忆力
- 7B参数可以记住复杂的语义关系
- 例如:"银行柜员" ≈ "金融服务人员" ≈ "储蓄业务办理"
- Encoder参数少,无法记住这么细粒度的语义等价关系
2. 更多数据 → 更多语言模式
- 见过1TB的互联网文本:网页、对话、代码、论文...
- 自然学会"Java工程师" ≈ "后端开发" ≈ "Server端程序员"
- Encoder只见过16GB维基百科,覆盖面有限
3. 自回归训练 → 深度理解强制
- 预测"我是一名___"的下一个词需要深度理解上下文
- 不能靠简单的词频统计,必须真正理解语义
- MLM填空可以靠局部线索,要求较低
4. 指令理解(副产品)
- TB级数据包含大量"指令-回答"模式
- 自然学会区分指令和内容
- 可以零样本理解:"为这个查询生成向量:Java工程师"
实际效果对比
| 能力 | Encoder (BGE) | Decoder (E5-Mistral) | 提升来源 |
|---|---|---|---|
| 语义相似度 | 67.8分 | 71.2分 | 参数+数据+训练 |
| 零样本适应 | 需微调 | 直接可用 | 见过更多模式 |
| 复杂语境理解 | 一般 | 更强 | 更深的网络 |
| 长文本支持 | 512 tokens | 4K-8K tokens | RoPE位置编码 |
关键洞察
Decoder做好Embedding的核心不是"能生成文本",而是:
✓ 见过100倍的数据 → 知识更丰富
✓ 20-200倍的参数 → 记忆力更强
✓ 更难的训练目标 → 理解更深入
✓ 副产品:指令理解、长文本支持
生成能力只是自回归训练的副作用,
真正有价值的是训练过程学到的深层语义理解。
技术实现细节
除了上述核心差异,还有一些技术层面的差异:
- 输出选择:Encoder通常用Mean Pooling(平均所有token),Decoder用最后一个token(因为单向注意力,只有它看到了全文)
- 位置编码:Decoder多用RoPE相对位置编码,更容易扩展到长文本;Encoder多用绝对位置编码,固定最大长度(512 tokens)
3.3 核心技术:如何让Decoder做好Embedding?
关键问题:训练方式是什么?
❌ 常见误解
"在预训练LLM上加几层,然后做SFT(监督微调)和强化学习"
这是错误的!LLM-based Embedding的训练完全不同于LLM的对话微调。
✅ 实际训练流程
Step 1: 加载预训练LLM
↓
Mistral-7B (已预训练好的基座模型)
Step 2: 架构微调(可选)
↓
- 修改最后几层的注意力机制(因果 → 双向)
- 不增加新层!
Step 3: 对比学习训练
↓
- 使用(query, positive, negative)三元组
- 损失函数:InfoNCE(不是交叉熵,不是PPO)
- 目标:让相似文本向量距离近,不相似的远
Step 4: 指令微调(可选)
↓
- 仍然用对比学习损失
- 只是加上指令前缀
- 不涉及强化学习!
与LLM对话微调的对比
| 维度 | LLM对话微调 | LLM-based Embedding |
|---|---|---|
| 训练目标 | 生成正确回答 | 生成语义向量 |
| 损失函数 | 交叉熵(预测下一个token) | 对比学习(拉近拉远向量) |
| 训练数据 | (指令, 回答)对 | (query, doc+, doc-)三元组 |
| 是否生成文本 | 是 | 否(只取向量) |
| 是否用RL | 常用(RLHF, PPO) | 几乎不用 |
| 是否加层 | 可能加LoRA | 一般不加 |
技术1:架构微调(修改注意力)
问题:Decoder的因果注意力(只看前文)不利于理解全文语义
解决方案:修改最后几层为双向注意力
# E5-Mistral的架构修改(不是加层,是改层)
class ModifiedMistral(nn.Module):
def __init__(self, pretrained_mistral):
# 加载预训练模型(32层)
self.layers = pretrained_mistral.layers # 保持32层不变
def forward(self, input_ids):
x = input_ids
# 前28层:保持因果注意力(保留LLM能力)
for i in range(28):
x = self.layers[i](x, causal_mask=True)
# 后4层:改为双向注意力(增强Embedding)
for i in range(28, 32):
x = self.layers[i](x, causal_mask=False) # 移除因果mask
return x
# 关键:层数不变(32层),只是改变最后4层的mask
效果
| 配置 | STS-B | Retrieval | 说明 |
|---|---|---|---|
| 全因果注意力 | 0.78 | 0.52 | 基线 |
| 后4层双向 | 0.84 | 0.61 | 推荐 |
| 全双向 | 0.82 | 0.58 | 丢失LLM能力 |
技术2:对比学习训练(核心!)
训练数据格式
# 训练样本:三元组
{
"query": "招聘5年Java工程师",
"positive": "我有6年Java开发经验,熟悉Spring Boot...", # 相关文档
"negatives": [ # 不相关文档(通常8-32个)
"Python全栈工程师,3年经验...",
"前端开发,精通React和Vue...",
"产品经理,5年工作经验..."
]
}
训练过程(对比学习)
# 编码
query_emb = model.encode(batch['query']) # shape: (B, D)
pos_emb = model.encode(batch['positive']) # shape: (B, D)
neg_embs = model.encode(batch['negatives']) # shape: (B, N, D)
# InfoNCE损失(不是交叉熵!)
def contrastive_loss(query, positive, negatives, temperature=0.05):
# 计算相似度
pos_sim = cosine_similarity(query, positive) / temperature # (B,)
neg_sims = cosine_similarity(query, negatives) / temperature # (B, N)
# 分子:exp(正例相似度)
numerator = torch.exp(pos_sim)
# 分母:exp(正例) + sum(exp(负例))
denominator = numerator + torch.sum(torch.exp(neg_sims), dim=1)
# 损失:-log(正例概率)
loss = -torch.log(numerator / denominator)
return loss.mean()
# 反向传播更新模型
loss = contrastive_loss(query_emb, pos_emb, neg_embs)
loss.backward()
optimizer.step()
关键点
- ✅ 使用对比学习损失(InfoNCE)
- ✅ 不需要生成文本
- ✅ 不需要标注"正确答案"
- ❌ 不使用强化学习(不需要PPO、RLHF)
- ❌ 不使用交叉熵损失(那是生成任务用的)
技术3:指令微调(Instruction Tuning)
目的:让模型理解不同任务的指令
数据构造
# 加入指令前缀
{
"instruction": "为这个查询生成检索向量",
"query": "招聘5年Java工程师",
"positive": "我有6年Java开发经验...",
"negatives": [...]
}
# 构造输入
query_prompt = f"Instruct: {instruction}\nQuery: {query}"
doc_prompt = positive # 文档不加指令
# 训练(仍然用对比学习损失!)
query_emb = model.encode(query_prompt)
pos_emb = model.encode(doc_prompt)
neg_embs = model.encode(negatives)
loss = contrastive_loss(query_emb, pos_emb, neg_embs) # 同样的损失
注意
- 指令微调≠SFT(监督微调)
- 这里的"指令"只是输入的一部分
- 损失函数仍是对比学习,不是生成损失
指令类型
| 指令 | 用途 | 示例 |
|---|---|---|
| 检索查询 | 用户搜索 | "为这个搜索查询生成向量" |
| 文档表示 | 被检索文档 | "为这个文档生成向量" |
| 相似性判断 | 判断两个文本是否相似 | "判断以下两段文本的相似度" |
| 分类 | 文本分类 | "将以下文本分类到合适的类别" |
优势
- ✅ 单个模型支持多种任务(统一框架)
- ✅ 可以通过改变指令调整行为
- ✅ 零样本泛化能力强
技术4:两阶段训练策略
完整训练流程
[预训练基座] Mistral-7B(已有)
↓
[Stage 1] 对比学习预训练
- 数据:1亿+自动构造的(query, doc+, doc-)三元组
- 损失:InfoNCE对比学习
- 目标:学习通用语义表示
↓
[Stage 2] 指令微调(可选)
- 数据:1万-10万高质量指令数据
- 损失:仍是InfoNCE(不是生成损失)
- 目标:理解不同任务指令
↓
[最终模型] E5-Mistral-7B Embedding模型
Stage 1:对比学习预训练
# 使用海量无监督数据(百万到亿级)
# 数据自动构造:从搜索日志、问答对等构造三元组
for batch in large_scale_data:
query_emb = model(batch['query'])
pos_emb = model(batch['positive'])
neg_embs = model(batch['negatives']) # In-batch negatives
# InfoNCE损失
loss = contrastive_loss(query_emb, pos_emb, neg_embs)
loss.backward()
optimizer.step() # 更新模型参数
# 关键:只更新模型参数,不增加新层,不用RL
Stage 2:指令微调
# 使用高质量指令数据(千到万级)
for batch in instruction_data:
# 加上指令前缀
query_prompt = f"Instruct: {batch['instruction']}\nQuery: {batch['query']}"
query_emb = model(query_prompt)
pos_emb = model(batch['positive'])
neg_embs = model(batch['negatives'])
# 仍然是对比学习损失(不是SFT的交叉熵)
loss = contrastive_loss(query_emb, pos_emb, neg_embs)
loss.backward()
optimizer.step()
两阶段对比
| 阶段 | 数据量 | 数据来源 | 损失函数 | 目标 |
|---|---|---|---|---|
| Stage 1 | 1亿+ | 自动构造(搜索日志、爬虫) | InfoNCE | 学习通用语义 |
| Stage 2 | 1-10万 | 人工标注(高质量) | InfoNCE | 学习任务理解 |
关键要点
✅ 两阶段都用对比学习,不是SFT/RLHF ✅ 不增加新层,只是微调现有参数 ✅ 不涉及强化学习(PPO/RLHF) ✅ 不需要"奖励模型" ❌ 与LLM对话微调完全不同!
3.4 效果对比:MTEB Benchmark
MTEB(Massive Text Embedding Benchmark)排行榜
| 排名 | 模型 | 架构 | 参数量 | 平均分 | 检索分数 |
|---|---|---|---|---|---|
| 1 | NV-Embed-v1 | Mistral-7B (Decoder) | 7B | 69.3 | 71.2 |
| 2 | Voyage-2 | 未公开 | - | 68.5 | 70.8 |
| 3 | text-embedding-3-large | GPT (Decoder) | - | 64.6 | 68.9 |
| 4 | BGE-large-zh | BERT (Encoder) | 326M | 63.5 | 67.8 |
| 5 | E5-large-v2 | BERT (Encoder) | 335M | 62.3 | 66.2 |
关键发现
- LLM-based模型登顶:NV-Embed (Decoder) 超越所有Encoder模型
- 参数量更大效果更好:7B参数 vs 300M参数
- 但并非绝对:需要精心设计训练策略
3.5 真实场景效果对比
测试场景:简历检索(10万份中文简历)
| 模型 | 架构 | Recall@100 | Precision@10 | 推理速度 (GPU) |
|---|---|---|---|---|
| BGE-large-zh | Encoder | 88.5% | 73.2% | 50ms |
| E5-Mistral-7B | Decoder | 91.2% | 76.8% | 180ms |
| text-embedding-3 | Decoder | 92.1% | 78.5% | 200ms (API) |
观察
- ✅ LLM-based效果领先3-5%
- ⚠️ 推理速度慢3-4倍
- ⚠️ 显存占用大10倍(7B vs 300M)