Administrator
发布于 2024-11-16 / 5353 阅读
146

Elasticsearch 混合检索:BM25 与向量的融合

搜索改版做了三个月,用户还在搜不到东西

公司的技术文档站搜索,原来是纯 Elasticsearch BM25。今年 8 月我接手改版,第一版直接换成了向量检索,想着"语义搜索肯定更强"。上线一个月,搜索结果的点击率从 68% 掉到 51%,用户投诉反而变多了。

我把 200 条低点击率的 query 捞出来人工看了一遍,总结出两类问题。

先量化两种检索各自错在哪

我从搜索日志里抽了 800 条有明确点击结果的 query 作为评测集,标注了"应该排第一的文档"。然后分别跑纯 BM25 和纯向量,统计失败模式。

失败类型纯 BM25纯向量典型例子
同义词/表达差异186 条 (23.3%)31 条 (3.9%)搜"怎么扩容"找不到"水平扩展"的文档
专有名词/型号失配22 条 (2.8%)174 条 (21.8%)搜"ER-2024-0903"返回语义相近的错误码
数字/版本不敏感15 条 (1.9%)96 条 (12.0%)搜"3.2 版本的配置"返回 3.4 的文档
长尾生僻词71 条 (8.9%)44 条 (5.5%)
其他48 条 (6.0%)53 条 (6.6%)

结论很直白:BM25 输在同义表达,向量输在精确匹配。这两类错误互补,正好说明混合检索有戏。

再补一个关键观察:两者同时答对的 query 有 512 条(64%),两者同时答错的有 63 条(7.9%)。也就是说融合的上限大概在 92%,不是 100%。做之前先知道天花板在哪,免得后面瞎调参。

混合检索的三种融合方式

方式一:加权求和(我一开始写的,效果最差)

最朴素的想法是把两个分数归一化后加权相加:

score = 0.6 * bm25_norm + 0.4 * vector_norm

实现起来要在 ES 里用 script_score 或者在外面自己算。我第一版是在 Java 侧做的,两路各取 200 条再合并:

// 第一版:外部融合
Map<String, Double> bm25 = searchBm25(q, 200);      // 归一化到 [0,1]
Map<String, Double> knn  = searchKnn(qVec, 200);    // 余弦相似度 [0,1]

Map<String, Double> fused = new HashMap<>();
for (String id : union(bm25.keySet(), knn.keySet())) {
    fused.put(id, 0.6 * bm25.getOrDefault(id, 0.0)
                 + 0.4 * knn.getOrDefault(id, 0.0));
}

问题出在归一化上。BM25 的分数没有上界,跟 query 长度、词频、文档长度都有关,我用"除以本批最大值"来归一化,结果每批的尺度都不一样。同一条文档,跟强 query 一起搜是 0.3,跟弱 query 一起搜就变成 0.9。权重完全失真。

# 同一文档在不同 query 下的 BM25 原始分
"如何扩容集群"      → _score 18.42  (归一化后 0.31)
"扩容"              → _score  5.11  (归一化后 1.00)  ← 它成了第一名

方式二:RRF(倒数排名融合)

RRF 不看分数,只看排名,天然回避了不同检索系统分数不可比的问题。公式:

score(d) = Σ  1 / (k + rank_i(d))
           i

k 是常数,一般取 60。rank_i(d) 是文档在第 i 路检索结果里的名次,没进结果的就不计分。

ES 8.14 之后内置了 RRF,用 retriever 语法直接写:

POST docs/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "multi_match": {
                "query": "怎么扩容集群",
                "fields": ["title^3", "content"],
                "type": "best_fields"
              }
            }
          }
        },
        {
          "knn": {
            "field": "embedding",
            "query_vector": [0.021, -0.118, ...],
            "k": 50,
            "num_candidates": 500
          }
        }
      ],
      "rank_window_size": 100,
      "rank_constant": 60
    }
  },
  "size": 10,
  "_source": ["title", "doc_id"]
}

Java 侧用 8.x 的客户端:

var req = SearchRequest.of(s -> s
    .index("docs")
    .retriever(r -> r.rrf(rrf -> rrf
        .retrievers(List.of(
            Retriever.of(b -> b.standard(st -> st.query(q -> q
                .multiMatch(mm -> mm.query("怎么扩容集群")
                                .fields("title^3", "content"))))),
            Retriever.of(b -> b.knn(k -> k
                .field("embedding")
                .queryVector(toFloatList(vec))
                .k(50).numCandidates(500)))))
        .rankWindowSize(100)
        .rankConstant(60)))
    .size(10));

