Embedding 模型本质是一个编码器神经网络(通常基于 Transformer),将文本压缩成固定长度的向量
输入文本
↓
┌─────────────────┐
│ Tokenizer │ "苹果是水果" → [101, 679, 3221, 3717, 1963, 102]
└────────┬────────┘
↓
┌─────────────────┐
│ Transformer │ 多层 Self-Attention 提取语义特征
│ Encoder 层 │
│ (BERT结构) │
└────────┬────────┘
↓
┌─────────────────┐
│ 池化层 │ 取 [CLS] token 或平均池化
│ (Pooling) │
└────────┬────────┘
↓
┌─────────────────┐
│ L2 归一化 │ 向量长度归一化为 1
└────────┬────────┘
↓
[0.023, -0.156, 0.891, ..., 0.034] ← 768/1024/1536 维向量
一、Tokenizer
RAG 中的 Tokenizer(分词/切块)策略主要决定如何将原始文档切分成适合检索的片段(chunk),直接影响召回质量。
1、Tokenizer的常见策略有一下几种:
- 1、按固定 token 数或字符数切分,可设置重叠窗口(overlap)避免语义断裂。
[chunk1: 0-512 tokens]
[chunk2: 400-912 tokens] ← overlap=112
[chunk3: 800-1312 tokens]
优点: 简单高效,易于实现
缺点: 可能在句子/段落中间截断,破坏语义完整性
- 2、递归字符切分(Recursive Character Splitting)
按优先级依次尝试分隔符:\n\n → \n → 。 → → 字符,尽量保持语义完整。
代表: LangChain 的 RecursiveCharacterTextSplitter
优点: 比固定切分更自然,尊重段落/句子边界
缺点: chunk 大小不均匀
- 3、语义感知切分(Semantic-aware Chunking)
3.1 基于句子嵌入的语义切分
将文本按句子分割,计算相邻句子的语义相似度
相似度骤降处作为切分点
代表: LlamaIndex SemanticSplitterNodeParser
3.2 基于 NLP 的句子边界切分
使用 NLP 工具(spaCy、jieba)识别句子/词语边界
按句子为单位组合,控制 chunk 大小
- 4、结构化文档切分(Structure-aware Chunking)
根据文档本身结构切分,保留层级关系:
| 文档类型 | 切分依据 |
|---|---|
| Markdown | 标题层级(H1/H2/H3) |
| HTML | <section>, <p>, <div> |
| 段落、页面、表格 | |
| 代码 | 函数、类、模块 |
优点: 语义最完整,chunk 有明确的业务含义
- 5、父子切分(Parent-Child / Hierarchical Chunking)
存储大 chunk(parent),索引小 chunk(child):
检索时用小 chunk 精准匹配
返回时扩展到父级大 chunk 保证上下文完整
[父 chunk: 整个段落 1000 tokens]
├── [子 chunk1: 128 tokens] ← 用于向量检索
├── [子 chunk2: 128 tokens]
└── [子 chunk3: 128 tokens]
- 6、命题切分(Proposition Chunking)
用 LLM 将段落分解为原子性的、自包含的陈述句(proposition),每个 proposition 独立可理解。
优点: 检索精度极高
缺点: 预处理成本高(需要调用 LLM)
- 7、Late Chunking(延迟切分)
先对整个文档做 embedding,再切分,让每个 chunk 的向量带有全局上下文信息,解决"切分后语义孤立"问题。
2、策略选型建议
| 场景 | 推荐策略 |
|---|---|
| 快速原型 | 固定大小 + overlap |
| 通用文档 | 递归字符切分 |
| 结构化文档(Markdown/HTML) | 结构感知切分 |
| 高精度问答 | 父子切分 / 命题切分 |
| 长文档全局理解 | Late Chunking |
| 中文文档 | 结合 jieba 的句子边界切分 |
3、核心调参指标:
- chunk_size:通常 256~1024 tokens,取决于 embedding 模型上下文窗口
- chunk_overlap:通常 10%~20% 的 chunk_size
- 切分后建议对每个 chunk 添加元数据(来源、标题、页码),增强检索后的可解释性
二、Transformer Encoder
一句话:把一段话变成一组数字,让电脑能"比较"两段话是不是在说同一件事。
你和朋友聊天时,你能马上听出"我饿了"和"想吃饭"是一个意思。但电脑只认数字,所以需要一个翻译器,把文字翻译成数字,且保证意思相近的话,翻译出来的数字也相近。
Transformer Encoder 就是这个翻译器。
整个过程只有 4 步
第 1 步:拆字
电脑不认识"苹果手机推荐"这个字符串,需要先拆成小块:
"苹果手机推荐"
↓ 拆开
["苹果", "手机", "推荐"]
↓ 每个词给一个编号
[ 678, 312, 891 ]
就像给每个词发了一张编号卡,编号是查字典得来的。
第 2 步:给每个词一个初始描述
光有编号没用,需要给每个词一个"画像"——用 768 个数字描述它。
"苹果" → [0.5, 0.1, 0.3, 0.8, 0.2, ... ] 共768个数字
"手机" → [0.3, 0.8, 0.6, 0.1, 0.4, ... ] 共768个数字
"推荐" → [0.7, 0.2, 0.5, 0.9, 0.3, ... ] 共768个数字
但此时有个大问题: 每个词的画像是"死"的。不管"苹果"出现在"苹果手机"还是"苹果好吃"里,画像都一样。它还不理解上下文。
第 3 步:让词和词之间"聊天"(Self-Attention)
这是最核心的一步。
打个比方:你新来一家公司,你只知道自己的岗位(初始画像)。你需要和周围同事聊天,了解部门整体在做什么,才能真正理解自己在这个团队里的角色。
Self-Attention 做的就是这件事:让每个词去问其他所有词——"你和我什么关系?"
场景: "苹果 手机 推荐"
"手机"开始问:
→ 问"苹果": 你和我什么关系?
"苹果"回答: 我是你的品牌修饰词,关系很密切! (分数: 80分)
→ 问"推荐": 你和我什么关系?
"推荐"回答: 我是动作,你是对象,有关系 (分数: 50分)
→ 问自己: 你和我什么关系?
自己回答: 我就是我 (分数: 90分)
然后按分数高低,把其他词的信息"吸收"过来更新自己的画像:
新的"手机"画像 = 自身信息×40% + "苹果"信息×35% + "推荐"信息×25%
更新后,"手机"的画像里就融入了"苹果"的信息,电脑就知道这里说的是苹果品牌的手机了。
每个词都这样做一遍。一轮之后,所有词都"理解"了上下文。
为什么要做 12 轮?
一轮只能理解最直接的关系。多轮之后理解越来越深:
第 1-2 轮: 知道"苹果"和"手机"是挨在一起的
第 3-5 轮: 理解"苹果手机"是一个品牌产品
第 6-9 轮: 理解整句话是"有人在找苹果手机的推荐"
第 10-12轮: 形成高度抽象的语义理解,能和"iPhone哪款好"匹配上
类比:你在新公司第一天只认识隔壁同事。一个月后认识了整个部门。半年后理解了公司的战略方向。每一轮"聊天"都让理解更深一层。
多头注意力是什么?
每一轮聊天不只聊一个话题,同时聊 12 个话题:
话题1: 谁修饰谁? → "苹果"修饰"手机"
话题2: 谁是动作对象? → "推荐"的对象是"手机"
话题3: 谁和谁意思相近? → "推荐" ≈ "建议"
话题4: 谁挨着谁? → "苹果"紧挨"手机"
... 共12个话题同时进行
每个话题聊出来的结果拼在一起,信息更全面。
第 3.5 步:FFN(深度消化)
每次"聊天"之后,每个词需要单独"消化"吸收到的信息:
"手机"刚吸收了"苹果"和"推荐"的信息
↓ 经过FFN"消化"
"手机"把新信息和自己已有的知识融合
→ 形成更成熟的语义理解
类比:开完会(Self-Attention)后回到工位整理笔记(FFN),把讨论内容转化成自己的理解。
第 4 步:合并成一个句子向量
12 轮之后,每个词都有了充分理解上下文的向量。最后需要合并成一个向量代表整句话:
"苹果" → [0.31, 0.67, ...]
"手机" → [0.58, 0.44, ...]
"推荐" → [0.37, 0.71, ...]
↓
池化层(Pooling)
↓
句子向量 → [0.42, 0.61, ...] 768个数字
这 768 个数字,就是"苹果手机推荐"这句话在电脑眼里的"身份证号"。
总结
| 步骤 | 做了什么 | 大白话 |
|---|---|---|
| 拆字 | 文本变Token | 把句子拆成词 |
| 初始向量 | Token变768维向量 | 给每个词一张初始画像 |
| Self-Attention ×12轮 | 词与词交互 | 让每个词看看周围词,理解上下文 |
| FFN | 深度变换 | 开完会后整理笔记 |
| Pooling | 多个向量→1个 | 把所有词的理解合并成句子的理解 |
本质就是:拆词 → 给初始画像 → 反复交流更新画像 → 合并成一个代表整句话的数字
三、池化层
经过 Transformer Encoder 的 12 层处理后,每个 Token 都有了自己的 768 维向量。但做检索时,我们需要一个向量代表整段话。
池化层的工作就是:把多个词向量,压缩成一个句子向量。
Encoder 输出:
[CLS] → [0.31, 0.67, 0.45, ...] 768维
苹果 → [0.45, 0.52, 0.38, ...] 768维
手机 → [0.58, 0.44, 0.61, ...] 768维
推荐 → [0.37, 0.71, 0.29, ...] 768维
[SEP] → [0.12, 0.33, 0.55, ...] 768维
│
池化层(Pooling)
│
▼
句子向量 → [?, ?, ?, ...] 768维 ← 怎么算这个?
不同的池化策略,"怎么算"的方式不同,效果也不同。
四种主流池化策略
- CLS Pooling(取 [CLS] 向量)
做法: 直接取 [CLS] 这个特殊 Token 的向量作为句子向量,其他词全部丢弃。
[CLS] → [0.31, 0.67, 0.45, ...] ← 直接用这个
苹果 → [0.45, 0.52, 0.38, ...] ← 丢弃
手机 → [0.58, 0.44, 0.61, ...] ← 丢弃
推荐 → [0.37, 0.71, 0.29, ...] ← 丢弃
[SEP] → [0.12, 0.33, 0.55, ...] ← 丢弃
句子向量 = [0.31, 0.67, 0.45, ...]
原理: BERT 在预训练时,专门训练 [CLS] Token 去汇聚整句话的语义。经过 12 层 Self-Attention,[CLS] 已经"看过"所有词,理论上包含了全句信息。
优点:
- 计算最快,只取一个向量
- BERT 原生设计就是用 [CLS] 做句子级任务
缺点:
- [CLS] 的预训练目标是 NSP(下一句预测),不是语义相似度
- 实测在语义检索任务上效果不如 Mean Pooling
- 信息过度集中在一个 Token 上,容易丢失细节
适用: 分类任务(情感分析、文本分类)
- Mean Pooling(平均池化)
做法: 把所有 Token 的向量逐维取平均值。
[CLS] → [0.31, 0.67, 0.45, ...]
苹果 → [0.45, 0.52, 0.38, ...]
手机 → [0.58, 0.44, 0.61, ...]
推荐 → [0.37, 0.71, 0.29, ...]
[SEP] → [0.12, 0.33, 0.55, ...]
第1维平均: (0.31+0.45+0.58+0.37+0.12) / 5 = 0.366
第2维平均: (0.67+0.52+0.44+0.71+0.33) / 5 = 0.534
第3维平均: (0.45+0.38+0.61+0.29+0.55) / 5 = 0.456
...
句子向量 = [0.366, 0.534, 0.456, ...]
带 Attention Mask 的版本(实际使用)
实际文本长度不一样,短文本会用 [PAD] 填充到统一长度。Padding 位置不应该参与计算:
Token: [CLS] 苹果 手机 推荐 [SEP] [PAD] [PAD] [PAD]
Mask: [ 1, 1, 1, 1, 1, 0, 0, 0 ]
只对 Mask=1 的位置求平均:
句子向量 = (CLS + 苹果 + 手机 + 推荐 + SEP) / 5
优点:
- 每个词都参与,信息保留最完整
- 不依赖某个特殊 Token 的训练质量
- 在语义检索任务上效果最好
缺点:
- 所有词权重相同,"的""了"这类虚词也参与,会稀释关键词信息
适用: RAG 语义检索(业界主流方案)
- Max Pooling(最大池化)
做法: 在每一维上,取所有 Token 中的最大值。
第1维 第2维 第3维
[CLS] → 0.31 0.67 0.45
苹果 → 0.45 0.52 0.38
手机 → 0.58 0.44 0.61
推荐 → 0.37 0.71 0.29
[SEP] → 0.12 0.33 0.55
取每列最大值:
第1维: max(0.31,0.45,0.58,0.37,0.12) = 0.58 ← 来自"手机"
第2维: max(0.67,0.52,0.44,0.71,0.33) = 0.71 ← 来自"推荐"
第3维: max(0.45,0.38,0.61,0.29,0.55) = 0.61 ← 来自"手机"
句子向量 = [0.58, 0.71, 0.61, ...]
原理: 每一维取最大值,相当于保留了"最显著的特征"。如果某个词在某个语义维度上激活很强,就保留它。
优点:
- 对关键词敏感,突出特征明显的词
- 不容易被虚词和 Padding 稀释
缺点:
- 丢失了词序和比例信息
- 只取极端值,对细腻语义差异不敏感
- 一个"噪声"词的异常激活可能影响整体
适用: 关键词匹配、短文本检索
- Weighted Mean Pooling(加权平均池化)
做法: 给不同层/位置的 Token 分配不同权重,再加权平均。
4.1 按层加权(越深层权重越大)
BERT 有12层,每层都有输出:
Layer 1 的"手机"向量 × 权重 0.02
Layer 2 的"手机"向量 × 权重 0.03
...
Layer 11 的"手机"向量 × 权重 0.15
Layer 12 的"手机"向量 × 权重 0.20 ← 最后一层权重最大
原理: 深层语义更抽象更适合检索,给更大权重
4.2 按位置加权(越靠后权重递增/递减)
Token: [CLS] 苹果 手机 推荐 [SEP]
权重: 0.1 0.2 0.3 0.3 0.1
句子向量 = 0.1×CLS + 0.2×苹果 + 0.3×手机 + 0.3×推荐 + 0.1×SEP
4.3 按注意力分数加权(Attention-weighted)
利用最后一层 Self-Attention 中 [CLS] 对各词的注意力权重:
[CLS] 对各词的注意力:
苹果: 0.30 ← 模型认为"苹果"很重要
手机: 0.35 ← "手机"最重要
推荐: 0.25 ← 其次
[SEP]: 0.10
句子向量 = 0.30×苹果 + 0.35×手机 + 0.25×推荐 + 0.10×SEP
优点:
- 重要的词贡献更大,更精准
- 灵活性强,可以根据任务调整权重
缺点:
- 权重需要额外学习或手工设计
- 实现复杂度高
适用: 对精度有极高要求的场景
四种策略对比
| 策略 | 计算方式 | 信息保留 | 检索效果 | 计算成本 |
|---|---|---|---|---|
| CLS Pooling | 取[CLS]向量 | 低(只取一个Token) | 一般 | 最低 |
| Mean Pooling | 所有Token平均 | 高(每个词都参与) | 好 | 低 |
| Max Pooling | 每维取最大值 | 中(只保留极端值) | 中等 | 低 |
| Weighted Mean | 加权平均 | 最高(按重要性分配) | 最好 | 较高 |
主流 Embedding 模型都用什么池化?
| 模型 | 池化策略 | 备注 |
|---|---|---|
| Sentence-BERT | Mean Pooling | 开创性工作,验证了Mean优于CLS |
| OpenAI text-embedding-ada-002 | 未公开(推测Mean) | 闭源模型 |
| BGE 系列(智源) | CLS Pooling | 但经过专门的对比学习训练CLS |
| GTE 系列(阿里) | Mean Pooling | 中文效果好 |
| E5 系列(微软) | Mean Pooling | 多语言支持好 |
| Jina Embeddings | Mean Pooling | 长文本支持好 |
实际建议
大多数 RAG 场景 → 直接用 Mean Pooling,简单且效果最好
关键词检索场景 → 可以尝试 Max Pooling
追求极致精度 → 用 Weighted Mean 或选择已经训练好的模型(如 BGE)
最重要的一点: 池化策略的选择远不如 Encoder 本身的训练质量重要。一个经过对比学习精心训练的模型,用 CLS 也能超过未经训练的 Mean Pooling。选好模型比选池化策略更关键。
四、归一化
每个 Transformer Block 中出现两次:
输入
│
▼
┌──────────────────────────┐
│ Self-Attention │
└────────────┬─────────────┘
│
Add & LayerNorm ← 第1次
│
▼
┌──────────────────────────┐
│ Feed-Forward (FFN) │
└────────────┬─────────────┘
│
Add & LayerNorm ← 第2次
│
▼
传入下一层
这里的 "Add & LayerNorm" 其实是两步操作:残差连接(Add) + 层归一化(LayerNorm)。
残差连接(Add)
输出 = 子层输出 + 原始输入
用大白话说:
你参加了一个培训班(Self-Attention / FFN)
不用残差: 你把以前学的全忘了,只记得培训班的内容
用残差: 你保留了以前学的,再叠加培训班的新知识
原始输入: [0.5, 0.3, 0.8] ← 你已有的知识
子层输出: [0.1, 0.4, -0.2] ← 培训班学到的新东西
残差相加: [0.6, 0.7, 0.6] ← 新旧结合
作用:
- 保证信息不会在深层网络中完全丢失
- 解决深层网络"梯度消失"问题(训练时梯度可以通过残差通道直接回传)
- 没有残差连接,12 层 Encoder 根本训练不起来