多路召回架构——精确→覆盖→语义三层召回与 RRF 融合实战

多路召回架构------精确→覆盖→语义三层召回与 RRF 融合实战

系列第 7 篇。前面六篇把「ES 怎么找文档、怎么打分、怎么调权」讲完了,都是单路召回视角。但医药搜索的真实场景里,单路召回根本不够------用户搜「阿莫西林」,可能要匹配「阿莫西林胶囊」「羟氨苄青霉素」(通用名别名)、「Amoxicillin」(中英文混输)。一条路走到底,召回的天花板很低。这篇讲我们落地的一套三层召回架构:精确→覆盖→语义,以及 RRF 多路融合机制。


一、为什么需要多路召回

1.1 单路 BM25 的困境

用户搜「阿莫西林 0.25g」,BM25 召回:

  • 命中了「阿莫西林胶囊 0.25g*24 粒」------文本匹配,没问题
  • 漏掉了「羟氨苄青霉素 0.25g」------这是阿莫西林的通用名别名,BM25 看不到
  • 漏掉了「amoxicillin capsules 0.25g」------英文名,分词后和「阿莫西林」没有重叠词项

BM25 的天花板:必须在倒排索引中有共同词项。语义相关的词(「阿莫西林」和「羟氨苄青霉素」)在 BM25 的世界里是完全无关的。

1.2 单路向量召回的困境

换成纯向量召回(dense vector):

  • 能召回「羟氨苄青霉素」------语义相似度抓住了
  • 但「阿莫西林」和「阿莫西林克拉维酸钾」的向量距离几乎一样------精确度不如 BM25
  • 「感康」和「感冒灵」在向量空间里很近,但这是两种完全不同的药------过度泛化

向量召回的天花板:语义泛化会导致精确度下降,区分不了近似但不相同的实体。

1.3 出路的思路

不是 BM25 vs 向量二选一,而是两者同时召回,各取所长。BM25 保证精确匹配的优先性,向量召回补充语义相关的长尾商品。再加一层精确命中兜底------形成三层架构。


二、三层召回架构全景

第一层:精确召回(Exact Match)

目的:用户输入和商品名/通用名/批准文号精确匹配------这一层的召回必须是「就是它」,零容忍误召回。

手段 适用字段 示例
term 查询 通用名、品牌名 「阿莫西林」→ 通用名字段精准匹配
prefix 查询 输入联想 「阿莫」→ 前缀匹配补充
wildcard 查询 批准文号 「国药准字H*」→ 模式匹配
拼音/别名倒排 别名字段 「amx」→ 别名索引命中
json 复制代码
{
  "query": {
    "bool": {
      "should": [
        { "term": { "generic_name.keyword": "阿莫西林" } },
        { "term": { "brand_name.keyword": "阿莫西林" } },
        { "prefix": { "generic_name.keyword": "阿莫西林" } },
        { "term": { "alias": "amoxicillin" } }
      ],
      "minimum_should_match": 1
    }
  }
}

关键设计 :精确召回的 boost 设得高(如 10.0),确保精确匹配的结果在融合阶段不会被覆盖层淹掉。

第二层:覆盖召回(BM25 Full Text)

目的:保证只要有文本重叠的都能回来,保障召回的基本盘。

json 复制代码
{
  "query": {
    "multi_match": {
      "query": "阿莫西林 0.25g",
      "fields": [
        "drug_name^3",
        "generic_name^2",
        "brand_name^2",
        "indications^0.5",
        "specification^1"
      ],
      "type": "best_fields",
      "operator": "or"
    }
  }
}

字段权重设计思路:

  • drug_name^3:商品名称权重最高
  • generic_name^2:通用名次之
  • brand_name^2:品牌名同等
  • specification^1:规格信息重要但不主导
  • indications^0.5:适应症信息是辅助,权重低

第三层:语义召回(Dense Vector)

目的:捕获文本不重叠但语义相关的商品------这是 BM25 完全做不到的。

json 复制代码
{
  "knn": {
    "field": "drug_name_vector",
    "query_vector": [0.12, -0.34, ...],
    "k": 50,
    "num_candidates": 200
  }
}

向量生成 :我们用的是 ES 8.x 的 text_embedding pipeline,写入时自动通过 _inference 端点调用嵌入模型生成向量:

json 复制代码
// ingest pipeline
{
  "processors": [
    {
      "inference": {
        "model_id": "text-embedding-3-small",
        "target_field": "drug_name_vector",
        "field_map": { "drug_name": "text_field" }
      }
    }
  ]
}

写入时一个文档,ES 自动生成向量。查询时同理,knn 查询接收文本、在 query 阶段自动转为向量再做 ANN 检索。


三、三层召回如何协作------不是简单并列

3.1 层级关系

