CC 开源源码解读(一):为什么 Claude Code 不需要 RAG?

55 阅读1分钟

CC 开源源码解读(一):为什么 Claude Code 不需要 RAG?

本文基于 Claude Code 开源仓库的实际源码分析,代码路径来自 src/ 目录。


最近在阅读 Claude Code(CC)的开源源码,发现一个有意思的设计决策:CC 完全没有 RAG(Retrieval Augmented Generation),没有 embedding,没有向量数据库,没有语义检索。

但它能精准地在一个大型代码库里找到相关文件、记住你上周说过的事情、并且在复杂探索任务里自动派遣子 Agent。

这是怎么做到的?翻源码之后发现,CC 用了一套完全不同的路子。


一、起点:让 AI 自己搜代码

RAG 的核心思路是:先把文档 embedding 成向量,然后用相似度检索找相关片段,塞进 prompt。

CC 完全不这样做。它的策略更"野蛮"——直接给 Claude 一堆搜索工具,让它自己决定搜什么

  • Grep:正则搜代码内容
  • Glob:文件名模式匹配
  • Read:读具体文件
  • Bash:跑 git logfind 等系统命令

这就像把 Claude 变成了一个有 shell 权限的工程师,而不是一个查文档的问答机器人。

优点是精准——Grep 找到的就是真实代码,不存在语义偏差。缺点也很明显:项目大了之后,Grep 扛不住


二、问题来了:精准但没有排序

想象一个 5 万文件的 monorepo,你搜 handle,Grep 可能返回 200 个匹配。每个都是"精确"结果,但 Claude 的 context window 是有限的,200 个结果全塞进去既浪费 token 又干扰判断。

RAG 至少还有个相关性排序。Grep 的输出是按文件路径字母序排的——完全没有"哪个更重要"的概念。

CC 怎么解决这个问题?不是引入向量检索,而是 5 层策略的组合


三、CC 的 5 层策略

第 1 层:CLAUDE.md — 人工项目地图

最简单粗暴也最有效的一层:让开发者手写项目的关键信息,放进 CLAUDE.md

CC 会在每次会话加载这个文件,相当于给 Claude 一份"项目导览"。实质上是手动索引——开发者用自然语言告诉 Claude 哪些文件重要、架构是什么、哪些目录干什么用。

关于 CLAUDE.md 的写法和最佳实践,可以参考我之前的文章:CLAUDE.md 到底该怎么写?从「给 AI 的交接文档」说起。这里重点从源码角度看 CC 是怎么加载和使用它的。

源码 utils/claudemd.ts 的注释清楚地写出了 4 层加载逻辑:

/**
 * Files are loaded in the following order:
 *
 * 1. Managed memory (/etc/claude-code/CLAUDE.md) - Global for all users
 * 2. User memory (~/.claude/CLAUDE.md) - Private global for all projects
 * 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md) - Checked into codebase
 * 4. Local memory (CLAUDE.local.md) - Private project-specific instructions
 *
 * Files are loaded in reverse order of priority — the latest files are highest priority
 * with the model paying more attention to them.
 */

越靠近当前目录的配置优先级越高,文件路径越深越重要。支持 @include 指令做文件嵌套——你可以把大型项目的文档拆成多个文件,按需 include。

本质:这是一个人工维护的语义索引,代替了 embedding 的"自动理解"。代价是需要开发者维护,收益是零幻觉、零延迟。


第 2 层:FileIndex — 文件名模糊搜索 + nucleo 算法评分

当 Claude 不知道具体文件在哪、只有模糊印象时(比如"有个处理权限的文件"),CC 提供了 FileIndex

源码在 src/native-ts/file-index/index.ts,注释说明这是对 Rust NAPI 模块(基于 Helix 编辑器的 nucleo)的纯 TypeScript 重写。

下面这张流程图展示了 FileIndex 从收集文件到返回排序结果的完整过程:

FileIndex 工作流程

整个流程分 4 步:ripgrep 收集文件列表 → 构建位图索引(26bit 字符位图实现 O(1) 快速排除)→ 模糊匹配 → 多维度评分排序返回 top-k。

核心评分常量:

// nucleo-style scoring constants (approximating fzf-v2 / nucleo bonuses)
const SCORE_MATCH = 16
const BONUS_BOUNDARY = 8    // 路径边界处匹配(/ \ - _ . 空格)
const BONUS_CAMEL = 6       // 驼峰命名边界
const BONUS_CONSECUTIVE = 4 // 连续字符匹配
const BONUS_FIRST_CHAR = 8  // 首字符匹配
const PENALTY_GAP_START = 3
const PENALTY_GAP_EXTENSION = 1

性能优化有几个亮点

① Bitmap 预过滤(O(1) 拒绝)

