RAG(Retrieval-Augmented Generation,检索增强生成)曾经是让大语言模型理解外部知识的默认方案:先把文档或代码切块,生成 embedding,写入向量数据库;用户提问时,再从向量库里找相似片段,塞进上下文交给模型回答。
但在 AI 编程工具里,事情开始变得不一样。Claude Code 和 Codex CLI 这类 Agent 编程工具,核心代码搜索并不依赖 embedding,也不依赖向量数据库,而是把搜索任务交给 LLM(Large Language Model,大语言模型)自己规划,再通过 Grep、Glob、Read 等工具一轮轮探索代码库。
这不是简单地“回到命令行搜索”,而是搜索范式发生了变化:
- 传统 RAG 是预先检索:系统先猜模型可能需要哪些上下文。
- Agent 搜索是按需探索:模型根据当前问题,决定搜什么、读什么、要不要继续追踪。
在代码库这种数据形态里,标识符、文件路径、函数名、类名本身就携带大量语义。getUserById 通常不会被改写成一段自然语言描述,精确匹配反而非常可靠。再加上现代 ripgrep 的速度足够快,零索引搜索在本地项目里有了很强的工程吸引力。
Claude Code 的核心搜索循环
Claude Code 的搜索机制可以抽象成一个工具调用循环:模型拿到用户问题和可用工具列表后,决定要不要调用工具;工具执行结果再追加到对话历史里,模型基于新的上下文继续决策,直到信息足够为止。
flowchart TD
A[用户提出问题] --> B[把问题、历史上下文、工具列表交给 LLM]
B --> C{LLM 是否调用工具}
C -- 否 --> D[直接生成回答]
C -- 是 --> E[执行工具调用]
E --> F[把工具结果追加到对话历史]
F --> B
C -. 退出条件 .-> G[达到轮次上限 / 预算限制 / 用户中断 / 权限拒绝]
这个循环没有写死“必须先 Grep 再 Read”。模型可以直接读取已知文件,也可以同时发起多个搜索请求,还可以调用子 Agent 做更大范围的探索。系统只是通过工具设计和提示词引导模型选择合理路径,而不是把搜索流程硬编码死。
四类基础搜索工具
Claude Code 的代码探索主要依赖这几类工具:
| 工具 | 底层能力 | 典型用途 |
|---|---|---|
GrepTool | ripgrep / rg | 按正则搜索文件内容 |
GlobTool | glob 路径匹配 | 按文件名、目录、扩展名找文件 |
FileReadTool | 文件系统读取 | 读取指定文件的指定行范围 |
AgentTool | 启动独立子 Agent | 把大范围搜索任务交给隔离上下文处理 |
还有一类重要补充是 LSP(Language Server Protocol,语言服务器协议)工具,例如 “go to definition” 和 “find references”。LSP 更适合处理语义精确的代码跳转,能减少 Grep 后再读多文件的次数。
子 Agent 的价值:隔离上下文
AgentTool 并不是简单的搜索命令,而是启动一个独立对话环境。比如 Explore 类型的子 Agent 只具备搜索和读取能力,不能编辑文件,也不能执行危险命令。它可以在自己的上下文窗口里完成多轮 Grep、Glob、Read,最后只把整理后的结论返回主对话。
这种设计解决了一个很现实的问题:搜索过程会产生大量中间结果。如果所有 Grep 输出和代码片段都塞进主对话,主上下文很快会被噪声占满。子 Agent 相当于一个临时研究员,消化完材料后只交付结论。
flowchart LR
Main[主对话] --> Task[分派搜索任务]
Task --> Sub[Explore 子 Agent]
Sub --> G[Grep / Glob]
Sub --> R[Read 文件片段]
G --> Sub
R --> Sub
Sub --> Summary[压缩后的结论]
Summary --> Main
GrepTool 如何控制返回信息量
直接把所有匹配代码都返回给模型,很容易烧掉大量 token。Claude Code 的 GrepTool 通过输出模式控制信息密度,让模型按需选择“只要路径”还是“要代码片段”。
| 输出模式 | 返回内容 | 适合场景 | 后续动作 |
|---|---|---|---|
files_with_matches | 只返回命中文件路径 | 先定位相关文件 | 通常再调用 Read |
content | 返回匹配行及上下文 | 确认常量、函数签名、局部逻辑 | 不一定需要 Read |
count | 返回每个文件的命中次数 | 判断关键词分布密度 | 帮助缩小搜索范围 |
默认模式是 files_with_matches,只返回路径,不返回代码内容。这个选择很关键:一次宽泛搜索可能命中几百个文件,如果默认倾倒匹配行,模型上下文会立刻被污染。只返回文件名能让模型自己判断哪些文件值得深入读取。
例如:
Grep({
pattern: "class.*Transport",
output_mode: "files_with_matches"
})
可能只返回:
cli/transports/WebSocketTransport.ts
cli/transports/SSETransport.ts
模型看到路径后,再选择读取最相关的文件:
Read({
file_path: "cli/transports/WebSocketTransport.ts",
offset: 1,
limit: 120
})
如果只是想确认一个常量怎么定义,content 模式就足够:
Grep({
pattern: "TOOL_VERBS",
path: "bridge/sessionRunner.ts",
output_mode: "content",
"-C": 5
})
这里 -C: 5 表示返回匹配行前后各 5 行。很多时候,局部上下文已经能回答问题,不需要读取整个文件。
一个完整例子:追踪 GrepTool 的执行记录
假设要弄清楚一个问题:当 LLM 调用 GrepTool 搜索代码时,bridge 系统如何追踪并记录这次工具调用?
这个问题的答案不在单个文件里,需要从工具调用、session 输出、活动解析、UI 渲染几条链路拼起来。Agent 搜索的过程大致会这样展开。
第 1 轮:用宽泛关键词定位文件
模型会把问题拆成几个可能出现的代码关键词,例如:
GrepTooltool.*tracktool.*activity
然后在 TypeScript 文件里搜索:
Grep({
pattern: "GrepTool|tool.*track|tool.*activity",
glob: "*.ts"
})
返回的可能是这些文件:
structuredIO.ts
sessionRunner.ts
bridgeUI.ts
bridgeStatusUtil.ts
其中多个文件位于 bridge/ 目录,而问题关注 bridge 系统。sessionRunner.ts 从命名上看也很关键:它可能负责启动 session、读取 session 输出、处理工具执行事件。
第 2 轮:查看关键文件中的局部上下文
模型继续搜索 sessionRunner.ts 里的相关片段:
Grep({
pattern: "GrepTool|tool.*activity",
path: "bridge/sessionRunner.ts",
output_mode: "content",
"-C": 5
})
局部代码里能看到类似“工具名到动词”的映射,例如:
const TOOL_VERBS = {
Grep: 'Searching',
GrepTool: 'Searching',
Glob: 'Searching',
GlobTool: 'Searching',
Read: 'Reading',
FileReadTool: 'Reading',
Edit: 'Editing',
FileEditTool: 'Editing',
Bash: 'Running',
BashTool: 'Running',
}
这说明 GrepTool 被归类成 Searching 活动。工具名不是动态猜出来的,而是在映射表里显式维护。
第 3 轮:读取完整逻辑
局部片段只能看到映射表的一部分。模型会继续读取更大的行范围,找到摘要生成逻辑。
可以把核心流程理解成这样:
function summarizeToolCall(toolName: string, input: ToolInput) {
const verb = TOOL_VERBS[toolName] ?? 'Using'
const target =
input.file_path ??
input.pattern ??
input.command ??
input.url ??
''
return `${verb} ${target}`
}
也就是说:
GrepTool({ pattern: "reconnect|backoff" })
会被整理成类似这样的活动摘要:
Searching reconnect|backoff
桥接系统并不需要理解 Grep 的完整语义,只要抽取工具名和关键参数,就能把后台行为变成用户可读的状态。
第 4 轮:追踪活动事件去向
知道活动事件怎么生成后,还要继续找它被谁消费。模型会搜索活动类型和当前活动字段:
Grep({
pattern: "SessionActivity|currentActivity",
path: "bridge/",
output_mode: "content",
"-C": 2
})
搜索结果会把几处关键代码串起来:
| 文件 | 作用 |
|---|---|
bridge/types.ts | 定义活动事件结构,例如类型、摘要、时间戳 |
bridge/sessionRunner.ts | 从 session 输出中解析工具调用事件,并生成活动摘要 |
bridge/bridgeMain.ts | 周期性轮询每个 session 的当前活动,维护最近工具调用轨迹 |
bridge/bridgeUI.ts | 把活动摘要渲染到 bridge 状态面板 |
完整链路可以画成这样:
sequenceDiagram
participant LLM as LLM
participant Session as Session 进程
participant Parser as 活动解析器
participant Main as bridge 主进程
participant UI as bridge UI
LLM->>Session: 调用 GrepTool
Session-->>Parser: stdout 输出工具调用 JSON
Parser->>Parser: 提取工具名和输入参数
Parser->>Parser: 生成摘要 Searching pattern
Parser-->>Main: 更新 currentActivity
Main->>Main: 维护最近活动轨迹
Main-->>UI: 推送工具活动状态
UI->>UI: 渲染 Searching / Reading / Editing
这个例子体现了 Agent 搜索和传统一次性检索的区别。每一步搜索词都来自上一轮发现:先找相关文件,再读局部上下文,再追踪类型引用,最后拼出跨文件调用链。这种探索路径很难通过预先向量检索一次猜中。
为什么暴力扫描代码库仍然够快
“每次都 Grep 整个项目”听起来很粗暴,但 Claude Code 调用的不是传统 GNU grep,而是 ripgrep,命令名通常是 rg。
ripgrep 是面向大型代码库搜索设计的现代工具,默认遵守 .gitignore,会跳过二进制文件,支持多线程,并且在匹配层面使用 SIMD(Single Instruction Multiple Data,单指令多数据)等优化。它不是傻傻地把所有文件逐字节扫一遍。
ripgrep 的五层过滤
一次搜索真正进入正则匹配之前,会经过多层过滤:
flowchart TD
A[项目全部文件] --> B[目录级剪枝: .gitignore]
B --> C[path 限制: 只遍历指定目录]
C --> D[glob 过滤: 只保留匹配文件名]
D --> E[二进制检测: 跳过非文本文件]
E --> F[内容匹配: 正则搜索]
F --> G[返回命中文件或匹配片段]
以前面的 bridge 搜索为例:
Grep({
pattern: "SessionActivity|currentActivity",
path: "bridge/",
glob: "*.ts"
})
过滤过程大致是:
| 阶段 | 文件数量变化 | 说明 |
|---|---|---|
| 原始文件 | 4,471 | 代码快照内的全部文件 |
.gitignore 剪枝 | 4,471 | 如果没有 node_modules,这一层变化不明显 |
path: bridge/ | 32 | 只遍历 bridge 目录 |
glob: *.ts | 32 | bridge 下基本都是 TypeScript 文件 |
| 二进制检测 | 32 | 文本文件继续进入匹配 |
| 正则匹配 | 3 | 只有 3 个文件命中 |
path 和 glob 的组合非常重要。Agent 多轮搜索时,模型会不断根据已知信息缩小范围:从全项目,到某个目录,再到某类文件,最后到某个文件的局部行号。
文件内匹配为什么快
ripgrep 在文件内容匹配上也做了多层优化:
| 优化 | 作用 |
|---|---|
| SIMD 向量化 | 一条 CPU 指令并行比较多个字节,快速定位可能命中的位置 |
| Boyer-Moore 跳跃 | 固定字符串搜索时,遇到不匹配可跳过多个字符 |
| Page Cache | 操作系统会把读过的文件缓存在内存中,重复搜索不必重新读磁盘 |
| mmap 内存映射 | 对大文件减少内核态到用户态的数据复制 |
| 多线程 | 一个线程遍历目录,多个 worker 并行搜索不同文件 |
开发者日常处理的代码库通常会频繁被编辑器、语言服务器、构建工具访问,因此大量文件已经在操作系统 Page Cache 里。一次搜索更像是在内存里扫文本,而不是每次都从磁盘冷启动。
实测级别的差距
在一个约 4,500 个文件、95 万行代码的 TypeScript 代码库上,ripgrep 和 GNU grep -r 搜同样关键词,耗时差距很明显:
| 搜索模式 | ripgrep | GNU grep -r | 差距 |
|---|---|---|---|
TOOL_VERBS 低频词 | 0.09s | 2.55s | 约 28 倍 |
async.*generator 正则 | 0.10s | 3.30s | 约 33 倍 |
import.*from 高频词 | 0.10s | 2.45s | 约 25 倍 |
0.1 秒级别的搜索对交互式 Agent 来说基本可接受。真正慢的往往不是 rg 本身,而是模型推理、工具调度、网络请求和上下文处理。
数据规模决定可行性
零索引 Grep 成立的前提,是搜索对象通常是开发者本地代码库,而不是全互联网文档或公司级知识库。
| 维度 | 向量检索暴力扫描 | Grep 暴力扫描 |
|---|---|---|
| 常见数据规模 | GB 到 TB | MB 到几百 MB |
| 单次比较 | 高维向量相似度计算 | 字节或字符串匹配 |
| 命中目标 | 语义相似片段 | 精确标识符、路径、文本 |
| 是否适合全量扫描 | 通常不适合 | 中小型代码库可行 |
假设一个代码库大小为 250MB,并且文件已经命中 Page Cache。按现代机器 30GB/s 左右的内存带宽估算,单纯把 250MB 数据从内存扫一遍的理论下界大约是:
250MB / 30GB/s ≈ 8ms
真实搜索还要加正则匹配、线程调度和结果整理,所以常见耗时会到几十毫秒或一百多毫秒。这个数量级不一定值得专门维护一套索引系统。
Cursor、Claude Code、Codex 的架构分歧
行业里并不是所有 AI 编程工具都放弃索引。Cursor 代表的是另一条路线:语义索引加精确搜索索引。
Cursor 的双索引路线
Cursor 的代码检索可以理解成两套系统叠加:
| 索引类型 | 做法 | 解决的问题 |
|---|---|---|
| 语义索引 | 用 tree-sitter 按语法结构切块,生成 embedding,写入向量搜索引擎 | 处理概念性查询、跨文件语义关联 |
| 精确搜索索引 | 用 trigram(三字符组合)倒排索引加速 grep 类搜索 | 快速定位字符串、符号、路径 |
trigram 索引的思路并不复杂。比如字符串 OAuth 可以切成:
OAu
Aut
uth
索引会记录每个 trigram 出现在哪些文件里。搜索时先取这些 trigram 对应文件集合的交集,得到候选文件,再对候选文件跑真正的正则匹配。
flowchart LR
A[查询字符串 OAuth] --> B[拆成 trigram: OAu / Aut / uth]
B --> C[查倒排索引]
C --> D[求文件集合交集]
D --> E[候选文件]
E --> F[执行精确正则匹配]
F --> G[返回结果]
Cursor 的路线是预处理:后台先切块、建索引、同步变化,检索时直接利用索引加速。Claude Code 的路线是按需搜索:不预处理、不维护索引,模型在对话中实时决定搜索动作。
两者不是简单的优劣关系,而是面向的规模和产品目标不同。
| 方案 | 优点 | 代价 | 适合场景 |
|---|---|---|---|
| 零索引 + Grep | 无启动成本、无索引过期、实现简单、直接搜索最新文件 | 多轮搜索可能增加 token,语义召回弱 | 本地中小型代码库、精确符号搜索 |
| 向量索引 | 能处理概念性查询,适合大规模代码库和跨仓库搜索 | 需要切块、同步、存储、更新、权限管理 | 大型代码库、团队级代码搜索、自然语言查询 |
| trigram / 倒排索引 | 精确搜索更快,适合超大文件集合 | 需要预处理和增量维护 | 大型代码库里的字符串搜索 |
Codex CLI 的相同选择
Codex CLI 和 Claude Code 在“是否建索引”上选择相近:不建向量索引,不依赖 embedding,搜索时优先使用 rg。
两者不同点在工具封装层面:
| 方面 | Claude Code | Codex CLI |
|---|---|---|
| 搜索入口 | 专用 GrepTool、GlobTool、FileReadTool | 通过 shell 执行 rg、find、cat 等命令 |
| 参数结构 | 工具有明确 schema | 模型自己写 shell 命令 |
| 输出控制 | 工具层可限制模式、行数、返回格式 | 依赖命令组合和模型自控 |
| 灵活性 | 较可控 | 更接近真实终端,可组合管道 |
| 风险 | 工具能力边界清晰 | shell 输出更非结构化,命令写错概率更高 |
Claude Code 把搜索能力封装成结构化工具,便于控制返回内容和权限边界。Codex CLI 则更像把一个熟悉 Unix 工具链的工程师放进终端,让模型直接组合 rg、sed、cat、git 等命令。
共同点更关键:两者都认为,对于开发者本地代码库,LLM 驱动 ripgrep 已经足够支撑大量代码理解任务。
Grep 方案的成本:token 会不会失控
Grep-only 搜索最常见的批评是 token 成本。多轮 Grep 和 Read 会把越来越多结果追加到对话历史里,而每轮调用模型时,历史上下文都要重新参与请求。搜索范围一旦过宽,模型可能读到大量无关代码,既慢又贵。
这个问题真实存在,不能用 “ripgrep 很快” 掩盖。rg 的本地搜索耗时可能只有 100ms,但如果返回 500 行噪声,后续模型处理这些文本的成本会远高于搜索命令本身。
Claude Code 主要靠三类机制控制上下文膨胀。
prompt cache:降低重复前缀成本
Agent 循环的请求有一个特点:相邻两轮输入大部分是相同的,只是在尾部追加了最新工具结果。
第 N 轮请求:
[system prompt][历史消息 A][历史消息 B][工具结果 N]
第 N+1 轮请求:
[system prompt][历史消息 A][历史消息 B][工具结果 N][工具结果 N+1]
如果模型服务支持 prompt cache,就可以复用相同前缀的计算结果。这样每轮不必按完整上下文重新付全价,重复部分按缓存策略计费或复用计算。
Claude Code 在工程上会把稳定的 system prompt 拆成多个块,尽量让不变部分命中缓存,避免被动态内容破坏缓存前缀。
auto-compaction:把旧历史压缩成摘要
当对话历史接近上下文窗口上限时,可以触发自动压缩:让模型把早期工具调用、搜索结果、阶段性结论压缩成摘要,再用摘要替换原始长消息。
flowchart LR
A[长对话历史] --> B[触发压缩]
B --> C[LLM 生成阶段摘要]
C --> D[摘要替换旧消息]
D --> E[释放上下文空间]
这会丢失一部分细节,但能保留搜索方向、关键文件、已确认结论。对于长任务来说,不压缩就会被上下文窗口硬性截断,压缩是更可控的折中。
子 Agent:把噪声留在隔离上下文里
前面提到的 Explore 子 Agent 也是成本控制手段。主对话不直接接收每一轮搜索的原始输出,只接收子 Agent 的整理结果。这样主上下文更像“决策层”,子 Agent 的上下文则承担“资料消化层”。
flowchart TD
A[主 Agent] --> B{任务是否需要大范围搜索}
B -- 否 --> C[主 Agent 直接 Grep / Read]
B -- 是 --> D[启动 Explore 子 Agent]
D --> E[子 Agent 多轮搜索]
E --> F[子 Agent 总结关键结论]
F --> A
这些机制只能缓解 token 成本,不能消除 Grep 在语义召回上的弱点。Grep 方案的本质取舍是:用更多交互轮次和上下文管理,换取零索引、零维护、实时读取工作区文件。
Grep 为什么特别适合代码搜索
自然语言搜索和代码搜索有一个关键差异:自然语言表达经常换词,代码则大量依赖稳定标识符。
在自然语言里,用户问“如何撤销订单”,文档可能写的是“取消交易流程”。如果只做字面匹配,很可能搜不到。embedding 擅长处理这种词汇不一致问题。
代码里就不一样了。函数名、类名、变量名、配置项、错误码、文件路径都是强信号:
getUserById
SessionActivity
GrepTool
bridgeUI.ts
TOOL_VERBS
这些标识符通常不会被同义改写。只要模型能把问题转换成合适的关键词,Grep 就能非常直接地命中相关代码。
代码搜索里的常见问题也天然适合精确匹配:
| 问题类型 | 典型搜索词 |
|---|---|
| 某个函数在哪里定义 | 函数名 |
| 某个类被谁引用 | 类名 |
| 某个配置在哪里生效 | 配置 key |
| 某个日志从哪里打印 | 日志文本 |
| 某个错误如何抛出 | 错误码或错误消息 |
| 某条链路如何串起来 | 类型名、事件名、方法名 |
LLM 的价值在于把模糊问题改写成一串可能命中的代码符号。例如“bridge 怎么显示工具正在搜索”可以被拆成:
bridge
tool activity
currentActivity
GrepTool
Searching
SessionActivity
这一步是传统手写 grep 的痛点,也是 Agent 搜索比人工搜索更强的地方。人需要反复猜关键词,LLM 可以基于中间结果持续改写查询。
Grep 的边界在哪里
零索引 Grep 并不适合所有场景。判断是否该用它,可以看两个因素:数据形态和数据规模。
适合 Grep 的场景
| 场景 | 原因 |
|---|---|
| 本地中小型代码库 | ripgrep 扫描足够快 |
| 精确符号定位 | 标识符、路径、错误码适合字面匹配 |
| 文件实时变化频繁 | 不建索引就没有同步和过期问题 |
| Agent 需要多轮探索 | 每轮可根据新发现调整关键词 |
| 权限和隐私要求高 | 不必把代码切块上传到索引服务 |
不适合只靠 Grep 的场景
| 场景 | 问题 |
|---|---|
| 大型 monorepo | 全量扫描延迟可能不可接受 |
| 跨仓库知识检索 | 本地 Grep 搜不到远端或历史知识 |
| 自然语言文档问答 | 同义改写多,字面匹配召回弱 |
| 概念性代码理解 | 不知道符号名时,关键词难生成 |
| 需要稳定高召回 | 向量、BM25、图检索或混合检索更稳 |
BM25 是一种基于词项匹配的排序算法,常用于搜索引擎。实际工程里,很多系统会采用混合检索:Grep / BM25 处理精确匹配,embedding 处理语义相似,再用 rerank 或 rank fusion 合并结果。
RAG 没死,死的是“代码搜索必须先建向量索引”的默认假设
把 Claude Code、Codex CLI 和 Cursor 放在一起看,可以得到一个更准确的结论:
- RAG 作为“让模型使用外部信息”的思想仍然成立。
- embedding 向量库不是代码搜索的唯一入口。
- 在本地代码库里,LLM 驱动
ripgrep往往足够快、足够直接。 - 当仓库规模变大,或者查询从代码符号变成自然语言概念,索引仍然有价值。
- 更现实的方向不是 Grep 和 RAG 二选一,而是按数据规模、查询类型、成本约束组合使用。
Agent 时代的检索重点,不再只是“提前把知识切好块放进库里”,而是让模型能主动提出更好的问题、选择更合适的工具、根据反馈继续探索。对于代码搜索来说,Grep 的回归并不是倒退,而是 LLM 把一个老工具变成了可迭代推理流程的一部分。