那道反复出现的题
如果你跟着这个系列一路读下来,会对一道题印象深刻——Q8:process payment and create Stripe charge(处理支付并创建 Stripe 扣款)。
它像个幽灵,在第 03 篇和第 04 篇里反复出没。每次向量检索都在这道题上栽跟头:Recall@5 = 0.50,只命中了两个相关函数里的一个。
漏掉的那个是 calculate_order_total(计算订单总价)。它的职责是"累加商品价格、打折、算税",实现里全是 sum、discount、tax,压根没有 payment 或 Stripe 的影子。在向量空间里,它离查询 "create Stripe charge" 隔着一道天堑。任何 Embedding 策略、任何 Chunking 策略,都填不平这道语义鸿沟。
第 04 篇的结尾我留了个悬念:既然语义相似度失灵,那就换个武器——代码的结构关系。calculate_order_total 和 create_payment_intent 语义上八竿子打不着,但它们在调用图上是邻居:都被结账流程 process_checkout 调用。这条确定性的边,理论上能把漏检的函数拉回来。
于是本篇我搭了一套图增强检索(graph-augmented retrieval),拿它去修 Q8。结果是——Q8 确实修好了。但总分一分没涨,还在 Q1 上摔了一跤。
这篇文章讲的就是这个"一换一"的故事,以及它背后关于"该怎么用图"的真相。
知识图谱,其实没那么玄
一提"知识图谱",很多人脑子里浮现的是 Neo4j、图数据库、复杂的本体建模。用在代码上,其实没那么重。
代码的知识图谱,最朴素的形态就是一张调用图(call graph):节点是函数,边是"谁调用谁"。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)
# 先收集所有函数名
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 反转一下。
针对本次实验的 payment 模块,解析出来的调用图长这样:
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 连起来的中间节点。记住这个节点,后面它是主角。
[图片:payment 模块调用图。process_checkout 在顶部,两条 CALLS 边分别指向 calculate_order_total 和 create_payment_intent;process_refund 孤立在一旁,没有任何边。中间用虚线圈出 process_checkout / calculate_order_total / create_payment_intent 三者构成的"支付流程"业务簇。]
图增强检索:种子 → 扩展 → 重排
有了这张图,检索流程从"一步到位"变成了三步:
Step 1(种子):先用纯向量检索取 top-3,作为"种子节点"。这一步和前几篇的基线完全一样——AST 函数级分割、原始代码 Embedding。
Step 2(扩展):从种子出发,沿调用图做 BFS,向外扩展 2 跳。每一跳同时走 CALLS 和 CALLED_BY 两个方向——既看"我调用了谁",也看"谁调用了我"。
Step 3(重排):扩展后候选集变大了,用向量分数对整个候选集重新排序,取 top-5。
代码是这样的:
def graph_retrieve(query_emb, indexed, call_graph, called_by, k=5, seed_k=3, hops=2):
# Step 1: 向量检索取种子
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 扩展
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: 按向量分数重排
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 ←(图检索退步)
process payment and create Stripe charge 0.50 1.00 ←(图检索进步)
(其余10道题:两种方法完全一致,均为1.00)
看清楚了吗?这不是"打平手",这是一换一:
- Q8(Stripe 支付):向量 0.50,图 1.00。图检索修好了那个幽灵。
- Q1(验证身份 + JWT):向量 1.00,图 0.50。图检索反而把一道原本满分的题做砸了。
两道题一进一退,恰好抵消,所以总分纹丝不动。但底下发生的事,比总分刺激多了。
[图片:一换一示意图。左边 Vector-only 柱状图:Q1=1.00(绿)、Q8=0.50(红);右边 Graph-augmented 柱状图:Q1=0.50(红)、Q8=1.00(绿)。中间一个双向箭头标注 "fix one, break one",下方标 "total unchanged: R@5=0.958"。]
Q8:图检索是怎么修好的
先看好消息。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']
盯着这几行看。向量检索的 top-3 种子里有 process_checkout——这个结账流程函数语义上和"process payment"很贴。然后 BFS 顺着它的 CALLS 边往下走,发现 process_checkout 调用了 calculate_order_total。就这一跳,把那个语义鸿沟外的孤儿函数拽进了候选集。
对比一下两种方法的 top-5:
- 向量 top-5 的第 4、5 名是
get_payment_history和verify_webhook_signature。这俩和 "payment"、"Stripe" 在字面上很像,向量分数不低,但对这道查询来说是无用的——它们不是 ground truth。 - 图 top-5 里,
calculate_order_total挤掉了那两个"语义相关但无用"的函数,成功命中。
这正是调用图的价值所在:process_checkout 调用 calculate_order_total,意味着这两个函数在业务上是协同工作的。 向量空间看不到这层协同(因为词汇不重叠),但调用图看得一清二楚。语义距离远、调用距离近的函数,靠这条确定性的边被拉了回来。
到这里,故事本可以完美收尾——"图检索填平了向量的语义鸿沟"。但 Q1 不答应。
Q1:图检索的代价
现在看坏消息。Q1 是 verify user identity and check JWT token validity(验证用户身份并检查 JWT 令牌有效性),它的 ground truth 是两个函数: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 位的 verify_password 挤出了 top-5。
一句话:图扩展把候选集撑大了,重排时新来的"结构相关但查询无关"的函数稀释了排序,把原本能命中的函数挤下了榜。
[图片:Q1 挤出效应示意图。左边 Vector top-5 一列,validate_jwt_token 在第1位(绿),verify_password 在第5位(绿,勉强上榜);右边 Graph top-5 一列,扩展进来两个灰色的 "structurally-related but query-irrelevant" 函数插进了第4、5位,把 verify_password 挤到第6位(红,掉出榜单)。]
这就是朴素图扩展的代价。它不是免费的午餐——每多召回一个语义鸿沟外的真相关函数,就要冒着挤掉一个原本靠向量排序命中的函数的风险。
双刃剑:扩展候选集是一把有代价的手术刀
把 Q8 和 Q1 放在一起看,图增强检索的本质就清楚了:它是一把双刃剑。
锋利的一面(Q8):找到语义距离远、但调用关系近的函数。calculate_order_total 跟 "Stripe" 没有任何语义关联,纯向量永远够不着,但它通过 process_checkout 这个中间节点两跳命中。这是向量检索的天花板之上的收益。
割手的一面(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就是一簇,它们天然应该一起被召回。
图扩展出错的场景:
- 种子函数恰好挂在某个 hub 节点(高出度节点,比如
execute_query这种被到处调用的工具函数)的调用者名单里。沿CALLED_BY扩展,会把一大堆毫不相关的业务函数一股脑拉进来。 - 函数之间虽然有调用关系,但业务含义差异巨大(工具函数 vs 业务函数)。
get_payment_history调用了execute_query,但这条边对"支付业务"的检索毫无帮助。
基于这两组场景,给出几条工程实践建议:
1. 扩展要有方向性。 优先只走 CALLS 边("我依赖谁"),谨慎走 CALLED_BY 边("谁依赖我")。因为"被谁调用"引入的噪声通常更多——一个工具函数可能被几十个地方调用,反向扩展会瞬间引爆候选集。
2. 扩展要有模块约束。 只在同模块内扩展才有意义。跨模块的调用关系(比如 payment 模块的函数调了 database 模块的 execute_query)扩展进来,大概率是噪声。给扩展加一道 same_module 的过滤。
3. 扩展步数要控制。 1 跳通常就够了。本实验用了 2 跳,Q8 恰好需要 1 跳(process_checkout → calculate_order_total)就够。2 跳开始,候选集指数级膨胀,噪声急剧上升。
4. 最好的方案,是根本不在检索时遍历图。
这条最关键。与其在检索时临时做 BFS——每次查询都要遍历、扩展、重排,既慢又引入噪声——不如在建索引时就把图结构信息编码进 embedding 的内容里。
具体做法:在每个函数的 chunk 里,额外拼上它的调用图元数据字段:
{
"content": (
f"# module: payment\n"
f"# called_by: process_checkout\n" # 谁调用我 —— 结构信息进正文
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 相关词汇时,calculate_order_total 自己的 embedding 里就含有 process_checkout 这个信号——向量检索本身就能感知到这层结构关系,根本不需要在检索时再单独遍历图。
这么做的好处是双重的:
- 没有候选集膨胀:结构信息被"融进"了向量本身,不需要扩展候选池,也就没有 Q1 那种挤出噪声。
- 重排精度不受影响:向量排序仍然是唯一的排序信号,结构信息只是让相关函数的向量彼此更靠近,而不是额外塞一堆候选进来。
换句话说:别在检索时把向量和图生硬地叠加,而是在建索引时就让向量"学会"图。 结构信息从"检索时的外挂"变成了"embedding 的一部分"。
总结
- 总分骗人。 纯向量和图增强的 Recall@5 都是 0.958,完全一致——但这不是"图没用",而是两股力量精确对冲的结果。
- 出错的题目互换了。 图检索修好了 Q8(
calculate_order_total通过process_checkout两跳命中),却做砸了 Q1(候选集扩展把verify_password挤出了 top-5)。一进一退,抵消归零。 - 调用图的价值是真的。
A 调用 B意味着 B 和 A 在业务上协同工作,这是向量空间看不到的结构信号。Q8 证明它能填平向量的语义鸿沟。 - 朴素图扩展是双刃剑。 扩大候选集这一个机制,同时带来"捞回漏检函数"的收益和"挤掉正确函数"的噪声——你无法只要好处。
- 工程上要给扩展加约束: 优先走
CALLS边、限制在同模块内、步数控制在 1 跳。 - 最优解不是检索时遍历图,而是把结构信息编码进 embedding 内容。 在函数 chunk 里加
called_by/calls字段,让向量检索本身就能感知调用关系——没有候选集膨胀,没有重排噪声,结构信息从"外挂"变成 embedding 的一部分。
下一篇,我们就来动手验证这个"把结构编码进内容"的方案,看它能不能在不引入 Q1 那种退步的前提下,把 Q8 稳稳修好。
参考资料
- 本系列完整 Demo 代码:codebase-kb-05-graph
欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页