作者对比原始代码、结构化前缀和文档字符串三种索引策略,检验将调用关系编码进 Embedding 能否改善代码检索。实验也揭示结构增强虽能提升关联函数召回,却不足以独立解决检索噪声问题。
兑现承诺
在第 05 篇文章的结尾,我留了一项明确的作业。既然朴素的图扩展是一把双刃剑(它修复了 Q8,却破坏了 Q1),那就换一种方法——不要在检索时遍历图,而是在建立索引时,把结构信息编码到嵌入内容中。
那篇文章中包含了一段代码草图:在 calculate_order_total 的分块中加入 # called_by: process_checkout 这一行,让它自身的向量携带“我被结账流程调用”这一信号。理论上,当查询“process payment”命中与 process_checkout 相关的 token 时,calculate_order_total 的向量本身就能够被感知——不需要扩大候选集,因此也不会产生 Q1 那种挤占结果的噪声。
听起来很美好。这篇文章就要把这个假设扔进实验熔炉中检验。
我准备了三种策略,它们都对同一个支付模块的代码进行嵌入:
策略 A(raw):原始代码,不添加任何内容。这是基线。
策略 B(struct prefix):在函数体之前添加一行注释,明确写出 called_by 和 calls。
策略 C(struct doc):把结构信息融入函数现有的文档字符串中,不单独添加一行。
然后,我用相同的 12 个查询测试这三种嵌入,看看 Q8 能否得到修复。
我先把结论剧透出来,免得你以为我在故意制造悬念:结构信息确实有效——calculate_order_total 的相似度真的提高了。但提升得还不够,Q8 仍然失败了。而且,策略 B 还破坏了另一个之前表现完美的查询。
这并不是一个令人泄气的“费了这么大力气却一无所获”的故事。恰恰相反——这次失败精确测出了嵌入能力的边界在哪里,而这个数值本身比一句“已修复”更有价值。
三种策略分别是什么样的
首先,直观感受一下不同策略提供给嵌入模型的文本有何差异。以我们屡次漏检的主角 calculate_order_total 为例:
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 - comment before the function body):
# 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 - structural info added into the 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. 拼接到文档字符串的末尾。两者的核心意图完全相同——都希望 token process_checkout 出现在 calculate_order_total 的嵌入文本中,让它在向量空间里稍微向支付流程靠近一些。
区别在于放置位置。B 把它放在函数体之外最显眼的第一行;C 则把它藏在文档字符串中,与自然语言描述混合在一起。这种“位置”上的差异,稍后会引发一个意想不到的副作用。
B 和 C 的实现都很简短:
# Strategy B: struct prefix
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: struct info fused into 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)}.")
# inject to the end of the existing 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 崩溃的种子。
[图片:提供给嵌入模型的三种策略。三个并排的代码卡片分别标记为 Strategy A(raw)、Strategy B(顶部添加前缀注释)、Strategy C(将说明融入文档字符串)。每张卡片都有一根箭头指向同一个标有“Embedding model”的方框,旁边的说明写着“same intent: inject 'process_checkout' token”。]
主要对比:一种保持稳定,一种出现退化,但都没有修复 Q8
使用 12 个查询测试这三种嵌入后,总体结果如下:
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。最先映入眼帘的是,策略 B 实际上出现了退化:Recall@5 从 0.958 降到了 0.931。我们添加了结构信息,最终总分却不升反降。与此同时,策略 C 与基线持平,一分未动。
再来看每个查询的 Recall@5,真相就藏在这里:
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, all strategies fail)
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
盯着最后两个箭头看——整个故事都在那里:
Q8(process payment and create Stripe charge):A、B 和 C 都是 0.50。我们费尽周折把结构信息编码进嵌入,Q8 却纹丝不动——两个相关函数中仍然只能命中一个。这个假设被证伪了。
Q7(execute SQL query safely against the database):A 和 C 都取得了完美的 1.00,但策略 B 降到了 0.67。正是它拖低了 B 的总分。
我们想修复的查询依旧有问题;一个原本正常的查询却被 B 搞坏了。这种情形听起来很熟悉——第 05 篇文章中的图扩展同样是“修好一个,弄坏一个”。但这一次,原因完全不同,而且更加根本。
我们逐一分析。
Q8:结构信息确实起到了帮助——只是不够
先来看 Q8——本文真正的主角。我提取了查询与几个关键函数之间的余弦相似度:
Query: process payment and create Stripe charge
Cosine similarity to the query:
0.4617 calculate_order_total (raw)
0.5137 calculate_order_total (+called_by: process_checkout prefix)
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.5970,process_checkout 为 0.6341——两者天然比 calculate_order_total 高出一大截。为什么?因为它们的代码中明明白白写着 payment、Stripe、charge——这些词与查询在字面上完全匹配。
现在算一下:calculate_order_total 的相似度从 0.4617 升至 0.5137,提高了 0.052。但挡在它前面的那群函数——不只是 create_payment_intent 和 process_checkout,还包括 process_refund、get_payment_history、verify_webhook_signature,所有带有支付词汇的函数——得分全都高于 0.51。看看它最终排在了哪里:
Q8 top-5 hits:
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']
在这三种策略中,排名前五的第 4 和第 5 位始终是 get_payment_history 和 verify_webhook_signature——它们只是带有支付词汇的冒牌货,并不属于真实答案。而我们真正想找的 calculate_order_total,却被这五个“富含支付词汇”的函数牢牢压在第 6 名之后。
提高 0.052 听起来相当可观,但就是不够。它把 calculate_order_total 从 0.46 推到了 0.51,却没能让它越过那堵墙——一堵从 0.51 开始、由字面词汇支撑起来的墙。看似近在咫尺,却永远无法跨越——因为挡在它前面的,是一整排“名字里带 payment”的函数。
[Image: 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".]
讲到这里,很多人会想:如果更激进地添加结构信息呢?再加几行注释,多重复几次 process_checkout,甚至把整条调用链都塞进去?
停下来吧。这条路是死胡同,而且很有必要解释清楚原因。
calculate_order_total 的语义是“对价格求和、应用折扣、计算税费”。这才是其代码的真实含义,嵌入模型也忠实地将它编码成了一个“订单金额计算”向量。与此同时,“创建 Stripe 扣款”的含义是“调用第三方支付网关,创建一笔扣款”。在现实世界中,这是两件不同的事情——一个负责计算应付金额,另一个负责收款。它们之间的语义距离由嵌入模型掌握的知识决定,并不是靠我们的注释技巧就能跨越的。
我们添加的这一行 # called_by: process_checkout,本质上只是在一个代表“税费计算代码”的向量中,混入了一小撮“结账流程”的味道。它确实让向量稍微向支付方向倾斜了一些(+0.052),但这一点味道无法压过代码主体本身发出的强烈信号:“我正在计算税费。”模型读取这段代码时,看到的核心仍然是求和、折扣和税费——一行注释无法改变这个根本基础。
换句话说,结构注入是在微调向量,而不是改变向量的方向。它可以把一个“很接近但还差一点”的函数推过线,但对于 calculate_order_total 这种与查询之间隔着一整个业务概念的函数来说,+0.052 只是杯水车薪。
这就是语义鸿沟的本质:它不是排序算法的 Bug,不是分块方式有问题,也不是嵌入维度不足。它是“代码的真实含义”与“查询表达的意图”之间客观存在的距离。无论对嵌入内容施加什么技巧——更换分块策略、添加结构前缀、在文档字符串中堆入信息——都无法从根本上跨越这段距离。
回顾第 05 篇文章:图扩展之所以修复了 Q8,正是因为它没有走嵌入这条路径。它依靠确定性的调用边 process_checkout → calculate_order_total,直接把该函数拖入候选集合——绕过了语义相似度的裁决。而本文的三种策略仍然全都在嵌入相似度框架内运作,因此自然会撞上同一堵墙。
现在来看被策略 B 搞砸的查询 Q7:安全地对数据库执行 SQL 查询。它的真实答案包含三个函数:execute_query、bulk_insert 和 paginate_query。
A 和 C 都干净利落地命中了全部三个函数(1.00),但 B 降到了 0.67——漏掉了一个。
为什么只有 B 出了问题?回忆一下策略 B 的实现:它会在每个具有调用关系的函数最顶部添加结构前缀。execute_query 是一个典型的工具函数,数据库模块中的一大群函数都会调用它——bulk_insert 调用它,paginate_query 调用它,就连支付模块中的 get_payment_history 也会调用它。因此,在策略 B 下,这些调用方的嵌入文本顶部都被贴上了一行 # calls: execute_query。
问题就在这里。# calls: execute_query 这一行包含 execute 和 query 两个词,而它们恰好与 Q7 的查询“execute SQL query”高度重合。于是,一批实际上与 Q7 没那么相关的函数,仅仅因为前缀中出现了“execute query”,向量得分就被人为抬高,打乱了排名,并把一个真正相关的真实答案函数挤出了前五。
这与第 05 篇文章中关于枢纽节点的教训遥相呼应:execute_query 就是那个高出度的工具函数。一旦把它的名称不加区分地印到每个调用方嵌入文本的开头,它就会污染一大片查询的排名。第 05 篇文章的问题是“沿枢纽节点扩展会引爆候选集”;这一次则是“把枢纽节点的名称塞进每个调用方的嵌入文本,会引爆字面词汇噪声”——机制不同,但散发着同一种代码坏味道。
为什么策略 C 没有这个问题?因为它把结构信息缝进了文档字符串,与一整段自然语言描述混合在一起,而不是单独放在最顶部。当 # calls: execute_query 独占一行时,execute 和 query 是毫无遮掩的高权重词元;但当 Calls: execute_query. 被埋在文档字符串的句子中时,其影响会被周围的语义稀释,对整体向量的扰动小得多。因此,C 保住了 Q7 的满分,代价则是它对 Q8 的提升也更温和(下一节会讨论这个权衡)。
[Image: 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.]
把 Q8 和 Q7 放在一起看,策略 B 与 C 的对比就很有意思了:
相同的结构信息(called_by / calls)——B 和 C 插入的内容几乎完全一样。
不同的放置位置:B 在函数最顶部单独新增一行;C 则把它缝进文档字符串,与描述内容混合在一起。
截然不同的结果:B 对 Q8 的提升更大(calculate_order_total 已经到达前五的边缘,甚至把 create_payment_intent 推到了第 1 位),但代价是 Q7 崩溃、总成绩退步;C 对 Q8 的提升更温和(不足以进入前五),但胜在稳定——没有引入任何回归。
这里有一条反直觉的工程经验:在嵌入文本中,信息所在的位置及其呈现形式,对向量的影响可能和信息本身一样大。
单独占一行的 # called_by: process_checkout 是一种高权重、高纯度的信号——它对目标函数(calculate_order_total)的提升很强,但对那些无辜的高出度工具函数调用方造成的副作用也同样强。融合到文档字符串中的 Called by: process_checkout. 则是一种被自然语言稀释过的信号——提升幅度小,副作用也小。
天下没有免费的午餐。B 用“更强的扰动”换取“对目标函数更大的提升”,但同样强烈的扰动也会带来更严重的附带噪声。C 用“更温和的扰动”换取“零副作用”,但它又温和到不足以修复 Q8。
而且,无论 B 还是 C,都没有真正解决 Q8——因为它们的扰动幅度(几个百分点的相似度)从一开始就不足以跨越语义鸿沟。信息放在哪里、采用什么形式,只能决定“这几个百分点”是更精确还是更嘈杂;它们无法突破“只有这几个百分点”的上限。
本系列写到第 06 篇,值得停下来盘点一下。到目前为止,我们已经逐一尝试了“向量检索技术路线”的主要变体:
文章 03:替换嵌入输入(原始代码 vs 添加签名 vs 添加文档)——Q8 失败。
文章 04:替换分块策略(整文件 vs 函数级 vs 滑动窗口)——Q8 失败。
文章 05:在检索时附加图遍历——Q8 修复了,但代价是 Q1 出现回退,总分保持不变。
文章 06(本文):将结构信息编码进嵌入内容——Q8 失败,而且策略 B 导致 Q7 出现回退。
看出规律了吗?只要我们仍停留在“通过嵌入相似度进行检索”这一框架内,无论怎样优化输入、分块或内容,Q8 都无法通过。唯一一次修复 Q8(文章 05),恰恰依赖了一个跳出嵌入范畴的机制(确定性的调用边遍历)——而且该机制也带来了自身的副作用。
这并不是因为我们的实现不够好,也不是因为调优不足。这就是这条路线的边界。calculate_order_total 与“Stripe charge”之间的语义鸿沟,取决于嵌入模型对代码语义的真实理解能力。任何试图在嵌入内部绕过这一限制的努力(无论是输入、分块还是内容增强),本质上都是在一个“计算税费”的向量中混入“支付”的味道——可以混入一点,但远不足以翻过那堵由字面词汇砌成的高墙。
每一次失败,都精确测量了这堵墙的高度。文章 03 和 04 告诉我们“墙确实存在”;文章 05 告诉我们“绕过这堵墙需要付出什么代价”;文章 06 则测出了“正面推墙最多能推进多少”——0.052,远远不够。这些数字共同指向一个清晰的结论:问题不在于如何使用嵌入,而在于把嵌入当作唯一的检索路线。
那么,我们应该如何解决 Q8?既然结论是“嵌入相似度这条路线走不通”,出路就不应该是继续在嵌入上堆叠技巧,而应该是在嵌入之外增加机制:承认它的局限,再通过其他手段覆盖它无法触及的地方。
方向已经很明确:
BM25 关键词检索。在 Q8 的标准答案中,calculate_order_total 的确与查询存在较大的语义距离,但它与查询共享的是“业务上下文”——它位于支付流程之中。嵌入的弱点纯粹在语义层面。引入 BM25 这类基于词频的关键词检索,并配合适当的查询扩展,可以从一个正交维度捞取候选结果。关键词检索与向量检索具有不同的失败模式,将两者结合起来(即混合搜索),往往能够相互弥补盲区。
显式图遍历(带去噪)。文章 05 已经证明,调用边 process_checkout → calculate_order_total 可以直接修复 Q8。它的问题并非没有用,而是朴素的附加方式会引入噪声。按照文章 05 中描述的约束——仅遍历 CALLS 边、限制在同一模块内、最多遍历 1 跳——图遍历可以作为向量检索之外的一条精准补充通道,而不是一把肆意挥舞的双刃剑。
代码符号索引。许多“漏检”根本不需要语义匹配——用户查询的是明确的符号、调用关系或依赖路径。对于这类查询,确定性的符号索引(谁定义了什么、谁引用了什么)比任何嵌入都更加准确。
这三条路径有一个共同点:它们都没有试图让嵌入变得更强,而是承认嵌入存在无法触及的区域,再使用其他机制进行覆盖。面对“根本性限制”,这才是正确的工程姿态——不是寻找某种更神奇的嵌入技巧,而是构建一个多通道召回的混合系统,让每条通道各自发挥所长。
下一篇文章中,我们将正式构建混合搜索:把 BM25 关键词检索和向量检索编织成一根绳索,看看这两种互补的失败模式,能否最终让这个已经困扰我们四篇文章的幽灵 Q8 安息。
结构信息确实有效。添加 # called_by: process_checkout 前缀后,calculate_order_total 与 Q8 查询之间的余弦相似度从 0.4617 上升到 0.5137(+0.052),方向完全正确。文章 05 的假设得到了数据验证。
但提升幅度远远不够。create_payment_intent、process_refund 和 get_payment_history 等“富含支付词汇”的函数,其相似度都高于 0.51,而 calculate_order_total 仍然被卡在前 5 名之外。三种策略下的 Q8 都失败了(0.50)。
语义鸿沟是一种根本性限制。calculate_order_total(计算税费)和“Stripe charge”(收款)在现实世界中是两件不同的事情,它们之间的语义距离由嵌入模型所掌握的知识决定。一行注释只能起到“微调”作用,无法“改变方向”——+0.052 不足以跨越这道鸿沟。
朴素的前缀注入是一把双刃剑。策略 B 为所有函数添加了前缀,将高出度工具函数 execute_query 的名称写到每个调用方嵌入内容的开头,由此产生字面噪声,导致 Q7 回退(1.00 → 0.67)。
放在哪里,比放什么更重要。在结构信息完全相同的情况下,单独占一行(B)会对向量产生强烈扰动,并带来较大的副作用;融合进文档字符串(C)则只会产生轻微扰动,没有副作用,但提升幅度也较小。放置位置和表现形式对向量的影响,与信息本身处于同一个量级。
失败的价值在于测量边界。本系列尝试了向量检索路线的主要变体,而 Q8 每次都会令其受挫——这不是实现问题,而是路线问题。出路不在嵌入内部,而在嵌入之外:BM25 关键词检索、经过降噪的显式图遍历,以及代码符号索引。
下一篇文章中,我们将构建混合搜索(BM25 + 向量),让两种正交的失败模式相互补位。
完整演示代码:codebase-kb-06-struct-embed
欢迎了解 PrimeSkills——一个精选的 AI 智能体与技能市场,其中的内容都经过了真实世界企业级工作流的验证。没有华而不实的噱头,只有真正有效的方案。
在我的主页上发现更多实用知识和有趣产品
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为