代码库知识库系列(06):把调用图编码进 Embedding——结构增强有效,但不够

兑现一个承诺

第 05 篇的结尾我留了个明确的作业:既然朴素图扩展是双刃剑(修好 Q8 的同时做砸了 Q1),那就换个思路------别在检索时遍历图,而在建索引时就把结构信息编码进 embedding 的内容里

那篇文章我给了一段设想的代码:给 calculate_order_total 的 chunk 加上 # called_by: process_checkout 这行文本,让它自己的向量里就带上"我被结账流程调用"这个信号。理论上,当查询 "process payment" 命中 process_checkout 相关词汇时,calculate_order_total 的向量本身就能被感知到,不需要候选集膨胀,也就没有 Q1 那种挤出噪声。

听起来很美。这篇文章,就是来把这个假设扔进实验炉里烤一烤。

我准备了三种策略,对同一份 payment 模块代码做 embedding:

  • Strategy A(raw):原始代码,什么都不加。这是基线。
  • Strategy B(struct prefix) :在函数体前面加一行注释,写清楚 called_bycalls
  • Strategy C(struct doc):把结构信息塞进函数已有的 docstring 里,不单独开一行。

然后拿这三套 embedding,跑同样的 12 道查询,看 Q8 能不能被修好。

先剧透结论,免得你读到一半以为我在卖关子:结构信息确实有效------calculate_order_total 的相似度真的涨了。但涨得不够,Q8 还是失败了。而且 Strategy B 还顺手做砸了另一道原本满分的题。

这不是一个"努力白费"的沮丧故事。恰恰相反------这次失败精确地量出了 embedding 的边界在哪里,而这个数字本身,比"修好了"更有价值。


三种策略,长什么样

先直观感受一下三种策略给 embedding 喂的文本有什么不同。以那个反复漏检的主角 calculate_order_total 为例:

