Boss 关,第五次挑战
如果你从第 03 篇一路读到这里,那你应该已经认识 Q8 这个老对手了。
它是我们那 12 道测试查询里的第 8 道:process payment and create Stripe charge(处理支付并创建 Stripe 扣款)。它的 ground truth 有两个函数,其中一个 calculate_order_total,我们已经追了整整四篇文章都没抓到。
- 第 03 篇,向量检索基线,它漏了。
- 第 04 篇,换了三种 chunking 策略,它还漏。
- 第 05 篇,图增强检索总算把它捞回来了------代价是 Q1 退步,总分原地踏步。
- 第 06 篇,把
called_by结构信息编码进 embedding,相似度从 0.46 涨到 0.51,方向对,但推不过那堵词汇高墙,它又漏了。
四篇,五次交手(第 04 篇试了三种切法),Q8 就像游戏里那个反复复活的 boss 关,你每次以为找到了破绽,它总能换个姿势再站起来。
而这一篇,我带了业界公认的"终极武器"------BM25 关键词检索 ,把它和向量检索拧成一股绳,做成 hybrid search(混合检索)。这套组合拳在无数生产系统里被验证为向量检索的最佳增强方案。逻辑很直白:向量检索靠语义,BM25 靠字面词频,两条路的失败模式不一样,理论上能互相补位。第 06 篇结尾我自己也是这么规划的------用两条正交的路盖住彼此的盲区,Q8 理应有救。
先把结论摊开,免得你读到一半空欢喜一场:Q8 还是失败了。不仅如此,混合检索继承了 BM25 在另一道题上的退步,总分从 0.958 掉到了 0.931------比纯向量检索还差。
但这次失败,是整个系列最有价值的一次。因为它不是"又一种方案没成",而是------它彻底揭开了 Q8 的根因,量出了整条文本路线的边界在哪里。 读完这一篇,你会明白为什么前面四篇的每一次失败都是必然的,为什么它们其实一直在做同一件事。
先补两个概念:BM25 和 RRF
在动手之前,花两分钟把两个关键词说清楚。
BM25 是信息检索领域的经典算法,你可以把它理解成"加了权重的关键词匹配"。它不理解语义,只数词频------查询里的词在某个文档里出现得越多、而这个词在整个语料里越稀有,这个文档的得分就越高。搜索引擎、Elasticsearch 的默认相关性排序,底层都是它。
用一个类比:向量检索像一个读过很多书的语言学家,你说"收钱",他知道你可能想找"结账""扣款""支付网关";而 BM25 像一个一丝不苟的图书管理员,你说"收钱",他就去翻哪本书里"收钱"这两个字出现得最多。 语言学家懂意思但可能想多,管理员认死理但绝不会张冠李戴。
RRF(Reciprocal Rank Fusion,倒数排名融合) 是把多路检索结果合并成一个排序的方法。它不看每一路给出的原始分数(因为向量的余弦相似度和 BM25 的分数根本不在一个量纲上,没法直接加),只看排名 。某个函数在向量结果里排第 2、在 BM25 结果里排第 5,RRF 就给它算 1/(k+2) + 1/(k+5) 的分,k 是个平滑常数,业界标准取 60。谁在多路里都排得靠前,谁的融合分就高。
RRF 的代码短得可爱:
python
def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[str]:
"""Reciprocal Rank Fusion。k=60 是标准常数。"""
scores: dict[str, float] = {}
for ranked in ranked_lists:
for rank, name in enumerate(ranked):
scores[name] = scores.get(name, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores, key=lambda n: scores[n], reverse=True)
就这么点。RRF 的好处是不用调权重、对量纲不敏感、鲁棒性强,是混合检索里最省心的融合方式。
所以我们这篇的三个对手是:
- A_vector:纯向量检索。这是第 03 篇以来的老基线。
- B_bm25:纯 BM25 关键词检索。
- C_hybrid:向量 + BM25,用 RRF 融合。
图片:hybrid search pipeline. A query box on the left splits into two arrows: top arrow to a "Vector search" box, bottom arrow to a "BM25 search" box. Each produces a ranked list. Both lists feed into an "RRF fusion" box on the right, which outputs a final ranked list. A small note under RRF reads "merge by rank, k=60".
关键一步:BM25 怎么给代码分词
BM25 的效果,几乎全押在 tokenization(分词) 上------它只认它切出来的 token,切错了,后面全盘皆输。而代码不是自然语言,calculate_order_total、processCheckout、create_payment_intent 这种命名,直接扔进普通分词器只会得到一坨没法匹配的整串。
所以得写一个懂代码命名习惯的分词器:先按非字母数字符号切开,再拆 snake_case,再拆 camelCase:
python
def tokenize(text: str) -> list[str]:
# 按非字母数字符号切分
raw = re.split(r"[^a-zA-Z0-9_]", text)
tokens = []
for tok in raw:
if not tok:
continue
# snake_case 拆分
parts = tok.split("_")
for part in parts:
# camelCase 拆分:insertBefore → [insert, Before]
sub = re.sub(r"([a-z])([A-Z])", r"\1 \2", part).split()
tokens.extend(s.lower() for s in sub if len(s) > 1)
return tokens
这么一来,create_payment_intent 会被切成 ['create', 'payment', 'intent'],processCheckout 会被切成 ['process', 'checkout']。词都被拆到最细的粒度,BM25 就能拿它们和查询里的词做匹配了。
现在,在揭晓最终结果之前,我们先做一件事------把分词器套在 Q8 上,看看那个反复漏检的 calculate_order_total 到底能匹配到什么。 这一步,是这整篇文章的胜负手。
Q8 查询分词后:
arduino
Query: 'process payment and create Stripe charge'
Tokens: ['process', 'payment', 'and', 'create', 'stripe', 'charge']
六个 token。然后我们看三个候选函数,各自的全部 token 里,和查询有多少交集:
bash
calculate_order_total:
与查询的 token 交集: set() ← 零交集!
函数总 token 数: 59
create_payment_intent:
与查询的 token 交集: {'stripe', 'create', 'payment'}
process_checkout:
与查询的 token 交集: {'stripe', 'create', 'payment', 'process'}
看第一行。calculate_order_total 整整 59 个 token,和查询的交集是------set()。空集。零。一个词都对不上。
这不是"匹配得不够好",是"根本没有可以匹配的东西"。BM25 是个认死理的图书管理员,你问它"哪本书里 payment、stripe、charge 出现得最多",它翻遍 calculate_order_total 这本书,一个字都没找到,于是理直气壮地给它打 0 分。
反观 create_payment_intent 和 process_checkout------它们的代码里明晃晃写着 payment、stripe、create、process,交集一抓一大把,BM25 立刻就把它们排到前面。
读到这里,你其实已经能预感到结果了:BM25 这条"终极武器",对 calculate_order_total 完全无效,因为它跟查询连一个共同的词都没有。 而向量检索在第 06 篇已经证明也够不着它(语义太远)。两条路,一条靠语义、一条靠词频,居然在同一个函数上双双失灵。
我们把结果跑出来验证一下这个预感。
结果:不仅没修好,还退步了
三种方案跑完 12 道查询,总分表:
kotlin
方案 R@3 R@5 vs 向量
───────────────────────── ─────── ─────── ──────────
A_vector 0.889 0.958 base
B_bm25 0.889 0.931 -0.028
C_hybrid 0.889 0.931 -0.028
第一眼就够扎心:BM25 和 Hybrid 的 Recall@5 都是 0.931,比纯向量的 0.958 还低 0.028。 我们兴冲冲加了一路关键词检索,融合之后总分不升反降。
再看逐查询的 Recall@5,看清楚分到底丢在哪:
sql
Query Vec BM25 Hyb
────────────────────────────────────────────────── ────── ────── ──────
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 0.67 ← Q7,BM25 和 Hybrid 双双退步
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
两个箭头,把故事讲完了:
- Q8 (
process payment and create Stripe charge):Vec、BM25、Hyb 三种全部 0.50。跟前四篇一模一样,只命中两个 ground truth 里的一个。我们的"终极武器"哑火了。 - Q7 (
execute SQL query safely against the database):向量满分 1.00,但 BM25 掉到 0.67,Hybrid 也跟着掉到 0.67。这就是拖累总分的元凶------而且 Hybrid 因为融合了 BM25 的结果,把这个退步一并继承了过来。
一道我们想修的题没修好,一道本来满分的题被 BM25 拖垮、还传染给了 Hybrid。这就是"总分退步"的全部来源。
我们先看 Q8 的细节,把预感坐实。
Q8:语义够不着,词频也够不着
把三种方案在 Q8 上的 top-5 拉出来:
css
Q8 top-5:
A_vector: ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']
B_bm25: ['process_checkout', 'process_refund', 'create_payment_intent', 'verify_webhook_signature', 'get_payment_history']
C_hybrid: ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']
calculate_order_total: 与 Q8 的 token 交集 = set() ← 完全零交集
三种方案的 top-5,装的都是同一批"payment 词汇富集"的函数:process_checkout、process_refund、create_payment_intent、get_payment_history、verify_webhook_signature。它们的名字和函数体里写满了 payment、stripe、charge、refund------无论你用语义还是用词频去量,它们都稳稳排在前面。
而我们真正要的 calculate_order_total,在三种方案里都进不了 top-5。原因,就是那个我们提前看到的 set():
- 向量检索够不着它------第 06 篇已经量过,它和查询的语义距离太远,加了结构前缀也才推到 0.51,翻不过那堵 0.51 起步的词汇高墙。
- BM25 更够不着它 ------它和查询的 token 交集是空集,BM25 直接给 0 分,连参与排序的资格都没有。
- Hybrid RRF 融合 ------RRF 是靠"谁在多路里都排得靠前"来提分的。可
calculate_order_total在向量里排在 top-5 之外,在 BM25 里干脆是 0 分垫底。两路都把它排在后面,融合能变出什么?两个都够不着的信号加在一起,还是够不着。 RRF 不是魔法,它没法凭空造出一个两路都没给的高排名。
这就是 Q8 最本质的真相:它不是被排在第 6 位差一点,而是无论用哪种文本信号都根本感知不到它的存在。 语义、词频,这是文本检索的两大支柱,Q8 把两根柱子都撞穿了。
图片:Q8 dual failure. Two parallel channels on the left labeled "Vector (semantic)" and "BM25 (lexical)". A function node calculate_order_total in the middle. From the Vector channel, a dashed arrow labeled "too far, sim 0.51" fails to reach it; from the BM25 channel, a dashed arrow labeled "token overlap = empty set" also fails to reach it. Both arrows greyed out. On the right, an RRF box receives two "not found" signals and outputs "still not found".
Q7:BM25 的翻车,和第 06 篇同源
再看被 BM25 拖垮、又传染给 Hybrid 的 Q7:execute SQL query safely against the database(安全地对数据库执行 SQL 查询)。它的 ground truth 是 execute_query、bulk_insert、paginate_query 三个函数。
向量检索稳稳命中全部三个(1.00),BM25 却掉到了 0.67,漏了一个。
为什么?问题出在 execute 这个词上。execute_query 是个典型的 hub 函数(高出度工具函数),被 database 模块里一大堆函数调用。而"execute"这个词------它太常见了。很多和 SQL 查询八竿子打不着的函数,body 里也写着 execute(执行某个动作、执行回调、执行任务......),BM25 一视同仁地把它们的 execute token 和查询里的 execute 匹配上,分数一路抬高,把排序搅乱,硬生生把 bulk_insert 或 paginate_query 里的一个挤出了 top-5。
这个坑,眼熟吗?它和第 06 篇 Strategy B 的翻车是同一个病根。 第 06 篇里,Strategy B 把 hub 函数 execute_query 的名字无差别地印到了所有调用者的 embedding 头部,制造了 execute query 字面噪声,同样是 Q7 退步。这一篇,BM25 因为"execute"这个高频通用词的字面匹配,把一堆无关函数抬了上来,还是 Q7 退步。
不同的机制,同一个坏味道:hub 节点 + 高频通用词 = 字面噪声污染排序。 第 06 篇是把 hub 名字塞进 embedding 里污染,这篇是 BM25 直接对 hub 相关的通用词过度匹配。文本路线上,只要有 execute 这种既通用又高频的词,纯词频匹配就会翻车。
而 Hybrid 呢?它用 RRF 融合了向量和 BM25 的排名。向量在 Q7 上是满分的,本该能把 BM25 的错误纠正回来。可 RRF 是"两路排名的加权平均"------BM25 把那个被挤出的 ground truth 函数排得太靠后,即便向量把它排得很靠前,融合之后的排名依然被 BM25 拖累,没能挤回 top-5。Hybrid 没能隔离 BM25 的错误,反而把它稀释着继承了下来。 这就是它总分同样是 0.931 的原因。
五篇实验,其实一直在做同一件事
走到这里,是时候把 03 到 07 篇连起来,看看我们到底在干什么了。
表面上,这五篇是五种不同的技术方案。但如果你退后一步看,会发现它们本质上是在系统地排查同一个问题:用文本匹配找到 calculate_order_total,到底有没有可能?
我们把每一种"文本路线"的变种都试了一遍:
- 语义向量? 不行。
calculate_order_total(算税)和"Stripe 支付"(收钱)在真实世界就是两回事,语义距离太远,向量根本靠不近。(第 03、04 篇) - 注释/docstring 增强? 不行。它的 docstring 也是"sum prices, apply discount, compute tax"------写的还是 order、discount、tax,掺不进 payment 的味道。(第 06 篇 Strategy C)
- 结构前缀? 方向对,但不够。加了
# called_by: process_checkout能把相似度从 0.46 推到 0.51,可前面挡着一整排 0.51 起步的 payment 函数,推不过去。(第 06 篇 Strategy B) - BM25 关键词? 更不行。token overlap 是空集,连 0 分以上都上不去,根本没资格参与排序。(本篇)
- Hybrid RRF? 还是不行。两个都够不着的信号融合在一起,依然够不着,还顺手继承了 BM25 的 Q7 退步。(本篇)
看出来了吗?这五种方案,全都是"文本路线"------都在试图从 calculate_order_total 的文本内容里,找到一根能连到"Stripe 支付"的线。 而每一次失败,都在告诉我们同一件事:
这根线,在文本里根本不存在。
calculate_order_total 的函数体里,有 subtotal、discount、tax、price、quantity。它自始至终就是在算一笔订单的总价,完全没有 payment、stripe、charge 这些词。你从任何文本角度去量它------语义、词频、注释、结构前缀------都得不到它和"Stripe 支付"的关联,因为这个关联压根就不写在它的文本里。
那这个关联藏在哪儿?藏在一条代码结构关系 里:calculate_order_total 被 process_checkout 调用,而 process_checkout 才是那个真正调用 Stripe 支付网关的函数。是"被谁调用"这个结构事实,把 calculate_order_total 和支付流程绑在了一起。这个信息,文本内容永远无法编码------它不在任何一个函数体里,它在函数之间的边上。
这就是为什么第 05 篇的图检索是唯一一次修好 Q8 的方案:它没走文本这条路,它直接沿着 process_checkout → calculate_order_total 这条调用边,把函数拽进了候选集。绕开了文本,直接读结构。
系列回顾:文本路线的边界,被量出来了
把五篇的实验结果汇成一张表,整条路线的轮廓就清清楚楚了:
| 篇 | 方案 | Q8 状态 | 总 R@5 |
|---|---|---|---|
| 03 | AST 函数级 + 原始代码 embedding | 失败 (0.50) | 0.958 |
| 04 | Chunking 对比(固定行 / 文件 / AST) | AST 失败 | 0.958 |
| 05 | 图增强检索(BFS 2 跳) | 修好!但 Q1 退步 | 0.958 |
| 06 | 结构增强 embedding(called_by 前缀) | 失败(+0.05 不够) | 0.958 |
| 07 | BM25 + 向量 RRF | 失败(token 零交集) | 0.931 ↓ |
五种方案,总分从未超过基线 0.958。Q8 只在第 05 篇被真正修好一次------而那唯一一次,恰恰是唯一一个跳出文本、直接读代码结构的方案。第 07 篇的混合检索,本该是文本路线上最强的一招,结果不仅没修好 Q8,还因为继承了 BM25 的字面噪声,把总分拉到了全系列最低的 0.931。
这不是一个"又失败了"的沮丧结局。恰恰相反------这五篇实验,把文本匹配路线的边界,精确地量了出来。 它告诉我们一个此前只是模糊感觉、现在有数字支撑的结论:
calculate_order_total和"Stripe 支付"的关联,纯粹是结构性 的。它存在于代码的调用图里,不存在于任何函数的文本内容里。因此,任何纯文本匹配的方案------无论向量、BM25,还是它们的混合------都无法覆盖这类查询。这不是算法选择问题,不是参数调优问题,不是 chunk 大小问题,是路线的物理边界。
知道边界在哪,本身就是极有价值的工程知识。它让我们不再在文本这口枯井里继续打水。
出路:把图检索升格为一等公民
既然根因在结构,出路也就清楚了:别再把图检索当成向量检索的附加补丁,要把它升格为一等公民。
第 05 篇的朴素图扩展(无脑 BFS 2 跳)之所以是双刃剑,是因为它把候选集撑爆了、引入了噪声。但那不是图检索本身的错,是"用法太糙"。正确的用法应该是精准、有条件的结构补充:
如果向量 top-k 里出现了某个函数(比如
process_checkout),就显式查询它的called_by/calls邻居,按业务规则过滤后,把相关的邻居(比如calculate_order_total)补充进结果。
这套做法和第 05 篇的无脑扩展有本质区别:
- 只对 top-k 里的高置信函数触发扩展,不是对所有候选无差别 BFS------避免候选集爆炸。
- 只走确定性的
CALLS边,限制在 1 跳、同模块内------避免把八竿子打不着的函数拉进来。 - 按业务规则过滤邻居------比如只补充"参与支付流程"的邻居,把纯工具函数排除掉。
这样,calculate_order_total 就能靠"被 process_checkout 调用"这个结构事实被精准捞回来,而不会像第 05 篇那样把 Q1 一起做砸。图检索不再是事后打的补丁,而是和向量检索并列的一路一等召回------向量管语义、图管结构,各司其职。
这才是处理"本质极限"的正确工程姿势:不是找一个更神的 embedding 技巧、也不是给 BM25 调更好的参数,而是承认文本路线够不着的地方,用一个正交的结构信号去补上。
终章:一条技术路线的收束
写到这里,向量检索这条技术路线,就正式走完了。
从第 03 篇的 embedding 基线,到第 04 篇的 chunking 对比,到第 05 篇的图增强,到第 06 篇的结构编码,再到这一篇的混合检索------五个篇章,我们几乎穷尽了"用文本相似度检索代码"的所有主流变种。我们没有找到一个能修好 Q8 又不引入副作用的纯文本方案,但我们得到了比"修好了"更宝贵的东西:一条清晰的边界线,和边界之外那个正确的方向。
Q8 这个缠了我们五篇的幽灵,最终没有被一个更聪明的文本技巧驯服,而是被我们看清了它的本质------它是一道结构题,不是文本题。这个认知本身,就是这五篇实验的全部意义。
那么下一步呢?既然文本路线的边界已经量清楚,继续在向量、BM25 上抠细节,边际收益已经很低了。真正值得投入的方向,是把代码结构作为一等信号纳进来------这需要我们从"检索"这个视角,抬升到"如何为代码库建一套完整的知识表示"这个更宏观的工程视角。
所以下一个系列,我们会换个高度:不再纠结单个检索算法的召回率,而是从整个代码库知识库的系统架构出发,看看向量、图、符号索引这几路信号,该如何组织成一个真正能用的生产系统。Q8 的故事在这里落幕,但它留下的那句话,会一直提醒我们:
有些关联,天生就不在文本里。
总结
- BM25 对
calculate_order_total完全无效。 它和 Q8 查询的 token 交集是空集(set())------59 个 token 里没有一个能匹配payment/stripe/charge。BM25 直接给它 0 分,连参与排序的资格都没有。 - 三种方案 Q8 全部失败(0.50)。 向量够不着(语义太远),BM25 够不着(词频为零),Hybrid RRF 把两个够不着的信号融合,结果还是够不着。RRF 不是魔法,造不出两路都没给的高排名。
- Hybrid 反而拉低了总分。 BM25 因"execute"这个高频通用词的过度匹配,让 Q7 从 1.00 退到 0.67;Hybrid 通过 RRF 继承了这个错误,也退到 0.67。总 Recall@5 从 0.958 降到 0.931,比纯向量更差。
- Q7 的翻车和第 06 篇同源。 hub 函数 + 高频通用词 = 字面噪声污染排序。第 06 篇是把 hub 名字塞进 embedding,这篇是 BM25 对 hub 相关通用词过度匹配,同一个坏味道。
- 五篇实验量出了文本路线的边界。 语义、注释、结构前缀、BM25、Hybrid------每一种文本方案都在 Q8 上栽了。因为
calculate_order_total和"Stripe 支付"的关联纯粹是结构性的(它被process_checkout调用),根本不写在任何函数的文本里。 - 出路是把图检索升格为一等公民。 不是给 embedding 加技巧或给 BM25 调参,而是承认文本够不着的地方,用一个正交的结构信号补上:对 top-k 高置信函数显式查询其
called_by/calls邻居,按业务规则过滤后补充进结果。
向量检索技术路线到此收束。下一个系列,我们从更宏观的系统架构视角,重新组织向量、图、符号索引这几路信号。
参考资料
- 本系列完整 Demo 代码:codebase-kb-07-hybrid
欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页