代码库知识库系列(05):向量检索 vs 知识图谱——加了调用图并没有变更好

0 阅读14分钟

那道反复出现的题

如果你跟着这个系列一路读下来,会对一道题印象深刻——Q8:process payment and create Stripe charge(处理支付并创建 Stripe 扣款)。

它像个幽灵,在第 03 篇和第 04 篇里反复出没。每次向量检索都在这道题上栽跟头:Recall@5 = 0.50,只命中了两个相关函数里的一个。

漏掉的那个是 calculate_order_total(计算订单总价)。它的职责是"累加商品价格、打折、算税",实现里全是 sumdiscounttax,压根没有 paymentStripe 的影子。在向量空间里,它离查询 "create Stripe charge" 隔着一道天堑。任何 Embedding 策略、任何 Chunking 策略,都填不平这道语义鸿沟。

第 04 篇的结尾我留了个悬念:既然语义相似度失灵,那就换个武器——代码的结构关系calculate_order_totalcreate_payment_intent 语义上八竿子打不着,但它们在调用图上是邻居:都被结账流程 process_checkout 调用。这条确定性的边,理论上能把漏检的函数拉回来。

于是本篇我搭了一套图增强检索(graph-augmented retrieval),拿它去修 Q8。结果是——Q8 确实修好了。但总分一分没涨,还在 Q1 上摔了一跤。

这篇文章讲的就是这个"一换一"的故事,以及它背后关于"该怎么用图"的真相。


知识图谱,其实没那么玄

一提"知识图谱",很多人脑子里浮现的是 Neo4j、图数据库、复杂的本体建模。用在代码上,其实没那么重。

代码的知识图谱,最朴素的形态就是一张调用图(call graph):节点是函数,边是"谁调用谁"。process_checkout 调用了 calculate_order_total,就画一条 CALLS 边;反过来看,calculate_order_totalprocess_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_totalcreate_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 跳。每一跳同时走 CALLSCALLED_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_historyverify_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_tokenverify_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_checkoutcalculate_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 的一部分"。


总结

  1. 总分骗人。 纯向量和图增强的 Recall@5 都是 0.958,完全一致——但这不是"图没用",而是两股力量精确对冲的结果。
  2. 出错的题目互换了。 图检索修好了 Q8(calculate_order_total 通过 process_checkout 两跳命中),却做砸了 Q1(候选集扩展把 verify_password 挤出了 top-5)。一进一退,抵消归零。
  3. 调用图的价值是真的。 A 调用 B 意味着 B 和 A 在业务上协同工作,这是向量空间看不到的结构信号。Q8 证明它能填平向量的语义鸿沟。
  4. 朴素图扩展是双刃剑。 扩大候选集这一个机制,同时带来"捞回漏检函数"的收益和"挤掉正确函数"的噪声——你无法只要好处。
  5. 工程上要给扩展加约束: 优先走 CALLS 边、限制在同模块内、步数控制在 1 跳。
  6. 最优解不是检索时遍历图,而是把结构信息编码进 embedding 内容。 在函数 chunk 里加 called_by / calls 字段,让向量检索本身就能感知调用关系——没有候选集膨胀,没有重排噪声,结构信息从"外挂"变成 embedding 的一部分。

下一篇,我们就来动手验证这个"把结构编码进内容"的方案,看它能不能在不引入 Q1 那种退步的前提下,把 Q8 稳稳修好。


参考资料


欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页