通过具体查询失败案例对比向量检索和知识图谱,发现语义鸿沟无法通过调用图完全弥补;揭示 codebase AI 系统的根本挑战。
那个反复出现的查询
如果你一直在关注本系列,那么有一个查询一定已经深深刻在你的记忆里——Q8:处理付款并创建 Stripe 扣款。
它就像一个幽灵,纠缠着第 03 篇和第 04 篇文章。每一次,向量检索都会在它身上栽跟头:Recall@5 = 0.50,两个相关函数中只能命中一个。
漏掉的那个函数是 calculate_order_total。它的职责是“汇总商品价格、应用折扣、计算税费”——函数体中充满了 sum、discount、tax,却完全没有 payment 或 Stripe 的踪迹。在向量空间中,它与查询“create Stripe charge”之间隔着一道鸿沟。无论采用哪种嵌入策略或分块策略,都无法弥合这道语义鸿沟。
在第 04 篇文章的结尾,我留下了一个悬念:既然语义相似度失效了,那就换一种武器——利用代码的结构关系。从语义上看,calculate_order_total 和 create_payment_intent 毫无共同之处;但在调用图上,它们却是邻居:二者都会被结账流程 process_checkout 调用。理论上,这条确定性的边应该能把漏掉的函数重新拉回来。
因此,在本文中,我构建了一套图增强检索系统,并让它处理 Q8。结果是——Q8 的确被修复了。但总分纹丝未动,而且系统在 Q1 上栽了跟头。
本文讲述的就是这场“一换一”,以及它所揭示的真相:你究竟应该如何使用图。
一提到“知识图谱”,很多人脑海里就会浮现 Neo4j、图数据库和复杂的本体建模。将它应用到代码时,实际情况要轻量得多。
最基本的代码知识图谱就是调用图:节点是函数,边表示“谁调用了谁”。process_checkout 调用 calculate_order_total,因此画一条 CALLS 边;反过来,calculate_order_total 被 process_checkout 调用,因此这是一条 CALLED_BY 边。
使用 Python 的 ast 模块,只需几十行代码就能构建这张图:
def parse_call_graph(source: str) -> dict[str, list[str]]:
tree = ast.parse(source)
# First collect all function names
all_funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
call_graph = {}
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
callees = []
for child in ast.walk(node):
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name):
if child.func.id in all_funcs and child.func.id != node.name:
callees.append(child.func.id)
call_graph[node.name] = list(dict.fromkeys(callees))
return call_graph
思路很直接:首先遍历一次语法树,收集所有函数名(这样就能判断某次调用是否属于“内部”调用);然后检查函数体内的每个 ast.Call 节点——如果被调用方的名称位于函数集合中,就记录一条边。CALLED_BY 只不过是将 CALLS 反转过来。
对于本次实验中的支付模块,解析出的调用图如下:
create_payment_intent CALLS → (none) CALLED_BY ← process_checkout
calculate_order_total CALLS → (none) CALLED_BY ← process_checkout
process_checkout CALLS → [calculate_order_total, create_payment_intent]
process_refund CALLS → (none) CALLED_BY ← (none)
注意,这个数据集中新增了一个函数 process_checkout——这是一个“结账流程”函数,它将“计算订单总额 → 创建 Stripe 支付意图”串联成一个完整的业务流程。它是连接 calculate_order_total 与 create_payment_intent 的中间节点。请记住这个节点——它是接下来故事的主角。
[Image: payment module call graph. process_checkout at the top, two CALLS edges pointing to calculate_order_total and create_payment_intent; process_refund sits alone off to the side with no edges. A dashed circle groups process_checkout / calculate_order_total / create_payment_intent into a "payment flow" business cluster.]
有了这张图,检索就从“一步到位”变成了三个步骤:
第 1 步(种子):执行普通的向量检索,取排名前 3 的结果作为“种子节点”。这一步与前几篇文章中的基线完全一致——按 AST 函数级别分块,对原始代码进行嵌入。
第 2 步(扩展):从种子节点出发,沿调用图执行 BFS,向外扩展 2 跳。每一跳都会同时遍历 CALLS 和 CALLED_BY——既查看“我调用了谁”,也查看“谁调用了我”。
第 3 步(重排序):扩展后的候选集会变得更大;按照向量分数对整个候选集重新排序,并取前 5 个结果。
def graph_retrieve(query_emb, indexed, call_graph, called_by, k=5, seed_k=3, hops=2):
# Step 1: vector retrieval for seeds
scored = sorted(indexed, key=lambda x: cosine_sim(query_emb, x[1]), reverse=True)
seeds = {func["name"] for func, _ in scored[:seed_k]}
# Step 2: BFS expansion
candidates = set(seeds)
frontier = set(seeds)
for _ in range(hops):
next_f = set()
for name in frontier:
next_f.update(call_graph.get(name, []))
next_f.update(called_by.get(name, []))
new = next_f - candidates
candidates.update(new)
frontier = new
# Step 3: re-rank by vector score
scores = {func["name"]: cosine_sim(query_emb, emb) for func, emb in indexed}
return sorted(candidates, key=lambda n: scores.get(n, 0), reverse=True)[:k]
其设计直觉是:向量检索“粗略找到正确区域”(种子),调用图“拉入被向量检索漏掉的邻居”(扩展),最后再通过向量分数做一次质量筛选(重排序)。听起来无懈可击。
我使用相同的数据和相同的 12 个查询,将它与普通向量检索进行了对比。方案 A 是纯向量检索(基线);方案 B 是图增强检索(seed_k=3,BFS 扩展 2 跳)。
Approach R@3 R@5
─────────────────────────────────── ─────── ───────
A_vector_only 0.889 0.958
B_graph_augmented (seed=3) 0.889 0.958
完全相同。二者的 Recall@3 都是 0.889,Recall@5 都是 0.958。添加调用图、执行 BFS 扩展——做了这么多工作,总分却连一个点都没有变化。
如果只看这张表,很容易得出结论:“图增强毫无用处,纯属浪费精力。”——但这个结论是错误的,而且错得很有意思。因为总分相同,并不意味着两种方法在每个查询上的表现都相同。
我拆解了全部 12 个查询,发现其中 10 个查询在两种方法下的结果完全一致(都达到了完美的 1.00),但有 2 个查询出现了分歧:
Per-query Recall@5 (divergent cases):
Query Vector Graph
────────────────────────────────────────────────── ─────── ───────
verify user identity and check JWT token validity 1.00 0.50 ← (graph regresses)
process payment and create Stripe charge 0.50 1.00 ← (graph improves)
(the other 10 queries: identical, both 1.00)
看出来了吗?这并不是“平局”,而是一场一换一:
Q8(Stripe 支付):向量检索 0.50,图检索 1.00。图检索修复了那个幽灵般的问题。
Q1(验证身份 + JWT):向量检索 1.00,图检索 0.50。图检索破坏了一个原本已经完美的查询。
两个查询,一个上升,一个下降,恰好相互抵消——因此总分纹丝不动。但底层发生的事情,远比总分有趣得多。
[Image: one-for-one swap diagram. Left, Vector-only bar chart: Q1=1.00 (green), Q8=0.50 (red); right, Graph-augmented bar chart: Q1=0.50 (red), Q8=1.00 (green). A bidirectional arrow in the middle labeled "fix one, break one," with "total unchanged: R@5=0.958" below.]
先说好消息。Q8 就是那个反复漏检的幽灵,而图检索终于抓住了它。我追踪了扩展过程:
Seeds (vector top-3): ['process_checkout', 'process_refund', 'create_payment_intent']
process_checkout CALLS→['calculate_order_total', 'create_payment_intent']
create_payment_intent CALLED_BY←['process_checkout']
Candidates after expansion: ['calculate_order_total', 'create_payment_intent', 'process_checkout', 'process_refund']
Vector top-5: ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']
Graph top-5: ['process_checkout', 'process_refund', 'create_payment_intent', 'calculate_order_total']
仔细看这些内容。向量检索排名前 3 的种子中包含 process_checkout——这是一个结账流程函数,在语义上与“process payment”很接近。随后,BFS 沿着它的 CALLS 边向下遍历,发现 process_checkout 调用了 calculate_order_total。仅仅这一跳,就把那个孤立的函数——被困在语义鸿沟另一侧的函数——拖进了候选集。
对比两种方法的前 5 个结果:
向量检索前 5 名中的第 4 和第 5 个位置分别是 get_payment_history 和 verify_webhook_signature。从表面上看,它们都与“payment”和“Stripe”有关,因此向量分数并不低——但它们对这个查询毫无用处,也不属于真实相关结果。
图检索的 top-5 中,calculate_order_total 挤掉了那两个“语义相关但毫无用处”的函数,成功命中。
这正是调用图的价值所在:process_checkout 调用 calculate_order_total,意味着这两个函数在业务层面相互协作。向量空间无法发现这种协作关系(两者的词汇并不重叠),但调用图对此一目了然。一个语义上相距甚远、结构上却十分接近的函数,可以通过这条确定性的边重新被召回。
故事原本可以在这里完美结束——“图检索填补了向量检索的语义鸿沟”。但 Q1 并不同意。
现在来说坏消息。Q1 的查询是 verify user identity and check JWT token validity,其标准答案包含两个函数:validate_jwt_token 和 verify_password。
纯向量检索在这里拿到了完美的 1.00——它将这两个函数都稳稳地排进了 top-5。但图增强检索仅得到 0.50,漏掉了 verify_password。
一个原本正确的查询,在加入图之后反而错了。为什么?
问题出在种子节点与扩展之间引发的连锁反应。在向量排名中,validate_jwt_token 排名很高(进入了 top-3 种子节点),但 verify_password 的排名稍低——大约位于第 4 或第 5。在纯向量检索的 top-5 中,它恰好落在第 4、5 位,因此能够命中。
但图增强多了一个“扩展”步骤。BFS 从种子节点出发,将种子节点调用的所有函数,以及调用种子节点的所有函数,全部塞进候选池。候选池骤然膨胀。随后在重新排序时,这些新扩展出来的函数也会争夺排名——其中一些函数的向量分数碰巧高于 verify_password,于是将它挤出了原本稳居第 4、5 位的 top-5。
一句话概括:图扩展扩大了候选集,在重新排序期间,新加入的这些“结构相关但与查询无关”的函数稀释了排名,将一个原本能够命中的函数挤了出去。
[图片:Q1 挤出效应示意图。左栏为 Vector top-5:validate_jwt_token 位于第 1 位(绿色),verify_password 位于第 5 位(绿色,勉强留在列表中);右栏为 Graph top-5:两个灰色的“结构相关但与查询无关”函数经扩展进入第 4、5 位,将 verify_password 推到第 6 位(红色,掉出列表)。]
这就是朴素图扩展的代价。天下没有免费的午餐——每当你从语义鸿沟之外召回一个真正相关的函数时,也会面临挤掉一个原本依靠向量排名就能命中的函数的风险。
把 Q8 和 Q1 放在一起看,图增强检索的本质就很清楚了:它是一把双刃剑。
锋利的一面(Q8):它能找到语义上相距甚远、结构上却十分接近的函数。calculate_order_total 与“Stripe”在语义上没有关联——纯向量检索永远无法触达它——但通过中间节点 process_checkout,可以在 2 跳内命中。这是突破向量检索能力上限的收益。
伤人的一面(Q1):扩展候选集会引入“结构相关但与查询无关”的函数。当这些噪声进入重新排序阶段时,它们会稀释向量排名的精度,并挤掉原本仅凭向量检索就能命中的函数。
关键在于:这两种效果源自同一种机制——扩大候选集。你无法只获得 Q8 的收益而不承担 Q1 的代价,因为它们是一枚硬币的两面。候选集越大,找回遗漏函数的机会就越高,挤掉正确函数的风险也越大。
因此,“总分不变”这个看似平淡的结果,实际上是这两股力量恰好精确抵消的产物,而不是“图毫无用处”。在这个仅包含 28 个函数的小型数据集上,碰巧是一项上升、一项下降;换一个数据集,天平可能倒向任何一边。
这也解释了为什么许多团队满怀热情地采用 GraphRAG,将调用图遍历加入检索流程,随后却发现 A/B 指标毫无变化,甚至略有下降——朴素地外挂图遍历,往往会带来同等量级的收益和噪声。
结论并不是“图毫无用处”——恰恰相反。Q8 证明了,调用图携带的结构信息是一种向量检索无法触达的事实。问题在于使用方式:在检索时粗暴地执行 BFS 扩展和重新排序,是使用图最粗糙的方式。
首先,我们需要明确朴素图扩展在什么情况下有帮助,又在什么情况下有害。
图扩展有帮助的情况:
查询指向一个“入口函数”,而真正的实现函数是它的依赖项。查询“process payment”时,命中入口函数 process_checkout,而实际执行工作的函数 calculate_order_total 隐藏在它的 CALLS 边之下——沿着这条边扩展,就能把它一并捞出来。这就是 Q8。
一条业务流程中包含多个函数。支付流程中的 create_payment_intent、calculate_order_total 和 process_checkout 构成了一个集群;它们本来就应该被一同召回。
图扩展有害的情况:
某个种子函数恰好出现在一个枢纽节点的调用方列表中(枢纽节点是出度很高的节点,例如被各处调用的 execute_query 工具函数)。沿着 CALLED_BY 扩展,会拖进一大批完全不相关的业务函数。
函数之间存在调用关系,但它们的业务含义截然不同(工具函数与业务函数)。get_payment_history 调用了 execute_query,但对于“payment business”检索而言,这条边毫无价值。
基于上述两类场景,可以得出以下工程建议:
扩展应该具有方向性。优先只沿 CALLS 边(“我依赖谁”)遍历,对 CALLED_BY 边(“谁依赖我”)保持谨慎。“谁调用我”通常会引入更多噪声——一个工具函数可能被几十处调用,反向扩展会让候选集瞬间爆炸。
扩展应该带有模块约束。只有同模块扩展才有意义。跨模块调用关系(例如支付模块中的函数调用数据库模块的 execute_query)一旦被扩展进来,极有可能成为噪声。应为扩展添加 same_module 过滤器。
控制跳数。通常 1 跳就足够了。本实验使用了 2 跳;Q8 实际上只需要 1 跳(process_checkout → calculate_order_total)。从 2 跳开始,候选集会呈指数级膨胀,噪声也会急剧增加。
最佳方案是完全不要在检索时遍历图。
这一点至关重要。与其在检索时执行临时 BFS——每次查询都进行遍历、扩展和重新排序,既慢又嘈杂——不如在建立索引时,将图的结构信息编码进 embedding 内容。
具体来说,在每个函数的 chunk 中拼接调用图的元数据字段:
{
"content": (
f"# module: payment\n"
f"# called_by: process_checkout\n" # who calls me — structure goes into the body
f"# calls: (none)\n"
f"def calculate_order_total(items):\n"
f" ...\n"
),
"metadata": {"name": "calculate_order_total", "module": "payment"},
}
现在,calculate_order_total 的 chunk 中包含 called_by: process_checkout 这一行。当查询“process payment”命中与 process_checkout 相关的 token 时,calculate_order_total 自身的 embedding 已经包含了 process_checkout 的信号——向量检索本身现在就能感知这种结构关系,不再需要在检索时单独遍历图。
这样做有两方面的好处:
不会扩大候选集:结构信息被“融合”进向量本身,因此无需扩展候选池,也就不会产生 Q1 那样的挤出噪声。
不影响重新排序精度:向量排名仍然是唯一的排名信号;结构信息只会让相关函数的向量彼此靠近,而不会强行塞入一大批额外候选项。
换句话说:不要在检索时粗暴地把向量和图外挂到一起——而要在建立索引时,让向量“学习”图。结构信息将从“检索时的附加项”转变为“embedding 的一部分”。
总分会骗人。纯向量检索和图增强检索的 Recall@5 都是 0.958,完全相同——但这并不意味着“图毫无用处”,而是两股力量恰好精确抵消了。
失败模式发生了互换。图检索修复了 Q8(通过 process_checkout 在 2 跳内命中 calculate_order_total),却破坏了 Q1(候选集扩展将 verify_password 挤出了 top-5)。一项上升,一项下降,净结果为零。
调用图的价值是真实存在的。“A 调用 B”意味着 B 与 A 在业务层面相互协作——这是一种向量空间无法感知的结构信号。Q8 证明,它能够填补向量检索的语义鸿沟。
朴素图扩展是一把双刃剑。扩大候选集这一种机制,既带来了“找回遗漏函数”的收益,也带来了“挤掉正确函数”的噪声——你无法只取其一而不承担另一面。
从工程角度讲,要约束扩展:优先使用 CALLS 边,限制在同一模块内,将跳数上限设为 1。
最优方案并不是在检索时遍历图——而是将结构信息编码到嵌入内容中。向函数块中添加 called_by / calls 字段,使向量检索本身能够感知调用关系——这样既不会导致候选集膨胀,也不会产生重排噪音,结构从"附加方案"升级为"嵌入的有机部分"。
在下一篇文章中,我们将实现并验证这种"将结构编码到内容中"的方法——看看它能否稳妥地修复 Q8,同时不引入 Q1 风格的性能退化。
完整演示代码:codebase-kb-05-graph
查看 PrimeSkills —— 一个精选的 AI 智能体和技能市场,这些都已在真实的企业级工作流中验证过。没有冗余,只有真正有效的方案。
在我的主页上了解更多有用知识和有趣产品。
如需进一步操作,你可以考虑屏蔽此人和/或举报滥用。