cc_rag_tutorial

第 10 章:参考实现(端到端)——从 0 写一个最小可用外挂 RAG

1. 开篇:从理论到工程

在前面的章节中,我们已经拆解了 RAG 的各个零件:切分、向量化、检索。现在,我们要把这些零件组装成一台精密的机器。

本章将提供一个 Reference Implementation(参考实现) 的架构蓝图。我们的目标不是写一个仅用于演示的玩具,而是一个遵循 Local-First(本地优先) 原则、具备 增量更新 能力、且深度适配 CC(Claude Code)交互协议 的最小可用产品(MVP)。

本章核心目标

  1. 工程标准化:定义一套清晰的目录结构和数据流管道,让项目可维护。
  2. 闭环实现:打通 文系统 -> 向量索引 -> 检索接口 -> CC 上下文 的完整链路。
  3. CC 适配性:重点解决“如何把检索结果喂给 CC,让它不仅能读,还能据此写代码”的问题。

2. 系统全景图 (System Architecture)

我们将系统划分为两个独立生命周期的子系统:构建器 (Builder/Ingestor)服务器 (Server/Retriever)。这种读写分离的设计是生产级系统的基础。

[ 文件系统 (User Codebase) ]
       |
       | (1. 扫描与哈希比对)
       v
+-------------------------------------------------------+
|  构建器 (Builder Pipeline) - 离线/触发式运行           |
|                                                       |
|  [过滤器] -> 忽略 .gitignore, 二进制, 超大文件          |
|     |                                                 |
|  [解析器] -> 提取文本, 识别语言, 提取元数据(Path/Time)   |
|     |                                                 |
|  [切分器] -> 语义块 (Chunking) + 绑定 Line No.       |
|     |                                                 |
|  [向量化] -> 调用 Embedding 模型 (Batch 处理)          |
+-------------------------------------------------------+
       |                  |
       | (写入向量)        | (写入元数据 & 原始内容)
       v                  v
[ 向量索引 (FAISS/Chroma) ]   [ 关系库/KV (SQLite/JSONL) ]
       ^                  ^
       | (ANN 搜索)        | (ID 查找 & 注水)
       |                  |
+-------------------------------------------------------+
|  服务器 (Server/CLI) - 实时响应 CC 请求                |
|                                                       |
|  CC (User/Agent) --> [Query] --> [意图识别/改写]       |
|                                       |               |
|  [格式化器 (XML Packer)] <------- [检索器 (Retriever)] |
+-------------------------------------------------------+

Rule of Thumb: ID 决定生死

经验法则:在参实现中,最关键的设计不是选什么向量库,而是 Chunk ID 的生成策略


3. 模块 1:项目脚手架与数据协议

一个健壮的 RAG 项目需要清晰的物理结构。以下是推荐的目录布局:

3.1 目录结构

my-cc-rag/
├── corpus/                 # (可选) 如果你不是直接挂载当前目录,这是放置文档的地方
├── data/                   # 数据持久化目录
│   ├── .registry.json      # [关键] 增量更新注册表 (Path -> FileHash)
│   ├── embeddings.index    # 向量索引文件
│   └── metadata.db         # SQLite, 储 Chunk 文本和详细元数据
├── src/
│   ├── ingest/             # 写入侧逻辑
│   ├── retrieve/           # 读取侧逻辑
│   └── utils/              # 共享工具 (Hash计算, 文本清洗)
├── templates/              # Prompt 模板 (用于 Query 改写或结果包装)
└── config.yaml             # 配置文件 (模型选择, 忽略列表, 切分参数)

3.2 数据对象定义 (Data Schema)

为了让 CC 能够利用检索结果进行代码修改,我们的数据模型必须包含 定位信息

Chunk 对象模型 (TypeScript 风格伪代码):

interface Chunk {
  // 核心标识
  id: string;             // Deterministic Hash
  
  // 向量化字段
  text: string;           // 实际切分后的文本内容
  vector: number[];       // Embedding 向量

  // CC 必需元数据 (Metadata)
  source_file: string;    // 相对路径 (如 src/utils.ts)
  line_start: number;     // 起始行号 (CC edit 需要)
  line_end: number;       // 结束行号
  language: string;       // 编程语言标识
  