python 复制代码
Strategy A(raw):
def calculate_order_total(items: list[dict], discount_code: str = None) -> dict:
    """Sum item prices, apply discount, compute tax. Returns breakdown dict."""
    subtotal = sum(item["price"] * item["quantity"] ...

Strategy B(struct prefix - 在函数体前加注释):
# called_by: process_checkout
def calculate_order_total(items: list[dict], discount_code: str = None) -> dict:
    """Sum item prices, apply discount, compute tax. Returns breakdown dict."""
    subtotal = sum(item["price"] * item["quantity"] ...

Strategy C(struct doc - 把结构信息加进 docstring):
def calculate_order_total(items: list[dict], discount_code: str = None) -> dict:
    """Sum item prices, apply discount, compute tax. Returns breakdown dict. Called by: process_checkout."""
    subtotal = sum(item["price"] * item["quantity"] ...

区别就这么点:B 在最前面塞了 # called_by: process_checkout,C 把 Called by: process_checkout. 缝进了 docstring 末尾。核心意图完全一致------都是想让 process_checkout 这个词出现在 calculate_order_total 的 embedding 文本里,好让它在向量空间里往支付流程靠一靠。

区别在于"放哪儿"。B 放在函数体外、最显眼的第一行;C 藏在 docstring 里,混在自然语言描述中间。这个"放哪儿"的差异,后面会引出一个意想不到的副作用。

Strategy B 和 C 的实现都很短:

python 复制代码
# Strategy B: 结构前缀
def strategy_b_struct_prefix(func, call_graph, called_by):
    callers = called_by.get(func["name"], [])
    callees = call_graph.get(func["name"], [])
    prefix = []
    if callers:
        prefix.append(f"# called_by: {', '.join(callers)}")
    if callees:
        prefix.append(f"# calls: {', '.join(callees)}")
    if prefix:
        return "\n".join(prefix) + "\n" + func["text"]
    return func["text"]

# Strategy C: 结构信息融入 docstring
def strategy_c_struct_doc(func, call_graph, called_by):
    callers = called_by.get(func["name"], [])
    callees = call_graph.get(func["name"], [])
    if not callers and not callees:
        return func["text"]
    struct_note = []
    if callers:
        struct_note.append(f"Called by: {', '.join(callers)}.")
    if callees:
        struct_note.append(f"Calls: {', '.join(callees)}.")
    # 注入到已有 docstring 末尾
    old_doc = f'"""{func["docstring"]}"""'
    new_doc = f'"""{func["docstring"]} {" ".join(struct_note)}"""'
    return func["text"].replace(old_doc, new_doc, 1)

注意 B 对每个有调用关系的函数 都加前缀------不只是 calculate_order_total。这个细节,是后面 Q7 翻车的伏笔。

图片:three strategies fed to embedding. Three side-by-side code cards labeled Strategy A (raw), Strategy B (prefix comment on top), Strategy C (note fused into docstring). An arrow from each card points into a shared box labeled "Embedding model", and a note reads "same intent: inject 'process_checkout' token".


主对比结果:一个稳,一个退步,都没修好 Q8

三套 embedding 跑完 12 道查询,总分表长这样:

csharp 复制代码
Strategy                      R@3      R@5     vs A
───────────────────────── ───────  ───────  ───────
A_raw_code                  0.889    0.958     base
B_struct_prefix             0.889    0.931   -0.027
C_struct_doc                0.889    0.958     base

先别急着找 Q8。第一眼最扎眼的是 Strategy B 反而退步了:Recall@5 从 0.958 掉到 0.931。加了结构信息,总分不升反降。而 Strategy C 稳稳持平基线,一分没动。

再看逐查询的 Recall@5,真相就出来了:

sql 复制代码
Query                                                   A       B       C
────────────────────────────────────────────────── ──────  ──────  ──────
verify user identity and check JWT token validity    1.00    1.00    1.00
encrypt and store user password securely             1.00    1.00    1.00
generate JWT access token for authenticated user     1.00    1.00    1.00
check if user has permission to perform an action    1.00    1.00    1.00
store and retrieve data from Redis cache             1.00    1.00    1.00
limit how many times a user can call an API          1.00    1.00    1.00
execute SQL query safely against the database        1.00    0.67    1.00  ←
process payment and create Stripe charge             0.50    0.50    0.50  ← (Q8,所有策略均失败)
issue refund to customer                             1.00    1.00    1.00
send email notification to user                      1.00    1.00    1.00
send mobile push notification                        1.00    1.00    1.00
delete a record without permanently removing it fr   1.00    1.00    1.00

盯着最后两个箭头看,故事全在这里:

  • Q8process payment and create Stripe charge):A、B、C 三种策略全是 0.50 。我们费尽心思把结构信息编码进 embedding,Q8 一动没动,依然只命中两个相关函数里的一个。假设,被证伪了。
  • Q7execute SQL query safely against the database):A 和 C 都是满分 1.00,唯独 Strategy B 掉到了 0.67。这就是拖累 B 总分的元凶。

一个我们想修的题没修好,一个本来好好的题被 B 弄坏了。这局面听着眼熟------第 05 篇的图扩展也是"修一个坏一个"。但这次的原因,和上次完全不同,而且更本质。

我们一个一个拆。


Q8:结构信息真的起作用了,但就是不够

先说 Q8------这才是本篇真正的主角。我把查询和几个关键函数的余弦相似度拉出来看:

scss 复制代码
查询:process payment and create Stripe charge
与查询的余弦相似度:
0.4617  calculate_order_total (raw)
0.5137  calculate_order_total (+called_by: process_checkout 前缀)
0.5970  create_payment_intent (raw)
0.6341  process_checkout (raw)

看第一、二行。calculate_order_total 加了 # called_by: process_checkout 前缀之后,和查询的相似度从 0.4617 涨到了 0.5137------足足 +0.052。

这是个好消息,而且方向完全正确。 结构信息不是没用------它实实在在地把 calculate_order_total 往查询的方向拽近了。第 05 篇的假设"结构编码能提升相关函数的向量相似度",在数字上被证实了。我们没白折腾。

坏消息在第三、四行。同一份数据里,create_payment_intent 的相似度是 0.5970process_checkout0.6341 ------它们俩天然就比 calculate_order_total 高一大截。为什么?因为它们的代码里明晃晃地写着 paymentStripecharge 这些词,和查询字面上就贴。

现在算一笔账:calculate_order_total 从 0.4617 涨到 0.5137,涨了 0.052。可它前面挡着的一票函数------不只是 create_payment_intentprocess_checkout,还有 process_refundget_payment_historyverify_webhook_signature 这些含有 payment 词汇的函数------个个都在 0.51 以上。看看它最终的排名:

css 复制代码
Q8 top-5 命中情况:
A_raw_code:    ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']
B_struct_prefix:['create_payment_intent', 'process_checkout', 'process_refund', 'get_payment_history', 'verify_webhook_signature']
C_struct_doc:  ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']

三种策略的 top-5 里,第 4、5 位永远是 get_payment_historyverify_webhook_signature------这俩含 payment 词汇但不是 ground truth 的"李鬼"。而我们真正想要的 calculate_order_total,被这五个"payment 词汇富集"的函数死死压在第 6 位开外。

+0.052 的提升,听起来不小,但根本不够。它把 calculate_order_total 从 0.46 推到 0.51,却推不过那道 0.51 起步、由字面词汇撑起来的高墙。 就差那么一点点,可这一点点是永远跨不过去的------因为它前面站着一整排"名字里带 payment"的函数。

图片:Q8 similarity ladder. A vertical axis of cosine similarity. calculate_order_total(raw) at 0.46, an upward arrow labeled "+0.052 struct" to calculate_order_total(struct) at 0.51. Just above, a cluster of bars at 0.51-0.63 labeled create_payment_intent / process_refund / get_payment_history / verify_webhook_signature / process_checkout, drawn as a wall. calculate_order_total still sits just under the wall, tagged "still below top-5".


根因:这是语义鸿沟,不是排序 bug

到这里,很多人会想:那我把结构信息加得更狠一点呢?多加几行注释、把 process_checkout 重复几遍、甚至直接把整个调用链拼进去?

打住。这个方向是死路,值得说清楚为什么。

calculate_order_total 的语义,就是"累加价格、打折、算税"。这是它代码的真实含义 ,embedding 模型忠实地把它编码成了一个"订单计算类"的向量。而 "create Stripe charge" 的语义是"调用第三方支付网关、创建扣款"。这两件事在真实世界里就是两回事------一个是算钱,一个是收钱。它们的语义距离,是 embedding 模型的知识决定的,不是我们的注释技巧能填平的。

我们加的那行 # called_by: process_checkout,本质上是往一段"算税代码"的向量里,掺了一小撮"结账流程"的味道。它确实让向量往支付方向偏了一点(+0.052),但这一小撮味道,压不过整段代码本身"我就是在算税"的强烈信号。模型看这段代码,主体依然是 sumdiscounttax------一行注释改变不了这个基本盘。

换句话说:结构注入是在给向量做微调,不是重定向。 它能把一个"接近但差一点"的函数推过线,但对 calculate_order_total 这种"和查询语义隔着一整个业务概念"的函数,+0.052 就是杯水车薪。

这就是语义鸿沟(semantic gap)的本质 :它不是排序算法的 bug,不是 chunk 切得不好,也不是 embedding 维度不够。它是"这段代码的真实含义"和"查询想表达的意图"之间客观存在的距离。这道距离,任何在 embedding 内容上做文章的技巧------换 chunk 策略、加结构前缀、塞 docstring------都无法根本性地跨越。

回头看第 05 篇,图扩展之所以能修好 Q8,恰恰是因为它没走 embedding 这条路 。它靠的是 process_checkout → calculate_order_total 这条确定性的调用边,直接把函数拽进候选集------绕开了语义相似度的裁决。而本篇的三种策略,全都还在 embedding 相似度的框架内打转,自然全都撞在同一堵墙上。


Q7:Strategy B 的翻车,是另一个警示

再看被 Strategy B 做砸的 Q7:execute SQL query safely against the database(安全地对数据库执行 SQL 查询)。它的 ground truth 是 execute_querybulk_insertpaginate_query 三个函数。

A 和 C 都稳稳命中全部三个(1.00),唯独 B 掉到了 0.67------漏了一个。

为什么单单 B 出问题?回想 Strategy B 的实现:它给每个有调用关系的函数 都在最前面加了结构前缀。execute_query 是个典型的工具函数,被 database 模块里一大堆函数调用------bulk_insert 调它、paginate_query 调它、还有 payment 模块的 get_payment_history 也调它。于是这些调用者的 embedding 文本里,全都被 Strategy B 顶头加上了一行 # calls: execute_query

问题就出在这。这一行 # calls: execute_query 里带着 executequery 两个词------恰好和 Q7 的查询 "execute SQL query" 字面高度重合。结果,一批本来和 Q7 没那么相关的函数,因为前缀里多了 "execute query" 字样,向量分数被人为抬高了,排序被打乱,把某个真正相关的 ground truth 函数挤出了 top-5。

这和第 05 篇 hub 节点的教训遥相呼应:execute_query 就是那个高出度的工具函数,一旦它的名字被无差别地印到所有调用者的 embedding 文本头部,就会污染一大片查询的排序。 第 05 篇是"沿 hub 节点做图扩展会引爆候选集",这篇是"把 hub 节点的名字塞进所有调用者的 embedding 会引爆字面噪声"------不同的机制,同一个坏味道。

而 Strategy C 为什么没这个问题?因为它把结构信息缝进了 docstring 里 ,混在一整段自然语言描述中间,而不是孤零零顶在最前面。# calls: execute_query 单独成行时,executequery 是赤裸裸的高权重 token;而 Calls: execute_query. 埋在 docstring 一堆句子里,被周围的语义稀释了,对整体向量的扰动小得多。所以 C 保住了 Q7 的满分,代价是它对 Q8 的提升也更温和(这个权衡我们下一节说)。

图片:Q7 regression from prefix noise. Left, execute_query as a central hub node with edges from bulk_insert, paginate_query, get_payment_history. Right, each caller's embedding text shown with a red top line "# calls: execute_query", arrows pointing to a query box "execute SQL query" with a "spurious token match" tag. A bumped-out function marked red at slot 6.


B vs C:放哪儿,比放什么更重要

把 Q8 和 Q7 合起来看,Strategy B 和 C 的对比就很有意思了:

  • 同样的结构信息called_by / calls),B 和 C 塞进去的内容几乎一样。
  • 放的位置不同:B 单独开一行顶在函数最前面,C 缝进 docstring 混在描述里。
  • 结果天差地别 :B 对 Q8 的提升更大(calculate_order_total 排到了 top-5 边缘、甚至把 create_payment_intent 顶到了第 1 位),但代价是 Q7 翻车、总分退步;C 对 Q8 的提升更温和(不足以进 top-5),但胜在稳,没有引入任何退步。