复制代码
用户输入
  │
  ├── 第 1 层:精确召回(term/prefix/wildcard)
  │   条件:必须匹配,boost=10.0
  │   产物:精确匹配的商品列表(通常 0~10 条)
  │
  ├── 第 2 层:覆盖召回(BM25)
  │   条件:宽松匹配,boost=1.0
  │   产物:文本匹配的商品列表(通常 100~500 条)
  │
  └── 第 3 层:语义召回(Dense Vector)
      条件:k=50, num_candidates=200
      产物:语义相似的商品列表(固定 50 条)

层级关系不是简单的「三个 query 加起来」。核心设计逻辑:

  1. 第 1 层必须命中------精确匹配的商品无条件排前面,不管 BM25 分多低
  2. 第 2 层是基本盘------保证万无一失,啥都能召回
  3. 第 3 层是增量------补充 BM25 召不回的语义相关商品

3.2 为什么不是并列三路?

如果把三个 query 平级放在 should 里:

json 复制代码
{ "should": [
    { "term": { ... } },
    { "match": { ... } },
    { "knn": { ... } }
]}

那精确匹配的优势会因为 BM25 打分过高而被淹没。比如:

  • 精确匹配「阿莫西林校准片」vs「阿莫西林」
  • 「阿莫西林校准片」在 drug_name 字段有更多匹配,BM25 分更高
  • 结果「校准片」(不相关的商品)排在了「阿莫西林」(用户想要的)前面

解决方案 :精确匹配不给它们走 BM25 的机会,用 ES 的 constant_score + 高 boost 直接赋予固定高分,跳过文本相关性计算。


四、RRF 融合机制------多路召回的粘合剂

4.1 问题:不同路的分数不可比

召回路径 分数范围 打分逻辑
精确匹配 0 或 10(固定) constant_score
BM25 0~30 词频、长度归一化
向量 0.0~1.0(余弦相似度) 向量距离

这三路的分数没有任何可比性。 BM25 的 5 和向量的 0.8 谁更好?没法比。直接按分数排序,排序结果没有意义。

4.2 RRF 的解法

Reciprocal Rank Fusion(RRF)的核心思想:不看分数,看排名

复制代码
RRF_score(d) = Σ 1 / (k + rank_i(d))

其中:

  • k:平滑常数,防止排名太靠前的文档主导结果(默认 60,我们设 25)
  • rank_i(d):文档 d 在第 i 路召回中的排名(从 1 开始)

计算示例

文档 BM25 排名 向量排名 精确匹配排名 RRF 分数
商品 A 1 5 - 1/(25+1) + 1/(25+5) = 0.072
商品 B 3 1 - 1/(25+3) + 1/(25+1) = 0.074
商品 C 2 3 1 1/(25+2) + 1/(25+3) + 1/(25+1) = 0.117

商品 C 因为在三路都排名靠前,RRF 分数最高。B 虽然在 BM25 只有第 3,但向量第 1,综合分高于 A。

4.3 ES 8.x 的 RRF 配置

json 复制代码
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "bool": {
                "should": [
                  { "term": { "generic_name.keyword": { "value": "阿莫西林", "boost": 10.0 } } },
                  { "term": { "brand_name.keyword": { "value": "阿莫西林", "boost": 10.0 } } }
                ]
              }
            }
          }
        },
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "阿莫西林",
                "fields": ["drug_name^3", "generic_name^2", "brand_name^2"]
              }
            }
          }
        },
        {
          "knn": {
            "field": "drug_name_vector",
            "query_vector_builder": {
              "text_embedding": {
                "model_id": "text-embedding-3-small",
                "model_text": "阿莫西林"
              }
            },
            "k": 50,
            "num_candidates": 200
          }
        }
      ],
      "rank_window_size": 30,
      "rank_constant": 25
    }
  }
}

4.4 参数调优效果

我们从默认参数调整到生产参数后的对比:

参数 ES 默认 我们的配置 调整原因
rank_constant (k) 60 25 降低使排名权重更敏感,精确匹配的第 1 名优势更明显
rank_window_size 100 30 只考虑每路前 30 名的排名,后面的一视同仁------减少尾部噪音
num_candidates - 50(knn),200(ANN) 200 候选集足够覆盖语义空间,50 个结果的上限控制耗时

实际性能数据:

复制代码
BM25 单路:194ms
向量召回单路:2.21s
RRF 三路融合:~2.5s

性能分析:瓶颈在向量召回(2.21s),不在 RRF 本身。RRF 的排名融合只在 30 × 50 的矩阵上做数学运算,微秒级别。


五、多路召回的去重与归一化

5.1 文档去重

三路可能召回同一个文档(BM25 命中了,向量也命中了)。RRF 天然处理了去重------同一个文档在不同路上的排名各自贡献分数。

但需要注意:ES 的 rank_window_size 对去重的影响。如果 BM25 某文档排第 31 名(不在 rank_window 内),但向量路排第 1 名------BM25 这一路对它来说就是没有贡献的。这不是 bug,而是有意为之:不在窗口内的排名被视为「未命中」,不参与 RRF 计算。

5.2 score 的置信度衰减

同一个文档被 3 路都命中 vs 只被 1 路命中------前者更「可信」。RRF 天然实现了这个效果:被越多路召回、排名越靠前,RRF 分数越高。

