Learning

Record learning from practice

View project on GitHub

Embedding模型的内部机制详解

1. Transformer Embedding的三种主流方案

方案A: CLS Token(BERT风格)

输入: [CLS] 如何 初始化 DDR4 内存 控制器 ? [SEP]
       ↓      ↓    ↓    ↓   ↓    ↓     ↓    ↓
    Token Embeddings
       ↓
   ┌─────────────────────────────────────┐
   │  Transformer Layers (12层)          │
   │  - Self-Attention                   │
   │  - Feed-Forward                     │
   └─────────────────────────────────────┘
       ↓
    最后一层隐藏状态 (Last Hidden State)
    [h_CLS, h_如何, h_初始化, ..., h_SEP]
       ↓
    只取 h_CLS 作为整个句子的embedding
       ↓
    768维向量 → 用于相似度计算

关键点

  • [CLS] token在训练时学会”汇总”整个句子的语义
  • 通过12层的Self-Attention,它”看到”了所有其他token
  • 但是信息瓶颈:所有信息都必须压缩到这一个768维向量里

问题所在(长文本):

长chunk: "内存初始化...PCI枚举...硬盘检测...网络启动..." (500 tokens)
        
[CLS]必须同时表示所有8个主题
        
每个主题只能分配到 768/8  96
        
信息严重稀释

方案B: Mean Pooling(Sentence-BERT风格)

输入: 如何 初始化 DDR4 内存 控制器 ?
      ↓    ↓    ↓   ↓    ↓     ↓
   Transformer (最后一层)
      ↓    ↓    ↓   ↓    ↓     ↓
    [h1, h2, h3, h4, h5, h6, h7]  ← 每个token的768维向量
      ↓
    Mean Pooling: embedding = (h1+h2+h3+...+h7) / 7
      ↓
    768维向量

Python实现

# 伪代码
def mean_pooling(last_hidden_state, attention_mask):
    """
    last_hidden_state: [batch, seq_len, hidden_size]
    例如: [1, 500, 768] - 500个token,每个768维
    """
    # 考虑attention mask(忽略padding)
    input_mask_expanded = attention_mask.unsqueeze(-1).expand(last_hidden_state.size())
    
    # 求和
    sum_embeddings = torch.sum(last_hidden_state * input_mask_expanded, 1)
    
    # 除以有效token数
    sum_mask = torch.clamp(input_mask_expanded.sum(1), min=1e-9)
    
    return sum_embeddings / sum_mask  # [batch, 768]

问题所在(长文本):

短问题: mean([h_如何, h_初始化, h_DDR4, h_内存, h_控制器])
        5个向量求平均每个向量贡献20%

长chunk: mean([h_内存, h_初始化, ..., h_PCI, ..., h_硬盘, ...])
        500个向量求平均每个向量贡献0.2%
        "内存初始化"的信号被稀释到只有 1/50

方案C: Max Pooling

def max_pooling(last_hidden_state):
    """对每个维度取最大值"""
    return torch.max(last_hidden_state, dim=1)[0]  # [batch, 768]

问题

  • 会保留不同token在不同维度的峰值
  • 长文本会混合多个主题的”最强信号”
  • 导致主题混淆

2. 实际Embedding模型的选择

OpenAI text-embedding-ada-002

  • 使用类似CLS Token的方式
  • 但维度更高(1536维)
  • 经过对比学习训练

Sentence-BERT / all-MiniLM-L6-v2

  • 使用Mean Pooling
  • 在sentence pair任务上微调
  • 适合短到中等长度文本(<512 tokens)

BGE / MTEB排行榜的模型

  • 大多使用Mean Pooling
  • 有些加入了特殊的attention机制
  • 对长文本优化不足

3. 为什么长文本会被”稀释”

数学原理(以Mean Pooling为例)

假设简化到3维空间:

# 短问题(5个token)
tokens = ["如何", "初始化", "DDR4", "内存", "控制器"]
hidden_states = [
    [0.9, 0.1, 0.0],  # "如何" - 主要在维度0
    [0.8, 0.2, 0.0],  # "初始化" - 主要在维度0
    [0.0, 0.9, 0.1],  # "DDR4" - 主要在维度1
    [0.0, 0.8, 0.2],  # "内存" - 主要在维度1
    [0.1, 0.7, 0.2],  # "控制器" - 主要在维度1
]