这里有个反直觉的工程教训:在 embedding 文本里,信息放在哪个位置、以什么形式呈现,对向量的影响可能和信息本身一样大。

单独成行的 # called_by: process_checkout,是一个高权重、高纯度的信号------它对目标函数(calculate_order_total)的提升猛,但对那些无辜的高出度工具函数调用者,副作用也猛。融进 docstring 的 Called by: process_checkout.,是一个被自然语言稀释过的温和信号------提升小,副作用也小。

没有免费的午餐。 B 用"更强的扰动"换来"对目标函数更大的提升",但同样的强扰动也带来了更大的附带噪声。C 用"更温和的扰动"换来"零副作用",但温和到不足以修好 Q8。

而无论 B 还是 C,都没能把 Q8 这道题真正解决------因为它们扰动的幅度(几个百分点的相似度),从一开始就不在能跨越语义鸿沟的量级上。放在哪儿、怎么放,都只是在决定"这几个百分点更精准还是更嘈杂",改变不了"就那么几个百分点"这个天花板。


我们到底测出了什么

这个系列走到第 06 篇,值得停下来盘一盘。我们已经把"向量检索技术路线"的主要变种挨个试了一遍:

  • 第 03 篇:换 embedding 输入(原始代码 vs 加签名 vs 加文档)------Q8 失败。
  • 第 04 篇:换 chunking 策略(整文件 vs 函数级 vs 滑动窗口)------Q8 失败。
  • 第 05 篇:检索时叠加图遍历------Q8 修好了,但代价是 Q1 退步,总分不变。
  • 第 06 篇(本篇):把结构信息编码进 embedding 内容------Q8 失败,Strategy B 还引入了 Q7 退步。