  // 辅助元数据
  checksum: string;       // 源文件的 Hash (用于检测过期)
  last_modified: number;  // 时间戳 (用于在 Prompt 中提示时效性)
  breadcrumbs: string[];  // 面包屑导航 (如 ["src", "utils", "class Auth"])
}

4. 模块 2:数据摄取管道 (Ingest Pipeline)

这是系统的“消化系统”。为了保证性能,必须实现 增量更新

步骤 A:智能扫描与指纹比对

不要盲目读取所有文件。

  1. 加载 data/.registry.json:读取上次运行时的 { file_path: file_hash } 映射。
  2. 遍历文件系统
    • 应用 .gitignore 规则(必须!否则你会索引 node_modules)。
    • 计算当前文件的 Hash。
  3. 三路决策
    • New: 注册表中无此路径 -> 加入 processing_queue
    • Modified: Hash 不匹配 -> 加入 processing_queue,并标记需删除旧 Chunk。
    • Unchanged: Hash 匹配 -> 跳过(Log: Skipping cached file...)。
    • Deleted: 注册表中有但磁盘无 -> 标记需删除旧 Chunk。

步骤 B:切分与元数据绑定

在切分时,不仅要切文字,还要保留行号

步骤 C:向量化与落盘


5. 模块 3:检索与上下文组装 (Retrieval & Packing)

这部分定义了 CC 如何使用你工具。

步骤 A:Query 预处理

用户(或 CC)发出的 Query 往往是模糊的。

步骤 B:混合检索 (Hybrid Search - MVP版)

为了避免向量检索在这就“精确匹配”场景下的失灵(例如搜具体的变量名),MVP 也应包含简单的关键词匹配。

  1. 向量召回Top_K = 20
  2. 关键词过滤:如果 Query 包含明显的代码特征(如 CamelCase 变量名),对召回结果进行二次过滤,保留包含该字符串的 Chunk。
  3. 最终截断:取 Top_K = 5

步骤 C:CC 专用协议包装 (Context Packing) —— 重点

这是将 RAG 真正变成“外挂”的关键。你需要将 JSON 数据转换为 CC 能够理解其结构的 XML

设计原则

  1. 显式引用:告诉 CC 这个片段来自哪个文件的哪一行。
  2. XML 标签:使用标签隔离不同来源,防止 Prompt 注入。

输出模板 (Payload):

<rag_response>
  <meta>
    <query_latency>120ms</query_latency>
    <total_results>3</total_results>
  </meta>

  <!-- 结果 1:代码片段 -->
  <result id="1" score="0.92">
    <file_path>src/auth/jwt_handler.py</file_path>
    <lines>45-58</lines>
    <last_modified>2023-10-25</last_modified>
    <content_block>
<![CDATA[
def verify_token(token):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        return payload
    except jwt.ExpiredSignatureError:
        return None
]]>
    </content_block>
  </result>

  <!-- 结果 2:文档片段 -->
  <result id="2" score="0.85">
    <file_path>docs/api_spec.md</file_path>
    <lines>102-110</lines>
    <content_block>
<![CDATA[
## Error Handling
If the token is expired, the API will return a 401 Unauthorized status
with the message "Token expired".
]]>
    </content_block>
  </result>
</rag_response>

Rule of Thumb:始终使用 <![CDATA[ ... ]]> 包裹代码内容。 代码中常包含 < > & 等字符,直接放入 XML 会导致解析错误。CDATA 是最安全的做法。


6. 集成方式:如何让 CC 调起来?

你有两种方式将这个系统暴露给 CC:

方式 A:CLI 管道 (适合 Ad-hoc 使用)

用户手动运行命令,将结果贴给 CC,或通过 Pipe 传递。 python rag.py search "JWT 验证" | claudecode

方式 B:MCP 工具 (适合 Agent 模式)

编写一个简单的 MCP Server(Model Context Protocol),定义工具描述:

