实验表明,调用关系等纯结构信息无法通过向量、BM25 或混合文本检索可靠找回。文章进一步讨论向量、调用图与符号索引如何组成面向生产环境的代码知识库。
我们花了五篇文章测量边界——现在需要一张更大的地图
如果你从第 03 篇一路读到这里,那么我们已经共同送走了一个纠缠我们整整五篇文章的幽灵——Q8。
让我们回顾一下这场漫长的追逐。Q8 是 process payment and create Stripe charge,它的标准答案中包含 calculate_order_total。为了找到它,我们几乎用遍了文本检索领域的所有技巧:向量基线不行;三种分块策略不行;把 called_by 编码进嵌入,只将相似度略微推高到 0.51,仍然不行;最后,就连业界普遍推崇的“终极武器”——BM25 + 向量混合搜索——也失败了。它不仅没能修复 Q8,反而将总分从 0.958 拉低到了 0.931。
五篇文章的结论可以浓缩成一句话:
calculate_order_total 与“Stripe 支付”之间的联系纯粹是结构性的。它存在于调用图的一条边上(process_checkout 调用了它),并未写在任何函数的文本中。因此,任何纯文本方案——向量、BM25、混合检索——在物理上都不可能触达它。
这是我们通过五篇文章的实验换来的、由数字支撑的硬结论。它很有价值——但只是整个拼图中的一块。
因为在追逐 Q8 的过程中,我们也顺带勘测出了三条不同检索路径的疆域:向量擅长什么、无法触达什么;图擅长什么、代价又是什么;以及一整类完全不需要语义的查询——例如“名为 validate_jwt_token 的函数在哪里”——精确匹配可以在一毫秒内返回结果。把这三块疆域拼接起来,就得到了代码库检索的完整地图。
因此,这篇文章将提升观察高度。前五篇文章拿着放大镜,聚焦于单个检索算法的召回率;这一篇则退到卫星视角,绘制一套系统架构:向量、图和符号信号究竟应该如何组织成一个真正可用的生产系统。
在继续阅读之前,请记住一个提示:本文中所有带有具体 Recall 数字的内容,都是已经在前五篇文章的实验中验证过的结论;所有描述“如何设计系统”的内容,则是从这些结论推导出的工程设计建议,尚未在完整的生产系统中进行端到端验证。我会在关键位置反复标明这一点——不要把设计建议误认为实验结论。
首先,清晰划分三种信号的疆域
在设计任何东西之前,先把前五篇文章测量出的三块疆域摆到桌面上。这是整个架构的基石,而这块基石中的每一块砖,都带有实验数据。
路径一:向量检索——语义相似查询的主场。
这是第 03 篇文章建立的基线,并在之后四篇文章中反复得到验证:AST 函数级分块 + 原始代码嵌入,Recall@5 = 0.958,是所有文本方案中最强的向量基线(已经实验验证)。它擅长处理这种形式的查询:“用自然语言描述一种能力,然后寻找它的实现”。例如,“找到那个对密码进行哈希并安全存储的函数”,即使你从未提到函数名,它也能命中 hash_password。
它的边界也测量得同样清晰:语义鸿沟无法填补(第 06、07 篇文章)。在现实世界中,calculate_order_total 和“Stripe 支付”确实是两件不同的事情,任何嵌入技巧都无法让它们变得更加接近。
路径二:图检索——结构关系查询的主场。
第 05 篇文章证明,调用图承载的结构信息是一种向量无法触达的正交真相(已经实验验证):通过 process_checkout → calculate_order_total 这条调用边进行两跳扩展,Q8 被重新找了回来。但第 05 篇文章也测量了它的代价——朴素的 BFS 两跳扩展会使候选集膨胀,把 Q1 中原本正确命中的 verify_password 挤出 top-5;修好一个、破坏一个,净收益为零。
这个结论很微妙:方向正确,方法粗糙。图信号很有价值,但“检索完成后进行朴素的 BFS 扩展”是错误的使用方式。这个教训直接决定了图检索在下述架构中扮演的角色。
路径三:符号检索——精确匹配查询的主场。
前五篇文章没有针对这条路径进行专门实验,但它的价值不证自明:当查询本身就是一个精确符号时——例如“名为 validate_jwt_token 的函数定义在哪里”“哪些文件导入了 redis.Redis”——你需要的不是“语义接近”,而是“字面精确命中”。在这里,向量无异于用大锤砸坚果,而且很容易被语义近似带偏;grep 或一张“函数名 → 文件 → 行号”的符号表,则可以在 10 毫秒内精确返回结果。
将这三块疆域并列摆放,一个漂亮的事实便浮现出来——它们几乎互不重叠:
查询类型 最佳路径 示例
──────────────────────────────── ────────── ─────────────────────────
用自然语言描述能力并寻找实现 向量 "find the function that validates JWT"
精确符号名 / import 符号 "where is validate_jwt_token"
结构关系 / 调用链 图 "who calls createPayment"
──────────────────────────────── ────────── ─────────────────────────
第 01 篇文章提出的“四个知识层”框架与此恰好对应:语法层(AST/符号)、语义层(向量)、架构层(图),再加上意图层(Git 历史)。生产系统的第一原则,就是接受这四种信号彼此独立、缺一不可——不存在一种能够统治所有四个层次的单一“最佳检索方法”。
原则一:多路径检索,各司其职
第一条原则,也是最反直觉的一条:不要再寻找“唯一最佳的检索方法”。
这正是我们在五篇文章中一直做的事情——不断追问“向量有效吗?BM25 有效吗?混合检索有效吗?”,每一次都押注某一条路径能够统治所有查询。连续失败五次之后,我们不得不承认:对于 Q8 这样的结构查询,文本路径在物理上就无法触达。
换一种思路:不要寻找一个全能选手,而是让每条路径只做自己最擅长的事。向量负责语义,图负责结构,符号负责精确匹配——三条路径并行检索,各自覆盖自己的疆域。
打个比方,这就像医院的分诊台。你不会指望一名全科医生同时负责心脏搭桥、拔牙和血液检查。你会先进行分诊——骨折去骨科,牙痛去口腔科,验血去检验科。每个科室都是自己领域里的专家,而在其他领域只是外行。检索也是如此——使用向量进行精确符号匹配,就像让牙医来做心脏搭桥;当然,从技术上说,他确实知道心脏是什么,但你绝对不会希望由他来主刀。
这条原则直接源于前五篇文章的教训:我们已经用五篇文章的失败证明了,强迫一种信号去做它不擅长的工作会发生什么。我们强迫向量去触达 Q8 的结构关系,尝试了五篇文章,最终仍未成功。所以,让图来完成这项工作,让向量回到它擅长的语义主场。
提示:“多路径检索”这一架构方向,是直接从第 05、07 篇文章的实验结论中推导出的设计原则。每条路径的疆域都有实验支撑(向量为 0.958,图修复了 Q8,符号检索则无需实验),但“将三条路径组合成完整系统之后的总体 Recall”尚无端到端实验数据——这是设计建议,不是实验结论。
原则二:查询路由,根据意图激活正确路径
有了三条检索路径,一个问题立刻出现:当查询到来时,我们应该激活哪些路径?
如果每次都盲目运行全部三条路径,然后再进行融合,问题不只是速度慢——这些路径还会相互污染。第 07 篇文章生动地证明了这一点:向量在 Q7 上取得了完美的 1.00,但 BM25 因为过度匹配高频通用词“execute”,将结果拖到了 0.67;一旦 RRF 对它们进行融合,表现良好的路径就被表现糟糕的路径拖累了。盲目融合会让不擅长某类查询的路径,拖累真正擅长它的路径。
因此,我们需要一个 Query Router:先判断查询属于哪种类型,再决定激活哪些路径。
Query "where is the validate_jwt_token function" → Symbol route (exact match)
Query "the function that validates JWT tokens" → Vector route (semantic)
Query "what does createPayment call" → Graph route (structure)
Query "the full payment flow call chain" → Graph route + Vector route (mixed)
实现时不需要一开始就求助于 LLM。绝大多数查询意图都可以通过少量规则识别:
查询中包含精确标识符(例如 snake_case/camelCase 函数名、导入的模块名)→ 优先使用符号路由。
出现结构性关键词——“谁调用了”“它依赖什么”“调用链”“完整流程”→ 激活图路由。
其他所有自然语言描述 → 使用向量路由。
复合查询(同时包含结构意图和语义描述)→ 多条路由协同处理。
规则无法分类的边界情况可以借助小模型兜底——但这并非必需;规则足以覆盖绝大多数场景。
标注(重要):这里的“查询路由”是一种伪代码层面的设计概念,并非经过实验验证的结果。上面的“查询 → 路由”映射表,是我根据三类检索领域手工设计的一组路由规则;它与前五篇文章得出的实验直觉一致,但“路由准确率如何、路由错误的代价有多大”等问题,本系列尚未进行实验。请将其视为一项设计建议。
这是整个架构中最重要的一次转变,也是对第 05 篇文章失败方案的直接修正。
回顾一下第 05 篇文章为何会失败:它的流程是先通过向量检索取回 top-3 种子,然后从这些种子开始对图进行 BFS 扩展。图遍历只是跟在向量检索之后的后处理步骤。这个顺序是错误的——它把图当作向量检索的补丁,让图扩展引入的那些“结构上相关、但与查询无关”的函数污染向量排序,最终破坏了 Q1 的结果。
正确的做法是将图检索提升为与向量检索地位平等的一等检索路由:
图索引和向量索引并行构建,二者都是相互独立的一等检索入口,谁也不依赖谁。
向量路由独立获取自己的 top-k;图路由也独立执行——它识别查询中提到的函数名或模块名,直接遍历图(沿着 CALLS/CALLED_BY 边),返回结构上相关的函数。
两条路由的结果在最后进行融合。需要注意,这种融合不是第 07 篇文章中那种无视上下文的 RRF,而是根据查询类型进行加权合并:对于结构性查询,图路由权重更高;对于语义查询,向量路由权重更高。
一张图即可清楚说明这种转变:
❌ 第 05 篇文章(将图作为后处理补丁)
查询 → 向量 top-k → BFS 扩展 → 重排序
↑ 图在这里,跟在向量之后,污染排序
✅ 生产架构(将图作为地位平等的一等公民)
查询 ─┬─→ 向量路由 ─┐
└─→ 图路由 ─┴─→ 根据查询类型加权融合 → 结果
两条路由并行,各自独立检索
为什么这一改变能够解决第 05 篇文章中的问题?因为第 05 篇文章失败的根本原因是“图扩展使向量检索的候选集膨胀,并稀释了向量排序”。当图路由成为拥有自身检索逻辑和触发条件的独立路由后(第 07 篇文章结尾的建议是:仅由高置信度函数触发、只扩展 1 跳,并按照业务规则过滤邻居),它就不会再不加区分地把噪声塞进向量检索的候选池。图路由负责找回 Q8 的 calculate_order_total,向量路由负责守住 Q1 的 verify_password,各司其职,互不干扰。
标注:“将图作为一等公民,并根据查询类型进行加权融合”,是从第 05/07 篇文章的失败中推导出的设计修正方向。第 05 篇文章中“图后处理会污染排序”是经过实验验证的失败;但“并行的一等路由 + 加权融合可以同时守住 Q1 和 Q8”仍然只是设计推论,本系列尚未通过实验验证。这个方向有实验依据,但具体的融合权重和触发条件仍需在后续实战文章中调优。
前三项原则解决了“如何查询”的问题;第四项原则则要处理一个更残酷的工程现实:代码每天都在变化。
前五篇文章都基于一个包含 28 个函数的静态玩具数据集运行。但真实项目不是静态标本——每天会产生数十到数百个 commit,函数不断被新增、删除和修改,签名会发生变化,调用关系也会重新连接。如果索引策略是“每次都全量重建”,那么对于一个包含数十万行代码的代码库,仅构建向量索引就要花费数十分钟,图和符号表也必须随之全部重新计算。开发者修改一行代码后还要等待十分钟重新建立索引——不会有人使用这样的系统。
因此,第四项原则是:由 Git diff 驱动增量更新,绝不全量重建。
由 Git diff 驱动。当一个 commit 进入系统时,首先精确计算它修改了哪些文件和函数,只对这些变更点重新建立索引。对于未发生变化的函数,其 embedding、图节点和符号表条目连一个字节都不需要重新计算。
变更传播。这一点很容易被忽略,却至关重要。函数变更并不是孤立的——如果 create_payment_intent 的签名发生变化,那么所有调用它的函数也都需要更新各自的调用图边。因此,增量更新不只是“重新计算发生变化的函数”,还必须沿调用图把变化传播到受影响的邻居。这也是将图视为一等公民的另一个好处:图结构本身就是变更传播路径。
版本快照。支持通过 commit hash 查询历史版本——这与第 01 篇文章的第四层“意图层”相呼应。“这个函数三个月前是什么样子”“哪个 commit 引入了这一行,它当时要解决什么问题”——这些查询需要的不是当前代码,而是代码的演进历史,答案存在于 Git 中。
代码变更(git commit)
│
┌─────▼──────┐
│ Git Diff │ ← 只找出发生变化的文件 / 函数
└─────┬──────┘
│
┌─────▼──────┐
│ AST 解析器 │ ← 只重新解析发生变化的文件
└──┬──────┬──┘
│ │
┌───▼──┐ ┌─▼──────┐
│Embed │ │ 图 │ ← 两条路由并行进行增量更新
│更新 │ │ 更新 │ 图还会沿调用边将变更
└──────┘ └────────┘ 传播到邻居
标注:增量更新纯粹是一项设计建议——此前五篇文章都在静态数据集上进行实验,完全没有开展任何增量更新实验。不过,这个方向没有争议——它是任何生产级索引系统的标准配置,也是对第 01 篇文章中“动态性是最大挑战”这一观点的直接回应。
将这四项原则组合起来,就得到了完整的系统架构。可以从两个角度观察它:用户查询时会发生什么,以及建立索引时会发生什么。
查询流程(在线,用户发起查询时):
查询
│
┌──────▼──────┐
│ 查询路由器 │ ← 识别查询意图(规则优先)
└──┬───┬───┬──┘
│ │ │
┌───────▼┐ ┌▼──────┐ ┌▼────────┐
│ 向量 │ │ 图 │ │ 符号 │
│ 索引 │ │ 索引 │ │ 索引 │
│(AST + │ │(CALLS/│ │(grep / │
│embedding│ │CALLED_│ │AST │
│) │ │BY) │ │symbols) │
└───┬────┘ └──┬────┘ └────┬────┘
│ │ │
┌───▼─────────▼───────────▼────┐
│ 结果合并器 │
│ (根据查询类型加权合并并 │
│ 去重) │
└──────────────┬────────────────┘
│
Top-k 结果
三条索引路由并行检索(由路由器决定激活哪些路由),随后结果合并器根据查询类型进行加权合并、去重,并生成最终结果。注意,合并器不是盲目的 RRF——它知道当前查询属于结构性查询还是语义查询,并据此调整每条路由的权重。这正是第 07 篇文章带给我们的启示:融合必须感知上下文,否则表现较差的路由会拖累表现良好的路由。
索引流程(离线,由 Git hook 触发):
这就是上一节介绍的、由 Git diff 驱动的增量更新流程图。每次 commit 时,三条索引路由都会并行执行增量更新,图索引还会额外处理变更传播。
将这两张图结合起来,你就能看到这个架构的全貌:查询时,三条路径并行检索,并根据意图感知权重进行融合;索引时,由 Git diff 驱动三条路径并行执行增量更新。每一项设计决策,都可以追溯到前五篇文章中通过实验得到的经验。
设计归设计,真正落地时,你需要了解每条路径的工程成本。下表列出了三条路径(外加意图层)的构建成本、更新成本、查询延迟和适用场景。
有几点值得注意:
符号索引是性价比之王。构建只需几秒,查询只需 10ms,成本几乎可以忽略不计,却能完整覆盖一整类精确查询。任何系统都应该优先上线它。
向量索引是成本最高的路径。Embedding 需要运行模型,构建时间以分钟计,查询延迟也是最高的(约 100ms)。正因如此,增量更新对它最为重要——你绝对不希望每次提交后都重新执行一次全量 Embedding。
调用图索引出乎意料地便宜。它仅仅是 AST 解析加一张边表,几秒即可构建,查询速度也很快。第 05 篇已经证明了它的价值,而且成本低到没有理由不上线。图检索长期遭到忽视,并不是因为它成本高,而是因为人们一直没弄清楚该如何使用它(第 05 篇的经验)。
Git 历史索引第一次运行时成本最高。完整扫描提交历史并不便宜,但此后的每次新提交都可以增量处理,边际成本很低。
注意:表中的成本与延迟是数量级估算(秒/分钟/毫秒),来源于前五个 Demo 的实际运行经验和常规工程经验,并非针对生产规模代码库进行的精确基准测试。可以用它判断相对优先级(先上线成本较低的符号和图路径,之后再上线成本较高的向量路径),但不要把它当成 SLA。
无论架构看起来多么漂亮,一次性全部上线都会让你不堪重负。正确的做法是分阶段推出,并确保每个阶段都能独立创造价值;先让一个阶段正常运行,再进入下一个阶段。
首先上线成本最低、争议最少的两条路径:
符号索引:ripgrep + AST 符号表(函数名 → 文件 → 行号字典)。几秒即可构建,覆盖所有精确查询。
向量索引:AST 函数级分块 + 原始代码 Embedding。这是第 03 篇验证过的最强向量基线(Recall@5 = 0.958,已经过实验验证),直接照搬即可。
查询路由:先从最简单的规则开始——查询中包含精确标识符时走符号路径,否则走向量路径。
这一阶段已经覆盖了“精确符号查询”和“语义描述查询”这两大类需求,系统可以立即投入使用。不要低估它——日常代码检索中的绝大多数请求都属于这两类。
阶段 1 稳定运行后,再加入第三条路径:
调用图索引:通过 AST 解析构建 CALLS/CALLED_BY 边表。成本低(秒级),价值高(第 05 篇已验证它能够解决 Q8)。
将图路径接入为一条独立的一等检索路径——从查询中识别函数名,独立遍历图,绝不能采用第 05 篇中那种“让 BFS 跟在向量检索后面”的后处理方式。
升级查询路由器:识别“谁调用了”“调用链”等结构化关键词,并激活图路径。
升级 Result Merger:从“单路径直通”升级为“按查询类型进行加权融合”。
这一阶段补齐了 Q8 这类结构化查询——也就是文本路径在物理上根本无法触及的那部分版图。
前两个阶段解决的是“能否准确查询”;这一阶段解决的是“能否在生产环境中生存”:
Git 历史索引:接入第 01 篇中的意图层,支持“为什么这一行要这样写”“三个月前的版本”等查询。
增量更新:重构三条路径的索引构建流程,使其全部由 Git diff 驱动。这是从“Demo”跨越到“生产”的关键一步——没有增量更新,上述架构就无法在真实项目中运行。
变更传播:当函数签名发生变化时,沿调用图更新受影响的相邻节点及其边。
分阶段实施的意义在于,每个阶段都是一个完整的系统,都可以独立上线、独立创造价值。阶段 1 解决大多数检索需求;阶段 2 覆盖结构化盲区;阶段 3 让系统能够适应真实项目的日常演进。你不必等三个阶段全部完成后再交付——恰恰相反,你应该在阶段 1 上线后收集真实查询,并用这些数据指导阶段 2 和阶段 3 的优先级。
最后,我们深入到真正的工程细节。这部分属于常规工程选型建议,并非实验结论。
向量存储:pgvector(PostgreSQL 扩展)或 Qdrant。选择它们的关键原因是支持元数据过滤——在向量检索过程中,可以附加“仅限支付模块”“仅限这几个文件”等条件,这对代码检索极其有用,因为许多查询天然带有模块范围。
图存储:不需要 Neo4j。这一点值得特别强调,因为一提到“知识图谱”,许多人就会下意识地选择图数据库。本系列所有 Demo 的调用图都只是一个保存在内存中的 Python 字典({function_name: [called functions]}),并被序列化到文件中,而这已经完全够用(第 05 篇已验证)。对于内存无法容纳的大型项目,使用 NetworkX,或者干脆在 SQLite 中保存一张边表,仍然比 Neo4j 轻量得多。代码调用图的规模不足以证明专用图数据库的运维成本是合理的。
符号索引:使用 ripgrep(15.1.0 版本支持 PCRE2 + JIT,速度足够快)进行全文精确匹配,再加上通过 AST 解析生成的符号表(函数名 → 文件 → 行号),用于定义级查询。两者结合即可覆盖所有精确符号查询需求。
Inc