看出规律了吗?只要还在"用 embedding 相似度做检索"这个框架内,无论怎么优化输入、切分、内容,Q8 都过不了。 唯一一次修好 Q8(第 05 篇),恰恰是靠一个跳出 embedding 的机制(确定性的调用边遍历)实现的------而那个机制自带副作用。

这不是我们实现得不好,也不是调参不够。这是路线 的边界。calculate_order_total 和 "Stripe charge" 之间那道语义鸿沟,是 embedding 模型对代码语义的真实认知决定的。任何试图在 embedding 内部(无论输入、切分还是内容增强)绕过它的努力,本质上都是在往一个"算税"向量里掺"支付"味道------能掺一点,但掺不到能翻越那堵由字面词汇筑起的高墙。

每一次失败,都精确地量出了这堵墙的高度。 第 03、04 篇告诉我们"墙在那儿";第 05 篇告诉我们"绕过墙的代价";第 06 篇量出了"往墙上使劲能推多高"------0.052,远不够。这些数字加在一起,指向一个清晰的结论:问题不在 embedding 的用法,而在于单纯依赖 embedding 这条路本身。


承认极限,才能找到出路

那 Q8 到底该怎么办?既然结论是"embedding 相似度这条路走不通",出路就不该是继续在 embedding 上加技巧,而是在 embedding 之外补充机制------承认它的极限,然后用别的手段补上它够不着的地方。

方向已经很清楚了:

1. BM25 关键词检索。 Q8 的 ground truth 里,calculate_order_total 确实和查询语义远,但它和查询共享的是"业务上下文"------它就在支付流程里。而 embedding 的短板是纯语义的。如果引入 BM25 这类基于词频的关键词检索,配合合适的查询扩展,能从另一个正交的维度捞候选。关键词检索和向量检索的失败模式不一样,把两者结合(也就是 hybrid search),常常能覆盖彼此的盲区。

2. 显式图遍历(配合去噪)。 第 05 篇已经证明,process_checkout → calculate_order_total 这条调用边能直接修好 Q8。它的问题不是没用,而是朴素叠加会引入噪声。如果给图遍历加上第 05 篇讲的那些约束------只走 CALLS 边、限制同模块、控制在 1 跳------它可以作为向量检索之外的一路精准补充,而不是一把乱挥的双刃剑。

3. 代码符号索引。 很多"漏检"其实根本不需要语义匹配------用户查的是明确的符号、调用关系、依赖路径。一个确定性的符号索引(谁定义了什么、谁引用了什么),在这类查询上比任何 embedding 都准。

这三条路有个共同点:它们都不试图把 embedding 修得更好,而是承认 embedding 有它够不到的地方,然后用别的机制补上。 这才是工程上处理"本质极限"的正确姿势------不是找一个更神的 embedding 技巧,而是搭一个多路召回、各取所长的混合系统。

下一篇,我们就正式动手搭 hybrid search:把 BM25 关键词检索和向量检索拧成一股绳,看看这两条失败模式互补的路,能不能终于让那个缠了我们四篇文章的幽灵 Q8,安息。


总结

  1. 结构信息确实有效。 加了 # called_by: process_checkout 前缀后,calculate_order_total 与 Q8 查询的余弦相似度从 0.4617 涨到 0.5137(+0.052),方向完全正确。第 05 篇的假设在数字上被证实了。
  2. 但提升幅度远不够。 create_payment_intentprocess_refundget_payment_history 等"payment 词汇富集"的函数个个在 0.51 以上,calculate_order_total 依然被压在 top-5 之外。Q8 三种策略全部失败(0.50)。
  3. 语义鸿沟是本质极限。 calculate_order_total(算税)和 "Stripe charge"(收钱)在真实世界就是两回事,它们的语义距离由 embedding 模型的知识决定。一行注释是"微调"不是"重定向",+0.052 填不平这道鸿沟。
  4. 朴素前缀注入是双刃剑。 Strategy B 给所有函数加前缀,把高出度工具函数 execute_query 的名字印到了所有调用者的 embedding 头部,制造了字面噪声,让 Q7 退步(1.00 → 0.67)。
  5. 放哪儿比放什么更重要。 同样的结构信息,单独成行(B)扰动强、副作用大;融进 docstring(C)扰动温和、零副作用但提升也小。位置和形式对向量的影响,和信息本身同量级。
  6. 失败的价值在于量出边界。 系列测遍了向量检索路线的主要变种,Q8 每次都栽------这不是实现问题,是路线问题。出路不在 embedding 内部,而在 embedding 之外:BM25 关键词检索、去噪后的显式图遍历、代码符号索引。

下一篇,我们动手搭 hybrid search(BM25 + 向量),让两条正交的失败模式互相补位。


参考资料


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

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

相关推荐
冬奇Lab1 小时前
开源项目第175期:Buzz — Jack Dorsey 的 Block 用 Nostr 重新定义团队协作,AI Agent 拥有自己的加密身份
人工智能·开源·资讯
AI分享猿1 小时前
游戏原画与建筑灵感:AI图像生成如何服务前期设计
人工智能·游戏
字节跳动视频云技术团队2 小时前
为什么 AI 视频,需要“懂生成”的画质增强
人工智能
后端小肥肠2 小时前
我做了个能一键搭建个人工作台的 Skill,已开源
人工智能·aigc·agent
码云之上2 小时前
AI Agent 工程化总览篇:从 Prompt 到 Harness
前端·人工智能
2501_926978332 小时前
以说明书 DNA 为模板——完整 AGI 的结构图景
前端·人工智能·经验分享·笔记·ai写作
IT_陈寒2 小时前
Vite静态资源引用这个坑我踩得有点疼
前端·人工智能·后端
天天爱吃肉82182 小时前
商用车多体动力学实战笔记|第6篇:动力传动系统(发动机、变速箱、分动器、TCS、LSD限滑差速)
大数据·人工智能·笔记·python·嵌入式硬件·汽车