{
  "name": "search_codebase",
  "description": "Semantically search the local codebase for snippets. Use this when you don't know file paths.",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": { "type": "string" },
      "file_pattern": { "type": "string", "description": "Optional glob pattern to filter files" }
    },
    "required": ["query"]
  }
}

当 CC 决定调用 search_codebase 时,你的 Python 脚本执行检索逻辑,并返回上述 XML 字符串。


7. 本章小结


8. 练习题

基础题 (50%)

  1. 路径标准化: 在 metadata.db 中,应该存储绝对路径(/User/bob/project/src/a.py)还是相对路径(src/a.py)?为什么?
    点击查看答案 * **答案**:**相对路径**。 * **理由**: 1. **可移植性**:如果你把索引文件发给同事,或者在 CI/CD 容器中运行,绝对径会失效。 2. **CC 友好**:CC 在项目根目录下运行时,更习惯接收 `src/a.py` 这样的路径来执行 `cat` 或 `edit`。
  2. 脏数据处理: 如果用户删除了 src/old_feature.py,但在运行检索时,向量库里还有它的 Chunk。这会造成什么后果?你的 Ingest 脚本应该如何处理这种情况?
    点击查看答案 * **后果**:CC 可能会引用不存在的文件,甚至尝试去 `edit` 它,导致工具报错。 * **处理**:在 Ingest 启动时,第一步是对比 `registry.json` 和当前磁盘文件列表。发现磁盘上不存在的文件,必须立即从 `registry`、向量库和 SQLite 中同步删除对应的记录。
  3. Token 估算: 假设 Embedding 模型限制 512 token,而你切分的一个函数有 1000 token。你会怎么做?
    点击查看答案 * **策略**: 1. **切分**:强制在 500 token 处截断(可能会切断逻辑)。 2. **滑动窗口 (Sliding Window)**:生成两个 Chunk,Chunk A (0-600), Chunk B (400-1000),保持 200 token 重叠。 3. **摘要向量化**:用 LLM 对 1000 token 代码做摘要,对摘要进行 Embedding,但存储原始 1000 token 文本(Retrieval 时返回原文)。

挑战题 (50%)

  1. 设计“防抖”机制: CC 在思考过程中可能会连续多次调用搜索工具(例如先搜 “Auth”,发现不对又搜 “Login”)。如果你的 Embedding API 是按次收费或很慢,如何设计一个简单的缓存层?
    点击查看答案 * **提示**:在 Server 端增加一个 LRU Cache。 * **逻辑**: 1. Server 接收 Query。 2. 检查内存中的 `Query_Cache` (Key=Query_String, Value=XML_Response)。 3. 如果命中,直接返回。 4. 如果不命中,执行 Embedding -> Search -> Pack,写入 Cache。
  2. 元数据注入: 为了提高检索准确率,你决定在 Embedding 阶段不仅仅把代码本身喂给模型,还要喂一些“上下文信息”。对于一个 Python 类中的方法,你会把什么信息拼接到 Embedding 输入中?(注意:只影响向量计算,不改变存储的原文)。
    点击查看答案 * **方案**:构建 **"Embedding String"** vs **"Display String"**。 * **Embedding String**:`FileName: user_controller.py | Class: UserController | Method: login | Content: def login(self)...` * **解释**:将文件名、类名显式拼接到文本前方。这样当用户搜 "User Controller login" 时,向量相似度会更高,即使代码内部没有出现 "Controller" 这个词。
  3. 思考:多项目隔离: 如果你想用同一个 RAG 服务支持本地的三个不同项目(Project A, Project B, Project C),你的架构需要做什么修改?
    点击查看答案 * **数据层**:向量库和 Metadata DB 需要增加 `project_id` 字段,或者为每个项目创建独立的索引文件夹(`data/project_a/`, `data/project_b/`)。 * **接口层**:Search API 需要接受 `project_id` 参数。 * **CC 集成**:MCP 工具定义中需要增加 `project_root` 参数,或者自动根据 CC 当前的工作目录(CWD)推断项目 ID。

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

1. 相对路径的地狱 (The Relative Path Hell)

2. 忽略了二进制文件

3. XML 注入风险

4. 向量库的“冷启动”延迟