第 6 章:切分(Chunking)与索引策略——RAG 好坏的分水岭
1. 开篇段落:切分是给 CC 的“咀嚼”预处理
在 RAG 系统中,如果数据摄取是“买菜”,向量化是“烹饪”,那么 切分(Chunking) 就是至关重要的“刀工”。
很多开发者有一个误区:认为 RAG 效果不好是因为 Embedding 模型不够强。实战经验表明,80% 的检索失败源于糟糕的切分策略。
- 切分太碎:CC 只能看到只见树木不见森林的碎片,无法理解代码全貌。
- 切分太粗:包含了大量无关噪声,稀释了语义,且容易撑爆 Prompt 上下文窗口。
- 切分位置不对:把一个函数切成两半,或者把 Markdown 表格的表头切掉,导致片段完全不可读。
对于 Claude Code (CC) 这样的智能体,切分不仅仅是为了“搜得到”,更是为了“能干活”。一个好的 Chunk 必须是一个 “可执行的最小语义单元”。它不仅要包含知识,还要携带让 CC 能够定位、引用并修改源文件的坐标信息。
本章学习目标:
- 掌握 固定窗口、语义结构、代码 AST 三种切分流派的适用场景。
- 理解并应用 父子索引(Parent-Child Indexing) 策略,解决“搜得准”与“看得全”的矛盾。
- 设计一套面向 CC 的 元数据(Metadata)标准,确保每个 Chunk 都可溯源。
- 学会处理 索引的生命周期:增量更新、去重与过期数据清理。
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)作为边界。
- 核心逻辑:永远不在一个段落或列表中间下刀,除非该段落本身超过了最限制。
- 上下文保留:当切分 H3 标题下的内容时,必须将 H1 和 H2 的标题作为“背景信息”保留下来,否则片段会丢失语境。
第三重:代码语义切分(Code/AST Splitting)—— CC RAG 的核心
这是做代码库 RAG 的必经之路。你不能把代码当文本处理,你必须把代码当“逻辑树”处理。
利用解析器(如 Tree-sitter 逻辑)识别代码块边界。
- 类(Class)粒度:保持类定义、成员变量、核心方法的内聚性。
- 函数(Function)粒度:一个函数就是一个天然的 Chunk。
- 注释绑定:函数上方的 DocString 必须与函数体绑定在同一个 Chunk 中,因为 DocString 往往蕴含了搜索用的自然语言关键词。
2.2 高级策略:父子索引 (Parent-Child Indexing)
这是提升 RAG 效果的 Rule of Thumb No.1。
痛点:
- Chunk 切小了:搜索非常精准(向量相似度高),但 CC 拿到后看不懂下文。
- Chunk 切大了:包含了太多杂音,向量被稀释,很难搜到。
解决方案:把“用来搜的”和“给 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
[向量库索引] <----映射----> [大块内容存储]
工作流:
- 对 Child Chunks 进行向量化搜索。
- 命中 Child Chunk 后,不返回 Child 的内容。
- 通过 ID 找到并返回对应的 Parent Chunk(包含完整上下文的大块代码)。
- 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):
last_modified: 文件的最后修改时间(CC 可以倾向于信任更新的代码)。
type: 标记是 code、documentation 还是 config(用于检索时的 filter)。
breadcrumbs: 面包屑路径,如 src > modules > auth > UserClass > login(极大地增强语义)。
2.4 索引维护:增量更新与去重
不要每次运行 RAG 都把整个代码库删了重练,那太慢且费钱。你需要一个 “索引注册表 (Manifest)”。
增量更新逻辑 (Rule of Thumb):
- 扫描:遍历文件系统,计算每个文件的 Hash。
- 对比:与上次运行的 Manifest 对比。
- 新增:直接切分并入库。
- 修改:先删除该文件旧版本产生的所有 Chunk(根据
file_path 过滤删除),再插入新 Chunk。
- 删除:必须检测到文件消失,并清理向量库中的残留数据(避免“幽灵代码)。
- 未变:跳过,不做任何操作。
3. 本章小结
- 代码 != 文本:永远优先使用 AST 或基于缩进的代码专用切分器,保持函数/类的完整性。
- 重叠是保险带:机械切分必须有 10%-20% 的 Overlap,防止边缘关键词丢失。
- 小搜大回(Parent-Child):这是平衡检索精度与上下文完整性的最佳实践。
- 元数据即指令:必须保留
file_path 和行号,否则 CC 只能“纸上谈兵”,无法落地修改。
- 拒绝脏数据:建立增量更新机制,及时清理已删除文件的索引,防止 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)
- 现象:用户删除了
OldClass,重构为 NewClass。但 RAG 检索时,依然把 OldClass 的 Chunk 喂给了 CC。CC 甚至建议你调用这个不存在的类。
- 原因:只管
add 没管 delete。向量库里积累了大量已删除文件的尸体。
- 调试技巧:实现一个 Pruning(修剪) 脚本。每次索引结束前,对比向量库中所有的
file_path 和本地实际文件列表。如果本地文件存在,必须执行 vector_db.delete(filter={file_path: ...})。
2. 只有代码,没有 Imports
- 现象:CC 看到一段完美的代码,但不知道里面的
db.connect() 也就是 db 变量是从哪来的,也不知道该 import 哪个包。
- 原因:切分时,Imports 区域通常在文件头,而逻辑代码在文件中间。它们被切分到了不同的 Chunk。
- Fix (Rule of Thumb):对于 Python/TS/Java 等语言,建议提取文件顶部的
import/using 语句,作为 Global Context 附加到该文件产生的所有 Chunk 的 Metadata 或文本前缀中。
3. 多语言混合文件的噩梦
- 现象:Vue 文件(包含 HTML, JS, CSS)或 Jupyter Notebook(包含 Markdown, Code, Output)。使用单一的 Python Splitter 或 Markdown Splitter 都会导致部分内容格式错乱。
- 原因:单一解析器无法处理混合语法。
- Fix:
- Vue/React:使用专门的组件切分器,或将其视为纯文本但按
<script>, <template> 标签进行正则切分。
- Notebooks:先转换为纯 Python 脚本或 Markdown 再处理,或者提取其中的
Code Cell 单独索引。
4. 忽略了 .gitignore
- 现象:RAG 搜出了一堆
node_modules 里的库代码,或者 build/dist 里的压缩代码。
- 后果:不仅浪费 Token,而且这些压缩代码对 CC 毫无阅读价值。
- Fix:在数据摄取(Ingest)阶段,必须严格加载项目的
.gitignore 文件,并递归过滤掉所有被忽略的目录。这是新手最容易犯的错。