很多人第一次做 RAG,默认流程都是:
- 文档切 chunk
- 生成 embedding
- 向量检索 top_k
- 拼 prompt
- 让模型回答
这条路径能跑,但往往不稳。真正让 RAG 质量提升一个台阶的,通常是三件事:
- chunking 做得更合理 — 切出来的 chunk 能独立成为证据
- 召回从单路 dense 变成多路召回 — 结合语义和关键词
- 用真正的 rerank 做精排 — 不是简单重排,而是用 cross-encoder 重新打分
这篇文章会深入讲这三个环节的算法原理。
一、Chunking:为什么"切短一点"不简单
Chunking 的目标不是凑 embedding 输入长度,而是让"一个 chunk 刚好能独立成为证据"。
好的 Chunk 应该满足什么
四个标准:
- 语义完整 — 一个 chunk 能回答一个独立的问题
- 粒度适中 — 太大噪声多,太小信息不够
- 上下文自洽 — 不依赖外部信息也能理解
- 可追溯 — 知道来自哪个文档、哪个章节
固定长度切分的问题
最简单的做法是按字符数切:
每 500 个字符切一段
问题:
问题 1:语义被切断
原文:
"所有 API 请求必须在 Header 中携带 Bearer Token。Token 有效期为 24 小时,
过期后需重新获取。获取方式:调用 /auth/token 接口,传入 client_id
和 client_secret。"
固定切分后,某个 chunk 可能变成:
"过期后需重新获取。获取方式:调用 /auth/token 接口,传入 client_id"
用户问"Token 有效期多久?",这个 chunk 不会召回。
问题 2:上下文丢失
原文:
"### 2.3 校准流程
将旋钮调至 3 档,等待指示灯变为绿色后松开。"
按段落切分后:
"将旋钮调至 3 档,等待指示灯变为绿色后松开。"
用户问"ZK-200 怎么校准",这个 chunk 不会召回。
因为"ZK-200"在标题里,"校准"在章节名里——都被切掉了。
语义切分的思路
更好的方法是按文档的自然结构切分:
1. 按 Markdown 标题切分
Markdown 文档有天然的层级结构:
# 一级标题
## 二级标题
### 三级标题
切分策略:每个标题下的内容作为一个 chunk,并保留标题作为上下文。
Chunk 1:
【API 规范 > 2. 认证 > 2.1 认证方式】
所有 API 请求必须在 Header 中携带 Bearer Token...
Chunk 2:
【API 规范 > 2. 认证 > 2.2 Token 获取】
调用 /auth/token 接口获取 Token...
2. 按段落切分
对于没有明确结构的文档,按段落切分比按字符数好:
段落边界通常是语义边界。
一个段落讲一个观点,不会把一句话切两半。
3. 语义切分(高级)
用 Embedding 模型判断相邻段落的语义相似度:
步骤:
1. 把文档按句子切分
2. 计算相邻句子的 embedding 相似度
3. 相似度低的地方 = 语义边界,切分
直觉:如果两句话语义相似度低,说明在讲不同的话题,应该分开。
代价:计算量大,不一定比结构化切分更好。
Contextual Chunking:最重要的一步
无论用什么切分方法,都建议做一步:把父级标题拼到 chunk 前面。
原始 chunk:
"将旋钮调至 3 档,等待指示灯变为绿色后松开。"
补充上下文后:
"【ZK-200 操作手册 > 2. 使用方法 > 2.3 校准流程】
将旋钮调至 3 档,等待指示灯变为绿色后松开。"
为什么有效?
原理:Embedding 模型会把整个 chunk 编码成一个向量。如果 chunk 里包含"ZK-200"和"校准",当用户问"ZK-200 怎么校准"时,相似度就会高。
实测效果:
| 配置 | Recall@5 | 说明 |
|---|---|---|
| 原始 chunk | 41% | 大量 chunk 缺上下文 |
| 补充标题上下文 | 78% | 效果翻倍 |
成本几乎为零,但效果提升明显。这是 RAG 里性价比最高的优化之一。
Chunk 粒度的权衡
太大 vs 太小:
| 问题 | 太大(1000+ 字) | 太小(<100 字) |
|---|---|---|
| 噪声 | 噪声多,关键信息被埋 | 信息不够,上下文缺失 |
| 检索 | 容易召回不相关内容 | 可能召回片段,语义不完整 |
| 成本 | Token 消耗大 | Chunk 数量多,索引大 |
经验值:300-500 tokens(中文约 200-350 字)
但比长度更重要的是语义完整性。宁可长度不均,也不要语义被切断。
不同文档类型的切分策略
| 文档类型 | 推荐策略 | 原因 |
|---|---|---|
| FAQ | 一条一 chunk | 每条 FAQ 天然独立 |
| 技术文档 | 按标题/小节 | 标题是天然语义边界 |
| 产品手册 | 按操作步骤 | 一个步骤一个 chunk |
| 合同/法规 | 按条款 | 每条条款有独立法律意义 |
| 对话记录 | 按问答对 | 一问一答是完整语义单元 |
二、BM25:关键词检索的经典算法
向量检索擅长语义相似,但对精确匹配不稳定。解决方案是引入关键词检索。
最经典的关键词检索算法是 BM25(Best Matching 25)。
BM25 的原理
BM25 是 TF-IDF 的改进版。先理解 TF-IDF:
TF-IDF = TF × IDF
TF(Term Frequency):词在文档中出现的次数
- 出现越多,越重要?不完全是,需要惩罚高频词
IDF(Inverse Document Frequency):词的稀有程度
- 在所有文档中很少出现 → 区分度高 → 权重高
- 在所有文档中都出现(如"的"、“是”)→ 区分度低 → 权重低
问题:TF 会过度奖励高频词。一个词出现 10 次不代表比出现 1 次重要 10 倍。
BM25 的改进:对 TF 做饱和处理。
BM25 公式
score(D, Q) = Σ IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D|/avgdl))
其中:
- D:文档
- Q:查询(包含多个词 q1, q2, ...)
- qi:查询中的第 i 个词
- f(qi, D):词 qi 在文档 D 中的词频
- |D|:文档 D 的长度
- avgdl:平均文档长度
- k1:词频饱和参数(通常 1.2-2.0)
- b:长度归一化参数(通常 0.75)
IDF(qi) = log((N - n(qi) + 0.5) / (n(qi) + 0.5) + 1)
- N:总文档数
- n(qi):包含词 qi 的文档数
直觉理解:
词频饱和:
(f × (k1+1)) / (f + k1)是一个饱和函数。当 f 很大时,增长变慢。一个词出现 10 次,权重不是 10 倍,而是趋于 k1+1。长度归一化:长文档天然有更高的词频,需要惩罚。
|D|/avgdl是相对长度,通过参数 b 控制惩罚程度。IDF 奖励稀有词:在少数文档中出现的词,IDF 高,权重高。
BM25 的特点
| 优点 | 缺点 |
|---|---|
| 对精确匹配敏感 | 不理解语义 |
| 对编号、术语稳定 | 同义词问题(“电脑"和"计算机"是两个词) |
| 计算快,可解释 | 需要分词(中文需要额外的分词步骤) |
| 不需要训练 | 不适合自然语言问题 |
适用场景:
- 用户查询包含编号、版本号、专业术语
- 文档中有大量缩写、代码
- 需要精确匹配的场景
中文分词
BM25 依赖词的边界。英文天然有空格分隔,中文需要额外的分词步骤。
常用分词工具:
| 工具 | 特点 |
|---|---|
| jieba | 最流行,词典+HMM |
| pkuseg | 北大出品,准确率高 |
| HanLP | 功能全,支持多种任务 |
分词质量直接影响 BM25 效果。比如:
"用户体验设计"
分词 1:用户 / 体验 / 设计(三个词)
分词 2:用户体验 / 设计(两个词)
分词 3:用户 / 体验设计(两个词)
不同的分词,检索结果不同。
三、Hybrid Retrieval:语义 + 关键词
向量检索和 BM25 各有优劣,互补性很强。
为什么需要混合
向量检索:
- 擅长:语义相似、同义词、自然语言问题
- 弱项:精确匹配、编号、专业术语
BM25:
- 擅长:精确匹配、编号、专业术语
- 弱项:语义理解、同义词、自然语言问题
实测对比:
| 查询类型 | 向量检索 Recall@5 | BM25 Recall@5 |
|---|---|---|
| 语义查询(“怎么获取令牌”) | 82% | 58% |
| 精确匹配(“错误码 401”) | 47% | 76% |
| 编号查询(“版本 v2.3.1”) | 38% | 69% |
| 混合查询(“API v2 认证方式”) | 64% | 71% |
没有哪种方法在所有场景下都最优。混合检索可以兼顾两者。
混合检索的架构
Query
│
├─── 向量检索 ──→ Dense 候选集(top-50)
│
├─── BM25 检索 ──→ Sparse 候选集(top-50)
│
└─── Metadata 过滤 ──→ 过滤后的候选集
│
▼
结果融合
│
▼
Rerank(可选)
│
▼
最终结果(top-5)
结果融合:RRF(Reciprocal Rank Fusion)
两路检索结果怎么合并?简单的方法是按分数加权,但问题:向量分数和 BM25 分数不在同一个尺度,不好比较。
更优雅的方法是 RRF(Reciprocal Rank Fusion):不依赖分数,只依赖排名。
RRF 公式:
RRF_score(d) = Σ 1 / (k + rank_i(d))
其中:
- d:文档
- rank_i(d):文档 d 在第 i 路检索中的排名
- k:平滑参数(通常取 60)
直觉:
- 排名第 1 的文档得分:1/(60+1) ≈ 0.0164
- 排名第 2 的文档得分:1/(60+2) ≈ 0.0159
- 排名第 10 的文档得分:1/(60+10) ≈ 0.0143
排名越高,得分越高,但差距被平滑。k 越大,不同排名之间的得分差距越小。
为什么有效?
- 不依赖分数:避免了分数归一化的问题
- 鲁棒性强:单路检索的异常高分不会主导结果
- 简单高效:只需要排名信息,计算量小
示例:
Query:"错误码 401"
向量检索结果:
1. Doc_A(向量分数 0.82)
2. Doc_B(向量分数 0.79)
3. Doc_C(向量分数 0.76)
BM25 检索结果:
1. Doc_D(BM25 分数 5.2)
2. Doc_C(BM25 分数 4.8)
3. Doc_A(BM25 分数 4.1)
RRF 融合(k=60):
Doc_A: 1/(60+1) + 1/(60+3) = 0.0164 + 0.0159 = 0.0323
Doc_B: 1/(60+2) + 0 = 0.0159(BM25 没召回)
Doc_C: 1/(60+3) + 1/(60+2) = 0.0159 + 0.0159 = 0.0318
Doc_D: 0 + 1/(60+1) = 0.0164(向量没召回)
最终排序:Doc_A > Doc_C > Doc_B > Doc_D
Doc_A 在两路都有出现,最终得分最高。这就是 RRF 的魅力:两路都召回的文档,更有可能是相关的。
Metadata 过滤
除了向量检索和 BM25,还可以利用元数据做过滤:
用户问:"销售合同模板在哪下载?"
先过滤:source = "sales_docs"
再检索:在销售文档范围内检索
好处:
- 缩小检索范围,减少噪声
- 提高检索精度
- 支持权限隔离(不同用户可见不同来源)
过滤可以在检索前(pre-filtering)或检索后(post-filtering)。主流向量库都支持 pre-filtering,效率更高。
四、Rerank:精排的原理
召回阶段的目标是"别漏掉”,所以召回数量较多,难免混入噪声。Rerank 阶段的目标是"降噪",从候选中选出最相关的。
为什么需要 Rerank
向量检索和 BM25 都是"双塔"结构:
Query → Encoder → Query Vector ─┐
├→ Similarity
Document → Encoder → Doc Vector ─┘
Query 和 Document 各自编码,只在最后计算相似度。
问题:Query 和 Document 之间的交互太少。编码时看不到对方,只能依赖各自的信息。
Rerank 的思路:让 Query 和 Document 充分交互,更精确地判断相关性。
Cross-Encoder 的工作原理
Cross-Encoder 是"单塔"结构:
Query + Document → Transformer → Relevance Score
把 Query 和 Document 拼在一起,丢进 Transformer,让模型自己学习两者的交互。
示意:
输入:[CLS] Query [SEP] Document [SEP]
Transformer 会计算每个 token 和其他所有 token 的注意力,
包括 Query token 和 Document token 之间的交互。
输出:[CLS] 位置的向量 → 全连接层 → 相关性分数
为什么比双塔更准?
双塔:Query 编码时看不到 Document,只能用通用的语义表示。 单塔:Query 和 Document 一起编码,模型可以看到两者之间的关系。
比如:
Query:"苹果的价格"
Document 1:"苹果公司股价下跌"
Document 2:"水果店苹果 5 元一斤"
双塔可能把两个 Document 都召回(都包含"苹果"和"价格"相关词)
Cross-Encoder 能判断:Document 2 更相关(语义上"价格"指水果价格,不是股价)
为什么不能替代检索?
Cross-Encoder 需要把 Query 和每个 Document 都拼起来过一遍模型。
计算量 = 候选数量 × Transformer 计算量
如果候选是 100 万,计算量巨大,延迟无法接受。
所以只能用在召回之后,对小规模候选做精排。
Rerank 模型
常用 Rerank 模型:
| 模型 | 特点 | 延迟(100 候选) |
|---|---|---|
| bge-reranker-large | 开源,效果好 | ~180ms |
| bge-reranker-base | 开源,更快 | ~95ms |
| cohere rerank-3 | API,效果好 | ~120ms |
| ms-marco-MiniLM | 开源,轻量 | ~45ms |
| jina-reranker-v2 | 开源,多语言 | ~80ms |
选择考虑:
- 追求效果:bge-reranker-large 或 cohere rerank-3
- 追求速度:ms-marco-MiniLM 或 bge-reranker-base
- 中文场景:bge-reranker-large 或 jina-reranker-v2
- 私有化部署:bge 系列
Rerank 的效果
实测对比:
| 配置 | Recall@5 | 正确答案平均排名 |
|---|---|---|
| 向量检索 | 72% | 4.2 |
| 混合检索(无 Rerank) | 79% | 3.5 |
| 混合 + Rerank | 88% | 1.8 |
Rerank 后,正确答案的平均排名从 3.5 提升到 1.8——明显更靠前,更容易进入最终的 prompt。
五、完整的检索流程
结合以上内容,一个完整的检索流程如下:
┌─────────────────────────────────────────────────────────────────┐
│ 用户 Query │
│ "错误码 401 是什么意思" │
└────────────────────────┬────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 阶段 1:候选召回(Recall) │
│ │
│ 目标:不漏掉,召回足够多的候选 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 向量检索 │ │ BM25 检索 │ │
│ │ top-50 │ │ top-50 │ │
│ │ │ │ │ │
│ │ 擅长语义相似 │ │ 擅长精确匹配 │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ └─────────┬──────────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ RRF 融合 │ │
│ │ ~80 候选 │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Metadata │ │
│ │ 过滤 │ │
│ │ ~60 候选 │ │
│ └──────┬──────┘ │
└─────────────────────┼──────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 阶段 2:精排(Rerank) │
│ │
│ 目标:从候选中选出最相关的 │
│ │
│ 输入:~60 个候选 │
│ 处理:Cross-Encoder 重新打分 │
│ 输出:top-10 │
│ │
│ 耗时:~150ms │
└────────────────────────┬────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 阶段 3:上下文组装 │
│ │
│ 目标:控制进入 prompt 的内容 │
│ │
│ 输入:top-10 候选 │
│ 处理: │
│ 1. 控制总长度(如 4000 tokens) │
│ 2. 保持多样性(不同 source 的 chunk) │
│ 3. 去重 │
│ 输出:5-8 个 chunk │
└────────────────────────┬────────────────────────────────────────┘
▼
构建 Prompt
调用 LLM
答案
各阶段的参数建议
| 阶段 | 参数 | 推荐值 | 说明 |
|---|---|---|---|
| 向量召回 | top_k | 50-100 | 宁多勿少 |
| BM25 召回 | top_k | 50-100 | 宁多勿少 |
| RRF | k | 60 | 经验值,一般不用调 |
| Rerank | 输入 | 20-30 | 不用太大,计算慢 |
| Rerank | 输出 | 10-15 | 给 prompt 组装留空间 |
| Prompt 组装 | 最终数量 | 3-8 | 不宜过多 |
| Prompt 组装 | token 预算 | 3000-4000 | 根据模型调整 |
各阶段的延迟占比
总延迟:~300-500ms
向量检索:30-50ms(10%)
BM25 检索:10-20ms(5%)
RRF 融合:<5ms(<1%)
Rerank:150-200ms(50%)← 主要瓶颈
Prompt 组装:<10ms(<5%)
LLM 调用:100-200ms(35%)← 另一个瓶颈
优化方向:
- 减少 Rerank 候选数:从 30 减到 20,节省 ~50ms
- 用更快的 Rerank 模型:bge-reranker-base 比 large 快 ~80ms
- 缓存热门 query:相同问题直接返回,跳过整个流程
六、Query Rewrite:高级优化
检索质量不仅取决于检索算法,还取决于 Query 本身。
用户的问题往往不够清晰,需要"改写"才能更好地检索。
常见的改写策略
1. 同义词扩展
原始:"怎么获取令牌"
改写:"怎么获取令牌 OR token OR access_token"
2. 拼写纠正
原始:"错误码 4O1"(字母 O)
改写:"错误码 401"(数字 0)
3. 核心词提取
原始:"我想了解一下关于 API 超时时间的配置方法"
改写:"API 超时时间 配置"
去掉无意义的词,保留核心检索词。
4. Query 分解
原始:"对比 v2.0 和 v2.1 版本的 API 差异"
分解:
- Query 1:"v2.0 版本 API 特性"
- Query 2:"v2.1 版本 API 特性"
- Query 3:"v2.0 v2.1 版本差异"
复杂问题拆成多个简单问题,分别检索,再合并。
5. 假设文档生成(HyDE)
原始:"API 的认证方式是什么"
生成假设文档:
"API 的认证方式使用 Bearer Token。所有请求需要在 Header 中携带
Authorization: Bearer <token>。Token 有效期 24 小时..."
用假设文档去检索,而不是原始 query。
直觉:假设文档和真实文档的相似度,可能比 query 和文档的相似度更高。
Query Rewrite 的权衡
| 优点 | 缺点 |
|---|---|
| 提高检索质量 | 增加 LLM 调用成本 |
| 处理复杂问题 | 增加延迟 |
| 适应用户表达习惯 | 可能改写错误 |
建议:
- 简单问题不需要改写
- 复杂问题、长尾问题适合改写
- 可以用规则+LLM结合,降低成本
七、一个推荐的升级路线
如果你现在还是单路向量检索,建议按这个顺序升级:
第一步:优化 Chunking(成本低,效果好)
- 从固定长度改为按段落/标题切分
- 给每个 chunk 补充父级标题上下文
预期效果:Recall@5 从 60% 提升到 75%
第二步:引入 BM25(成本中)
- 部署 BM25 检索
- 实现 RRF 融合
预期效果:Recall@5 从 75% 提升到 82%
第三步:接入 Rerank(成本中)
- 部署 Cross-Encoder 模型
- 调整召回数量和精排数量
预期效果:Recall@5 从 82% 提升到 88%
第四步:高级优化(成本高)
- Query Rewrite
- Query Decomposition
- HyDE
预期效果:Recall@5 从 88% 提升到 92%+
收益递减规律
从 60% 到 80%:相对容易,基础优化即可 从 80% 到 90%:需要系统优化,混合检索+Rerank 从 90% 到 95%:难度大增,需要高级策略和大量调试
建议:先做到 85%,再考虑是否值得继续投入。
八、本文小结
RAG 做得好不好,很多时候不是模型不够强,而是:
- Chunk 没切好 — 语义不完整、缺上下文
- 候选召回太单一 — 只用向量,对精确匹配不稳定
- Rerank 没真正落地 — 用"假 rerank"或根本没 rerank
核心要点:
- Chunking:按语义结构切分 + 补充上下文
- 混合检索:向量(语义)+ BM25(精确)+ RRF 融合
- Rerank:Cross-Encoder 做真正的精排
- Query Rewrite:处理复杂和长尾问题
下一篇会讲:如何做 RAG 的评测和 Inspect,才能知道问题到底出在哪一层。
