文本Embedding模型演进:从Encoder-only到LLM-based的技术变革

Embedding模型演进:从Encoder-only到LLM-based的技术变革

深度拆解Transformer-based Embedding模型的两代演进:Encoder-only时代、LLM-based崛起

引言:为什么需要文本Embedding?

在深入技术演进之前,先理解一个核心问题:为什么需要把文本转换为向量?

现实场景

想象你是一个招聘平台的工程师,需要实现"找到与岗位需求最匹配的简历":

arduino 复制代码
岗位需求:"招聘5年Java后端工程师,熟悉Spring Boot和微服务架构"

简历库:10万份简历

挑战:如何快速找到最相关的简历?

传统方法的困境

方法 问题
关键词匹配 "Java"无法匹配"Java开发","微服务"无法匹配"分布式系统"
TF-IDF 无法理解语义,"银行工作"和"金融经验"无法关联
Word2Vec 词级别向量,无法表达句子/段落的整体语义

Embedding模型的解决方案

css 复制代码
岗位需求 → [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的时代。

架构特点

csharp 复制代码
输入文本: "我是一名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有三个问题:

  1. [CLS] Token不够优

    python 复制代码
    # BERT原生用法
    sentence_vector = bert_output['last_hidden_state'][:, 0, :]  # 取[CLS]
    
    # 问题:[CLS]只在NSP任务中训练,不是为句子相似度设计
  2. 无法直接计算句子相似度

    python 复制代码
    # 比较两个句子需要拼接后再过BERT
    input = "[CLS] 句子A [SEP] 句子B [SEP]"
    similarity = bert(input)  # 慢!每对都要过一次
  3. 推理效率低

    diff 复制代码
    比较10万份简历 vs 1个查询:
    - BERT:需要过BERT模型 10万次
    - 预计算向量:只需10万次向量相似度计算(快1000倍)

1.2 突破者:Sentence-BERT(2019)

核心创新:Siamese网络 + Mean Pooling

css 复制代码
         句子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 (相似)

训练策略:对比学习

python 复制代码
# 训练数据:(句子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倍

为什么更快?关键在于"预计算 + 重用"

ini 复制代码
场景: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倍

关键点

  1. 预计算可重用:10万份简历的向量只需计算一次(离线阶段)
  2. 向量比较极快:余弦相似度是简单点积,比BERT推理快10000倍
  3. 真实场景都是一对多:搜索引擎、推荐系统、简历匹配,都是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

架构对比

java 复制代码
┌────────────────────────────────────────────────────────┐
│  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:注意力机制

python 复制代码
# 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?

markdown 复制代码
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位置编码

关键洞察

arduino 复制代码
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的对话微调。

✅ 实际训练流程

markdown 复制代码
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的因果注意力(只看前文)不利于理解全文语义

解决方案:修改最后几层为双向注意力

python 复制代码
# 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:对比学习训练(核心!)

训练数据格式

python 复制代码
# 训练样本:三元组
{
  "query": "招聘5年Java工程师",
  "positive": "我有6年Java开发经验,熟悉Spring Boot...",  # 相关文档
  "negatives": [  # 不相关文档(通常8-32个)
    "Python全栈工程师,3年经验...",
    "前端开发,精通React和Vue...",
    "产品经理,5年工作经验..."
  ]
}

训练过程(对比学习)

python 复制代码
# 编码
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)

目的:让模型理解不同任务的指令

数据构造

python 复制代码
# 加入指令前缀
{
  "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:两阶段训练策略

完整训练流程

markdown 复制代码
[预训练基座] Mistral-7B(已有)
   ↓
[Stage 1] 对比学习预训练
   - 数据:1亿+自动构造的(query, doc+, doc-)三元组
   - 损失:InfoNCE对比学习
   - 目标:学习通用语义表示
   ↓
[Stage 2] 指令微调(可选)
   - 数据:1万-10万高质量指令数据
   - 损失:仍是InfoNCE(不是生成损失)
   - 目标:理解不同任务指令
   ↓
[最终模型] E5-Mistral-7B Embedding模型

Stage 1:对比学习预训练

python 复制代码
# 使用海量无监督数据(百万到亿级)
# 数据自动构造:从搜索日志、问答对等构造三元组
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:指令微调

python 复制代码
# 使用高质量指令数据(千到万级)
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

关键发现

  1. LLM-based模型登顶:NV-Embed (Decoder) 超越所有Encoder模型
  2. 参数量更大效果更好:7B参数 vs 300M参数
  3. 但并非绝对:需要精心设计训练策略

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)
相关推荐
游离态指针2 小时前
首字节响应 0ms?我用 1000 行代码驯服了 Spring AI Agent 的“不确定性”
后端
量子位2 小时前
中国AI音乐,悄悄把全球第一拿走了
aigc·openai
量子位2 小时前
OpenAI关停Sora!25个月从封神到退场
openai·sora
、BeYourself2 小时前
Scala 字面量
开发语言·后端·scala
zdl6862 小时前
搭建Golang gRPC环境:protoc、protoc-gen-go 和 protoc-gen-go-grpc 工具安装教程
开发语言·后端·golang
Memory_荒年2 小时前
Gateway:微服务前台的“瑞士军刀”小姐姐
后端
希望永不加班2 小时前
SpringBoot 内置服务器(Tomcat/Jetty/Undertow)切换
服务器·spring boot·后端·tomcat·jetty
Sammyyyyy2 小时前
9个Python库把一个月的AI开发周期缩短到了3天
人工智能·后端·python·servbay
爱吃的小肥羊3 小时前
从"世界模型"到"再见世界",Sora只活了两年
aigc