这是 RRF 相比「各路子查询 should 加分」的重大优势------不需要手动调 boost,排名靠前自然贡献更多。


六、坑与解法

6.1 向量召回延迟问题

现象:整个请求 2.5 秒,其中 2.21 秒在等向量召回。

根因model_text 需要在 query 阶段调用外部嵌入模型生成查询向量,有一轮网络 I/O。

解法

  1. 查询向量缓存:高频查询词(如「阿莫西林」)预计算向量,查询时直接复用,省掉 inference 调用
  2. HNSW 参数调优 :减少 num_candidates、增加 ef_search------权衡精度和速度
  3. 异步召回:向量召回可以异步执行,先返回 BM25+精确匹配的结果,向量召回结果追加补充(需要客户端支持多轮渲染)

6.2 精确匹配过度严格

现象:用户搜「阿莫西林胶囊」,精确匹配第 1 层没命中(因为精确匹配的 term 查询要求字段值和查询完全一致)。

解法 :精确匹配不只是 term,扩展为「精确匹配层」:

  • term:完全一致
  • prefix:前缀匹配
  • match_phrase:短语包含

6.3 RRF 融合后排序异常

现象:某不相关商品因为向量召回排名靠前,RRF 综合分偏高,排在 BM25 高相关商品前面。

根因:向量模型的泛化能力太强,把不相关的商品带进来了。

解法

  1. 向量相似度阈值:低于 0.7 的向量结果直接丢弃,不进入 RRF
  2. 精确匹配层兜底:精确命中的,不管 RRF 分数多少,直接排前面
  3. 增加后处理过滤:业务规则过滤(如过期商品、禁售商品)

七、Java 侧的适配

在 Spring AI + Java Client 中实现 RRF 的配置:

java 复制代码
// 构建 RRF retriever
RetrieverBuilderContainer rrfRetriever = RetrieverBuilder.of(r -> r
    .rrf(rrf -> rrf
        .retrievers(List.of(
            // 第 1 路:精确匹配
            RetrieverBuilder.of(r1 -> r1
                .standard(s -> s
                    .query(Query.of(q -> q
                        .bool(b -> b
                            .should(List.of(
                                Query.of(qt -> qt.term(t -> t
                                    .field("generic_name.keyword")
                                    .value(v -> v.stringValue("阿莫西林"))
                                    .boost(10.0f)
                                ))
                            ))
                        ))
                    ))
            )),
            // 第 2 路:BM25
            RetrieverBuilder.of(r2 -> r2
                .standard(s -> s
                    .query(Query.of(q -> q
                        .multiMatch(mm -> mm
                            .query("阿莫西林")
                            .fields(List.of("drug_name^3", "generic_name^2"))
                        ))
                    ))
            )),
            // 第 3 路:向量
            RetrieverBuilder.of(r3 -> r3
                .knn(k -> k
                    .field("drug_name_vector")
                    .k(50)
                    .numCandidates(200)
                    .queryVectorBuilder(qv -> qv
                        .textEmbedding(te -> te
                            .modelId("text-embedding-3-small")
                            .modelText("阿莫西林")
                        ))
                ))
            ))
        )
        .rankWindowSize(30)
        .rankConstant(25)
    ))
);

SearchRequest request = SearchRequest.of(s -> s
    .index("drugs")
    .retriever(rrfRetriever._toRetriever())
);

注意:ES 8.x 的 retriever API 在 Java Client 中可能还没有完美的类型化支持,必要时用 withJson() 方式兜底。


八、面试怎么讲

面试官:你们召回怎么做的?BM25 够吗?

标准答题思路

1. 先讲为什么单路不够:BM25 需要文本重叠,向量需要语义但容易泛化。两者互补,所以多路。

2. 画三层架构:精确层(免召回错误)→ 覆盖层(BM25 保底)→ 语义层(向量补充),不是并列三路而是层级设计。

3. 讲 RRF :三路分数不可比,用排名融合。公式讲清楚 1/(k+rank),以及 rank_constant 从 60 调 25 的原因。

4. 丢数据:BM25 194ms,向量 2.21s,RRF 融合 ~2.5s。瓶颈在向量 inference,不在融合。

5. 丢坑:向量泛化过度导致不相关商品排前------用相似度阈值 + 精确层兜底解决。


九、小结

记住这些 细节
三层架构 精确(term/prefix)→ 覆盖(BM25)→ 语义(向量),不是并列是层级
精确层策略 constant_score + 高 boost,跳过 BM25 打分
RRF 公式 Σ 1/(k+rank),不看分数看排名
参数调优 k=25(更敏感)、rank_window_size=30(减少尾部噪音)
性能数据 BM25 194ms,向量 2.21s,瓶颈在向量不在 RRF
向量泛化过度→相似度阈值;延迟→查询向量缓存

下一篇预告 :第 8 篇------排序三阶段:粗排→精排→重排,ESPreRankingCommand 开发经验。