方式三:RRF + 自定义加权

RRF 的问题是两路权重相等,没法表达"这个 query 更应该信向量"这种判断。ES 的 RRF 实现没有直接给权重参数,但有个变通办法:把某一路重复放进 retrievers 列表

"retrievers": [
  { "standard": {...} },
  { "knn": {...} },
  { "knn": {...} }     // 放两次,相当于加权 2 倍
]

这个技巧能用,但很粗糙。我们最后的做法是:默认用等权 RRF,只对特定 query 类型做动态加权。判断逻辑放在 Java 侧:

int knnWeight = 1;
if (looksLikeCode(query) || looksLikeVersion(query)) {
    // 含错误码、版本号、型号,向量不可靠,压低权重
    knnWeight = 0;
} else if (noExactTerm(query) && query.length() > 8) {
    // 长句且无精确词,靠语义
    knnWeight = 2;
}
// 判断是不是错误码/版本号
static boolean looksLikeCode(String q) {
    return Pattern.compile("[A-Z]{2,}-?\\d{3,}|\\d+\\.\\d+\\.\\d+|v\\d+")
                  .matcher(q).find();
}

实测效果

评测集 800 条,指标用 NDCG@10 和 MRR:

方案NDCG@10MRR首条命中率P95 延迟
纯 BM250.6120.57448.1%18 ms
纯向量(kNN)0.6710.63855.6%74 ms
加权求和(外部实现)0.6940.66158.3%96 ms
RRF 等权(k=60)0.7480.71264.4%88 ms
RRF + 动态加权0.7790.74668.9%91 ms

动态加权那 3.1 个点全部来自错误码和版本号类 query。这一类在评测集里有 127 条,纯 RRF 的 NDCG@10 只有 0.51,动态加权之后到 0.83。

rank_constant 调参

我们试了 k 从 10 到 200:

rank_constantNDCG@10说明
100.741排名靠前的文档权重过高
300.746
600.748默认值,已经接近最优
1200.747
2000.745趋近于纯排名求和

结论:60 这个默认值基本不用调。曲线很平坦,从 30 到 200 波动只有 0.003,不值得花时间。真正影响效果的是 rank_window_size 和两路各自的召回量。

num_candidates 与召回量的影响

k / num_candidatesNDCG@10P95 延迟
20 / 1000.71252 ms
50 / 5000.74888 ms
50 / 20000.753196 ms
100 / 20000.756201 ms

从 500 涨到 2000,准确率只涨 0.005,延迟翻倍。num_candidates 取 k 的 10 倍左右就够了

踩过的坑

坑一:分片数影响 kNN 精度

ES 的 kNN 是分片级近似再汇总的。我们的索引有 5 个主分片,num_candidates=500 意味着每个分片各自找 500 个候选,再全局排序。分片越多,每个分片分到的数据越少,单分片的近似误差越大。

我们把分片从 5 个降到 2 个(数据量只有 3.2 GB,不需要 5 个分片),NDCG@10 从 0.748 提到 0.761,延迟还降了 12 毫秒。

PUT docs_v3
{
  "settings": { "number_of_shards": 2, "number_of_replicas": 1 },
  "mappings": {
    "properties": {
      "title":     { "type": "text", "analyzer": "ik_max_word" },
      "content":   { "type": "text", "analyzer": "ik_max_word" },
      "embedding": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine",
        "index_options": {
          "type": "hnsw",
          "m": 16,
          "ef_construction": 100
        }
      }
    }
  }
}

坑二:中文分词器要和测试时保持一致

我们早期用默认 standard 分词器,中文被切成单字,"扩容集群"变成"扩""容""集""群",BM25 的区分度极差。换成 ik_max_word 之后,纯 BM25 的 NDCG@10 从 0.512 涨到 0.612。这个改动比后面所有融合调参加起来收益都大

坑三:过滤条件要放在 kNN 内部

早期我把 filter 写在 query 外面,导致过滤发生在 kNN 之后,召回的 50 条里可能十条都不符合条件:

// 错误:filter 在 knn 外面
"knn": { "field": "embedding", "k": 50, "num_candidates": 500 },
"post_filter": { "term": { "status": "published" } }

