搜索改版做了三个月,用户还在搜不到东西
公司的技术文档站搜索,原来是纯 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@10 | MRR | 首条命中率 | P95 延迟 |
|---|---|---|---|---|
| 纯 BM25 | 0.612 | 0.574 | 48.1% | 18 ms |
| 纯向量(kNN) | 0.671 | 0.638 | 55.6% | 74 ms |
| 加权求和(外部实现) | 0.694 | 0.661 | 58.3% | 96 ms |
| RRF 等权(k=60) | 0.748 | 0.712 | 64.4% | 88 ms |
| RRF + 动态加权 | 0.779 | 0.746 | 68.9% | 91 ms |
动态加权那 3.1 个点全部来自错误码和版本号类 query。这一类在评测集里有 127 条,纯 RRF 的 NDCG@10 只有 0.51,动态加权之后到 0.83。
rank_constant 调参
我们试了 k 从 10 到 200:
| rank_constant | NDCG@10 | 说明 |
|---|---|---|
| 10 | 0.741 | 排名靠前的文档权重过高 |
| 30 | 0.746 | |
| 60 | 0.748 | 默认值,已经接近最优 |
| 120 | 0.747 | |
| 200 | 0.745 | 趋近于纯排名求和 |
结论:60 这个默认值基本不用调。曲线很平坦,从 30 到 200 波动只有 0.003,不值得花时间。真正影响效果的是 rank_window_size 和两路各自的召回量。
num_candidates 与召回量的影响
| k / num_candidates | NDCG@10 | P95 延迟 |
|---|---|---|
| 20 / 100 | 0.712 | 52 ms |
| 50 / 500 | 0.748 | 88 ms |
| 50 / 2000 | 0.753 | 196 ms |
| 100 / 2000 | 0.756 | 201 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@10 | P95 延迟 | 单次推理成本 |
|---|---|---|---|
| RRF 融合 | 0.779 | 91 ms | 0 |
| RRF + rerank(top50→top10) | 0.836 | 340 ms | 0.0018 元 |
| RRF + rerank(top20→top10) | 0.821 | 198 ms | 0.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 ms | 93 ms |
延迟从 21 毫秒涨到 93 毫秒,但用户反而更满意了——因为一次搜对的概率高了。这印证了一个判断:搜索场景里,慢 70 毫秒没人注意,搜不到东西才会被投诉。
就写到这。如果哪天你也被《Elasticsearch 混合检索:BM25 与向量的融合》里同一个坑绊住,回来翻这篇,能省半小时。