cc_rag_tutorial

第 6 章:切分(Chunking)与索引策略——RAG 好坏的分水岭

1. 开篇段落:切分是给 CC 的“咀嚼”预处理

在 RAG 系统中,如果数据摄取是“买菜”,向量化是“烹饪”,那么 切分(Chunking) 就是至关重要的“刀工”。

很多开发者有一个误区:认为 RAG 效果不好是因为 Embedding 模型不够强。实战经验表明,80% 的检索失败源于糟糕的切分策略

对于 Claude Code (CC) 这样的智能体,切分不仅仅是为了“搜得到”,更是为了“能干活”。一个好的 Chunk 必须是一个 “可执行的最小语义单元”。它不仅要包含知识,还要携带让 CC 能够定位、引用并修改源文件的坐标信息。

本章学习目标

  1. 掌握 固定窗口语义结构代码 AST 三种切分流派的适用场景。
  2. 理解并应用 父子索引(Parent-Child Indexing) 策略,解决“搜得准”与“看得全”的矛盾。
  3. 设计一套面向 CC 的 元数据(Metadata)标准,确保每个 Chunk 都可溯源。
  4. 学会处理 索引的生命周期:增量更新、去重与过期数据清理。

2. 核心论述

2.1 切分的三重境界

第一重:机械切分(Naive Chunking)

最简单粗暴的方式:设定一个固定字符数(如 500 tokens),设定一个重叠区(Overlap),然后像切香肠一样切下去。

ASCII 演示:机械切分的悲剧

[原始代码]
def calculate_tax(income):
    if income < 5000:
        return 0
    return income * 0.2

[Chunk 1 (固定长度截断)]
def calculate_tax(income):
    if income < 5000:
        re
---------------------------- (语义断裂点)
[Chunk 2]
turn 0
    return income * 0.2

CC 读到 Chunk 1 会困惑:re 是什么?读到 Chunk 2 会困惑:turn 0 是什么指令?

第二重:结构化切分(Structure-Aware Chunking)

针对 Markdown、HTML 等标记语言。利用文档天生的层级(Headers, Paragraphs)作为边界。

第三重:代码语义切分(Code/AST Splitting)—— CC RAG 的核心

这是做代码库 RAG 的必经之路。你不能把代码当文本处理,你必须把代码当“逻辑树”处理。 利用解析器(如 Tree-sitter 逻辑)识别代码块边界。


2.2 高级策略:父子索引 (Parent-Child Indexing)

这是提升 RAG 效果的 Rule of Thumb No.1

痛点

解决方案:把“用来搜的”和“给 CC 看的”分开。

       [原始文档: auth_service.py] (200行)
                   |
       +-----------+-----------+
       | 切分 (Splitting)      |
       v                       v
[Parent Chunk A]        [Parent Chunk B]
(行 1-100, 完整类定义)    (行 101-200, 工具函数)
       |                       |
   +---+---+               +---+---+
   |  切分 |               |  切分 |
   v       v               v       v
[Child 1] [Child 2]     [Child 3] [Child 4]
(行10-20) (行30-50)     (行110-120)...
   |                       |
[向量化]                 [向量化]
   |                       |
   v                       v
[向量库索引] <----映射----> [大块内容存储]

工作流

  1. Child Chunks 进行向量化搜索。
  2. 命中 Child Chunk 后,不返回 Child 的内容。
  3. 通过 ID 找到并返回对应的 Parent Chunk(包含完整上下文的大块代码)。
  4. CC 既能精准命中某行特定代码,又能看到该函数所在的整个类结构。

2.3 元数据(Metadata)设计:CC 的导航仪

对于通用聊天机器人,返回文本就够了。但对于 CC,元数据与文本同等重要。因为 CC 可能需要根据元数据调用 edit 工具或 grep 工具。

必选元数据清单 (The Must-Haves)

字段名 类型 说明 CC 的用途
source / file_path String 相对项目根目录的路径 告诉 CC 文件在哪里,用于 read_file
start_line Int 片段在文件中的起始行 辅助定位
end_line Int 片段在文件中的结束行 辅助定位范围
language String python, typescript, md 帮助 LLM 启用对应的语法高亮和解析模式
digest / hash String 内容的哈希值 (md5/sha256) 用于索引维护,判断文件是否变更

推荐扩展元数据 (The Nice-to-Haves)


2.4 索引维护:增量更新与去重

不要每次运行 RAG 都把整个代码库删了重练,那太慢且费钱。你需要一个 “索引注册表 (Manifest)”

增量更新逻辑 (Rule of Thumb)

  1. 扫描:遍历文件系统,计算每个文件的 Hash。
  2. 对比:与上次运行的 Manifest 对比。
    • 新增:直接切分并入库。
    • 修改先删除该文件旧版本产生的所有 Chunk(根据 file_path 过滤删除),再插入新 Chunk。
    • 删除:必须检测到文件消失,并清理向量库中的残留数据(避免“幽灵代码)。
    • 未变:跳过,不做任何操作。

3. 本章小结

  1. 代码 != 文本:永远优先使用 AST 或基于缩进的代码专用切分器,保持函数/类的完整性。
  2. 重叠是保险带:机械切分必须有 10%-20% 的 Overlap,防止边缘关键词丢失。
  3. 小搜大回(Parent-Child):这是平衡检索精度与上下文完整性的最佳实践。
  4. 元数据即指令:必须保留 file_path 和行号,否则 CC 只能“纸上谈兵”,无法落地修改。
  5. 拒绝脏数据:建立增量更新机制,及时清理已删除文件的索引,防止 CC 引用不存在的代码。

4. 练习题

基础题(熟悉概念)

