1. 代码检索的目标与约束
代码检索的目标集中在一类符号上:函数名、变量名、错误码、配置键、文件路径。它们有几个共同特征——字符序列固定、大小写敏感、团队内部自造、出现频率低。
四条约束由此确定。
精确性优先于语义。 handlePaymentCallback 在嵌入空间里没有稳定的邻居:模型没见过这个项目刚起的函数名。同一个代号在不同上下文里会得到不稳定的向量,检索排名因此随机。精确匹配是唯一可靠的手段。
索引必须免维护。 一个活跃仓库每天几十次提交、几百个文件变化。嵌入索引需要跟着重建,重建间隙里它是过时的。文件系统索引不需要维护,它本身就是最新的。
冷启动要接近零。 交互式工具要求亚秒响应。嵌入索引需要加载模型或连接服务,第一次查询前还要准备索引。
结果必须可追溯。 检索到的路径要能直接用于后续读取,因此路径基准要与读取工具的解析基准一致。
四条约束共同指向一个结论:词法检索加文件系统索引。四家的实现都落在这个结论上,差异在工程细节。
2. Claude Code
2.1 工具集合与描述设计
三个专用工具加一支检索子代理。
Grep 是内容搜索,描述里第一句就写"基于 ripgrep 的强大搜索工具",并明确要求:永远用它,不要通过 shell 调用 grep 或 rg,因为专用工具做了权限与访问控制。
这条要求不只是风格约定。通过 shell 调用 grep 会绕过工具的权限检查——工具层的权限规则按工具名与参数匹配,shell 里的 grep 对权限系统是不可见的。所以"不要用 shell grep"是一条安全约束,被写在了描述的第一段。
描述里还给了几类具体提示:
- 支持完整正则、glob 过滤、类型过滤;
- 三种输出模式:内容行、仅文件路径、计数;
- 支持上下文行(前后若干行)与偏移;
- 支持多行匹配,并给出写法示例:跨行模式要用改行模式开关,因为默认只在单行内匹配;
- 正则里的字面大括号要转义,否则匹配失败。
最后两条把常见错误前置到描述里,减少一轮试错。这类"把已知陷阱写进工具描述"的做法成本很低——几十个 Token 换掉一次失败的往返。
Glob 是文件匹配,返回按修改时间排序的路径,上限 100 个文件,超出时结果带截断标记。
Read 是文件读取,默认最多 2000 行,支持偏移与限制,支持图片、PDF 与 Notebook。还有一条小优化:文件在本次对话中被读过且未变化时,返回一句"内容未变,参考之前的读取结果",避免重复占窗口。
这条优化的实现依赖一个细节:判断"未变化"要比较文件的修改时间与内容哈希。只比修改时间不够(有些工具会更新 mtime 而不改内容),只比哈希在文件很大时很贵。实际做法通常是先比 mtime,mtime 相同就认为未变,mtime 不同再算哈希。
2.2 结果体积的四层控制
| 层 | 阈值 | 动作 |
|---|---|---|
| 条数 | 默认 250 | 超出截断,可显式设为不限 |
| 单次结果 | 20,000 字符 | 超出触发持久化,完整内容写文件 |
| 单行列宽 | 500 字符 | 超出截断该行 |
| 进入上下文之后 | 视策略 | 旧结果清理、上下文折叠 |
四层对应四类不同的失败。条数上限防"结果太多",字符上限防"结果太长",列宽上限防"单行是压缩过的 JSON 或 minified 代码",第四层防"历史累积"。
第三层的场景值得说清。一份 minified 的 JS 文件可能只有三行,但每行几万字符。按行数控制完全无效,按总字符控制会在读到第一行时就触发。列宽上限是唯一能处理这种情况的层——它把每行截断到可读长度,同时保留行号信息。
第四层是运行时控制,与检索无关但影响检索的有效性:旧工具结果被清理后,模型看不到之前搜过什么,可能重复搜索。所以清理策略要保留"搜索过什么"的痕迹(例如保留第一次搜索的摘要),不能全部抹掉。
2.3 检索子代理
Explore 的定位在提示词里写得很清楚:只读模式,禁止创建、修改、删除、移动任何文件,禁止使用重定向与 heredoc,禁止任何改变系统状态的命令。
它有三条值得抄走的设计。
第一,只读是硬约束,不是风格建议。 提示词列出了具体禁止项:不能创建文件(包括临时文件)、不能修改、不能删除、不能移动或复制、不能用重定向与管道写文件。连"创建临时文件"都明确禁掉,因为临时文件是最容易被当作无害例外的口子。
第二,并行不是性能优化,是正确性要求。 提示词要求"尽可能并行发起多个搜索与读取"。理由是串行检索会让模型在第 N 次查询后忘记前面的发现——上下文里塞满了后续结果,早期的关键发现被推远;并行让它在同一时刻看到全部结果,比较与综合发生在信息齐全的基础上。
第三,模型选择按速度。 外部用户拿到的是小模型(haiku),内部用户继承主模型。理由是检索子代理的任务是"快速找到并汇报",不需要复杂的推理;用大模型只会让每次探索变慢。
触发判据写在主线提示词里:简单、有方向的搜索(找某个具体文件、类、函数)直接用工具;范围更大的探索或深度调研才用子代理,并且要求"只有在简单搜索明显不够,或任务明显需要超过 3 次查询时才用"。
这个判据的必要性在于成本。子代理有独立的上下文、独立的工具调用循环,慢但彻底。用一次的成本接近一次完整的小任务。
2.4 零结果与失败引导
几种引导写在工具描述里:结果被截断时提示"考虑用更具体的路径或模式";跨行模式要用多行开关并给出写法示例;正则里的字面大括号要转义。
这些引导的共同点是把"下一步动作"写在了错误可能发生的地方。模型读到截断提示时,正好在考虑要不要重搜;读到多行匹配示例时,正好在写跨行正则。写在别处的帮助不会在这个时刻被读到。
3. Codex
3.1 没有检索工具的选择
模型拿到的是 shell 与 exec,检索就是执行命令。
这看起来是缺口,实际上是一个明确的选择:Codex 把"用什么命令"交给模型自己决定。提示词与文档里给出推荐做法(用 rg、用 git grep、用 find),但不提供封装。
这个选择的收益是灵活性——模型可以用任何组合的管道、任何命令行工具、任何项目自带的脚本。代价是结果体积控制只能靠 shell 工具本身(例如 head、wc -l),而模型未必会主动加这些限制。
3.2 模糊文件匹配的定位
有一个组件在终端侧做模糊文件匹配,但它不是模型工具——它服务于用户输入 @ 时的补全。这一点容易被误读成"Codex 有文件检索能力"。
它的实现值得看。
遍历与忽略规则。 用带忽略规则的目录遍历器,遵守 .gitignore,把候选路径预加载进一个模糊匹配器。
打分与排序。 每个候选按匹配质量给一个相关性得分,按得分返回。得分考虑匹配位置(开头匹配优于中间匹配)、连续匹配长度、路径层级。
默认上限 64 条。
两个工作线程。 线程数这么小的原因写在注释里:文件树遍历是 I/O 瓶颈,并行度有限,实测超过 2 个线程收益不明显。这个观察与"多线程总是更快"的直觉相反,但它是对的——遍历的瓶颈在磁盘随机读与目录项解析,不在 CPU。
快照机制。 每次查询产生一个快照,包含当前匹配结果与"遍历是否完成"的标记。如果一次刷新收集到的是上一次查询的结果,快照会被判为无效并等待——避免把旧查询的结果标成新查询的。
这个快照机制解决的是一个并发问题:遍历在后台持续进行,查询随时可能到达。如果不做版本对齐,用户会看到"输入了新查询但结果是旧的"。
3.3 结果体积控制
- 工具输出有截断策略,按内容类型选择保留策略(文本保留头尾、图片按预算处理);
- 大结果外置到文件,模型拿到路径;
- 上下文压缩时对工具结果做修剪(超过阈值的切成头部加标记加尾部)。
3.4 权限与安全
检索与执行走同一套审批与沙箱。这意味着"能不能读某个目录"由执行环境决定,检索工具不单独决定。
这个统一的代价是检索也要走审批,收益是不会出现"检索绕过了执行的权限"。如果检索与执行是两套权限系统,迟早会出现不一致——某个目录执行时不可写但检索时可读,而检索结果里可能包含不该看的内容。
4. OpenCode
4.1 分层设计
内容搜索与文件匹配两个工具,底层是自建的 ripgrep 适配器。
适配器的设计意图写在文件头注释里:只暴露面向进程的原始行,不负责模型文本与权限行为;搜索工具负责把这些行映射成文件系统结果,具体的叶子工具负责展示与权限提示。
这个三分把三件事分开了:
- 进程管理:启动、终止、输出捕获、错误码;
- 结构解析:把 ripgrep 的 JSON 流解成行、列、子匹配;
- 展示:格式化、截断提示、权限弹窗。
分开的好处是每一层可以独立替换。换一个搜索后端时只需要重写进程管理与解析层,展示层不动;换展示风格时不影响解析。
代价是多一层间接,调试时要跨三处看。
4.2 参数与结果形态
内容搜索的参数有:正则或字面文本、路径、包含过滤(glob)、是否按字面匹配、是否区分大小写、条数上限(默认 100)。默认超时 30 秒。
结果按文件分组输出:每个文件一行路径,之后是缩进的匹配行(行号加内容)。末尾在截断时追加一句"结果被截断:显示前 N 条,考虑用更具体的路径或模式"。
这个格式比平铺列表更省 Token。算一下:同一个文件里十个匹配,平铺格式要在每行重复一次完整路径,按路径平均 60 字符算,多花 540 字符;分组格式只写一次路径,省下约 90%。
代价是模型要做一次轻微的解析(把缩进的行归到上一个文件)。这个解析很轻,但确实存在——如果模型在同一文件的不同匹配之间插入内容,格式会被打乱。所以分组格式对模型的格式遵循能力有要求。
4.3 零结果处理
零匹配时返回"未找到匹配",不用空字符串。空字符串在上下文里会被当作异常(工具调用失败或结果被截断),而"未找到匹配"是明确的语义结果。
这个细节看起来微不足道,但它影响后续行为:模型看到空字符串会倾向于重试,看到"未找到匹配"会倾向于换策略。
5. DeepSeek Harness
5.1 自带二进制与配置隔离
文件匹配与内容搜索两个工具,自带 ripgrep 二进制,通过一个子进程层调用。
两条设计约束写在文档里。
第一,宿主不需要安装 rg。Node 部署带平台包,Python SDK 把原生二进制作为运行时旁的伴随文件。这解决了"环境里没有 ripgrep"这类部署问题——一个依赖外部命令的工具在容器里经常不可用。
第二,每次调用前置 --no-config。原因是宿主可能设置了配置环境变量,让 ripgrep 加载一个包含预处理命令的配置——对不受约束的子进程来说,这是注入点。参数一律作为 argv 元素传入,不经过 shell,因此不涉及引号处理。
第二条值得展开,它属于安全设计。ripgrep 的配置可以指定一个预处理器("对匹配到的文件先跑这个命令"),如果攻击者能控制环境变量,就能让搜索过程执行任意命令。加 --no-config 之后配置被禁用,注入路径消失。
不经过 shell 的理由类似:shell 会解释引号、管道、变量替换,任何一个都可能被模型传入的参数利用。直接用 argv 传参,参数就是参数。
5.2 结果体积控制
六个参数,全部显式:
| 参数 | 默认值 | 作用 |
|---|---|---|
| 原始输出上限 | 20 MB | 超过则整个调用失败,不解析不完整的流 |
| 文件匹配条数 | 100 | 内联展示的最大路径数 |
| 内容匹配条数 | 250 | 内联保留的平铺匹配数 |
| 单行预览 | 2,000 字节 | 保留 UTF-8 字符完整性 |
| 超时 | 30 秒 | 协作式调用预算 |
| stderr 尾部 | 65,536 字节 | 诊断用 |
第一项选择"失败"、不走"截断",是刻意的:如果原始输出超过 20 MB 就截断,解析出来的结果是不完整的,而模型无法知道它不完整。直接失败并报错,模型有机会缩小查询范围重试。
超过内联上限的结果写入外置存储,页面携带定位信息。外置缺失或失败时保留内联页面,并报告完整结果无法保存,不让整个调用失败。
文件匹配还有一个必填配置:超过上限时是采样还是保留按修改时间排序的前部。没有默认值,部署必须显式选择。这个设计避免了"默认行为悄悄改变结果集"的问题——采样给出的是代表性样本,保留前部给出的是最新的 N 条,两者对模型的意义完全不同。
5.3 失败分类
四类错误码,对应四种恢复动作:
| 错误码 | 含义 | 恢复动作 |
|---|---|---|
| 非法模式 | ripgrep 拒绝正则或 glob | 改模式 |
| 搜索失败 | 启动失败、目标不可访问、被信号终止、输出格式错误 | 查环境 |
| 原始输出溢出 | 超过 20 MB | 缩小范围 |
| 被中止 | 超时或调用方取消 | 可以重试 |
退出码 0 表示成功且有结果,1 表示成功的空搜索——模型参数错误仍然走普通工具参数错误,不混进搜索错误。
这个分类的意义在于它把"失败"拆成了四种语义不同的情况。如果全部合并成一个"搜索失败",模型无法判断该改模式还是该重试。
5.4 相对路径
返回路径相对于会话工作目录。文档里明确写了一个前提:只有工作目录与文件系统根目录是同一个工作区时,才能用读取工具继续读这些路径。这条提醒针对的是"搜索之后读不到"这类看起来莫名其妙的失败。
6. 为什么四家都不建向量索引
这是这一章最值得说清的一件事。四个成熟的 Coding Agent 都没有引入嵌入索引,理由可以归纳成四条。
第一,代码标识符本来就是精确词。
handlePaymentCallback、STRIPE_WEBHOOK_SECRET、error_code_4021,这些是代码检索的主要目标。它们在语义空间里没有稳定的邻居——嵌入模型没见过这个项目里刚起的函数名,也没有见过这个团队自造的缩写。精确匹配是唯一可靠的手段。
同一个道理反过来也成立:语义检索擅长的是"用户用自然语言描述意图"。而 Coding Agent 的检索请求通常由模型发出,模型已经在提示词里学会了用标识符去搜,不靠人话描述。
第二,仓库高频变动。
一个活跃仓库每天几十次提交,几百个文件变化。嵌入索引需要跟着重建,否则检索结果会指向已经不存在的位置。维护索引的代价随提交频率上升,而收益随提交频率下降——每次重建的间隙里,索引都是过时的。
文件系统索引不需要维护:它本身就是最新的。
第三,文件系统已经提供了层级与从属。
src/payment/callbacks.ts 这个路径已经表达了"在 src 下的 payment 模块里,和 callbacks 相关"。目录结构是人工维护的、语义准确的索引。再建一层嵌入索引,是在已有结构上叠加一个更模糊的副本。
第四,冷启动代价。
词法检索的冷启动接近零:进程起来就能搜。嵌入索引需要加载模型或连接服务,第一次查询前还要准备索引。对一个要在几百毫秒内响应的交互式工具来说,这个差异很明显。
6.1 代价的量化
四条理由说起来都成立,但要让人信服需要算一笔账。用一个具体规模估一下。
假设仓库有五万个文件、两百万行代码,平均分成约二十万个块。一次嵌入要为二十万个块各生成一个向量:按每次请求批量处理两千块算,需要一百次调用;按每次几百毫秒算,一次全量索引要几分钟到十几分钟。这是一次性的,可以接受。
问题在增量。假设每天有五十次提交、每次改十个文件、每个文件平均两个块变化,那就是每天一千个块的更新。这个量级不大,但要求索引支持增量插入。如果选的是需要重建的索引类型,每天重建一次几分钟,尚可;如果选的是支持增量的索引,长期插入后精度下降,还得定期重建。
再看查询侧。每次检索要先算查询向量——一次嵌入调用,几百毫秒;然后查索引,几十毫秒。总计接近一秒。而词法检索在同一台机器上是几十毫秒,其中大部分是文件遍历。
这个对比说明关键点:嵌入的主要成本不在索引规模,在每次查询都要一次模型调用。 对一个每秒可能有多次检索请求的交互式工具,这个固定开销无法通过各种优化消除。
反过来,如果语料是几万份文档、每天更新几份、用户查询是自然语言,那么索引维护成本可以忽略,而查询时那几百毫秒相对用户的阅读时间也不明显。同样的技术在不同场景下成本完全不同。
6.2 四条理由的适用前提
四条理由不是普遍成立的。它们各自依赖一个前提,前提变化时结论也会变。
| 理由 | 依赖的前提 | 前提失效的情况 |
|---|---|---|
| 精确词为主 | 检索目标是代码标识符与命令 | 检索目标变成自然语言文档 |
| 高频变动 | 仓库每天多次提交 | 语料季度更新 |
| 已有层级 | 目录结构语义准确 | 目录组织混乱、扁平存放 |
| 冷启动为零 | 交互式、要求亚秒响应 | 后台批处理、可接受数秒 |
第三条最容易被忽略。"文件系统已经提供了层级"这个前提假设目录结构是有意义的。如果一个仓库把所有文件平铺在一个目录里(这在数据仓库与生成代码里常见),文件系统就不再是索引,第四条理由也不成立。
这解释了为什么数据仓库的检索工具通常有向量索引——它们的语料正是"扁平、自然语言、更新不频繁"。
7. 什么时候该建向量索引
反过来,条件变化时结论也会变。四种情况值得考虑引入:
| 条件 | 说明 |
|---|---|
| 语料以自然语言为主 | 文档、会议记录、客服对话,用户的查询也是自然语言 |
| 需要跨语言 | 中文问、英文材料,词法匹配完全失效 |
| 语义模糊且无稳定标识符 | 查询意图无法用关键词表达 |
| 体量大且相对稳定 | 几万到几百万条,更新频率低,索引维护成本可摊薄 |
判断顺序可以写成三个问题:
- 检索目标能用精确词命中吗?能,用词法。
- 语料更新频繁吗?频繁,用词法或直接读文件。
- 用户会用自然语言描述意图、且材料本身也是自然语言吗?是,才考虑嵌入。
三个问题都偏向词法时,建向量索引是负投入:它带来维护成本与不确定性,换来的召回提升在这个场景里不明显。
混合检索是第三条路:两路都跑,用排名融合。它的适用条件是"两种查询都存在"——既有精确标识符查询,也有自然语言意图查询。这在通用的知识库产品里常见,在代码检索里少见,因为后者几乎全是前者。
7.1 混合方案的现实成本
"两路都跑"听起来简单,实际要维护两套索引与两条链路。
| 维护项 | 词法 | 向量 | 混合 |
|---|---|---|---|
| 索引构建 | 无(文件系统即索引) | 需定期构建 | 两套 |
| 增量更新 | 无 | 需增量插入或重建 | 两套 |
| 查询延迟 | 几十毫秒 | 数百毫秒 | 两者之和 |
| 失败模式 | 环境问题 | 模型服务问题 | 两种都要处理 |
| 调试复杂度 | 低 | 中 | 高(要判断是哪一路的问题) |
最后一行是混合方案最容易被低估的成本。当检索结果不对时,需要先判断是词法侧漏了、向量侧偏了、还是融合权重出了问题。三条链路的排障成本高于单条链路的三倍——因为它们之间有交互。
所以混合方案的适用条件是:两种查询类型的比例都不低(各占两成以上)。如果九成查询是精确词,混合只会增加复杂度。
8. 检索子代理的价值
Claude Code 的 Explore 值得单独分析,因为它代表一种与"更强的检索算法"完全不同的思路:不改检索,改检索的组织方式。
它的触发判据是"预计超过 3 次查询"。这个数字背后是一个成本模型:主智能体每做一次检索,结果就进入主上下文,占窗口、占缓存、可能引入噪声。做十次检索,主上下文里就有十份检索结果。
子代理把这段过程装进自己的上下文。它做十次检索、读二十个文件,主智能体只收到一段结论加来源。主上下文干净了,代价是子代理看不到主对话的完整历史——所以交给它的任务必须自包含:目标、范围、输出格式、完成标准。
这个取舍与上下文工程里的"探索型工作交给子代理"是同一条原则,只是落在检索场景上。
它的提示词里有两条要求值得抄走:一是只读,明确禁止任何写操作,包括创建临时文件;二是并行,明确要求"尽可能并行发起多个搜索与读取"。第二条关乎正确性:串行检索会让模型在第 N 次查询后忘记前面的发现,并行让它在同一时刻看到全部结果。
8.1 什么时候不该用子代理
子代理不是免费的改进。三种情况它反而更差。
已知位置的读取。 用户说"看一下 README",直接用读取工具。走子代理等于为了查一个已知路径起一个完整的会话。
需要精确引用的任务。 子代理返回的是结论加来源摘要,不返回原文。如果任务需要引用代码原文(例如生成补丁),回到主智能体再读一次反而多一步。
快速迭代的探索。 用户与主智能体正在来回讨论,每次讨论都起一个子代理会让交互节奏变慢。子代理适合"一次性的深度调研",不适合"对话中的随手查"。
9. 最佳实践
| 实践 | 执行规则 | 验收方式 |
|---|---|---|
| 让精确词走词法 | 代码、标识符、错误码、路径使用正则或字面匹配 | 抽查检索请求是否带上了项目内真实出现的标识符 |
| 用专用工具,避免裸命令 | 提供带权限与结果整形的检索工具,提示词禁止绕过 | 审计 shell 调用里是否还有 grep 与 rg |
| 显式标注截断 | 结果被截断时返回明确提示与当前条数 | 构造超限查询,确认模型知道后续还有结果 |
| 分层控制结果体积 | 原始输出、条数、单行长度、上下文二次清理各设上限 | 统计每层的触发频率与释放的 Token |
| 超限结果外置 | 完整结果写文件并返回定位信息,内联保留样本 | 按定位信息能取回完整结果 |
| 检索失败分类 | 区分模式错误、环境错误、溢出、中止 | 每类错误给出不同的恢复动作 |
| 排除宿主配置 | 调用外部工具时禁用配置文件加载 | 在设置了配置环境变量的机器上验证结果一致 |
| 复杂探索交给子代理 | 只在预计多次查询时启用,任务必须自包含 | 对比主上下文体积与检索轮次 |
| 结果与路径同源 | 返回路径相对于当前工作目录,并说明可读前提 | 搜索后直接读取成功 |
| 分组输出 | 同一文件的多个匹配共用一次路径 | 统计同一结果集的字符数 |
最后两条看起来琐碎,实际是最常见的两类"莫名其妙失败"来源:路径基准不一致导致搜到了读不到;平铺输出让长路径重复几十次,把窗口吃掉。
四家在条数上限、单行长度、超时、外置时机、分组格式、错误分类上各不相同,但都把工程重心放在"控制注入模型的搜索结果体积"上。这个共同点说明检索系统的工程量主要不在"找得到",而在"找到的东西怎么进上下文"——上面十行里有六行直接服务于后者。