cc_rag_tutorial

第 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 意图识别与改写

3.2 元数据过滤 (Pre-filtering)

这是提升检索准确率最“廉价”且有效的方法。

Rule of Thumb (经验法则)Filter First, Search Later (先过滤,后搜索)。虽然某些向量库支持后过滤(Post-filtering),但在代码库这种数据量级下,先通过元数据把搜索范围缩小到特定目录或语言,能显著提升向量检索的精度和速度。


4. 阶段 1:混合检索 (Hybrid Retrieval)

在代码 RAG 场景中,单靠向量检索(Dense Retrieval)是绝对不够的。

4.1 为什么必须结合关键词(Sparse Retrieval / BM25)?

4.2 融合策略:RRF vs. 线性加权

当你分别拿到了“向检索 Top 100”和“BM25 Top 100”,怎么合并?

  1. 线性加权 (Linear Weighted Sum)
    • 公式:Score = alpha * Vector_Score + (1 - alpha) * BM25_Score
    • 痛点:向量分数通常在 0.7~0.9 之间,BM25 分数可能在 10~50 之间。必须做极其复杂的归一化(Normalization)才能让它们可加。
  2. 倒数排名融合 (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 的魔力

5.2 重排模型选择与优化

5.3 关键:分数阈值 (Thresholding)

重排模型会输出一个 0~1 的相关性分数。


6. 高级策略:父文档检索与窗口扩展

在第 6 章切分时,我们把代码切碎了。在检索时,我们需要把它们“拼回去”一点,以便 CC 理解上下文。

6.1 父文档检索 (Parent Document Retrieval)

6.2 窗口扩展 (Context Window Expansion)


7. 本章小结


8. 练习题

基础题 (熟悉材料)

  1. 为什么在代码检索中,纯向量检索(Dense Retrieval)往往表现不佳?请举两个具体场景。
    点击查看提示与参考答案 * **提示**:思考代码中的“非自然语言”成分。 * **参考答案**: 1. **精确标识符匹配**:用户搜索 `0x80040154` 或特定函数名 `do_not_call_this()`。向量模型关注语义相似度,可能会返回其他“看起来像错误码”或“具有否定意义”的函数,而不是字面精确匹配。 2. **版本差异**:`v1` 和 `v2` 在语义上极度相似,向量距离极近,但功能可能完全不同。纯向量检索很难区分这种细微的版本号差异。
  2. 解释 RRF(Reciprocal Rank Fusion)公式中的常数 k 的作用,以及它如何帮助融合向量和关键词检索的结果。
    点击查看提示与参考答案 * **提示**:考虑排名靠前的文档权重衰减速度。 * **参考答**: * 公式:`score = 1 / (k + rank)`。 * `k`(通常取 60)用于平滑排名的影响。它防止排名极高(如第 1 名)的文档在单一检索源中占据过大的统治地位。 * RRF 通过使用排名而非绝对分数,规避了向量分数(0-1)和 BM25 分数(0-infinite)分布不同、难以直接相加的问题,提供了一种鲁棒的“投票”机制。
  3. 什么是“父文档检索”(Parent Document Retrieval),它试图解决什么矛盾?
    点击查看提示与参考答案 * **提示**:检索粒度 vs 理解粒度。 * **参考答案**: * 它解决的是**切分粒度**的矛盾:小的 Chunk 语义更集中,更容易被检索到(召回率高);但小的 Chunk 缺乏上下文,LLM 难以理解(可读性差)。 * 策略是:用小块去匹配 Query,命中后返回该小块所属的大块(父文档)给 LLM。

挑战题 (架构与思考)

  1. 场景设计:你正在为 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)。
  2. 关于重排的性能:假设你的全量库有 100 万个 chunk。如果不做倒排索引,直接暴力扫描所有向量需要 200ms。如果你引入了一个高性能的 Cross-Encoder 重排模型(单次打分耗时 10ms)。请计算:如果对全量数据进行重排,大概需要多久?这说明了什么架构原则?
    点击查看提示与参考答案 * **提示**:简单的乘法,感受数量级的差异。 * **参考答案**: * 计算:1,000,000 chunks * 10ms = 10,000,000ms = 10,000秒 ≈ 2.7小时。 * 结论:这是不可接受的。说明了**漏斗原则**的必要性:重排(Stage 2)只能应用于极小规模的候选集(如 Top 50),绝不能应用于全量数据。必须先用廉价的算法(向量/索引)快速筛掉 99.99% 的数据。
  3. 开放性思考: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。系统自动截断了后半部分。 结果:关键词正好在文档后半部分。重排模型看了前半部分,觉得“不相关”,给了低分,导致包含答案的文档被过滤掉。 修复

陷阱 3:忽略“空结果”的价值

现象:为了保证“总有东西给 CC”,RAG 总是强制返回 Top 3,哪怕相似度极低。 结果:CC 被迫使用不相关的上下文进行回答,产生严重的幻觉(胡编乱造引用)。 修复:在返回给 CC 之前,必须检查重排分数。如果最高分 < 0.3(具体阈值需测试),必须返回空列表或明确的文字说明:“未找到相关资料”。这会触发 CC 尝试换个关键词搜索或请求人工帮助,而不是一本正经地胡说八道。

陷阱 4:测试集的 Query 过于简单

现象:开发时只用“这是什么?”类的问题测试,上线后 CC 总问“对比 A 和 B 的区别”或“由于 X 导致的 Y 怎么修”。 结果:简单的单跳检索无法满足复杂推理需求。 修复:在评估检索效果时,构建包含多跳逻辑(Multi-hop)和代码细节的真实 Query 集合。参考 Chapter 11 的评估方法。