习题 1:重叠窗口(Overlap)的计算 **问题**:假设你使用固定长度切分,Chunk Size = 500 tokens,Overlap = 50 tokens。 原始文本长度为 1200 tokens。 请问理论上会生成几个 Chunk?请粗略画出第三个 Chunk 的覆盖范围(Token 索引)。 **提示**:步长(Step) = Size - Overlap。 > **参考答案**: > * 步长 = 500 - 50 = 450 tokens。 > * Chunk 1: 0 - 500 > * Chunk 2: 450 - 950 > * Chunk 3: 900 - 1200 (1400截断到1200) > * **共生成 3 个 Chunk**。 > * Chunk 3 的范围是 Token 索引 **900 到 1200**。
习题 2:元数据的缺失后果 **问题**:你在向量库中存储了高质量的代码片段,但为了节省空间,没有存储 `file_path` 和 `line_number`。 当用户问 CC:“请帮我重构 `UserLogin` 类中的验证逻辑”时,RAG 检索到了相关代码并喂给了 CC。 请预测 CC 接下来的行为或回答,并说明为什么这对用户体验是糟糕的。 **提示**:CC 是一个需要“操作”文件的 Agent。 > **参考答案**: > CC 会回答:“我找到了相关的验证逻辑代码,建议你这样修改...(列出代码)”。 > 但 CC **无法直接执行修改**,它可能会追问用户:“请问这个 `UserLogin` 类在哪个文件里?”或者尝试盲猜文件名。 > 用户体验糟糕点在于:**自动化中断**。用户原本期望 CC 直接改代码,结果变成了“问答模式”,用户还得自己去文件系统里找位置。
习题 3:代码切分的边界 **问题**:对于 Python 代码,为什么建议把 `@decorator`(装饰器)和它下方的函数定义切分在同一个 Chunk 里?如果切开了会有什么影响? **提示**:装饰器通常改变了函数的行为。 > **参考答案**: > * **语义绑定**:装饰器(如 `@app.route('/login')` 或 `@staticmethod`)是函数逻辑的关键组成部分,定义了路由、权限或调用方式。 > * **切开的后果**:如果检索只命中了函数体而丢失了装饰器,CC 可能会误判该函数的触发条件(例如不知道它是 API 接口),或者生成代码时漏掉装器,导致程序运行错误。

挑战题(深入架构)

习题 4:上下文丢失与面包屑(Breadcrumbs) **问题**:在一个大型前端项目中,你有 10 个名为 `index.ts` 的文件(分别在不同的组件目录下)。 如果直接切分,Chunk 内容可能只包含 `export const Component = ...`,而无法区分是哪个组件。 请设计一种 **文本增强(Text Enrichment)** 策略,在不依赖 Metadata 过滤的情况下,让向量检索能区分这 10 个文件。 **提示**:修改“喂给 Embedding 模型的内容”,而不是修改原始文件。 > **参考答案**: > **策略:路径注入(Path Injection)**。 > 在进行 Embedding 之前,将文件路径或目录结构拼接到代码内容的头部。 > * 原始内容:`export const Button = ...` > * **Embedding 内容**:`Context: src/components/Auth/LoginButton/index.ts \n Content: export const Button = ...` > 这样,"Auth" 和 "Login" 这些语义词就成为了向量的一部分。即使用户搜 "Login Button implementation",也能准确命中 `Auth/LoginButton` 下的代码,而不是 `Shared/Button` 下的代码。
习题 5:处理“巨型文件”的策略 **问题**:项目中有一个历史遗留的 `utils.js`,大小为 2MB(约 50w tokens),且代码风格极差,全是杂乱的函数。 1. 这种文件能用 Parent-Child 策略吗?为什么? 2. 针对这种文件,应采用什么特殊的切分与索引策略? **提示**:Parent Chunk 有大小限制;垃圾进垃圾出。 > **参考答案**: > 1. **不能直接用 Parent-Child**。因为 Parent Chunk 通常对应“整个文档”或“大块逻辑”,50w tokens 远远超过了 Embedding 模型和 LLM 上下文的限制。如果 Parent 是整个文件,召回后无法使用。 > 2. **策略**: > * **滑动窗口 + 摘要**:使用较小的固定窗口(如 1000 tokens)进行切分。 > * **逻辑分组**:尝试按正则表达式匹配 `function` 关键字进行物理分割。 > * **降级处理**:对此类文件,仅索引函数签名(Signature)而非函数体。即:`function stupidLegacyName(param1, param2) ...`。让 CC 知道有这个函数存在即可,如果真需要看细节,CC 可以通过工具 `read_file` 自己去读取特定行,而不是通过 RAG 塞入上下文。
习题 6:去重的高级思考 **问题**:你的代码库中有很多 `try...catch` 块或 `license header` 是完全重复的。这导致搜索“错误处理”时,返回了 50 个几乎一样的 Chunk。 如何在**索引构建阶段**解决这个问题?(注意:不是在检索后去重) **提示**:SimHash 或 内容指纹。 > **参考答案**: > 1. **全局内容指纹**:在切分后,对每个 Chunk 的文本内容计算 Hash(如 MD5)。 > 2. **频率熔断**:维护一全局计数器。如果某个 Hash 出现的次数超过阈值(例如 5 次),则标记该 Chunk 为“高频样板代码(Boilerplate)”。 > 3. **处理策略**: > * **丢弃**:直接不建立索引。 > * **合并**:只保留一个范例,并在 Metadata 中记录它出现在哪些文件中(`file_paths: ["a.py", "b.py", ...]`)。 > 这样可以显著减少向量库的噪声,防止搜索结果被样板代码刷屏。

5. 常见陷阱与错误 (Gotchas)

1. 幽灵代码 (The Ghost Code)

2. 只有代码,没有 Imports

3. 多语言混合文件的噩梦

4. 忽略了 .gitignore