每个文件路径在索引时预计算一个 26-bit bitmap,对应 a-z 每个字母是否出现:

private indexPath(i: number): void {
  const lp = this.paths[i]!.toLowerCase()
  this.lowerPaths[i] = lp
  let bits = 0
  for (let j = 0; j < len; j++) {
    const c = lp.charCodeAt(j)
    if (c >= 97 && c <= 122) bits |= 1 << (c - 97)
  }
  this.charBits[i] = bits
}

搜索时先用 (charBits[i] & needleBitmap) !== needleBitmap 做 O(1) 位运算过滤。注释说"broad queries like 'test' → 10%+ free win; 90%+ rejection for rare chars"。

② 异步分块构建,不阻塞主线程

const CHUNK_MS = 4  // 每次处理 ~4ms,然后 yield 给 event loop

// 5万文件 ~2ms on M-series,可能 15ms+ on older Windows hardware
// Chunk sizes are time-based (not count-based) so slow machines get smaller chunks

③ Top-K 维护,不全量排序

不是搜完所有文件再 sort,而是维护一个大小为 limit 的升序数组,用二分插入保持有序——只在找到更好匹配时才更新:

} else if (score > threshold) {
  let lo = 0, hi = topK.length
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if (topK[mid]!.fuzzScore < score) lo = mid + 1
    else hi = mid
  }
  topK.splice(lo, 0, { path, fuzzScore: score })
  topK.shift()
  threshold = topK[0]!.fuzzScore
}

④ test 文件轻微降权

const finalScore = path.includes('test')
  ? Math.min(positionScore * 1.05, 1.0)
  : positionScore

测试文件在大多数搜索场景下不是首要结果,加 5% 惩罚。细节但体贴。

智能大小写

// Smart case: lowercase query → case-insensitive; any uppercase → case-sensitive
const caseSensitive = query !== query.toLowerCase()

和 vim / fzf 行为一致——全小写搜索忽略大小写,有大写字母就区分大小写。


第 3 层:Grep + Read — 在缩小范围内精确搜索

FileIndex 缩小了候选文件集,Grep 在这些文件里做精确内容搜索,Read 读取完整文件。

这一层没有特别的算法,但有个重要原则:Claude 自己决定搜什么、怎么搜、搜多深。这是 Agentic 模式的核心——不是预先计算好"相关文档"再喂给模型,而是让模型在搜索过程中动态调整策略。


第 4 层:Explore Subagent — 复杂探索时自动派遣子 Agent

对于更复杂的探索任务,CC 有一个内置的 Explore Agent。当主 Agent 判断需要深度探索代码库时,会自动 spawn 这个子 Agent。

源码 src/tools/AgentTool/built-in/exploreAgent.ts

export const EXPLORE_AGENT: BuiltInAgentDefinition = {
  agentType: 'Explore',
  // Ants get inherit to use the main agent's model;
  // external users get haiku for speed
  model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
  // Explore is a fast read-only search agent — it doesn't need commit/PR/lint
  // rules from CLAUDE.md. The main agent has full context and interprets results.
  omitClaudeMd: true,
  ...
}

几个设计细节:

使用 Haiku 而不是 Sonnet:探索任务是读多写少,用便宜快速的 Haiku(外部用户)降低成本。Anthropic 内部员工(ant)用 inherit 继承主 Agent 的模型。

omitClaudeMd: true:Explore Agent 不加载项目的 CLAUDE.md——它只需要搜索能力,不需要了解项目的 commit 规范、PR 流程之类的。主 Agent 负责解读结果。

严格只读:禁用了 Edit、Write、NotebookEdit 工具,系统提示里用全大写强调:

=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===

并行搜索:提示词明确要求"spawn multiple parallel tool calls for grepping and reading files",通过并行 IO 提高探索速度。


第 5 层:Compaction — 旧搜索结果压缩清理

随着对话进行,之前的搜索结果会越来越多,占用 context window。CC 有 Compaction 机制,将历史轮次压缩成摘要,清理不再需要的搜索结果。

这层解决的不是"怎么找到相关信息",而是"如何保持 context 干净"——本质上是另一种形式的"索引维护"。


四、记忆系统也不用 Embedding

