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+语义,实用)