在 MongoDB 里做混合检索,核心是用一个 $rankFusion 聚合阶段,把 $vectorSearch(语义相似)和 $search(关键词全文)两条独立管道的结果按名次融合成一份排名。它内部用的是倒数排名融合(Reciprocal Rank Fusion,RRF),不需要你自己去归一化两套量纲完全不同的分数。如果你更想直接对分数做加权运算,MongoDB 8.3 起还提供了 $scoreFusion。本文按「版本前提 → 建两套索引 → 写融合管道 → 调权重 → 排错」的顺序走一遍完整流程。

为什么单靠向量检索不够?

向量检索擅长「意思相近」,但它对精确串是弱项。用户搜 ERR_NGROK_3200CVE-2026-23479、某个内部工单号或一个不常见的函数名时,嵌入模型往往把它压缩成一团没有区分度的语义,召回的是「看起来像报错」的一堆文档,而不是那一条。

反过来,全文检索对精确串很强,但它不理解「续传断了怎么办」和「resume after disconnect」是同一件事,也处理不了同义改写。

混合检索的价值不在于「两个都用听起来更全面」,而在于两种召回的失败模式是互补的:向量漏掉的精确串,全文能抓住;全文漏掉的语义改写,向量能抓住。融合阶段要解决的,是怎么把两份互不相干的排名合并成一份可信排名。

RRF 到底在算什么

RRF 不看两条管道各自的原始分数,只看文档在各自结果里排第几。这一点很关键:$vectorSearch 返回的是 0 到 1 的余弦相似度,$search 返回的是 Lucene 风格的 BM25 分数,量纲和分布完全不同,直接相加是没有意义的。按名次融合天然回避了这个问题。

官方对 $rankFusion 的描述是:独立执行所有输入管道,跨管道去重(同一文档最多出现一次),再依据文档在各输入管道中的位置、出现在多少条管道里,以及管道权重,计算出最终排名。

一个文档如果在两条管道里都排得靠前,会明显压过只在单条管道里排第一的文档。这正是我们要的行为:两种召回路径都认可的结果,更可能是用户真正想要的。

版本前提:你的部署支持吗?

这是最容易踩空的一步,先确认再动手。

$vectorSearch 的可用范围,官方参考页写得很明确:MongoDB Atlas 6.0.11 及以上、MongoDB Enterprise 8.2 及以上(配合 Kubernetes Operator)、MongoDB Community 8.2 及以上。也就是说,向量检索已经不再是 Atlas 独占,8.2 起自建部署也能用。向量维度上限是 8192。

$rankFusion 在 MongoDB 8.0 引入,但要注意官方参考页的限定:在 8.0.X 系列上它并非正式 GA,需要开支持单才能使用;从 8.0 升级时,可能需要暂停正在执行的 $rankFusion 查询。生产上排期时,把这条写进升级前置条件,不要假设「8.0 就能直接用」。

$scoreFusion 是更晚的能力,官方参考页标注为 MongoDB 8.3 及以上引入。

$rankFusion$scoreFusion 怎么选

维度 $rankFusion $scoreFusion
融合依据 文档在各管道中的名次(RRF) 各管道的分数
引入版本 MongoDB 8.0(8.0.X 需支持单) MongoDB 8.3+
归一化 不需要,名次天然可比 input.normalization 支持 nonesigmoidminMaxScaler
组合方式 combination.weights 加权 combination.weights 加权,combination.methodavgexpression
上手成本 低,参数少 高,要理解分数分布
适合场景 首次落地、两路召回量纲差异大 需要精细控制分数曲线、已有可靠的分数标定

实务建议:先上 $rankFusion。它的参数少、失败模式少,能在一两天内拿到一条可用基线。等你已经有了离线评测集,能量化「换成分数融合后召回提升了多少」,再考虑迁到 $scoreFusion。没有评测集就直接上 $scoreFusion,多半只是把调参空间放大,效果却说不清楚。

第一步:准备两套索引

混合检索需要两个独立索引:一个向量索引,一个全文索引。它们建在同一个集合上,互不干扰。

假设集合是 articles,字段包括 title(标题)、body(正文)、embedding(正文向量)、lang(语言)。

向量索引定义(示例):

{
  "fields": [
    {
      "type": "vector",
      "path": "embedding",
      "numDimensions": 1024,
      "similarity": "cosine"
    },
    {
      "type": "filter",
      "path": "lang"
    }
  ]
}

lang 声明为 filter 字段,是为了后面能在 $vectorSearch 里做预过滤。预过滤发生在向量比较之前,比查完再用 $match 筛更省算力。

全文索引定义(示例):

