第 8 章:检索与重排——从“能搜到”到“搜得准”
1. 开篇与目标
在上一章,我们解决了数据的“存储”问题。现在,你的外挂 RAG 就像一个拥有数万个代码片段和文档的图书馆。然而,存储只是手段,检索才是目的。
当 Claude Code (CC) 试图解决一个 Bug 时,它发出的查询往往是模糊的(“为什么鉴权失败了?”)或者极其具体的(“查找 UserFactory.java 中处理 NullPtr 的逻辑”)。如果你只给它返回“相关的”但版本过期的代码,或者只返回了函数体却丢掉了类定义,CC 的表现就会大打折扣,甚至产生幻觉。
本章目标:
我们将构建一个工业级的多阶段检索流水线(Multi-Stage Retrieval Pipeline)。我们将超越简单的“向量相似度”,深入探讨 Query 理解、混合检索(Hybrid Search)、重排(Reranking) 以及 上下文窗口优化。
我们要把 RAG 从一个简单的“搜索引擎”变成一个懂代码结构、懂语义、能区分版本的“智能图书管理员”。
2. 核心架构:检索漏斗 (The Retrieval Funnel)
一个高精度的 RAG 系统,本质上是一个不断过滤噪音的“漏斗”。我们不能把所有可能相关的文档都交给昂贵的重排模型,更不能都塞进 CC 有限的上下文窗口。
2.1 漏斗模型详解
[ 用户/CC 的原始 Query ]
|
v
[ 阶段 0: Query 预处理与改写 (Query Understanding) ]
| --> 提取元数据过滤器 (Filter: language='python', repo='backend')
| --> 扩展同义词 / 补全上下文
v
[ 全量索引库 (100k+ Chunks) ]
|
| <-- 阶段 1: 混合召回 (Hybrid Retrieval)
| 并行执行:
| A. 稠密向量检索 (Dense) -> 语义相关 (Top 100)
| B. 稀疏关键词检索 (BM25) -> 精确匹配 (Top 100)
v
[ 粗排合并候选集 (Top 100-200) ]
| --> 应用 Reciprocal Rank Fusion (RRF) 或加权融合
v
[ 阶段 2: 精细重排 (Cross-Encoder Reranking) ]
| --> 深度模型逐对打分 (Query, Doc)
| --> 过滤低分噪音 (Score Thresholding)
v
[ 精选集 (Top 10) ]
|
| <-- 阶段 3: 上下文窗口适配 (Context Packaging)
| 移除重叠内容,按 Token 预算截断
v
[ 最终 Payload (喂给 CC) ]
3. 阶段 0:Query 理解与预处理 (Query Understanding)
直接拿 CC 的输入去搜,通常效果不好。CC 的输入可能是“查看那个文件”,而你需要知道“那个”是指哪个。
3.1 意图识别与改写
- 指代消解:如果 CC 处于多轮对话中,Query 可能是“它在哪里定义的?”。你需要结合对话历史(Conversation History),将其重写为“
auth_middleware 函数在哪里定义的?”。
- 假设性文档嵌入 (HyDE):对于非代码类的知识问答(如“如何配置环境变量?”),先让一个小模型生成一段“虚构的理想文档”,然后用这个虚构文档的向量去搜真实文档。这在语义对齐上往往比直接搜问题更有效。
- 代码实体提取:识别 Query 中的
camelCase 或 snake_case 词汇。如果 Query 包含明确的代码符号,强制提升关键词检索的权重。
3.2 元数据过滤 (Pre-filtering)
这是提升检索准确率最“廉价”且有效的方法。
- 文件类型锁定:如果 CC 问“如何编写测试用例”,自动添加
filter: path.endswith('_test.py')。
- 时间/版本切片:如果你的库包含旧版本代码,务必通过 Metadata 过滤掉
status: deprecated 或只检索 commit_date 在最近一的数据。
Rule of Thumb (经验法则):
Filter First, Search Later (先过滤,后搜索)。虽然某些向量库支持后过滤(Post-filtering),但在代码库这种数据量级下,先通过元数据把搜索范围缩小到特定目录或语言,能显著提升向量检索的精度和速度。
4. 阶段 1:混合检索 (Hybrid Retrieval)
在代码 RAG 场景中,单靠向量检索(Dense Retrieval)是绝对不够的。
4.1 为什么必须结合关键词(Sparse Retrieval / BM25)?
- 精确标识符:代码中充斥着
0x80040154、AbstractUserFactoryImpl、v1.2.3-beta 这种没有任何自然语言语义的符号。向量模型很难将它们映射到正确的空间。
- 缩写与术语:向量模型可能认为 “K8s” 和 “Container” 很像,但当你搜 “K8s Config” 时,你不需要一般的容器文档,你需要包含 “K8s” 这个词的确切文件。
4.2 融合策略:RRF vs. 线性加权
当你分别拿到了“向检索 Top 100”和“BM25 Top 100”,怎么合并?
- 线性加权 (Linear Weighted Sum):
- 公式:
Score = alpha * Vector_Score + (1 - alpha) * BM25_Score
- 痛点:向量分数通常在 0.7~0.9 之间,BM25 分数可能在 10~50 之间。必须做极其复杂的归一化(Normalization)才能让它们可加。
- 倒数排名融合 (Reciprocal Rank Fusion, RRF):
- 原理:不看具体分数,只看排名。如果一个文档在向量结果排第 1,在关键字结果排第 2,它的得分就很高。
- 公式:
RRF_Score(d) = 1 / (k + Rank_Vector(d)) + 1 / (k + Rank_BM25(d))
- 优势:无需归一化,鲁棒性极强,是目前工业界的主流选择。
Rule of Thumb (经验法则):
对于代码搜索,BM25 的重要性往往大于向量。
如果使用线性加权,建议给予稀疏检索更高的权重(例如 0.6 vs 0.4)。如果不想调参,直接使用 RRF,设置常数 k=60,效通常优于手动调参的线性加权。
5. 阶段 2:重排 (Reranking)——精度的分水岭
召回阶段看重的是“漏斗口径大”,重排阶段看重的是“火眼金睛”。
5.1 Cross-Encoder 的魔力
- Bi-Encoder (召回用):Query 和 Document 互不相见,分别算出向量,最后算距离。速度快,但丧失了细微的交互特征。
- Cross-Encoder (重排用):把 Query 和 Document 拼成一句话:
[CLS] Query [SEP] Document [SEP],扔进 BERT 模型层层交互。它能“读懂”文档是否真的回答了问题。
5.2 重排模型选择与优化
- 模型选择:不要使用生成式 LLM(如 GPT-4)做重排,太慢且贵。推荐使用专门训练的重排小模型,如
bge-reranker-v2-m3 或 cohere-rerank API。
- 输入截断:Cross-Encoder 通常有输入长度限制(如 512 tokens)。
- 技巧:如果你检索的是长文档,不要只传开头。尽量传包含“匹配关键词周边的窗口内容(Passage Snippet)。
5.3 关键:分数阈值 (Thresholding)
重排模型会输出一个 0~1 的相关性分数。
- 问题:如果用户问了一个知识库里完全没有的问题,召回阶段仍会强行返回 Top 100 个“最不坏”的垃圾片段。
- 对策:设置硬阈值(例如 0.5)。如果 Top 1 的分数都低于 0.5,直接返回空结果。这能有效防止 CC 产生幻觉(因为 CC 倾向于信任你给的工具返回)。
6. 高级策略:父文档检索与窗口扩展
在第 6 章切分时,我们把代码切碎了。在检索时,我们需要把它们“拼回去”一点,以便 CC 理解上下文。
6.1 父文档检索 (Parent Document Retrieval)
- 原理:索引时切成小块(Small Chunk),存储时保留大块(Large Chunk / Parent Document)。
- 流程:
- 用小块(200 tokens)进行高精度检索。
- 命中后,不返回小块,而是返回它所属的“父档块”(比如整个函数或 1000 tokens 的窗口)。
- 价值:既保留了检索的精准度,又保证了喂给 CC 的上下文是完整的。
6.2 窗口扩展 (Context Window Expansion)
- 如果检索到了第 50-60 行代码,自动前后扩展 10 行。
- 对于代码 RAG,这一步至关重要,因为孤立的一行代码往往毫无意义。
7. 本章小结
- 漏斗思维:从 100,000 条数据过滤到 10 条,每一层使用计算成本递增的算法。
- 混合检索是标配:代码库检索必须包含关键词匹配(BM25),否则无法处理变量名和错误码。RRF 是融合两者的最佳算法。
- 重排决定体验:Cross-Encoder 是提升 RAG 精度的“银弹”,务必配合分数截断使用。
- 宁缺毋滥:如果重排分数过低,告诉 CC “没找到”,比给它错误的参考资料更好。
8. 练习题
基础题 (熟悉材料)
- 为什么在代码检索中,纯向量检索(Dense Retrieval)往往表现不佳?请举两个具体场景。
点击查看提示与参考答案
* **提示**:思考代码中的“非自然语言”成分。
* **参考答案**:
1. **精确标识符匹配**:用户搜索 `0x80040154` 或特定函数名 `do_not_call_this()`。向量模型关注语义相似度,可能会返回其他“看起来像错误码”或“具有否定意义”的函数,而不是字面精确匹配。
2. **版本差异**:`v1` 和 `v2` 在语义上极度相似,向量距离极近,但功能可能完全不同。纯向量检索很难区分这种细微的版本号差异。
- 解释 RRF(Reciprocal Rank Fusion)公式中的常数
k 的作用,以及它如何帮助融合向量和关键词检索的结果。
点击查看提示与参考答案
* **提示**:考虑排名靠前的文档权重衰减速度。
* **参考答**:
* 公式:`score = 1 / (k + rank)`。
* `k`(通常取 60)用于平滑排名的影响。它防止排名极高(如第 1 名)的文档在单一检索源中占据过大的统治地位。
* RRF 通过使用排名而非绝对分数,规避了向量分数(0-1)和 BM25 分数(0-infinite)分布不同、难以直接相加的问题,提供了一种鲁棒的“投票”机制。
- 什么是“父文档检索”(Parent Document Retrieval),它试图解决什么矛盾?
点击查看提示与参考答案
* **提示**:检索粒度 vs 理解粒度。
* **参考答案**:
* 它解决的是**切分粒度**的矛盾:小的 Chunk 语义更集中,更容易被检索到(召回率高);但小的 Chunk 缺乏上下文,LLM 难以理解(可读性差)。
* 策略是:用小块去匹配 Query,命中后返回该小块所属的大块(父文档)给 LLM。
挑战题 (架构与思考)
- 场景设计:你正在为 CC 开发一个“私有库依赖查询”工具。用户经常会问:“最新的 auth-lib 里怎么配置超时?”。但在你的索引中,存在 auth-lib 的 v1.0, v1.2, v2.0 三个版本的文档。请设计一个检索策略,确保 CC 拿到的是 v2.0 的配置,而不是旧版的。
点击查看提示与参考答案
* **提示**:Metadata 是关键,不能仅靠重排。
* **参考答案**:
1. **索引阶段**:在 chunk 的 metadata 中必须包含 `library_name`, `version`, `release_date`。
2. **Query 理解阶段**:解析用户 Query 中的“最新”意图。这可以通过让 CC 先调用一个 `get_latest_version(lib_name)` 工具,或者在 RAG 内部维护一个版本映射表。
3. **检索过滤**:
* *策略 A(显式)*:将 Query 改写为包含过滤条件 `filter: { library: "auth-lib", version: "2.0" }`。
* *策略 B(隐式/衰减)*:如果必须召回所有版本,在**重排阶段**加入自定义打分逻辑(Function Score),根据 `version` 或 `release_date` 对旧文档进行分数降权(Decay Function)。
- 关于重排的性能:假设你的全量库有 100 万个 chunk。如果不做倒排索引,直接暴力扫描所有向量需要 200ms。如果你引入了一个高性能的 Cross-Encoder 重排模型(单次打分耗时 10ms)。请计算:如果对全量数据进行重排,大概需要多久?这说明了什么架构原则?
点击查看提示与参考答案
* **提示**:简单的乘法,感受数量级的差异。
* **参考答案**:
* 计算:1,000,000 chunks * 10ms = 10,000,000ms = 10,000秒 ≈ 2.7小时。
* 结论:这是不可接受的。说明了**漏斗原则**的必要性:重排(Stage 2)只能应用于极小规模的候选集(如 Top 50),绝不能应用于全量数据。必须先用廉价的算法(向量/索引)快速筛掉 99.99% 的数据。
- 开放性思考:CC 有时会生成多个 Query 来尝试从不同角度搜索(Multi-Query Strategy)。这会给你的检索系统带来什么压力?你应该如何在“多样性”和“响应延迟”之间做平衡?
点击查看提示与参考答案
* **提示**:并发、缓存、结果去重。
* **参考答案**:
* **压力**:Multi-Query 意味着检索请求量翻倍(N 倍),且重排计算量也翻倍。
* **平衡策略**:
1. **并发执行**:并行处理这 N 个 Query 的召回阶段。
2. **结果去重**:不同 Query 可能会召回相同的文档,必须在送入重排模型前进行 ID 去重(Deduplication),避免重复计算。
3. **共享重排**:将去重后的候选集统一进行一次排,而不是对每个 Query 的结果分别重排。
4. **最大限制**:限制 CC 生成 Query 的数量(如最多 3 个),防止 DOS 攻击式的调用。
9. 常见陷阱与错误 (Gotchas)
陷阱 1:迷信“向量万能论”
现象:开发者仅部署了向量数据库,未配置 BM25。
结果:当 CC 想要查找特定报错 Error: 1024 时,系统返回了包含 1024 端口号的配置代码,而不是错误定义,导致 CC 无法解决具体 Bug。
修复:务必实现混合检索。如果向量库(如 Chroma/Milvus)原生支持混合检索,请开启;如果不支持,需使用 Elasticsearch/OpenSearch 或手动集成 BM25 库(如 rank_bm25)。
陷阱 2:重排阶段的输入截断 (Truncation silently kills context)
现象:文档很长(2000 tokens),Cross-Encoder 模型限制 512 tokens。系统自动截断了后半部分。
结果:关键词正好在文档后半部分。重排模型看了前半部分,觉得“不相关”,给了低分,导致包含答案的文档被过滤掉。
修复:
- 确保索引时的 Chunk Size 小于重排模型的窗口限制。
- 或者在重排前,基于关键词在文档中的位置,动态截取包含关键词的片段喂给重排模型。
陷阱 3:忽略“空结果”的价值
现象:为了保证“总有东西给 CC”,RAG 总是强制返回 Top 3,哪怕相似度极低。
结果:CC 被迫使用不相关的上下文进行回答,产生严重的幻觉(胡编乱造引用)。
修复:在返回给 CC 之前,必须检查重排分数。如果最高分 < 0.3(具体阈值需测试),必须返回空列表或明确的文字说明:“未找到相关资料”。这会触发 CC 尝试换个关键词搜索或请求人工帮助,而不是一本正经地胡说八道。
陷阱 4:测试集的 Query 过于简单
现象:开发时只用“这是什么?”类的问题测试,上线后 CC 总问“对比 A 和 B 的区别”或“由于 X 导致的 Y 怎么修”。
结果:简单的单跳检索无法满足复杂推理需求。
修复:在评估检索效果时,构建包含多跳逻辑(Multi-hop)和代码细节的真实 Query 集合。参考 Chapter 11 的评估方法。