mean_embedding = mean(hidden_states)
= [(0.9+0.8+0.0+0.0+0.1)/5, (0.1+0.2+0.9+0.8+0.7)/5, (0.0+0.0+0.1+0.2+0.2)/5]
= [0.36, 0.54, 0.10]
# 维度1最强(内存相关),维度0次之(初始化相关)✅ 语义清晰
# 长chunk(10个token,包含多个主题)
tokens = ["内存", "初始化", ..., "PCI", "枚举", ..., "硬盘", "检测"]
hidden_states = [
    [0.0, 0.8, 0.0],  # "内存" - 维度1
    [0.7, 0.1, 0.0],  # "初始化" - 维度0
    [0.0, 0.0, 0.9],  # "PCI" - 维度2(新主题!)
    [0.0, 0.1, 0.8],  # "枚举" - 维度2
    [0.5, 0.0, 0.3],  # "硬盘" - 维度0和2(又一个主题)
    [0.6, 0.0, 0.2],  # "检测" - 维度0
    # ... 更多token
]

mean_embedding = mean(hidden_states)
= [0.3, 0.17, 0.35]  # 三个维度都有值
# 维度2最强(PCI),但问题是关于"内存初始化"(应该是维度1)
# ❌ 语义模糊,被其他主题"污染"

4. Chat模型 vs Embedding模型的区别

Chat模型(如GPT)

输入: "如何初始化DDR4内存?"
     ↓
Transformer Decoder
     ↓
最后一个token的hidden state → 预测下一个token
     ↓
输出: "首先需要..."

关键:只用最后一个token的状态来预测,不需要压缩整个序列

Embedding模型(如BERT)

输入: "如何初始化DDR4内存?"
     ↓
Transformer Encoder
     ↓
所有token的hidden states → 池化成一个向量
     ↓
输出: 768维向量(必须代表整个句子)

关键:必须把所有信息压缩到一个向量,这就是瓶颈所在


5. 解决长文本问题的前沿方法

方法1: 分层Embedding(Hierarchical)

# 不是直接embed整个chunk
long_chunk = "...500 tokens..."

# 而是先分句
sentences = split_sentences(long_chunk)  # 10个句子

# 每个句子单独embed
sentence_embeddings = [embed(s) for s in sentences]  # [10, 768]

# 然后再聚合句子级别的embedding
chunk_embedding = mean(sentence_embeddings)  # [768]
# 或者保留句子级别的embeddings用于检索

优点:每个句子的语义不会被其他句子稀释

方法2: ColBERT(晚期交互)

# 不池化!保留所有token的embedding
query_tokens = model(query)  # [7, 768]
chunk_tokens = model(chunk)  # [500, 768]

# 在查询时计算:每个query token与最相似的chunk token的得分
score = sum([
    max([cosine(q, c) for c in chunk_tokens])
    for q in query_tokens
])

优点:没有信息损失,query中的每个概念都能找到chunk中最匹配的部分

方法3: 长文本专用模型(LongFormer, BigBird)

# 使用稀疏attention
# 不是全局attention (O(n²))
# 而是局部+全局的混合 (O(n))

# 可以处理4096甚至16k tokens

6. 项目中验证

修改测试脚本,检查实际的hidden states:

def inspect_embeddings():
    from transformers import AutoTokenizer, AutoModel
    import torch
    
    model_name = "sentence-transformers/all-MiniLM-L6-v2"
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModel.from_pretrained(model_name)
    
    texts = [
        "如何初始化DDR4内存?",  # 短
        "第3章讨论了内存初始化、PCI枚举、硬盘检测等多个主题..."  # 长
    ]
    
    for text in texts:
        # Tokenize
        inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True)
        
        # 获取hidden states
        with torch.no_grad():
            outputs = model(**inputs)
            last_hidden_state = outputs.last_hidden_state  # [1, seq_len, 768]
        
        print(f"\n文本: {text[:30]}...")
        print(f"Token数: {last_hidden_state.shape[1]}")
        print(f"Hidden state形状: {last_hidden_state.shape}")
        
        # Mean pooling
        embedding = last_hidden_state.mean(dim=1)  # [1, 768]
        print(f"Embedding形状: {embedding.shape}")
        print(f"Embedding范数: {torch.norm(embedding).item():.4f}")
        
        # 分析每个token的贡献
        token_norms = torch.norm(last_hidden_state[0], dim=1)
        print(f"Token向量范数 - 最大: {token_norms.max():.4f}, 最小: {token_norms.min():.4f}")

总结

本质问题:把500个token的信息压缩到768维向量,必然损失大量细节

这就是为什么RAG系统需要精心设计chunk策略,而不是简单地”把文档扔进向量数据库”!

解决方向

  • 减小chunk size(治标)
  • 句子级检索(治本)
  • 语义化分块
  • 多粒度检索
  • 使用ColBERT等晚期交互模型(最优)
  • 混合检索(BM25+语义,实用)