Chico Notes
LLM Wiki / RAG

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

其中有五条关键原则:

  1. 权限、租户、状态和版本过滤必须在召回阶段生效。
  2. 第一阶段追求 Recall,第二阶段再追求 Precision。
  3. BM25 与向量分数不能默认直接比较,RRF 是更稳妥的基线。
  4. Reranker 只能重新排序已有候选,不能修复漏召回。
  5. 每一路候选、排名、过滤原因和阶段延迟都必须进入 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_RESET
  • kb_gateway_wiki_v2
  • speech-2.6-turbo
  • scene_001
  • 人名、公司名、接口名
  • 合同条款编号
  • 代码符号与配置项
  • 用户输入中必须精确保留的短语

这类查询的共同特点是:词本身就是强信号

2.2 Dense Vector 擅长什么

Dense Embedding 更适合:

  • “为什么语音 Agent 会抢话”
  • “怎样避免搜索索引被旧消息覆盖”
  • “用户换一种说法,但想问同一个概念”
  • 中英文或不同术语之间的语义映射
  • 原文没有出现问题中的精确词,但含义相近

它的强项是语义泛化,但也容易出现:

  • 数字与版本号区分弱;
  • 相似主题不等于包含答案;
  • 短文本向量容易受通用语义干扰;
  • 不相关但语气相似的内容进入 Top-K;
  • 不同 Embedding 模型的向量空间不可混用。

2.3 查询类型并不相同

建议把评测集至少拆成以下查询类型:

查询类型例子主要信号
Exact IDscene_001 在哪里使用keyword / term
Error Code1205 lock wait timeoutBM25 / phrase
Entity齐夏第一次出现在哪一集BM25 + metadata
Concept为什么向量库不是 Source of TruthDense + 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_id
  • acl_groups
  • doc_status
  • is_enabled
  • knowledge_base_id
  • language
  • updated_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 体现了三个层级:

  1. multi_match 负责广义词项召回;
  2. termmatch_phrase 提升精确命中;
  3. 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:knum_candidatessimilarity 分别控制什么

一个 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_score

BM25 分数会随查询、语料、字段和 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 RankVector RankRRF Score
A141/61 + 1/64
B2未出现1/62
C811/68 + 1/61
D未出现21/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_constantrank_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 k50–100语义候选
Dense num_candidates100–500按分片数、过滤选择性和 Recall 调优
RRF rank_window_size50–150必须覆盖希望进入 Rerank 的候选
Rerank Window30–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 后,不应再并列使用这些顶层元素:

  • query
  • knn
  • search_after
  • terminate_after
  • sort
  • rescore

需要重排时,应使用 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_id
  • acl_groups
  • is_enabled
  • doc_status
  • knowledge_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_1205TDW 和“分区删除”等精确信号。

推荐保留双查询:

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

相关内容可以继续阅读:


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
}

融合后按以下步骤处理:

  1. 先以 Child Rank 完成 BM25、kNN 和 RRF;
  2. 回填 Parent;
  3. 同一 Parent 的多个 Child 合并证据信号;
  4. 保留命中 Child 的位置和分数用于引用;
  5. Reranker 输入可以是 Parent,也可以是 Child + Parent 摘要;
  6. 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 TTFT

Elasticsearch 返回的 took 只覆盖集群内部搜索时间,不包含:

  • Embedding API 网络时间;
  • 应用排队;
  • Reranker;
  • 数据库回填;
  • JSON 编解码;
  • 连接池等待;
  • 跨地域网络;
  • LLM 生成。

一份示例预算:

阶段P95 目标示例
Query Rewrite80 ms
Embedding40 ms
BM25 + kNN + RRF100 ms
回填与去重30 ms
Rerank120 ms
Context Pack20 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_timeout

21. 面向 MySQL + Elasticsearch 的推荐实现

组件责任:

