查 findCycle 命中 98%,改查“find cycle”只剩 39%:我把给 AI 喂代码那层量了一遍
我机器上有个目录,堆着 120 个一次性脚本——都是某天为了验证一个想法写几行、跑完就扔在那儿的那种:模拟退避重试的、算 KV cache 的、扫防火墙策略的、查 DNS 记录的。加起来 425 KiB,按函数边界切开是 501 个代码块。
我最近想搞清楚一件事:把这种仓库交给 AI 去改的时候,到底该怎么把代码喂给它。不是"要不要用更好的模型"——我想看的是喂进去之前那一层,检索。因为我看过的翻车现场基本都是同一个形状:它信心十足地改了一个文件,改的地方跟我问的东西没关系。
所以我没有去调模型,也没有接 embedding API。我把检索这一层单独拎出来,自己写了个零依赖的实现,跑了几组能复现的数字。
第一件事:查询的答案必须是确定的
我最怕的是自己造一批"我觉得该命中"的查询,然后拿到一个我自己骗自己的准确率。所以这批查询全是从代码里自动抽出来的,答案天然确定:
ident型:查询就是函数名本身,比如findCycle,答案 = 定义它的那个函数块。nl型:把函数名拆成自然语言,findCycle→ "find cycle",答案不变。str型:从console.log/throw new Error里抠出一段文本,答案 = 它所在的代码块。
一共 441 条(211 / 211 / 19)。语料是我自己的脚本,所以"哪个函数在哪个文件"不需要我猜。
这三类的差别,是整篇文章里最要命的一处。
分词:代码里的词不是按空格分开的
我一开始用的是最偷懒的分词:非字母数字一律当分隔符,toLowerCase 收工。这样 findCycle 是一个整体 token。
// 字面分词:非字母数字即分隔
function tokLiteral(text) {
return text.split(/[^A-Za-z0-9\u4e00-\u9fa5]+/)
.map(s => s.toLowerCase()).filter(s => s.length > 0);
}
// 标识符分词:先把 camelCase / snake_case 拆开
function splitIdent(s) {
return s.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([A-Za-z])(\d)/g, '$1 $2')
.replace(/(\d)([A-Za-z])/g, '$1 $2');
}120 个文件、441 条查询,同一套 BM25(k1=1.2、b=0.75,Lucene 那套默认值),只换分词:
| 分档 | R@5 | P@1 |
|---|---|---|
| 整文件 + 字面分词 | 69.8% | 49.2% |
| 整文件 + 标识符分词 | 94.8% | 59.6% |
| 函数块 + 字面分词 | 57.1% | 39.9% |
| 函数块 + 标识符分词 | 94.1% | 49.9% |
拆开看类型才是重点。同样是"整文件 + 字面分词"这一档:
- 查
findCycle原文:98.1% - 查 "find cycle":39.3%
同一个函数,换个写法就掉 59 个点。换成标识符分词后两类都回到 94.3%。也就是说,那 25 个点的差距几乎全部来自"用自然语言描述一个 camelCase 名字"这件事。而 AI 恰恰是用自然语言提问的那一方。
切分:固定行数切是最差的选择
我试了三种切法:整个文件当一块、按函数边界切(括号配平,跳过注释和字符串)、按 40 行固定窗口切(重叠 10 行)。同为标识符分词:
| 切法 | 块数 | R@5 | P@1 | 文件级 R@3 |
|---|---|---|---|---|
| 整文件 | 120 | 94.8% | 59.6% | 88.4% |
| 函数边界 | 501 | 94.1% | 49.9% | 95.5% |
| 固定窗口 40 行 | 379 | 80.2% | 29.9% | 88.7% |
固定窗口在召回和排序上两头都不占。原因很直白:我的函数中位长度只有 10 行,40 行的窗口里通常塞着三四个不相干的函数,正确答案被稀释掉,而且切点会把一个函数劈成两半。
我还试了给文件路径的词加权重(boost 2 和 4)——我本来觉得"目录名往往就是功能名",应该很值钱。实测 P@1 从 49.9% 到 51.2%,R@5 还掉了 0.4 个点。这条路我放弃了。
符号名精确命中:+46 个点,但我自己拆穿了它
BM25 的 P@1 只有 49.9%,我想把它提上去。加了一档规则:如果查询的词序列跟某个函数的名字拆开后完全相等,直接给它加 5 分。
const qt = tokIdent(query).join(' ');
for (const d of docs) {
if (d.name && tokIdent(d.name).join(' ') === qt) bonus.set(d.id, 5);
}P@1 从 49.9% 一路涨到 94.3%(加权 5),R@5 到 99.5%。看起来是个漂亮工程。
但这里有个问题,我跑了实验五才看清楚:我的 ident 和 nl 查询是从函数名生成的,这条规则等于把答案写进了判据。真正不带这个嫌疑的是 str 类(19 条,查的是日志文本而不是函数名)——它在加权前后都是 84.2%,一点没动。
于是我把查询改残缺,看这条规则还灵不灵:
| 查询形态 | 无加权 P@1 | 加权 +5 P@1 |
|---|---|---|
| 完整("find cycle") | 48.3% | 94.8% |
| 丢掉首词("cycle") | 19.2% | 18.2% |
| 丢掉尾词("find") | 20.2% | 17.2% |
| 只留首词 | 20.4% | 19.0% |
只要少说一个词,加权的增益就从 +46.5 个点变成 -1 到 -3 个点,甚至略微变负(因为它把别的精确命中顶上来了)。
所以我对这条规则的判断是:它对"我记得它大概叫什么、而且说得全"这种查询非常有效,对"我只知道它干了什么"这种查询完全没用。我在自己的日志里把它记成一条高方差规则,不是通用提升。
切块的收益不在召回率,在塞不塞得进
这是整轮实验里最反直觉的一处。
我把检索结果按排名往下累加,超预算就停,看答案是不是在预算内被塞进去了:
| 预算(字符) | 函数块 | 整文件块 |
|---|---|---|
| 500 | 76.2% | 0% |
| 1000 | 90.9% | 0.2% |
| 2000 | 96.4% | 4.3% |
| 8000 | 99.3% | 47.2% |
| 16000 | 100% | 84.8% |
| 不设限 | 100%(平均 513 字 / 1.1 块) | 100%(平均 10323 字 / 2.1 块) |
预算不限的时候,两种切法都是 100%。切块完全没有提高"能不能找到",它提高的是"找不找得起"。 一个整文件 chunk 平均 10323 字符,你给 500 字符的预算它连一块都放不下——不是它错了,是它压根进不来。
我以前一直把"切分"当成召回率优化在看,量完才发现它是成本优化。这两个东西的调法完全不一样:召回率不够要改分词和排序,塞不进去要改粒度。
查不到的东西,要敢说查不到
比找错更难受的是:代码里根本没有这个功能,检索还是塞给你一个看起来很像的文件,AI 就照着它编。
我造了 175 条"语料里确实不存在"的查询(动词 × 名词交叉组合,逐条验证过没有任何一个代码块同时包含它的全部词,能匹配上的 5 条已剔除),跟 441 条真实查询比 top1 分数:
存在查询 n=441 p5=8.44 中位=14.94 p95=22.65
不存在查询 n=175 p5=0.00 中位=3.04 p95=8.07阈值扫描:
| 阈值 | 拒掉不存在的 | 误拒真实查询 |
|---|---|---|
| 5 | 61% | 0% |
| 8 | 94% | 4.1% |
| 10 | 100% | 7.5% |
| 12 | 100% | 16.3% |
没有免费的点。阈值 10 能拒干净,代价是把 7.5% 的真实查询一起拒了。阈值 8 拒掉 94%,误拒 4.1%——我会选 8 附近,然后让"没找到"成为一个明确的出口,而不是让 AI 拿着第四名的结果硬编。
顺带说一句:这些分数是跟语料规模绑死的,换个仓库就得重新扫一遍,别把 8 这个数抄走。
最后:这个体量下别搞增量索引
建 501 块的索引 55 ms,单条查询 1.77 ms。全量重建 70.3 ms,只重建 5 个改动文件 8.5 ms,差 8.3 倍。
8.3 倍听起来很值,但 70 ms 的全量重建在"改完就重扫"这个节奏下根本不是瓶颈。我本来准备写一整套增量更新(文件级版本号、删除墓碑、失效传播),量完之后没写——为了省 62 ms 引一整套状态,不划算。
我的判断
整轮跑下来,涨分最多的两步是标识符分词(+25~37 个点)和函数边界切分(比固定窗口 +14 个点)。这两件事都不需要模型,也不花钱。
而最容易被误判的是切分的目的:它不是为了让检索更准,是为了让结果塞得进上下文。我一开始把它当召回率问题调,方向就错了。
符号名精确加权那 +46 个点,我现在只把它当成"你确定名字时的快车道",不当成通用提升——查询少一个词就归零,这种规则得配日志盯着用。
至于空结果阈值,我的结论不是"设个 8",而是必须有一条"没找到"的路径。检索永远会返回一个第一名,你不显式地拒绝它,AI 就永远有东西可编。
