查 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@5P@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@5P@1文件级 R@3
整文件12094.8%59.6%88.4%
函数边界50194.1%49.9%95.5%
固定窗口 40 行37980.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 个点,甚至略微变负(因为它把别的精确命中顶上来了)。

所以我对这条规则的判断是:它对"我记得它大概叫什么、而且说得全"这种查询非常有效,对"我只知道它干了什么"这种查询完全没用。我在自己的日志里把它记成一条高方差规则,不是通用提升。

切块的收益不在召回率,在塞不塞得进

这是整轮实验里最反直觉的一处。

我把检索结果按排名往下累加,超预算就停,看答案是不是在预算内被塞进去了:

预算(字符)函数块整文件块
50076.2%0%
100090.9%0.2%
200096.4%4.3%
800099.3%47.2%
16000100%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

阈值扫描:

阈值拒掉不存在的误拒真实查询
561%0%
894%4.1%
10100%7.5%
12100%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 就永远有东西可编。

最后修改:2026 年 10 月 27 日
如果觉得我的文章对你有用,请随意赞赏