{
  "mappings": {
    "dynamic": false,
    "fields": {
      "title": { "type": "string" },
      "body": { "type": "string" },
      "lang": { "type": "token" }
    }
  }
}

dynamic 设为 false 是有意为之:只索引确实要参与检索的字段,避免把整个文档都塞进倒排索引,白白吃存储和写入开销。

第二步:写融合管道

先看单独一条向量管道该怎么写。$vectorSearch 必须是它所在管道的第一个阶段,这是硬约束:

{
  $vectorSearch: {
    index: "articles_vector",
    path: "embedding",
    queryVector: [/* 查询向量 */],
    numCandidates: 200,
    limit: 20,
    filter: { lang: "zh" }
  }
}

numCandidates 控制 HNSW 图搜索时优先队列的大小。官方给出的经验值是:至少设为 limit 的 20 倍。上面 limit: 20 对应 numCandidates: 200 其实只有 10 倍,偏保守;如果你发现召回明显不足,先把它抬到 400 再看。官方说明的取舍是:队列越大,图探索越深、越可能找到更好的匹配,代价是延迟上升;调到位时 ANN 结果与精确检索(ENN)的重合度大约在 90% 到 95%。

如果数据集很小、或者过滤条件把候选集砍得很窄,可以把 exact 设为 true 走 ENN,穷举计算所有已索引向量的距离。这在小规模精编数据集或强调「必须精确」的场景下更合适,但它是计算密集的,大集合上会直接把延迟拖垮。

把两条管道装进 $rankFusion

db.articles.aggregate([
  {
    $rankFusion: {
      input: {
        pipelines: {
          semantic: [
            {
              $vectorSearch: {
                index: "articles_vector",
                path: "embedding",
                queryVector: [/* 查询向量 */],
                numCandidates: 400,
                limit: 20,
                filter: { lang: "zh" }
              }
            }
          ],
          keyword: [
            {
              $search: {
                index: "articles_text",
                compound: {
                  must: [
                    { text: { query: "断线续传", path: ["title", "body"] } }
                  ],
                  filter: [
                    { equals: { path: "lang", value: "zh" } }
                  ]
                }
              }
            },
            { $limit: 20 }
          ]
        }
      },
      combination: {
        weights: { semantic: 1, keyword: 1 }
      },
      scoreDetails: true
    }
  },
  { $limit: 10 },
  {
    $project: {
      title: 1,
      lang: 1,
      scoreDetails: { $meta: "scoreDetails" }
    }
  }
])

几个细节值得说明。semantickeyword 是自定义的管道名,会出现在 scoreDetails 里,方便你回溯某个文档到底是被哪条路径召回的。scoreDetails: true 在调参阶段务必打开,上线后如果不需要可以关掉以减少响应体积。最后那个 $limit: 10 作用在融合之后,控制真正返回给调用方的条数,与各输入管道内部的 limit: 20 是两回事。

输入管道的硬性约束

这部分是报错重灾区,官方参考页列得很细,值得逐条对照:

  • 所有输入管道必须作用于同一个集合,不能跨库跨集合。
  • input.pipelines 至少要有一条管道,管道名必须唯一。
  • 管道名不能为空串、不能以 $ 开头、不能包含 .、不能包含 ASCII 空字符。
  • 每条输入管道必须同时满足两个身份:
  • 选择型管道:只能包含 $match$search$vectorSearch$sample$geoNear$sort$skip$limit,也就是说它只能取文档、不能改文档。你不能在输入管道里塞 $project$addFields$lookup
  • 有序管道:要么以 $search$vectorSearch$geoNear 开头,要么显式包含一个 $sort
  • combination.weights 里的权重必须是非负数,不写则默认为 1。

「不能改文档」这一条最常被忽略。很多人习惯在检索后立刻 $project 掉不需要的大字段(比如 embedding 本身),但那必须放到 $rankFusion 之后,不能放进输入管道里。

另外 $vectorSearch 本身也有使用位置限制:不能出现在视图定义、$lookup 子管道和 $facet 里;从 MongoDB 8.0 起可以用在 $unionWith 中。

第三步:权重怎么调

默认两路都是 1,是一个合理的起点,但几乎不会是终点。调整方向取决于你的查询分布:

  • 用户查询里精确标识符(错误码、版本号、API 名)占比高,就调高 keyword 权重。
  • 用户查询多是自然语言长句、口语化提问,就调高 semantic 权重。
  • 两类都不少(大多数产品文档站属于这种),保持接近 1:1,靠 numCandidates 和召回条数去优化,而不是靠权重硬掰。

调权重之前,先把评测集准备好。哪怕只是人工标注 50 到 100 条真实查询及其正确答案,也远比凭感觉调参可靠。没有评测集时,权重调整本质上是在拿线上流量做没有对照组的实验。