// 正确:filter 写在 knn 内部,走的是 ANN 的前过滤
"knn": {
  "field": "embedding",
  "k": 50,
  "num_candidates": 500,
  "filter": [ { "term": { "status": "published" } } ]
}

ES 8.x 的 kNN filter 会在 HNSW 图遍历时过滤,但注意它是暴力过滤——每个访问到的节点都判断是否满足条件,如果过滤条件非常严格(比如只剩 1% 数据),性能会显著下降。我们的 status 字段 87% 是 published,影响不大;但有个按 workspace_id 过滤的场景,命中率只有 0.4%,P95 直接飙到 800 毫秒。这种场景最后改用 routing 解决了。

坑四:向量要跟着文档一起更新

文档改了但 embedding 没重新生成,检索到的还是旧语义。我们用 _bulk 的乐观锁配合版本号:

if (!Objects.equals(doc.getContentHash(), storedHash)) {
    // 内容变了,重新 embedding 再写
    esClient.update(u -> u.index("docs").id(id)
        .doc(Map.of("content", doc.getContent(),
                    "embedding", embed(doc.getContent()),
                    "content_hash", doc.getContentHash())),
        Doc.class);
}

要不要再加一层 Rerank

RRF 融合之后拿到的 top 10 是"两路投票"的结果,它不知道文档和 query 的真实相关度。我们在上线前试过加一层交叉编码器(cross-encoder)做精排。

List<Hit> rerank(String query, List<Hit> candidates) {
    // 交叉编码器要把 query 和 doc 拼在一起过一遍模型
    var req = new ArrayList<String>();
    for (Hit h : candidates) {
        req.add(query + " [SEP] " + h.title() + " " + truncate(h.content(), 512));
    }
    float[] scores = reranker.predict(req);   // bge-reranker-base
    ...
}
方案NDCG@10P95 延迟单次推理成本
RRF 融合0.77991 ms0
RRF + rerank(top50→top10)0.836340 ms0.0018 元
RRF + rerank(top20→top10)0.821198 ms0.0007 元

精排确实能再涨 5.7 个点,但延迟从 91 毫秒涨到 340 毫秒,还要多一个 GPU 推理服务要维护。我当时算了一笔账:站内搜索日均 4.2 万次查询,rerank 每月多花 2300 元,人力上要多维护一个服务。

最后没上,理由是:点击率从 68% 涨到 81% 已经解决了投诉问题,再加 5 个点的 NDCG 用户感知不到,但 340 毫秒的延迟用户能感知到

不过我们留了开关,如果后面业务方要求提高专业文档的搜索质量(比如法务、财务的文档库,对准确率要求更高,对延迟不敏感),可以单独对这个库开。

Embedding 的更新流水线

文档更新后向量要跟着更新,这块我们踩过一次:文档改了但向量没重算,用户搜新内容检索到的是旧段落,然后模型基于旧段落回答。

@Scheduled(fixedDelay = 30_000)
public void reindexStale() {
    List<Doc> stale = esClient.search(s -> s.index("docs")
            .query(q -> q.bool(b -> b
                .filter(f -> f.term(t -> t.field("status").value("published")))
                .mustNot(m -> m.term(t -> t.field("content_hash").value("")))))
            .size(200), Doc.class);

    for (Doc d : stale) {
        String newHash = DigestUtils.sha256Hex(d.getContent());
        if (!newHash.equals(d.getContentHash())) {
            esClient.update(u -> u.index("docs").id(d.getId())
                .doc(Map.of("embedding", embed(d.getContent()),
                            "content_hash", newHash)), Doc.class);
        }
    }
}

30 秒一轮,每轮 200 条,一天能处理 57 万条,我们的更新量是每天 2000 条左右,绰绰有余。加了这个之后,"文档改了搜索结果没变"的反馈消失了。

上线结果

灰度两周后的线上数据(对照组是老搜索,各 50% 流量):

指标纯 BM25(老)混合检索(新)
结果点击率68.2%81.7%
零结果率7.4%3.1%
搜索后 5 秒内二次搜索率24.6%14.2%
平均单次搜索耗时21 ms93 ms

延迟从 21 毫秒涨到 93 毫秒,但用户反而更满意了——因为一次搜对的概率高了。这印证了一个判断:搜索场景里,慢 70 毫秒没人注意,搜不到东西才会被投诉

就写到这。如果哪天你也被《Elasticsearch 混合检索:BM25 与向量的融合》里同一个坑绊住,回来翻这篇,能省半小时。

参考