跳转到内容
cd /blog

多模态融合搜索:为每一次查询挑选正确的检索器

[搜索][架构]

> 像“parseConfig 是在哪里定义的”这样的查询,和“身份验证是怎么工作的”所需要的搜索方式截然不同。Maguyva 会对查询意图进行分类,据此为四种检索模态分配权重,再用加权的 Reciprocal Rank Fusion(倒数排序融合)把结果融合起来。

搜索查询从来都不是单一的一种东西。

parseConfig 在哪里定义”想要的是一个精确的符号——一个准确的位置,越快越好。“身份验证是如何工作的”想要的是含义——一整片能解释某个概念的相关代码。“如果我改动这个函数会破坏什么”想要的是依赖关系图。“查找字符串 ECONNREFUSED“想要的是字面匹配,不需要任何巧思。

Grep 非常擅长字面匹配,对某些引用查找也很有用,但它不是依赖关系图,也不理解含义。嵌入向量覆盖了语义这一侧,但对精确字符串匹配和影响分析来说,它是错误的工具。大多数代码搜索工具只挑一种引擎,然后让每一个查询都活在这个选择里。Maguyva 不做这种取舍。它会先判断你问的是哪一类问题,再按这个问题应得的比例混合四种检索器。

四种模态

底层有四种独立的代码查找方式:

  • 语义(semantic)——基于 Voyage 二进制嵌入的向量搜索;按含义查找代码。
  • 文本(text)——trigram 匹配;查找字面量、错误字符串、精确标识符。
  • 结构(structural)——AST 查询;查找定义、签名和语言构造。
  • 图(graph)——依赖关系图;查找调用者、被调用者和影响范围(blast radius)。

每一种模态都擅长应对不同类型的问题。诀窍在于针对眼前这个查询,决定该在多大程度上信任每一种模态。

意图分类

在任何检索开始之前,一个轻量级分类器会先把查询归入六种意图之一,并给出一个置信度分数。它被刻意做得很“便宜”——按顺序排列的启发式规则,第一个命中就采用——因为它运行在热路径上,只会额外增加一两毫秒:

  • def class func import 开头…… → find_definition(置信度 0.95)
  • “who calls”、“usages of”、“references to” → find_references(0.90)
  • “impact”、“blast radius”、“what depends on” → impact_analysis(0.90)
  • 带引号的 "string" 或像 traceback 这样的错误标记 → exact_match(0.85–0.90)
  • CamelCasesnake_case 这样的标识符 → find_definition(0.60–0.80)
  • “how”、“why”、“explain”、“architecture” → understand_code(0.75)
  • 什么都不匹配 → understand_code,低置信度(0.40)

每种意图在四种模态上都带有一套权重配置。以下是实际数值:

意图 semantic text structural (AST) graph
find_definition 0.2 0.1 0.6 0.1
find_references 0.1 0.2 0.2 0.5
understand_code 0.5 0.2 0.2 0.1
find_similar 0.4 0.3 0.2 0.1
impact_analysis 0.1 0.1 0.1 0.7
exact_match 0.0 0.9 0.1 0.0

所以“parseConfig 在哪里定义“会强烈偏向 AST(0.6)。“身份验证是如何工作的”会偏向语义向量(0.5)。“这个改动会影响到什么”几乎完全落在图模态上(0.7)。“查找 ECONNREFUSED“几乎全靠 trigram(0.9),嵌入模型完全被关闭——因为对精确字符串来说,语义相似度恰恰是错误的工具。

快速路径与融合路径

当分类器足够自信时——分数 ≥ 0.85——并且查询是一个常规查询,Maguyva 会完全跳过融合,直接路由到那个唯一占主导地位的模态。“X 在哪里定义”不需要四个检索器,它现在就需要 AST 索引。这条直接路径会被回报为 fusion_strategy: "direct"

一切模糊不清的查询都要经过融合。四种(或在默认预设下是三种)模态并行运行,各自返回自己的排序列表,然后我们把它们合并起来。

加权倒数排名融合(Weighted Reciprocal Rank Fusion)

融合异构检索器比听起来更难:0.82 的余弦相似度、137 的 trigram 分数和 0.004 的图中心度并不在同一个量纲上,所以不能直接相加。倒数排名融合(Reciprocal Rank Fusion)绕开了这个问题:它丢弃原始分数,只保留每个引擎给出的排名。一个结果来自某一模态的贡献值是:

contribution = weight × 1 / (k + rank + 1)

其中 rank 是它在该模态列表中的位置,k 是一个平滑常数。对于被不止一个引擎找到的结果,各模态的贡献会被累加——检索器之间的一致意见自然会浮到最上面。默认预设下我们使用 k = 40,在 thorough 上使用 60(quick 只运行语义检索,所以那里从不会触发融合)。RRF 原始论文对通用检索场景给出的是 k = 60;我们的默认值略微更“锐利”一些,这让排名靠前、被多个模态一致认可的结果获得更多一点的权重——我们不建议手动去调它。

除此之外,结果还会带有一个图重要性加成。一个枢纽节点——整个代码库都依赖的一个函数——即便文本相关性相同,也应该排在一个不起眼的叶子节点前面,所以我们会把每个贡献值乘以:

boost = min(1 + 0.3 × ln(1 + centrality), 1.5)

中心度来自流水线预先计算好的 PageRank / 度数指标,加成被限制在 1.5 倍以内,这样一个热门函数就不会把一个更相关但不起眼的函数完全埋没。最后,我们会降权来自 vendor、build 和 archive 路径的结果,并按文件对最佳片段去重。

仍不完美之处

意图分类器是一堆正则表达式,而不是一个学习出来的模型。它很好地覆盖了查询的常见形态——引入它的那项决策记录显示,零结果率从大约 15% 降到了不到 5%——但它终究是启发式的,一个真正模糊的查询会落入 understand_code,得到一个偏语义的混合结果。这是一个安全的默认选择,而不是一个聪明的选择。我们没有把它换成一个训练出来的分类器,因为这个便宜版本足够快、也足够好,而一个错得很自信的分类器,比一个诚实的兜底方案更糟。这些权重本身也是手工挑选的先验值,而不是从我们并未收集的点击数据中学来的。

问问题,而不是问该用哪个工具

智能体不应该需要知道该用 grep 还是嵌入向量还是调用图——它应该用大白话问出自己的问题,然后得到正确的答案。多模态融合正是让 find_symbol、语义搜索和依赖分析能够共处于同一个查询入口背后的原因:系统会读出问题的形状,并悄悄组装出适合它的检索器。给嵌入打分的模型固然重要,但知道什么时候不该用它同样重要。为每个查询挑选正确的工具,本身就是一种质量,而这是我们宁愿自己承担、也不愿推给调用方的事情。

// you bring the question. it brings the tools.

相关阅读

更多来自 Maguyva 开发日志的内容