代码搜索实测:混合检索胜过纯向量
作者在 requests 仓库的十个问题上测试:纯向量检索前五命中四题,关键词排序命中六题,混合检索前十命中七题。排除测试与文档的竞争后,混合检索命中九题,但结论仅来自这一小样本。
AI 深度解读
语义代码搜索真能胜过 grep 吗?作者拿 psf/requests 的 v2.32.3 做了一个小实验:准备 10 个用直觉短语 grep 不到答案的问题,再比较关键词排名、向量搜索和混合搜索。结果没有形成「向量碾压关键词」的故事,反而说明关键词依然很能打。
这篇最有用的发现是:搜索效果不只取决于模型。分块是否被截断、关键词如何拆分、测试和文档是否参与排名,都会改变结果。对正在给 AI 编程工具接入代码库检索的程序员,这些细节比一张漂亮的演示截图更有参考价值。
grep 没搜到,不代表关键词搜索输了
作者复用了 osFoundry 本地目录索引的思路:分块规则来自 folder_index.rs,混合排名来自 folder_search.rs,用不到 200 行 Python 重建,在一台 8 GB M1 Mac 上只用 CPU 运行。
实验的问题特意选成:用最直觉的短语做不区分大小写的 grep,结果为零。但作者又补了一个更合理的关键词基线:把完整问题交给 BM25,对代码块排序,同时按下划线和 camelCase 拆分标识符,让 should_strip_auth 能匹配到 strip。
两者的差别很实际。短语 grep 要求文字连在一起出现;BM25 可以利用问题里分散的词,把更相关的代码排到前面。
结果是:
- 单独使用 bge-m3,10 个问题中有 4 个在前 5 名找到目标。
- 单独使用 BM25,有 6 个在前 5 名找到目标。
- BM25 与 bge-m3 融合后,有 7 个在前 10 名找到目标。
- 限定到 src/ 后,融合搜索有 9 个在前 10 名找到目标。
短语 grep 零命中,不能作为向量搜索优于关键词搜索的证据。
Redirect、proxy、401、UTF-16 原本就存在于代码中,只是整句问法没有出现。另一端,单词 grep 又可能返回太多:GET 匹配 625 行,bytes 匹配 98 行。能找到文字,不等于能迅速定位实现。
这里还有一个统计口径:作者提前读代码选定目标,以包含目标函数 def 行的代码块作为正确答案,报告它在全部 318 个块中的名次。它衡量的是定位能力,不是 AI 能否理解代码并回答问题。
向量能补上语义差距,也会输给函数名
向量搜索确实有关键词难以覆盖的命中。
面对与函数没有共同措辞的问题,bge-m3 把 should_strip_auth 排到了第一。对于 rebuild_method,它把 BM25 的第 65 名推进到了第 10 名。较小的 bge-small-en-v1.5 则在「进程死亡时不会留下写到一半的文件」这个问题上,把 atomic_open 排到第一,BM25 只排到第 58 名。函数文档里写着 atomic fashion,模型接上了「原子写入」与问题描述之间的关系。
但直接搜索标识符 raise_for_status 时,bge-m3 把定义所在块排到第 70 名,bge-small 排到第 244 名。知道函数名时,精确文本搜索仍然很有价值。
作者采用倒数排名融合(Reciprocal Rank Fusion,RRF),从两份排名各取前 30 项,使用 k = 60。每个结果按所在位置贡献 1 / (k + r + 1),再累加排序。它不直接比较 BM25 分数和余弦相似度,因为两套分数没有共同尺度。
因此,一个在两边分别排第 3、第 5 的结果,可以超过只在一边排第一、另一边完全没出现的结果。融合的目标是降低偏科风险,不是保证每个问题都拿第一。
它也有共同盲区。「请求体有多大」没有被任何方案有效找到,对应函数 super_len 缺少贴近提问方式的文字说明。向量并不能自动补齐所有代码含义。
模型之前,先看它实际读到了什么
作者把文件切成约 700 tokens 的块,相邻块重叠 80 tokens,以 1 token 约等于 4 个字符估算,保留起止行号,返回结果可定位为 path:start-end。
分块只在行边界切开。实现还有一个防止停滞的保护:遇到单行 200 KB 的压缩文件,回退重叠区后必须继续向前,否则可能死循环。
扫描会跳过 node_modules、dist、build、.git、.venv 等目录,按完整文件名排除 package-lock.json、pnpm-lock.yaml 等锁文件,并跳过超过 1 MB 的文件,以及前 512 字节含空字节的文件。只检查 .lock 扩展名会漏掉上述两种锁文件。
最终,129 个文件保留 117 个,排除 10 个二进制文件、1 个超限文件和1 个 SVG,得到 625,261 字节、318 个块。
问题出在估算。用 bge-m3 自己的 tokenizer 测量,块长中位数是 771 tokens,最长 2,050,并非预期的 700。代码的标识符和符号,让字符数估算法产生了偏差。
下面这段代码负责生成归一化向量,并把路径一起送入模型:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3", device="cpu")
model.max_seq_length = 2048
texts = [f"{c['path']}\n{c['text']}" for c in chunks] # 路径也携带语义
vecs = model.encode(texts, batch_size=2, normalize_embeddings=True)
要看紧 model.max_seq_length = 2048:最长代码块已经超过这个长度,而且输入还加了路径。作者没有给出每块最终保留多少内容,不能默认全部被完整编码。
另一个模型 bge-small-en-v1.5 有 33M 参数,规模约小 17 倍,查询按推荐加上 Represent this sentence for searching relevant passages: 前缀。但它的 512 tokens 上限,使 318 个块中的 235 个只能读取开头。
bge-m3 首次下载约 2.3 GB,给这个约 625 KB 的仓库生成向量花了约 10 分钟,每块约 2 秒。osFoundry 用文本哈希复用未变块的向量,修改函数通常只需重算少量块,不过初次索引成本仍然存在。
还有一条必须守住:查询和索引必须使用同一个模型。另一个模型即使也输出 1,024 维向量,程序可能照常运行,排名却已经失去意义。
我的判断
我认同作者把混合搜索作为默认入口,但「胜过 grep」需要收窄理解。这是一个仓库、10 个特意选择的问题,且向量和 BM25 的前 5 命中数,不能与混合搜索的前 10 命中数直接比较。这些结果不足以证明某个模型或方案普遍更强。
实验也没有完全复刻产品:osFoundry 的关键词侧使用 tantivy 对整文件检索,再映射到文件开头的块;实验简化成了逐块 BM25。测到的是相近流程,不是完整产品表现。
更有启发的是测试和文档的竞争。询问 POST 为什么变成 GET,bge-m3 首先找到 tests/test_requests.py 中的 test_http_303_changes_post_to_get;询问主目录文件里的登录信息,首先找到 HISTORY.md 里的 .netrc 记录;另外两个问题则优先命中 docs/user/advanced.rst。它们用自然语言描述行为,确实更像用户的问题。
限定到 src/ 下的 84 个块,复用原向量,融合搜索就达到前 10 名命中 9 个。但这既改善了候选集合,也把搜索范围从 318 个块缩小到了 84 个,不能全部归功于排名质量。
实现定位适合优先源码,行为理解仍需要测试和文档。
作者猜测,小模型虽然大量截断仍能跟上,是因为函数定义和 docstring 常在块开头。这是合理解释,但没有对照实验。模型选择不如融合与路径过滤重要,也应当先作为这个实验里的经验看待。
能拿来做什么
- 给日常 AI 编程检索保留两条入口:已知函数名、错误文本时使用精确搜索;只知道行为描述时使用关键词与向量融合,并展示文件路径和行号。
- 为前端或 Node 仓库建立索引时,先排除依赖、构建产物和锁文件,再测真实 tokenizer 下的块长与截断比例,避免模型只读到了半段实现。
- 将源码、测试、文档标明类型,并允许切换搜索范围。查实现时优先源码,查预期行为时把测试纳入结果,不必一刀切删除。
- 做工具选型前,提前写好真实问题并人工确定目标位置,再固定查询跑评测。原文提供的复现命令如下,但输入中的脚本和 queries.json 未完整给出,仍需另行准备:
git clone --depth 1 --branch v2.32.3 https://github.com/psf/requests.git
python3 chunk.py requests chunks.json
uv run embed.py chunks.json BAAI/bge-m3 vec-m3.npy
uv run search.py chunks.json vec-m3.npy BAAI/bge-m3 queries.json
SCOPE=src/ uv run search.py chunks.json vec-m3.npy BAAI/bge-m3 queries.json