组件负责什么
Scope Resolver租户、权限、KB、语言、状态和版本范围
Query Classifier判断 Exact / Entity / Concept / Multi-hop
Embedding Gateway模型路由、缓存、批量、超时和 Model Key
ElasticsearchBM25、kNN、Filter 和初步融合
Hydrator回填完整 Chunk、Parent、引用和当前 Revision
Rerank Gateway模型调用、窗口控制、超时和回退
Dedup / MMR文档、Parent、版本和语义去重
Context PackerToken Budget、来源顺序和引用编号
Eval PipelineGolden 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
  • knum_candidatessimilarity 是否通过评测调优?
  • 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_idknowledge_idsource_revision
  • 是否明确 Hybrid Search 不承担深分页与全量扫描?
  • 降级状态是否会出现在响应和 Trace 中?

总结

Hybrid Retrieval 的真正价值,不是把两个检索器放在同一个请求里,而是把检索质量拆成可以独立优化的阶段:

Filter
→ 决定哪些文档有资格参与

BM25 / Dense / Sparse
→ 用互补信号扩大候选覆盖

RRF / Linear
→ 解决多路候选如何融合

Reranker
→ 选择真正可回答的证据

MMR / Dedup
→ 控制重复和来源偏置

Context Pack
→ 在 Token 预算内组织引用证据

Trace / Eval
→ 让每次优化可解释、可比较、可回归

最重要的工程判断是:

第一阶段不要急着“选得最准”,而要保证正确证据进入候选集;第二阶段再用融合、重排和多样性控制,把有限上下文留给真正能回答问题的证据。

官方资料

讨论

继续讨论这篇笔记

有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。

On this page

一句话结论1. 先看完整架构2. 为什么单一路径一定会遇到边界2.1 BM25 擅长什么2.2 Dense Vector 擅长什么2.3 查询类型并不相同3. Mapping 是检索质量的第一层3.1 为什么保留 retrieval_text3.2 不要把权限藏进动态 metadata4. BM25 通道不要只写一个 match4.1 Exact Query 应该有旁路4.2 BM25 的失败不只来自算法5. Dense kNN:knum_candidatessimilarity 分别控制什么5.1 k5.2 num_candidates5.3 similarity5.4 Filter 是 Pre-filter,不是普通 Post-filter6. 三种融合方式不能混为一谈6.1 顶层 query + knn:分数求和6.2 RRF:融合排名,不直接比较原始分数6.3 Linear Fusion:归一化后做加权求和7. rank_constantrank_window_size 怎么理解7.1 rank_constant7.2 rank_window_size8. Elasticsearch 8.16+:使用 Retriever API8.1 兼容 8.19 思路的等权 RRF8.2 当前 9.x 文档中的加权 RRF8.3 Retriever 不是普通 Query 的附加字段9. 旧版本或跨后端系统:在应用层做 RRF10. Filter 应该放在哪里10.1 所有通道共享的硬过滤10.2 某一路独有的过滤10.3 软条件不要伪装成权限11. Reranker:从“相关”推进到“可回答”11.1 当前 Elasticsearch 的 Text Similarity Reranker11.2 Reranker 的候选窗口11.3 Rerank 必须有降级12. MMR:避免十个结果都在重复同一段话13. Query Rewrite 不要破坏原始检索信号14. Parent-Child Chunk 如何参与 Hybrid Search14.1 Parent 分数怎么合并15. 深分页不是 Hybrid Relevance 的主要用途16. Explain、Profile 与 Trace 应该记录什么17. 评测要按阶段做,而不是只看最终答案17.1 Candidate Generation17.2 Fusion 与 Ranking17.3 Rerank 与 Context17.4 End-to-End18. 一份最小 Golden Set19. 延迟预算:不要只优化 Elasticsearch Took20. 失败降级矩阵21. 面向 MySQL + Elasticsearch 的推荐实现22. 分阶段落地方案P0:先建立可解释 BaselineP1:优化质量与成本P2:高级排序23. 十个常见反模式反模式一:只有向量召回反模式二:直接把 BM25 与向量 _score 相加反模式三:RRF rank_window_size 等于最终 size反模式四:权限只加在 BM25 Query反模式五:让 Reranker 修复漏召回反模式六:Reranker 失败就返回空反模式七:相邻 Chunk 全部塞进 Prompt反模式八:只看最终答案分数反模式九:把 current 文档语法直接复制到旧版 8.x反模式十:用 Hybrid Retriever 做深分页和全量导出24. 上线检查清单总结官方资料