Featured image of post RAG进阶:Chunking、召回、Hybrid Search 与 Rerank

RAG进阶:Chunking、召回、Hybrid Search 与 Rerank

RAG 做得好不好,很多时候不是模型问题,而是 chunking、召回策略和 rerank 设计的问题。

很多人第一次做 RAG,默认流程都是:

  1. 文档切 chunk
  2. 生成 embedding
  3. 向量检索 top_k
  4. 拼 prompt
  5. 让模型回答

这条路径能跑,但往往不稳。真正让 RAG 质量提升一个台阶的,通常是三件事:

  • chunking 做得更合理 — 切出来的 chunk 能独立成为证据
  • 召回从单路 dense 变成多路召回 — 结合语义和关键词
  • 用真正的 rerank 做精排 — 不是简单重排,而是用 cross-encoder 重新打分

这篇文章会深入讲这三个环节的算法原理。

一、Chunking:为什么"切短一点"不简单

Chunking 的目标不是凑 embedding 输入长度,而是让"一个 chunk 刚好能独立成为证据"。

好的 Chunk 应该满足什么

四个标准:

  1. 语义完整 — 一个 chunk 能回答一个独立的问题
  2. 粒度适中 — 太大噪声多,太小信息不够
  3. 上下文自洽 — 不依赖外部信息也能理解
  4. 可追溯 — 知道来自哪个文档、哪个章节

固定长度切分的问题

最简单的做法是按字符数切:

每 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说明
原始 chunk41%大量 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 的文档数

直觉理解

  1. 词频饱和(f × (k1+1)) / (f + k1) 是一个饱和函数。当 f 很大时,增长变慢。一个词出现 10 次,权重不是 10 倍,而是趋于 k1+1。

  2. 长度归一化:长文档天然有更高的词频,需要惩罚。|D|/avgdl 是相对长度,通过参数 b 控制惩罚程度。

  3. IDF 奖励稀有词:在少数文档中出现的词,IDF 高,权重高。

BM25 的特点

优点缺点
对精确匹配敏感不理解语义
对编号、术语稳定同义词问题(“电脑"和"计算机"是两个词)
计算快,可解释需要分词(中文需要额外的分词步骤)
不需要训练不适合自然语言问题

适用场景

  • 用户查询包含编号、版本号、专业术语
  • 文档中有大量缩写、代码
  • 需要精确匹配的场景

中文分词

BM25 依赖词的边界。英文天然有空格分隔,中文需要额外的分词步骤。

常用分词工具:

工具特点
jieba最流行,词典+HMM
pkuseg北大出品,准确率高
HanLP功能全,支持多种任务

分词质量直接影响 BM25 效果。比如:

"用户体验设计"

分词 1:用户 / 体验 / 设计(三个词)
分词 2:用户体验 / 设计(两个词)
分词 3:用户 / 体验设计(两个词)

不同的分词,检索结果不同。

三、Hybrid Retrieval:语义 + 关键词

向量检索和 BM25 各有优劣,互补性很强。

为什么需要混合

向量检索:
- 擅长:语义相似、同义词、自然语言问题
- 弱项:精确匹配、编号、专业术语

BM25:
- 擅长:精确匹配、编号、专业术语
- 弱项:语义理解、同义词、自然语言问题

实测对比

查询类型向量检索 Recall@5BM25 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 越大,不同排名之间的得分差距越小。

为什么有效

  1. 不依赖分数:避免了分数归一化的问题
  2. 鲁棒性强:单路检索的异常高分不会主导结果
  3. 简单高效:只需要排名信息,计算量小

示例

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-3API,效果好~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
混合 + Rerank88%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_k50-100宁多勿少
BM25 召回top_k50-100宁多勿少
RRFk60经验值,一般不用调
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%)← 另一个瓶颈

优化方向:

  1. 减少 Rerank 候选数:从 30 减到 20,节省 ~50ms
  2. 用更快的 Rerank 模型:bge-reranker-base 比 large 快 ~80ms
  3. 缓存热门 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 做得好不好,很多时候不是模型不够强,而是:

  1. Chunk 没切好 — 语义不完整、缺上下文
  2. 候选召回太单一 — 只用向量,对精确匹配不稳定
  3. Rerank 没真正落地 — 用"假 rerank"或根本没 rerank

核心要点:

  • Chunking:按语义结构切分 + 补充上下文
  • 混合检索:向量(语义)+ BM25(精确)+ RRF 融合
  • Rerank:Cross-Encoder 做真正的精排
  • Query Rewrite:处理复杂和长尾问题

下一篇会讲:如何做 RAG 的评测和 Inspect,才能知道问题到底出在哪一层。

使用 Hugo 构建
主题 StackJimmy 设计