给编码助手接入了代码向量索引:把整个仓库切块、embedding、建库,期望它"更懂代码上下文"。上线后效果反常——经常检索到 vendor/ 里的旧版本实现、废弃分支的同名类,甚至三个月前删掉的方法;而开发者自己 grep 一个函数名,两秒就找到了正确位置。
问题不在 embedding 模型,在代码这种文本的性质与自然语言相反:自然语言里"同义表述"很多,所以语义匹配有价值;代码里恰恰相反——同一个概念通常只有一个标识符,而不同概念可能长得极像。语义检索最擅长的能力,在代码里几乎用不上;它最不擅长的"精确匹配",恰好是代码检索的主要需求。
1. 三种检索手段,回答三种不同的问题
| 手段 | 回答什么 | 强项 | 弱项 |
|---|---|---|---|
| 文本检索(grep/全文索引) | "这个字符串在哪" | 精确、零延迟、可解释 | 不懂语义、怕拼错 |
| 结构性检索(LSP / tree-sitter) | "谁定义/谁调用/谁实现" | 精确到符号关系 | 需要语言支持、要建索引 |
| 语义检索(embedding) | "哪段代码和这个描述相近" | 跨表述、跨语言 | 版本污染、不可解释 |
第三行的"不可解释"很关键:它返回一个相似度分数,你说不清它为什么选中这一段,于是出了问题只能靠猜。而 grep 的每条结果都是"这行里确实有这个词"。
// 同一个问题,三种手段的结果
问:哪里处理了「订单超时取消」
grep "order.*cancel|cancelOrder|cancel_order" → 精确命中 4 处,全是真实实现
LSP "cancelOrder 的调用方" → 3 个调用点,含一个定时任务
embedding "订单超时取消逻辑" → 命中 vendor/ 的旧实现 + 两个注释块
// 第三种的"也对",混在噪声里,模型读进去就写错
2. 为什么语义检索在代码上容易失灵
| 原因 | 机制 |
|---|---|
| 标识符唯一 | 一个概念通常只有一个名字,同义匹配的收益消失 |
| 旧版本污染 | vendor/、废弃分支、.min.js 都能被索引进来 |
| 注释与实现混杂 | 注释描述"打算做什么",往往与代码不符(过期) |
| 切块破坏结构 | 按固定行数切,会把一个函数切成两半 |
// 一段被切坏的真实例子(按 512 token 硬切)
块1: public void cancel(Order o) { ... if (o.isPaid()) {
块2: refund(o); } } ... 后半段只剩一个调用 + 注释
// 两块单独 embedding 都不表达"取消订单要退款",检索时互不相关
第四行在代码上特别致命:一个函数被切成"上半段"和"下半段",各自 embedding 之后语义都不完整;而真正的语义单位是函数/类,不是 token 窗口。
3. 按"问题类型"选手段,而不是统一用一套
| 你要找的 | 首选 |
|---|---|
| 某个字符串/常量/错误码 | grep |
| 某个函数的定义与调用点 | LSP / tree-sitter |
| 某个类的继承链 | LSP |
| "这里的错误处理是怎么写的" | 先 grep 缩小范围,再让模型读 |
| "这个仓库有没有做过 X" | 语义 + 人工确认 |
第四行是全篇最实用的结论:在代码库里,"缩小范围"比"语义排序"重要得多。把 20 万个文件缩到 20 个,再让模型读那 20 个——准确率远高于直接向量检索 top-10。
// 一个可用的混合流程(三步,每步都便宜)
$ rg -l "featureFlag|FeatureToggle" src/ | head -20 // 1 缩小到候选文件
$ rg -n "featureFlag" src/order/*.java | head -50 // 2 定位到行
$ tree-sitter query "call_expression" src/order/Service.java | head // 3 看结构
// 三步之后再交给模型:它读的是"已经确定相关的代码",不是"猜出来相关的代码"
4. 索引的鲜度比索引的精度更容易被忽略
| 更新方式 | 滞后 | 成本 |
|---|---|---|
| 每次提交增量 | 秒–分钟 | 中(要维护增量逻辑) |
| 定时全量重建 | 小时–天 | 高(大仓库重建很慢) |
| 手动触发 | 不可控 | 低 |
| 只索引默认分支 | 秒 | 低,但漏掉分支上下文 |
第三行是"Agent 看不到我刚改的代码"的直接原因;而第二行的问题是大仓库全量重建可能几小时,中间窗口的索引是旧的。
工程上的折中是"增量 + 每周全量":增量保证新鲜,全量修正增量累积的偏差(删除的文件、重命名的符号)。而必须过滤掉不可达代码(vendor/、node_modules/、废弃目录)——否则索引再新也没用。
// 索引范围的白名单,比模型选型更重要
$ cat .ai-index.include
src/
lib/
docs/api/
// 明确排除:vendor/ node_modules/ dist/ *.min.js *.generated.*
// 判据:这个目录的代码"现在会被执行到吗"
5. 什么情况下语义检索真的有用
| 场景 | 为什么有用 |
|---|---|
| 用自然语言描述找实现("有没有限流") | 表述与代码用词不同 |
| 跨语言迁移(Java → Go 的对应实现) | 标识符不同、结构相似 |
| 大仓库初次探索 | 不知道从哪 grep |
| 文档与代码对齐 | 文档用词 ≠ 代码用词 |
// 该不该上语义:两个问题自测
1 我能说出要搜的关键词吗? 能 → 直接 grep,语义没有额外价值
2 我知道概念但不知道名字? 是 → 语义检索有价值
// 第 2 类在真实任务里的占比往往不到三成
第一行是它最合理的用途:你不知道该 grep 什么词的时候。一旦知道关键词,grep 永远更快更准。
6. 排错:五种"索引建了但不好用"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 检索到已删除/旧版本代码 | 索引未过滤不可达目录 | 加白名单 |
| 检索结果"看着相关但没用" | 切块破坏了函数边界 | 按 AST 切块 |
| 刚改的代码搜不到 | 索引滞后 | 增量更新 |
| 同一问题两次结果不同 | 向量库有近似搜索随机性 | 固定参数或改精确检索 |
| 模型引用了不存在的 API | 索引里有旧实现 | 清索引 + 重建 |
第五行是这类问题最危险的后果:模型照着旧实现写出"看起来对"的代码,编译不过或者语义错。而它的根因是索引污染,不是模型能力。
7. 边界:什么情况根本不需要索引
| 场景 | 为什么 | 做法 |
|---|---|---|
| 仓库 < 5,000 文件 | rg 全量扫一遍就够快 |
直接用 grep |
| 只要精确符号查找 | 结构性检索已经足够 | LSP / tree-sitter |
| 单文件改动任务 | 上下文就是那个文件 | 直接给文件 |
| 团队不维护索引 | 陈旧索引比没有更糟 | 别建 |
第三行值得强调:多数编码任务不需要"检索整个仓库"——它们需要的是"把这个文件、以及它依赖的两个接口读进来"。索引的价值集中在"探索型任务",而探索型任务在总量里占比不高。
判断口径一句话:代码检索的第一原则是"精确优先",语义只在"不知道该搜什么词"时才有价值——所以正确顺序是 grep/LSP 缩小范围 → 按需语义补充 → 模型读已确认相关的代码,而不是"先向量检索 top-10,再让模型自己分辨"。
8. 自测
- 为什么语义检索在自然语言上有效、在代码上收益低?两个文本的性质差在哪?
- 三种检索手段各自回答什么问题?"谁调用了这个函数"该用哪种?
- 为什么"缩小范围"比"语义排序"更重要?举一个三步流程。
- 切块为什么是代码索引的隐藏杀手?正确的语义单位是什么?
- 索引鲜度的四种更新方式各有什么问题?"增量 + 每周全量"解决了什么?
- 什么情况下根本不需要建索引?判据是什么?
9. 一句话总结
代码检索的第一原则是"精确优先":代码里同义表述少、标识符唯一,于是 embedding 最擅长的能力用不上,而它最不擅长的精确匹配恰是主要需求。正确顺序是 grep/LSP 缩小范围 → 按需语义补充 → 模型读已确认相关的代码。两个隐藏坑:切块破坏函数边界(应按 AST 切)、索引包含不可达目录(vendor/、废弃分支)——后者会让模型照着旧实现写出"看起来对但编译不过"的代码。