需要强调的是:这里给出的是调参方法,不是某个具体数值一定更好的结论。不同语料、不同嵌入模型、不同查询分布下的最优权重差异很大,任何声称「0.7 : 0.3 是最佳配比」的说法都没有普适性。

第四步:接进服务层

融合查询本身是一次普通的聚合调用,接进 FastAPI 没有特殊之处,但有几点工程约束值得提前定好。

嵌入生成是外部调用,会失败也会超时。把它和数据库查询放在同一个请求里同步执行,意味着嵌入服务抖动会直接变成检索接口的 5xx。合理的做法是给嵌入调用单独设超时和重试预算,并在嵌入失败时降级为纯 $search 单路检索——返回稍差的结果,好过返回错误页。

检索结果如果要边算边推给前端,可以走 SSE。这里的断线续传、心跳和 Nginx 缓冲有一整套需要处理的细节,我们在FastAPI SSE 生产实战里单独写过,混合检索的流式返回可以直接复用那套模式。

至于文档写入、发布状态和缓存失效怎么和检索索引协同,用 FastAPI、MongoDB 与 Redis 构建原子发布的双语内容系统里讨论的发布快照与乐观锁思路同样适用:先让文档进入稳定的已发布状态,再触发向量重算和索引更新,避免检索命中一个还在编辑中的中间态。

如果你打算把这条检索管道包装成给 AI Agent 调用的工具,边界问题会比检索本身更棘手——工具需要什么权限、返回多少内容、怎么防止 Agent 把内部文档拽出来。MCP Server 安全清单里的授权与审计边界可以直接对照使用。

常见失败模式与排错

融合结果几乎等于单路结果。 通常是某一路的召回量太小。检查两条管道各自的 limit:如果 keyword 只返回 5 条而 semantic 返回 50 条,融合后自然被语义路主导。让两路的召回量级接近,再看融合效果。

报错说输入管道非法。 对照上面的约束清单,最常见的是在输入管道里写了 $project$addFields。把它们移到 $rankFusion 之后。

$vectorSearch 报位置错误。 它必须是所在管道的第一个阶段。如果你在它前面加了 $match 做过滤,改用 $vectorSearch 内置的 filter 参数。

预过滤后结果变少但分数没变。 这是预期行为。官方明确说明预过滤不影响返回的 vectorSearchScore——过滤只是缩小了候选范围,不参与相似度计算。

升级后 $rankFusion 查询报错。 回到版本前提那一节:8.0.X 上它不是 GA 状态,且官方提示从 8.0 升级时可能需要暂停相关查询。把这条纳入升级演练脚本。

召回不稳定,同一查询结果时好时坏。 检查 numCandidates 是不是太低。ANN 是近似算法,队列太小时图探索深度不足,结果会有波动。按官方建议先把它抬到 limit 的 20 倍以上。

上线检查清单

  • 确认部署版本满足 $vectorSearch$rankFusion(或 $scoreFusion)的要求,8.0.X 上的支持单前置条件已处理
  • 向量索引与全文索引都已建好,且过滤字段在向量索引里声明为 filter 类型
  • 两条输入管道只含允许的阶段,没有 $project/$addFields/$lookup
  • 两路召回量级接近,numCandidates 至少为 limit 的 20 倍
  • 融合后的 $limit 与输入管道的 limit 分别设定,不混用
  • 嵌入调用有独立超时与降级路径,失败时退回单路 $search
  • 有一份至少 50 条的人工评测集,权重调整基于评测而非直觉
  • scoreDetails 在调参环境开启、在生产按需关闭
  • 向量重算与文档发布状态解耦,避免检索命中编辑中的中间态

常见问题

必须用 Atlas 吗? 不是。$vectorSearch 在 MongoDB Community 8.2 及以上、Enterprise 8.2 及以上(配合 Kubernetes Operator)都可用,Atlas 从 6.0.11 起支持。

能融合超过两条管道吗? 可以。input.pipelines 是一个映射,官方只要求至少一条、名称唯一,没有给出上限。常见的三路组合是「向量 + 全文 + 近期热度排序」。

RRF 会不会让强匹配被稀释? 会,这是名次融合的固有取舍。一个在全文路排第一但语义路完全没召回的文档,得分会低于两路都排前三的文档。如果你的场景里精确匹配必须绝对优先,应该在融合之外单独加一条精确匹配短路逻辑,而不是靠调权重去逼近。

向量维度有上限吗? 有,8192。

要不要用 exact: true 数据集小、或过滤后候选很少、或业务要求结果可复现时用。大集合上它会显著抬高延迟,不适合作为默认值。