Elasticsearch Hybrid Search 生产实战:BM25、kNN、RRF 与 Rerank
从 Mapping、过滤、候选集、RRF 融合、语义重排、MMR 去冗余到评测与降级,完整设计一条可观测、可回归的 Elasticsearch 混合检索链路。
很多 RAG 系统接入向量检索后,会得到一种很容易产生误导的体验:
- 用自然语言问概念问题时,结果比关键词检索更好;
- 一旦查询包含错误码、版本号、接口名、人名、剧集名或内部缩写,召回又开始不稳定;
- 把 BM25 和向量结果简单相加后,排序会随着语料、分片、模型和查询长度变化;
- 加上 Reranker 以后,延迟上升了,但坏例仍然无法归因;
- 线上只看到“最终回答错了”,不知道正确证据究竟在哪一阶段丢失。
问题不在于 BM25 或向量检索谁更先进,而在于它们解决的是不同检索信号:
BM25
→ 词项、编号、专有名词、短语和罕见词
Dense Vector
→ 同义表达、语义近似、口语化问题和跨语言泛化
RRF / Linear Fusion
→ 把不同召回器的候选合并成一个可排序集合
Reranker
→ 判断候选是否真正包含回答所需证据
MMR / Diversity
→ 避免上下文被重复 Chunk 占满生产级 Hybrid Search 不是“同时跑两个查询”,而是一条有明确候选预算、过滤边界、融合协议、失败降级、Trace 和离线评测的检索流水线。
资料快照:本文依据 Elastic 官方文档与 RRF 原始论文核查,截止日期为 2026-08-05。Elastic 当前主文档主要覆盖 9.x 与 Serverless;Retriever API 在 8.14 引入、8.16 GA,但 8.19 与当前文档在加权 RRF、Linear、Reranker、Diversify 等能力上并不完全相同。生产落地前必须按目标集群版本和订阅能力核对。
一句话结论
对大多数企业知识库和 RAG 系统,推荐从下面这条链路开始:
权限与范围过滤
→ BM25 Top-N
→ Dense kNN Top-N
→ RRF 融合
→ 按文档与父块去重
→ Reranker
→ MMR / 多样性约束
→ Context Pack
→ LLM其中有五条关键原则:
- 权限、租户、状态和版本过滤必须在召回阶段生效。
- 第一阶段追求 Recall,第二阶段再追求 Precision。
- BM25 与向量分数不能默认直接比较,RRF 是更稳妥的基线。
- Reranker 只能重新排序已有候选,不能修复漏召回。
- 每一路候选、排名、过滤原因和阶段延迟都必须进入 Trace。
1. 先看完整架构
这条链路可以分成三层:
| 层 | 目标 | 典型组件 |
|---|---|---|
| Candidate Generation | 尽量不要漏掉正确证据 | BM25、Dense kNN、Sparse Retrieval、业务规则 |
| Candidate Selection | 把真正可回答的证据排到前面 | RRF、Linear Fusion、Reranker、MMR |
| Evidence Packaging | 控制重复、长度、顺序和引用 | Parent 回填、Context Pack、Citation、Token Budget |
不要把这三层压缩成一个 _score。一旦所有逻辑都藏在最终分数里,坏例就很难解释和修复。
2. 为什么单一路径一定会遇到边界
2.1 BM25 擅长什么
BM25 依赖词项匹配、词频、逆文档频率和字段长度归一化。它尤其适合:
ERR_CONNECTION_RESETkb_gateway_wiki_v2speech-2.6-turboscene_001- 人名、公司名、接口名
- 合同条款编号
- 代码符号与配置项
- 用户输入中必须精确保留的短语
这类查询的共同特点是:词本身就是强信号。
2.2 Dense Vector 擅长什么
Dense Embedding 更适合:
- “为什么语音 Agent 会抢话”
- “怎样避免搜索索引被旧消息覆盖”
- “用户换一种说法,但想问同一个概念”
- 中英文或不同术语之间的语义映射
- 原文没有出现问题中的精确词,但含义相近
它的强项是语义泛化,但也容易出现:
- 数字与版本号区分弱;
- 相似主题不等于包含答案;
- 短文本向量容易受通用语义干扰;
- 不相关但语气相似的内容进入 Top-K;
- 不同 Embedding 模型的向量空间不可混用。
2.3 查询类型并不相同
建议把评测集至少拆成以下查询类型:
| 查询类型 | 例子 | 主要信号 |
|---|---|---|
| Exact ID | scene_001 在哪里使用 | keyword / term |
| Error Code | 1205 lock wait timeout | BM25 / phrase |
| Entity | 齐夏第一次出现在哪一集 | BM25 + metadata |
| Concept | 为什么向量库不是 Source of Truth | Dense + BM25 |
| Paraphrase | 索引落后数据库怎么修 | Dense |
| Multi-condition | 北京区、中文、已发布的 Agent 文档 | Filter + Hybrid |
| Multi-hop | 角色 A 关联的场景使用了哪些资产 | Relation / Graph / 多阶段 |
| Freshness | 当前最新版的限制是什么 | 时间、版本、权威度过滤 |
如果只统计一个总 Recall@K,模型可能在概念问题上提升很多,却把错误码、专有名词和版本查询的退化掩盖掉。
3. Mapping 是检索质量的第一层
混合检索不是从 Query DSL 开始,而是从索引文档开始。
下面是一份适合 RAG Chunk 的简化 Mapping:
PUT kb_chunks_v3
{
"mappings": {
"dynamic": "strict",
"properties": {
"tenant_id": {
"type": "keyword"
},
"knowledge_base_id": {
"type": "keyword"
},
"knowledge_id": {
"type": "keyword"
},
"chunk_id": {
"type": "keyword"
},
"parent_chunk_id": {
"type": "keyword"
},
"source_revision": {
"type": "long"
},
"projection_schema_version": {
"type": "integer"
},
"is_enabled": {
"type": "boolean"
},
"doc_status": {
"type": "keyword"
},
"doc_type": {
"type": "keyword"
},
"language": {
"type": "keyword"
},
"acl_groups": {
"type": "keyword"
},
"tag_ids": {
"type": "keyword"
},
"updated_at": {
"type": "date"
},
"title": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"context_header": {
"type": "text"
},
"content": {
"type": "text"
},
"retrieval_text": {
"type": "text"
},
"embedding": {
"type": "dense_vector",
"dims": 1024,
"similarity": "cosine",
"index": true
}
}
}
}这里的 dims: 1024 只是示例,必须与实际 Embedding 模型一致。
3.1 为什么保留 retrieval_text
业务展示内容与检索内容不一定相同。
展示内容:
Chunk.Content
检索内容:
Title
+ ContextHeader
+ Chunk.Content
+ 少量稳定业务上下文可以预先生成:
retrieval_text =
title
+ "\n"
+ context_header
+ "\n\n"
+ content这样 BM25 与 Embedding 使用同一份经过规范化的文本,避免一个索引标题、另一个只索引正文。
3.2 不要把权限藏进动态 metadata
下面这些字段决定候选是否有资格进入结果集:
tenant_idacl_groupsdoc_statusis_enabledknowledge_base_idlanguageupdated_at
它们应该是显式字段,而不是藏在 flattened 里,更不能等召回完成后再交给 LLM 判断。
关于 Mapping 与动态对象边界,可以继续阅读:
4. BM25 通道不要只写一个 match
一个可用的 lexical 通道通常包含多个信号:
{
"standard": {
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "QUERY_TEXT",
"fields": [
"title^4",
"context_header^2",
"content",
"retrieval_text"
],
"type": "best_fields"
}
}
],
"should": [
{
"term": {
"title.keyword": {
"value": "QUERY_TEXT",
"boost": 8
}
}
},
{
"match_phrase": {
"title": {
"query": "QUERY_TEXT",
"boost": 5
}
}
},
{
"match_phrase": {
"content": {
"query": "QUERY_TEXT",
"slop": 1,
"boost": 2
}
}
}
]
}
},
"filter": [
{
"term": {
"tenant_id": "tenant_001"
}
},
{
"term": {
"is_enabled": true
}
},
{
"term": {
"doc_status": "published"
}
},
{
"terms": {
"acl_groups": [
"employee",
"rag-team"
]
}
}
]
}
}这段 DSL 体现了三个层级:
multi_match负责广义词项召回;term和match_phrase提升精确命中;filter决定文档是否有资格参与排序。
4.1 Exact Query 应该有旁路
如果查询已经被识别为稳定 ID:
scene_001
character:character_xxx
ERR_1205
kb_gateway_wiki_v2不必把它完全交给通用 multi_match。可以增加:
term查询;- 专用 keyword 字段;
- 正则识别后的业务路由;
- ID 命中后的直接回填。
一个通用模型没有必要重新“理解”已经明确的业务主键。
4.2 BM25 的失败不只来自算法
常见根因还包括:
- 中文 analyzer 与语料不匹配;
- 标题和正文没有合理字段权重;
- 代码、路径、版本号被错误分词;
- 同一内容有大量重复 Chunk;
- Query Rewrite 删除了原始专有名词;
- Filter 把正确文档提前排除。
因此 Bad Case 分析必须保留原始 Query、Rewrite Query 和具体 analyzer 结果。
5. Dense kNN:k、num_candidates 和 similarity 分别控制什么
一个 kNN Retriever 示例:
{
"knn": {
"field": "embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"k": 50,
"num_candidates": 200,
"similarity": 0.35,
"filter": [
{
"term": {
"tenant_id": "tenant_001"
}
},
{
"term": {
"is_enabled": true
}
},
{
"terms": {
"acl_groups": [
"employee",
"rag-team"
]
}
}
]
}
}如果 Embedding 在应用侧生成,也可以直接传 query_vector,但必须保证:
- 维度一致;
- 使用同一个模型或同一个物理向量空间;
- 归一化方式一致;
- Query 与文档使用相同的前缀或指令模板;
- 模型升级后进入新物理索引或新向量字段。
5.1 k
k 是最终希望从向量通道取得的近邻数量。
它不是最终送给 LLM 的数量。Hybrid Search 通常会先取较大的候选集,再经过融合、去重和重排压缩。
5.2 num_candidates
近似 kNN 会在每个分片探索候选,再合并为全局 Top-K。对于 HNSW,num_candidates 是主要的检索时 Recall / Latency 控制项:
num_candidates 增大
→ 更可能找到真实近邻
→ CPU 与延迟增加
num_candidates 减小
→ 查询更快
→ 可能漏掉正确候选它应通过离线数据调优,而不是机械设为 k × 10。
5.3 similarity
kNN 会尽力返回 k 个最近邻,即使过滤后的剩余文档都不够相关。
similarity 是原始向量相似度阈值,可以避免“只是相对最近,但绝对并不相关”的候选进入结果。
阈值不能跨模型复制。不同模型、相似度函数和向量归一化会产生不同分布,需要基于正负样本校准。
5.4 Filter 是 Pre-filter,不是普通 Post-filter
Elastic 的 kNN filter 会在近似搜索过程中生效,目标是返回同时满足过滤条件的 Top-K。
这与搜索完成后的 post_filter 不同:
Pre-filter
→ 在近邻搜索时就约束候选
→ 尽量返回 k 个符合权限的结果
Post-filter
→ 先找到近邻,再过滤
→ 可能最终不足 k 个结果需要注意:HNSW 上的过滤并不总是越严格越快。为了找到足够多满足条件的近邻,引擎可能需要探索更多图节点;在某些分段条件下还会切换为 brute-force 搜索。
6. 三种融合方式不能混为一谈
6.1 顶层 query + knn:分数求和
Elasticsearch 可以在一个请求中同时提供普通 Query 和 kNN:
POST kb_chunks/_search
{
"size": 10,
"query": {
"match": {
"retrieval_text": {
"query": "QUERY_TEXT",
"boost": 0.9
}
}
},
"knn": {
"field": "embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"k": 50,
"num_candidates": 200,
"boost": 0.1
}
}这个模式将 lexical 与 vector 命中按 OR 合并,并使用 Boost 后的分数求和。
它的优点是简单,但需要解决分数尺度问题:
final_score
=
lexical_boost × BM25_score
+
vector_boost × vector_scoreBM25 分数会随查询、语料、字段和 analyzer 变化;向量 _score 又由相似度函数转换而来。固定的 0.9 / 0.1 不代表长期稳定的业务权重。
6.2 RRF:融合排名,不直接比较原始分数
RRF 的基本形式是:
RRF(d) = Σ 1 / (k + rank_i(d))其中:
rank_i(d)是文档d在第i路结果中的名次;k是平滑常数;- 文档在多路都靠前时,最终分数更高;
- 不要求 BM25 与向量分数属于同一量纲。
假设 rank_constant = 60:
| 文档 | BM25 Rank | Vector Rank | RRF Score |
|---|---|---|---|
| A | 1 | 4 | 1/61 + 1/64 |
| B | 2 | 未出现 | 1/62 |
| C | 8 | 1 | 1/68 + 1/61 |
| D | 未出现 | 2 | 1/62 |
A 和 C 因为同时得到两路支持,通常会排到单路命中的 B、D 前面。
RRF 的优势是稳定和低调参成本;它的限制是只看排名,不利用原始分数差距。
6.3 Linear Fusion:归一化后做加权求和
当前 Elastic Retriever API 还提供 Linear Retriever:
Score(d)
=
Σ weight_i × Normalizer(score_i(d))它适合:
- 已经有稳定离线评测集;
- 各路分数分布可观测;
- 需要精细表达业务权重;
- 愿意维护 Normalizer 与权重。
在没有充足标注数据时,先用 RRF 往往更稳妥。
7. rank_constant 与 rank_window_size 怎么理解
7.1 rank_constant
值越大,低排名候选仍然保有更多影响:
较小 rank_constant
→ 更强调每一路最顶部结果
较大 rank_constant
→ 让较低名次也能贡献更多分数默认值是 60。不要只围绕一个查询手调,应在完整评测集上比较。
7.2 rank_window_size
它决定每个子 Retriever 有多少候选进入 RRF。
rank_window_size 太小
→ 正确证据可能在融合前被截断
rank_window_size 太大
→ 排序与内存成本上升
→ 后续 Rerank 候选也可能变多最终返回数量仍由顶层 size 控制,而且 rank_window_size 必须不小于 size。
一个常见起点是:
| 参数 | 起始范围 | 说明 |
|---|---|---|
| BM25 候选 | 50–100 | 精确词和实体召回 |
Dense k | 50–100 | 语义候选 |
Dense num_candidates | 100–500 | 按分片数、过滤选择性和 Recall 调优 |
RRF rank_window_size | 50–150 | 必须覆盖希望进入 Rerank 的候选 |
| Rerank Window | 30–80 | 受模型延迟和文本长度约束 |
| Context 输出 | 5–10 | 受 Token Budget 与证据密度约束 |
这些只是工程起点,不是官方推荐常数。正确值来自 Query Cohort、索引规模、分片数、模型和延迟目标。
8. Elasticsearch 8.16+:使用 Retriever API
Retriever API 在 8.14 引入,并在 8.16 GA。它把检索过程表达成一棵 Retriever Tree。
8.1 兼容 8.19 思路的等权 RRF
POST kb_chunks/_search
{
"size": 10,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": "QUERY_TEXT",
"fields": [
"title^4",
"context_header^2",
"content"
]
}
}
}
},
{
"knn": {
"field": "embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"k": 50,
"num_candidates": 200
}
}
],
"rank_constant": 60,
"rank_window_size": 100,
"filter": [
{
"term": {
"tenant_id": "tenant_001"
}
},
{
"term": {
"is_enabled": true
}
},
{
"terms": {
"acl_groups": [
"employee",
"rag-team"
]
}
}
]
}
}
}在 8.19 文档中,RRF 子 Retriever 是等权的。
8.2 当前 9.x 文档中的加权 RRF
当前文档允许给子 Retriever 增加权重:
POST kb_chunks/_search
{
"size": 10,
"retriever": {
"rrf": {
"retrievers": [
{
"retriever": {
"standard": {
"query": {
"multi_match": {
"query": "QUERY_TEXT",
"fields": [
"title^4",
"context_header^2",
"content"
]
}
}
}
},
"weight": 1.2
},
{
"retriever": {
"knn": {
"field": "embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"k": 50,
"num_candidates": 200
}
},
"weight": 1.0
}
],
"rank_constant": 60,
"rank_window_size": 100
}
}
}不要把这段语法直接复制到较老集群。先检查目标版本是否支持 per-retriever weight。
8.3 Retriever 不是普通 Query 的附加字段
指定顶层 retriever 后,不应再并列使用这些顶层元素:
queryknnsearch_afterterminate_aftersortrescore
需要重排时,应使用 Retriever Tree 中对应的 Rescorer 或 Reranker Retriever。
9. 旧版本或跨后端系统:在应用层做 RRF
如果集群版本不支持 Retriever API,或者 BM25、向量库和业务检索分布在不同系统,可以在应用层融合。
下面是一个简化 Go 实现:
package retrieval
import (
"sort"
)
type RankedHit struct {
ID string
Rank int
Source string
}
type FusedHit struct {
ID string
Score float64
BestRank int
From map[string]bool
}
func FuseRRF(
resultSets map[string][]RankedHit,
weights map[string]float64,
rankConstant float64,
limit int,
) []FusedHit {
byID := make(map[string]*FusedHit)
for source, hits := range resultSets {
weight := weights[source]
if weight == 0 {
weight = 1
}
for index, hit := range hits {
rank := hit.Rank
if rank <= 0 {
rank = index + 1
}
item, ok := byID[hit.ID]
if !ok {
item = &FusedHit{
ID: hit.ID,
BestRank: rank,
From: make(map[string]bool),
}
byID[hit.ID] = item
}
item.Score += weight / (rankConstant + float64(rank))
item.From[source] = true
if rank < item.BestRank {
item.BestRank = rank
}
}
}
output := make([]FusedHit, 0, len(byID))
for _, item := range byID {
output = append(output, *item)
}
sort.Slice(output, func(i, j int) bool {
if output[i].Score != output[j].Score {
return output[i].Score > output[j].Score
}
if output[i].BestRank != output[j].BestRank {
return output[i].BestRank < output[j].BestRank
}
return output[i].ID < output[j].ID
})
if limit > 0 && len(output) > limit {
output = output[:limit]
}
return output
}生产实现还应处理:
- 相同
chunk_id的不同 revision; - 多索引 Alias 切换期间的重复文档;
- 父块与子块去重;
- 每一路超时和部分成功;
- 稳定 Tie-break;
- Trace 中保留原始 Rank、Score 和来源;
- 某一路完全失败时的降级策略。
10. Filter 应该放在哪里
10.1 所有通道共享的硬过滤
适合放在 RRF 顶层或每个 Retriever 都复制:
tenant_idacl_groupsis_enableddoc_statusknowledge_base_id- 删除状态
- 合规与敏感级别
它们是安全边界,不能只作用于 BM25,而漏掉 kNN。
10.2 某一路独有的过滤
某些条件只适用于特定通道:
FAQ 只走 FAQ 向量索引
代码 Chunk 增加 language filter
Wiki-only KB 不参与向量召回
旧版文档只允许 BM25 精确查找这时应放在具体子 Retriever 中,而不是强加给所有通道。
10.3 软条件不要伪装成权限
更新时间、权威度、业务偏好可能是:
- 硬过滤;
- Query Boost;
- Rerank Feature;
- 最终业务规则。
例如,“优先新文档”不一定等于“删除所有旧文档”。把软偏好写成严格 Filter,容易造成漏召回。
11. Reranker:从“相关”推进到“可回答”
RRF 只知道多个召回器如何排名,不知道候选是否包含回答问题所需的关键条件。
例如问题是:
为什么 external version 的 Delete 不能永久防止旧 UPSERT 复活?下面两段都与 Elasticsearch 删除相关:
A:Delete API 的基本用法
B:index.gc_deletes 只在有限时间保留删除版本信息BM25 和向量都可能认为 A 很相关,但真正能回答问题的是 B。Reranker 更适合做这种细粒度判断。
11.1 当前 Elasticsearch 的 Text Similarity Reranker
POST kb_chunks/_search
{
"size": 8,
"retriever": {
"text_similarity_reranker": {
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": "QUERY_TEXT",
"fields": [
"title^4",
"context_header^2",
"content"
]
}
}
}
},
{
"knn": {
"field": "embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"k": 60,
"num_candidates": 240
}
}
],
"rank_window_size": 60,
"rank_constant": 60
}
},
"field": "retrieval_text",
"inference_id": "my-rerank-endpoint",
"inference_text": "QUERY_TEXT",
"rank_window_size": 50,
"min_score": 0.1
}
}
}具体 inference_id、Score 范围和模型部署方式取决于使用的 Rerank 服务。
11.2 Reranker 的候选窗口
窗口太小
→ 正确证据还没进入 Rerank 就被截断
窗口太大
→ Pair 数、Token、延迟和成本上升建议先测:
- Candidate Recall@50 / @100;
- Rerank 后 MRR / NDCG;
- Rerank P95;
- 每次请求的候选总字符数;
- 长文档截断比例。
11.3 Rerank 必须有降级
Reranker 成功
→ 使用重排结果
Reranker 超时
→ 回退到 RRF 结果
Reranker 返回零结果
→ 检查 min_score,必要时使用受控回退
Reranker 服务熔断
→ 短时间直接跳过,不阻塞所有搜索不要因为第二阶段模型失败,把第一阶段已经找到的可用证据全部丢弃。
更完整的二阶段检索讨论见:
12. MMR:避免十个结果都在重复同一段话
Reranker 可能把多个高度相似 Chunk 都排到前面:
同一文档的相邻切块
同一 FAQ 的不同改写
同一事件的重复摘要
同一页面的新旧版本这些候选单独看都相关,但一起进入 Prompt 会浪费 Token,并让证据看起来比实际更“多数”。
Maximum Marginal Relevance 同时考虑:
与 Query 的相关性
-
与已选结果的相似性当前 Elastic 文档提供 diversify Retriever,并使用 MMR:
POST kb_chunks/_search
{
"size": 8,
"retriever": {
"diversify": {
"type": "mmr",
"field": "embedding",
"lambda": 0.75,
"size": 8,
"rank_window_size": 30,
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"match": {
"retrieval_text": "QUERY_TEXT"
}
}
}
},
{
"knn": {
"field": "embedding",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-text-embedding-model",
"model_text": "QUERY_TEXT"
}
},
"k": 50,
"num_candidates": 200
}
}
],
"rank_window_size": 50
}
}
}
}
}lambda 越高,越偏向 Query 相关性;越低,越强调候选之间的差异。
上面的示例为了展示 Retriever Tree,在两处使用了 query_vector_builder。如果应用已经在外部生成 Query Embedding,生产实现应只生成一次,并把同一个 query_vector 复用于 kNN 与 MMR,避免重复模型调用。
这属于当前版本能力。旧版集群可以在应用侧实现 MMR,或者先用更便宜的规则去重:
- 同一
knowledge_id最多取 N 个; - 相邻 Chunk 合并;
- 相同
parent_chunk_id只保留最高分; - 相同
projection_hash去重; - 同文档版本只保留最高 revision。
13. Query Rewrite 不要破坏原始检索信号
Query Rewrite 可以补全上下文:
“它为什么会失败”
→
“RAG Reranker 服务为什么会失败”但 Rewrite 也可能删除强词项:
原始:ERR_1205 在 TDW 分区删除时怎么处理
改写:数据库锁等待超时如何处理改写后语义更完整,却丢失了 ERR_1205、TDW 和“分区删除”等精确信号。
推荐保留双查询:
BM25:
原始 Query + Rewrite Query
Dense:
Rewrite Query,必要时同时生成原始 Query 向量
Trace:
同时记录 original_query 与 rewritten_query还可以按查询类型路由:
| Query 类型 | 策略 |
|---|---|
| 短 ID / 错误码 | Exact + BM25 为主,Dense 降权或跳过 |
| 自然语言概念 | BM25 + Dense 等权 RRF |
| 多语言问题 | 多语言 Embedding + 原文 BM25 |
| 长问题 | 提取核心检索句,同时保留原始约束 |
| 多子问题 | 拆分后分别召回,再做全局融合 |
| 权限敏感 | 先确定 Scope,再生成 Query |
相关内容可以继续阅读:
14. Parent-Child Chunk 如何参与 Hybrid Search
Parent-Child Chunking 常见做法是:
小 Child
→ 用于 BM25 / Vector 精确召回
大 Parent
→ 用于最终上下文检索流程不能简单把 Child 替换成 Parent 就结束。
建议保留:
{
"chunk_id": "child_001",
"parent_chunk_id": "parent_001",
"knowledge_id": "doc_001",
"source_revision": 17
}融合后按以下步骤处理:
- 先以 Child Rank 完成 BM25、kNN 和 RRF;
- 回填 Parent;
- 同一 Parent 的多个 Child 合并证据信号;
- 保留命中 Child 的位置和分数用于引用;
- Reranker 输入可以是 Parent,也可以是 Child + Parent 摘要;
- Context Pack 只放一份 Parent,避免重复。
14.1 Parent 分数怎么合并
常见策略:
Max
→ 取该 Parent 最强 Child 分数
Top-N Sum
→ 累加前 N 个 Child 的贡献
RRF Again
→ 把 Child 命中视为多个排名信号
Feature Model
→ 把命中数量、最佳名次、覆盖章节作为特征最简单的基线通常是 Max,再用评测决定是否需要更复杂聚合。
15. 深分页不是 Hybrid Relevance 的主要用途
Compound Retriever 存在分页限制。当前文档明确指出,Retriever Tree 包含 Compound Retriever 时不支持 search_after;RRF 也不支持普通 Sort、Scroll 和传统 Rescore 组合。
这意味着 Hybrid Search 更适合:
Top-K 问答证据
搜索首页
相关推荐
候选集生成而不适合直接承担:
稳定遍历全部文档
导出十万条搜索结果
按业务字段做深分页需要全量遍历时,应使用确定性 Filter + Sort + PIT / search_after 的独立查询路径,不要把 RRF 排名接口当成扫描 API。
16. Explain、Profile 与 Trace 应该记录什么
当前 Retriever 示例支持 explain: true,RRF 也支持 Profiling。它们适合调试,但线上仍需要自己的结构化 Trace。
推荐每次检索至少记录:
{
"request_id": "req_001",
"original_query": "索引为什么被旧消息覆盖",
"rewritten_query": "Elasticsearch 索引为什么会被乱序旧事件覆盖",
"scope": {
"tenant_id": "tenant_001",
"knowledge_base_ids": [
"kb_rag"
],
"acl_groups": [
"employee",
"rag-team"
]
},
"embedding": {
"model_key": "bge-m3|endpoint-a",
"cache_hit": true,
"latency_ms": 8
},
"bm25": {
"candidate_count": 80,
"latency_ms": 18
},
"knn": {
"k": 80,
"num_candidates": 320,
"candidate_count": 80,
"latency_ms": 34
},
"fusion": {
"type": "rrf",
"rank_constant": 60,
"rank_window_size": 100,
"candidate_count": 126,
"latency_ms": 3
},
"rerank": {
"model": "cross-encoder-x",
"input_count": 50,
"output_count": 8,
"latency_ms": 71,
"fallback": false
},
"context": {
"document_count": 5,
"chunk_count": 8,
"token_estimate": 4200
}
}每个候选还应记录:
chunk_id
knowledge_id
source_revision
BM25 rank / score
Vector rank / score
RRF score
Rerank score
是否被 Filter 排除
是否因 Parent 去重被移除
是否进入最终 Context没有这份数据,“RAG 回答错了”无法被拆成可修复问题。
17. 评测要按阶段做,而不是只看最终答案
17.1 Candidate Generation
| 指标 | 回答的问题 |
|---|---|
| BM25 Recall@K | 精确通道有没有找到证据 |
| Vector Recall@K | 语义通道有没有找到证据 |
| Union Recall@K | 多路候选并集是否覆盖证据 |
| Unique Relevant Gain | 新增通道带来了多少独有正确证据 |
| Filter Recall Loss | 正确证据是否被过滤掉 |
17.2 Fusion 与 Ranking
| 指标 | 回答的问题 |
|---|---|
| MRR | 第一个正确证据排在多前 |
| NDCG@K | 多个相关证据整体排序是否合理 |
| Precision@K | 顶部候选噪声有多少 |
| RRF Gain | 融合是否优于最好单路 |
| Query Cohort Delta | 哪类查询提升或退化 |
17.3 Rerank 与 Context
| 指标 | 回答的问题 |
|---|---|
| Context Precision | 最终上下文中有用证据比例 |
| Answerability | 候选是否包含完整回答条件 |
| Redundancy Ratio | 重复内容占多少 |
| Source Diversity | 是否过度集中于一个文档 |
| Token Efficiency | 每个有效证据消耗多少 Token |
17.4 End-to-End
- Answer Correctness
- Faithfulness
- Citation Accuracy
- Refusal Accuracy
- P50 / P95 / P99 Latency
- 每次查询成本
- 无结果率与回退率
Elasticsearch 自带 _rank_eval API,可以基于典型查询和人工标注文档计算 MRR、Precision、DCG 等指标。对于完整 RAG,还需要在应用层把检索评测和生成评测串起来。
18. 一份最小 Golden Set
{
"query_id": "q_001",
"query": "为什么删除后的旧事件会让 ES 文档复活",
"query_type": "concept_with_constraint",
"scope": {
"tenant_id": "tenant_001",
"knowledge_base_ids": [
"kb_rag"
]
},
"relevant_chunks": [
{
"chunk_id": "chunk_gc_deletes",
"rating": 3,
"reason": "解释删除版本只在有限时间保留"
},
{
"chunk_id": "chunk_tombstone",
"rating": 3,
"reason": "给出 Tombstone 防复活方案"
},
{
"chunk_id": "chunk_delete_api",
"rating": 1,
"reason": "只介绍 Delete API,相关但不足以回答"
}
]
}标注不要只分“相关 / 不相关”,最好区分:
3:直接、完整回答问题
2:包含关键证据,但需要与其他片段组合
1:主题相关,但不能回答核心问题
0:不相关这样 NDCG 与 Rerank 评测更有意义。
19. 延迟预算:不要只优化 Elasticsearch Took
一次 Hybrid RAG 请求的延迟可能包括:
Query Classification
Query Rewrite
Query Embedding
BM25
kNN
Fusion
DB / ES 回填
Rerank
Context Pack
LLM TTFTElasticsearch 返回的 took 只覆盖集群内部搜索时间,不包含:
- Embedding API 网络时间;
- 应用排队;
- Reranker;
- 数据库回填;
- JSON 编解码;
- 连接池等待;
- 跨地域网络;
- LLM 生成。
一份示例预算:
| 阶段 | P95 目标示例 |
|---|---|
| Query Rewrite | 80 ms |
| Embedding | 40 ms |
| BM25 + kNN + RRF | 100 ms |
| 回填与去重 | 30 ms |
| Rerank | 120 ms |
| Context Pack | 20 ms |
| 检索总链路 | 300–450 ms |
这只是示例。真正预算要结合模型部署地域、索引规模、并发和整体 Voice / Agent 链路延迟。
20. 失败降级矩阵
| 故障 | 推荐反应 | 不推荐 |
|---|---|---|
| Embedding 超时 | 退化到 BM25 | 整次请求失败 |
| kNN 后端异常 | 返回 BM25 + 标记 partial | 返回空答案 |
| BM25 无结果 | 继续使用 Dense | 强制拒答 |
| RRF 失败 | 应用侧简单合并或使用最佳单路 | 丢掉所有候选 |
| Reranker 超时 | 回退到 RRF 顺序 | 返回 500 |
| Reranker 零结果 | 检查阈值后受控回退 | 让 LLM 无证据生成 |
| Parent 回填失败 | 使用命中 Child | 丢弃正确证据 |
| 某个 KB 无权限 | 从 Scope 排除并审计 | 将无权内容送入 Prompt |
| 某个分片慢 | 超时、部分结果或熔断 | 无限等待 |
| Query Rewrite 失败 | 使用原始 Query | 停止检索 |
降级不是隐藏错误。响应和 Trace 应明确:
retrieval_mode = hybrid
degraded_to = bm25_only
degrade_reason = embedding_timeout21. 面向 MySQL + Elasticsearch 的推荐实现
组件责任:
| 组件 | 负责什么 |
|---|---|
| Scope Resolver | 租户、权限、KB、语言、状态和版本范围 |
| Query Classifier | 判断 Exact / Entity / Concept / Multi-hop |
| Embedding Gateway | 模型路由、缓存、批量、超时和 Model Key |
| Elasticsearch | BM25、kNN、Filter 和初步融合 |
| Hydrator | 回填完整 Chunk、Parent、引用和当前 Revision |
| Rerank Gateway | 模型调用、窗口控制、超时和回退 |
| Dedup / MMR | 文档、Parent、版本和语义去重 |
| Context Packer | Token Budget、来源顺序和引用编号 |
| Eval Pipeline | Golden Set、Bad Case、版本对比和发布门禁 |
这里与前一篇文章的边界一致:
- MySQL 是业务事实;
- Elasticsearch 是可重建投影;
- 查询只访问稳定 Alias;
- Embedding Model Key、Projection Schema Version 和物理索引版本必须可追踪。
可继续阅读:
22. 分阶段落地方案
P0:先建立可解释 Baseline
- BM25 与 Dense 分别召回;
- 使用等权 RRF;
- 所有硬权限过滤作用于两路;
- 记录每一路 Rank、Score、候选数量和延迟;
- 建立至少 100–300 条真实 Golden Query;
- 分 Query Type 计算 Recall@K、MRR 和 NDCG;
- Reranker 失败时回退到 RRF。
验收标准:
每个 Bad Case 都能判断:
是 Filter、BM25、Vector、Fusion、Rerank 还是 Context 出错。P1:优化质量与成本
- Query Embedding 缓存和批量;
- Parent-Child 回填;
- 文档与 Parent 去重;
- Rerank Window 调优;
- Exact Query 路由;
- Query Rewrite 双查询;
- MMR 或业务多样性限制;
- 评测集进入发布门禁。
验收标准:
Hybrid 在主要 Query Cohort 上稳定优于最好单路,
且 P95 和成本在预算内。P2:高级排序
- Weighted RRF;
- Linear Retriever 与分数归一化;
- Sparse Retrieval;
- Learning to Rank;
- Query Rules / Pinned Results;
- 多模态 Retriever;
- 在线点击与人工反馈闭环。
前提是已经具备:
- 稳定标注数据;
- 分阶段 Trace;
- 可回放实验;
- 版本化 Query Template;
- 明确回滚路径。
23. 十个常见反模式
反模式一:只有向量召回
专有名词、编号、版本和错误码会持续产生坏例。
反模式二:直接把 BM25 与向量 _score 相加
不同分数尺度没有稳定可比性,固定 Boost 容易随语料变化失效。
反模式三:RRF rank_window_size 等于最终 size
最终只要 5 条,不代表每一路只应召回 5 条。过早截断会损失 Recall。
反模式四:权限只加在 BM25 Query
kNN 仍可能召回无权文档。硬 Filter 必须覆盖所有通道。
反模式五:让 Reranker 修复漏召回
正确证据没进候选集,第二阶段无法凭空创造它。
反模式六:Reranker 失败就返回空
第一阶段结果仍然可用,应该降级。
反模式七:相邻 Chunk 全部塞进 Prompt
它会制造重复、放大某一来源并浪费 Token。
反模式八:只看最终答案分数
无法判断检索、融合、重排还是生成出了问题。
反模式九:把 current 文档语法直接复制到旧版 8.x
Weighted RRF、Linear、Reranker 和 Diversify 的支持范围可能不同。
反模式十:用 Hybrid Retriever 做深分页和全量导出
相关性 Top-K 与稳定扫描是两个不同接口。
24. 上线检查清单
- BM25 与 Dense 是否分别有 Recall@K?
- 是否按 Exact、Entity、Concept、Error Code 等 Query Type 分组?
- Query Embedding 是否与文档使用同一模型和模板?
- 多 KB 是否校验同一 Embedding 空间?
-
tenant_id、ACL、状态和删除过滤是否覆盖所有通道? - kNN 使用的是 Pre-filter,而不是只靠
post_filter? -
k、num_candidates、similarity是否通过评测调优? - RRF
rank_window_size是否覆盖 Rerank 候选窗口? - 是否保留 BM25 Rank、Vector Rank 和最终 RRF Score?
- Reranker 是否有超时、熔断和回退?
- Parent-Child 回填后是否按 Parent 去重?
- 是否控制同一文档进入 Context 的 Chunk 数?
- 是否记录 original query 与 rewritten query?
- 是否监控 Embedding、Search、Rerank 和 Context 各阶段 P95?
- 是否建立 Golden Set、Bad Case 和上线回归门禁?
- 是否按目标 Elasticsearch 版本验证 Retriever 语法?
- 查询是否只访问稳定 Alias?
- 结果是否携带
chunk_id、knowledge_id和source_revision? - 是否明确 Hybrid Search 不承担深分页与全量扫描?
- 降级状态是否会出现在响应和 Trace 中?
总结
Hybrid Retrieval 的真正价值,不是把两个检索器放在同一个请求里,而是把检索质量拆成可以独立优化的阶段:
Filter
→ 决定哪些文档有资格参与
BM25 / Dense / Sparse
→ 用互补信号扩大候选覆盖
RRF / Linear
→ 解决多路候选如何融合
Reranker
→ 选择真正可回答的证据
MMR / Dedup
→ 控制重复和来源偏置
Context Pack
→ 在 Token 预算内组织引用证据
Trace / Eval
→ 让每次优化可解释、可比较、可回归最重要的工程判断是:
第一阶段不要急着“选得最准”,而要保证正确证据进入候选集;第二阶段再用融合、重排和多样性控制,把有限上下文留给真正能回答问题的证据。
官方资料
- Elastic:Hybrid search
- Elastic:Retrievers overview
- Elastic:Retriever reference
- Elastic:RRF retriever
- Elastic 8.19:Retriever API
- Elastic:kNN search
- Elastic:kNN retriever
- Elastic:Text similarity re-ranker retriever
- Elastic:Diversify retriever
- Elastic:Linear retriever
- Elastic:Ranking evaluation
- Cormack、Clarke、Büttcher:Reciprocal Rank Fusion
讨论
继续讨论这篇笔记
有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。