CC 有一个基于文件的记忆系统(~/.claude/projects/*/memory/),存储跨会话的上下文。有意思的是,它的记忆检索也不用 embedding

源码 src/memdir/findRelevantMemories.ts

const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful 
to Claude Code as it processes a user's query. You will be given the user's query 
and a list of available memory files with their filenames and descriptions.

Return a list of filenames for the memories that will clearly be useful 
to Claude Code (up to 5). Only include memories that you are certain will be helpful.`

export async function findRelevantMemories(
  query: string,
  memoryDir: string,
  signal: AbortSignal,
  recentTools: readonly string[] = [],
  alreadySurfaced: ReadonlySet<string> = new Set(),
): Promise<RelevantMemory[]> {
  const memories = await scanMemoryFiles(memoryDir, signal)
  
  const selectedFilenames = await selectRelevantMemories(
    query, memories, signal, recentTools,
  )
  // ...
}

机制很简单:Sonnet 做 side query,输入是用户的 query + 所有记忆文件的 frontmatter(文件名 + description 字段),输出是最多 5 个相关文件名。

const result = await sideQuery({
  model: getDefaultSonnetModel(),  // Sonnet
  system: SELECT_MEMORIES_SYSTEM_PROMPT,
  messages: [{ role: 'user', content: `Query: ${query}\n\nAvailable memories:\n${manifest}` }],
  max_tokens: 256,  // 只需要返回文件名列表,非常轻量
  output_format: { type: 'json_schema', ... },
})

一个有趣的细节:如果 Claude 最近在用某个工具(比如 mcp__X__spawn),记忆选择会主动过滤掉这个工具的"使用文档"类记忆:

// When Claude Code is actively using a tool (e.g. mcp__X__spawn),
// surfacing that tool's reference docs is noise — the conversation
// already contains working usage.
// DO still select memories containing warnings, gotchas, or known issues
// about those tools — active use is exactly when those matter.
const toolsSection = recentTools.length > 0
  ? `\n\nRecently used tools: ${recentTools.join(', ')}`
  : ''

逻辑:你都在用这个工具了,再给你这个工具的使用说明是噪声。但如果记忆里有"这个工具的已知坑",反而应该优先展示。

记忆保鲜期

src/memdir/memoryAge.ts 有个精妙的设计——记忆文件会带着年龄信息展示给主模型:

export function memoryFreshnessText(mtimeMs: number): string {
  const d = memoryAgeDays(mtimeMs)
  if (d <= 1) return ''
  return (
    `This memory is ${d} days old. ` +
    `Memories are point-in-time observations, not live state — ` +
    `claims about code behavior or file:line citations may be outdated. ` +
    `Verify against current code before asserting as fact.`
  )
}

注释说明了动机:"user reports of stale code-state memories (file:line citations to code that has since changed) being asserted as fact — the citation makes the stale claim sound more authoritative, not less."

这是一个真实踩坑后的修复。记忆里写着"AuthService 在 src/auth.ts:142 处理登录",但那个文件早就重构了,Claude 还一本正经地引用这个"出处"。解法不是引入更复杂的记忆管理,而是直接告诉模型"这条记忆 47 天前写的,信之前自己核实一下"。


五、边界:什么场景必须用 RAG

CC 的方案不是万能的,几个场景下 RAG 仍然是更好的选择:

1. 超大非结构化文档库:如果你有 10 万篇 Confluence 页面需要检索,让 AI 用 Grep 搜不现实。RAG 的向量索引在这个规模下有明显优势。

2. 语义相似而非关键词匹配:搜"用户登录相关的代码",Grep 能找到,但"身份验证"、"session 管理"、"OAuth 流程"这些语义相关但关键词不同的内容,向量检索更擅长。

3. 多语言混合文档:中文查询匹配英文文档,RAG 的向量空间天然支持跨语言语义对齐。

4. 无法给 AI 工具权限的场景:如果你的系统无法给 AI 直接执行搜索命令的权限(安全限制),RAG 是合理的替代。

CC 的场景是:代码库 + 有工具执行权限的 Agent。在这个场景下,精确搜索 + 分层策略比 embedding 检索更直接、更可控、更省成本。


总结

CC 不用 RAG 的根本原因是:它不需要

RAG 解决的问题是"如何从大量文档里找到相关片段,塞进有限的 context window"。CC 换了个思路——不预先计算相关性,而是让模型带着搜索工具自己探索,用 FileIndex 做模糊排序,用 CLAUDE.md 做手动索引,用 Compaction 管理 context 大小,用 Haiku 子 Agent 分担搜索负担。

每一层都很简单,但组合在一起,覆盖了代码库探索的大部分场景。

这让我想到一个更广泛的设计原则:不要为了用技术而用技术。Embedding + 向量数据库是一套完整的基础设施,引入它就要维护它、监控它、调 retrieval 参数。在能用更简单方案解决问题的情况下,复杂方案只是债务。


预告:下一篇

CC 开源源码解读(二):上下文压缩是怎么做的?

CC 的 Compaction 机制非常有意思——不是简单的截断,而是一套分层压缩策略。在长对话中,CC 如何判断什么信息可以丢弃、什么必须保留?如何在压缩信息量和保持 Agent 连贯性之间取得平衡?

源码层面深挖,下篇见。


如果你也在研究 CC 源码,欢迎评论区交流。
源码仓库:claude-code GitHub