AI 编程助手的爆发让「给代码建 RAG」成了高频需求:仓库问答、跨文件改代码、新人 onboarding「这个功能在哪实现的」。但把文档 RAG 的配方(切块→嵌入→向量检索)原样搬到代码上,效果普遍让人失望。原因不是代码「太难」,而是代码与自然语言文档在三个维度上结构性地不同,每个维度都要求对配方做实质修改。这篇讲清这三个特殊性、各自的工程应对,以及一个反直觉的结论:代码检索的最佳底座,可能仍然是个 1970 年代的工具。
特殊性一:代码是有语法的,切块不能瞎切
文档按字数切块(《切块策略实战》的各路策略)在代码上行不通——按 300 token 把代码拦腰切断,切出来的「块」经常是半个函数:开头没有签名、结尾缺了 return,嵌入向量编码的是一段语法残骸,检索得再准,给模型看的也是碎片。而代码的天然单元是语法结构:函数、类、方法,每个都是自包含的语义单元。
应对:语法感知切块——用 parser(tree-sitter 是事实标准,支持几十种语言、增量解析、容错解析)按 AST 切:每个函数/类/方法一个块,超长的函数再按语法边界(语句块)细分。块的元数据同时记下语法信息:语言、符号名、签名、所在文件与行号。这层投入的回报极大:检索命中后给模型的是完整可读的函数,而不是断章。
特殊性二:代码是有依赖的,单块命中不够用
文档的「相关」主要在语义层(讲同一主题),代码的「相关」还有一个硬维度:调用与引用关系。用户问「这个 API 怎么被调用的」,答案需要的是调用点集合;改一个函数的签名,受影响的是全仓的调用方。纯向量检索对此无能为力——「调用关系」不在任何单个函数的语义里,它分布在符号的全仓使用中。
应对:符号级索引与仓库图。两层结构:符号索引——定义表(符号 → 定义位置与签名)加引用表(符号 → 全部引用位置),可由 tree-sitter 提取或直接复用语言服务器(LSP)的现成能力,跳转、找引用、找定义全部秒级精确回答;仓库图(repo map)——把「文件 → 内的符号 → 依赖的符号」压成一棵紧凑的树形摘要(开源工具 aider 推广了这个形态),让模型一眼看到「仓库的骨架」,知道改哪个文件会牵动哪里。检索时「语义命中一个函数 + 符号索引拉出它的调用方」两路合并,才是代码问题的完整证据链。
特殊性三:代码是有海量重复的,嵌入相似度会被淹没
业务代码充满结构性重复:十几个 controller 的样板代码几乎一样、几十个 DAO 的 CRUD 模式相同。这对向量检索是灾难性的干扰:查询「处理订单退款的逻辑」,嵌入空间里最相似的可能是那几十个长得像的支付、发货 handler——语法相似淹没了语义相关。文档世界没有这么高密度的重复结构。
应对:提高区分度,而不是提高语义容量。几招有效:查询侧用标识符优先策略——查询里的函数名、类名、错误码是黄金信号,符号索引精确匹配的权重应高于语义相似(《元数据过滤》思想在代码域的应用:精确标识符是资格条件);块嵌入时把语义核心(函数名、docstring、关键调用)置于显著位置(拼接时签名与注释放前面);reranker 用代码特化模型或至少在代码语料上验证过的通用模型——通用 reranker 对代码 token 的判别力需要实测,不能想当然。
增量索引:跟着 git 走
代码库是所有语料里变更最频繁的(每天几十上百次提交),Code RAG 的索引必须事件驱动增量更新,定时全量重建在这个领域是奢侈品。挂钩 git 事件是最自然的方案:提交/合并触发「变更文件清单」,只对这些文件做语法切块与重嵌入(《增量更新与模型迁移》的文档级原子变更,这里粒度是文件级)。三个工程点:合并去抖(高频提交合并成批处理,每次 push 都触发全文件重嵌入会把嵌入服务打爆——按 1~5 分钟窗口合并变更);分支策略(多分支并行开发的仓库,索引跟主分支走,特性分支的检索需求由「主分支索引 + 工作区 diff」近似覆盖,不为每个分支建索引);符号表一致性(文件变更后符号定义/引用表要同步刷新——它比向量索引更敏感,一个过期的符号条目会让「找引用」给出错误答案)。这套机制与《语义缓存》的失效联动:文件变更触发相关缓存条目清理,「刚改的代码检索还是旧版」是 Code RAG 最招人恨的故障。
上下文组装的代码版
《上下文组装》的预算分配在代码域要按代码的「证据结构」重排。一次代码问答的上下文典型配方:命中函数全文(强证据区——签名 + 函数体,注释保留);类型与依赖声明(命中函数引用的关键类型定义、被调用的下游签名——只取签名不取函数体,省预算);调用点样例(2~3 个典型调用处,每个截取调用语句前后几行);仓库骨架(repo map 的相关子树,放最前面给模型定位感)。这个配方相对文档 RAG 的本质差异:证据不是同质的片段列表,而是「一个主体 + 多层辅助视图」的星形结构——命中点是恒星,依赖、类型、调用点是行星,组装层按这个结构拼装而不是按相关度平铺。实测里这个差异对改代码类任务的答案质量影响巨大:模型能改对函数的前提是「看见函数的完整签名与类型约束」。
评估:任务型基准替代问答题
代码检索的评测集也要代码化,推荐三种任务型用例替代文档式问答题:定位题(给出 bug 描述或功能描述,断言检索结果包含「真实修复涉及的文件/函数」——ground truth 从真实 commit 反推:修复 commit 改了哪些文件,就是黄金答案);影响面题(给出「修改某函数签名」,断言检索能召回全部调用点——ground truth 是符号索引的全量引用,可以程序化生成,标注成本近乎零);解释题(「这个函数的输入约束是什么」——检查召回的片段是否足以支撑回答,偏生成层)。三类题的 ground truth 都能从仓库本身(git 历史、符号表)程序化提取,这让 Code RAG 的评测集可以随仓库自动再生——代码库变了,测试集跟着变,这是文档评测集做不到的奢侈。
打底之争:grep 还是向量
代码检索工具生态里有个持续争论:ripgrep/精确搜索这么快这么准,向量检索到底值不值得加。务实的答案是分层互补,grep 打底。精确工具(ripgrep、LSP 符号索引、git grep)的特点是:零索引成本、结果精确可解释、对「已知标识符」的查询碾压向量方案;短板是不认语义——「处理超时重试的代码在哪」这种不带标识符的自然语言查询,grep 无从下手。向量检索恰好补这个缺口:自然语言描述 → 定位到候选区域。所以成熟 AI 编程工具的检索层几乎都是组合拳:符号索引(精确跳转)+ grep(文本模式)+ 向量(语义兜底),按查询特征路由(《查询路由》的代码版),检索结果再由语法感知的组装层拼装(签名 + 关键依赖 + 调用点)。
多语言仓库的处理
真实仓库几乎都是多语言的(Java 服务加 Python 脚本加 SQL 迁移加前端),Code RAG 的三个组件都要过一遍多语言适配:语法切块按语言选 parser(tree-sitter 的各语言 grammar 分别加载,切块器按文件后缀路由);符号索引按语言接不同的解析器或语言服务器(跨语言引用——Python 调 Java 的接口——符号索引天然断链,这类「跨语言边界」的查询要靠 API 契约文档兜底:把接口定义文档也纳入索引);嵌入模型选多语言代码模型(代码嵌入模型的训练语料语言覆盖差异很大,混合仓库要先实测各语言子集的检索召回)。多语言适配的成本不高,但没做的话,检索质量会在非主语言上断崖——评测集按语言分层抽样就能提前暴露。
仓库规模与索引范围
单仓百万行的规模下,「索引哪些文件」比「怎么索引」更先决定成败。排除清单先行:第三方依赖(vendor、node_modules)、生成物(build 产物、代码生成的代码)、锁文件、二进制——这些占了仓库体积的大头,检索价值却近零,排除后索引规模常常缩到十分之一;排除清单从 .gitignore 反推再人工补充,是十分钟投入回报最大的动作。语言加权:核心语言(业务主代码)全量索引,辅助语言(脚本、配置)按查询热度决定——查询日志里从没人问过 shell 脚本的仓库,不必为 shell 建语义索引。超大单文件的策略:几万行的生成文件或巨型配置,整文件切块会让单块稀烂,标记为「可检索性低」或干脆按需现读。索引范围是 Code RAG 的第一道性价比阀门——范围错了,后面所有精巧组件都在为垃圾候选忙活。
小结
Code RAG 与文档 RAG 的分野可以收成一张对照:切块从字数切改为语法切(tree-sitter),相关性从单块语义扩展到全仓符号图(定义/引用表 + repo map),区分度从纯向量改为标识符优先、语法感知重排。而打底策略上,grep 与 LSP 的精确能力仍是主力,向量检索负责语义描述的兜底。给评估者的最后一个提醒:代码检索的效果评估也要代码化——用「定位真实 bug 修复涉及的文件」这类任务型测试集(《评测集构建》),而不是文档领域的问答题,两把尺子不可混用。
参考资料
- tree-sitter(GitHub) —— 多语言语法感知解析的事实标准
- aider: Repo Map —— 仓库图形态与构建方法
- LSP(Language Server Protocol)规范 —— 定义/引用查找的现成基础设施
- 本系列前篇:《切块策略实战》《查询路由》《元数据过滤》《评测集构建》
读者留言
COMMENTS 暂无还没有留言,来说第一句?