Chapter 03
检索、相关性与聚合
上一章顺着"段不可变"讲完了写入。这一章换到另一根支柱——"分片数量固定",看一次检索怎么在多个分片上并行跑、相关性为什么会偏、以及聚合为什么不把堆撑爆。
本章你将建立的 schema
- 分片路由公式,以及"主分片数为什么创建后不能改"
- query 与 filter 的本质分野(打分 vs 是否 + 缓存),以及缓存为什么能不失效
- BM25 的三个旋钮;分布式 query_then_fetch 两阶段,以及 per-shard IDF 为什么让相同文档打分不同
- doc_values 怎么用列式结构撑起聚合 / 排序,对比 fielddata 为什么爆堆
检索路径上的每一个反直觉行为,根都扎在一句话里:数据被切成了固定数量的 shard,一次查询要在所有 shard 上并行跑、再归并。打分会偏、分片数改不了、聚合要走另一套存储——全是这句话的推论。本章五节顺着"一条查询从落点到结果"的链路走:先定位(路由),再筛选与打分(filter / query / BM25),再分布式归并(两阶段),最后是聚合走的那条独立数据通道(doc_values)。
3.1分片路由:文档落在哪个分片,分片数为什么不能改
一条文档落在哪个 shard,由一个取模公式唯一决定;而除数就是主分片数——这就是它创建后不能改的全部原因。
写入一条文档、或按 _id 读取一条文档时,Elasticsearch 不会去问每个分片"这条在不在你这",而是用一个确定性公式直接算出它该在哪个分片:
shard = hash(_routing) % number_of_primary_shards
# _routing 默认取文档的 _id
# 写入、按 id GET、按 id 删除,都用这同一个公式定位分片
公式是确定的,所以同一个 _id 永远算到同一个分片——写进 shard 1 的文档,之后按 id 取也只会去 shard 1 找,不必广播。代价是这个除数被钉死了:number_of_primary_shards(下文记作 N)一旦在建索引时定下,就不能再改。
为什么不能改?把 N 从 3 改成 4,对同一条文档,hash(_id) % 3 和 hash(_id) % 4 几乎必然得到不同的余数。换句话说,旧文档当初按"模 3"写进了 shard 1,新公式却按"模 4"去 shard 3 找它——找不到。主分片数不可变,不是一条人为限制,而是它在路由公式里当除数这一身份的直接后果:动了除数,等于把整张已经建好的映射表作废。
ES 内部其实不是直接拿 N 当除数。建索引时它把 hash 先散布到一个更大的固定空间 number_of_routing_shards(路由分片数),再映射到 N 个主分片。_split API 因此能把分片数按整数倍扩大(如 3→6)——靠硬链接底层 Lucene 文件、再删掉不属于新分片的文档,而不是真的在原地改除数。这解释了为什么 split 只能整数倍、且 number_of_routing_shards 本身也得在创建时定好。
既然主分片数定死,分片数估错了怎么补救?三条现实路径,代价递增:
| 办法 | 它怎么回应"除数不能改" | 代价 / 适用 |
|---|---|---|
_reindex 到新索引 |
新建一个 N' 不同的索引,把全部文档按新除数重新路由写一遍 | 最通用、任意改分片数;但要全量重写 + 切换别名,期间双倍存储、耗时与数据量成正比 |
_split(扩分片) |
借 routing_shards 空间,硬链接文件后按整数倍拆分 |
比 reindex 快(不重新分析文档);但只能整数倍放大,且源索引须先转只读 |
_shrink(缩分片) |
把多个分片的 segment 硬链接进一个分片,约数收缩(如 6→3 / 6→2 / 6→1) | 同样快;但要求分片先聚到同一节点、源索引只读,且目标数必须是原数的约数 |
三条路径都要求源索引进入只读或别名切换——没有一种是"在线原地改 N"。这正是为什么分片数要在建索引前就按数据规模估好:它不是一个事后可调的旋钮。
3.2query context 与 filter context
query 问"有多相关"、算出 _score;filter 问"是 / 否"、跳过打分,并把结果缓存成一个永不失效的 bitset。
同一个查询子句,放在 query context 还是 filter context,跑的是两套完全不同的逻辑。query context 要回答"这条文档跟查询有多相关",于是算出一个浮点 _score,结果按分数排序。filter context 只回答一个布尔问题——"这条文档符不符合这个条件",不打分、不参与排序。
全文搜索的核心诉求是排序(最相关的排最前),所以默认要打分。但很多条件本质是过滤——"状态是 published""时间在最近 7 天""租户 id 等于 42"。这些条件没有"相关程度"可言,对它们打分纯属浪费 CPU。filter context 就是把这类"是 / 否"条件从打分流程里摘出来,单独走一条更省、且可缓存的通路。
filter 缓存为什么能"免失效"——这是 02 章红利
filter 的结果会被缓存成一个 bitset:一个 segment 内有多少文档,就有多少个 bit,命中该 filter 的文档对应位置 1,其余置 0。下次任何查询用到同一个 filter,直接复用这个位图,连判断都省了。
关键在于这份缓存永远不需要失效。回忆 02 章那根支柱——段不可变:bitset 是按 segment 缓存的,而 segment 一旦写出就再不改动。既然底层数据不会变,"哪些文档命中这个 filter"这个答案在该 segment 的整个生命周期里恒定,缓存自然无需失效。新数据进来只会形成新的 segment,老 segment 的 bitset 原封不动继续用;老 segment 被 merge 掉时,它的 bitset 一起丢弃即可。filter 缓存的"免失效",是直接吃了 02 章"段不可变"的红利。
query context 的输出是一组浮点排名,而且依赖整个查询的词项组合(见 3.3 的 BM25);它不是一个"命中 / 不命中"的固定集合,没法压成一个可复用的位图。filter 的输出恰恰是一个稳定的文档集合——这才是它能缓存、而打分不能的根本差别。ES 还有个细节:只有当一个 filter 在"足够大的 segment 上反复被用"时才值得缓存,太小的 segment 缓存了也不划算,于是它有一套启发式决定缓存哪些。
有人把"最近 7 天"这个 range 条件从 filter 挪进了 must,功能上结果集没变。性能上会发生什么?
展开答案(先停 10 秒再点)
三件事一起变差。其一,range 进了 query context,每条命中文档都要白算一遍 _score——而时间范围本身没有"相关程度",这部分计算纯属浪费。其二,must 子句不进 filter 缓存,于是每次查询都重算这个集合,无法复用上一次的位图。其三,这个无意义的时间分量还会污染最终排序,把真正该决定排名的全文相关性稀释掉。
设计含义:凡是"是 / 否"型、与相关性无关的条件——精确 term、range、状态枚举——都应放进 filter。把它放进 must 是用三份代价换一个本不需要的分数。
| 维度 | query context | filter context |
|---|---|---|
| 核心问题 | 有多相关 | 是 / 否 |
| 是否打分 | 算 _score | 不打分 |
| 是否缓存 | 不缓存(输出是浮点排名) | 缓存为 bitset,按 segment、免失效 |
| 典型子句 | bool.must / match | bool.filter / range / term |
| 适用 | 全文相关性排序 | 精确过滤、范围、权限隔离 |
落到一个 bool 查询上,正确的分工是:相关性条件进 must(要打分),过滤条件进 filter(不打分、走缓存)。
{
"query": {
"bool": {
"must": [
{ "match": { "title": "elasticsearch 路由" } }
],
"filter": [
{ "range": { "created_at": { "gte": "now-7d/d" } } },
{ "term": { "status": "published" } }
]
}
}
}
must 全文相关性条件,参与 BM25 打分、决定排序。
filter range + term 是纯"是 / 否"判断:不打分,结果缓存成 bitset,跨查询复用且不失效。
3.3BM25:相关性怎么算出来
BM25 用三个旋钮——词频饱和、字段长度归一、逆文档频率——把"一条文档对一个查询有多相关"压成一个分数。
query context 要打分,打的就是 BM25 分。一个查询有多个词项 qᵢ,每个词项贡献一份分,加起来就是文档的 _score:
score = Σᵢ IDF(qᵢ) · ( tf · (k1 + 1) )
─────────────────────────────────────
tf + k1 · ( 1 − b + b · (fieldLen / avgLen) )
IDF(qᵢ) = log( (N − n + 0.5) / (n + 0.5) ) # N=文档总数, n=含该词的文档数
k1 = 1.2 # 词频饱和速度
b = 0.75 # 字段长度归一强度
三个旋钮各管一件事:
- 词频饱和(k1=1.2):词频
tf以tf / (tf + k1·…)的形式进入——这是一条渐近线。一个词出现第 1 次贡献很大,第 2 次还不错,到第 10 次几乎不再加分。k1控制这条曲线压平的快慢。 - 字段长度归一(b=0.75):分母里的
fieldLen / avgLen让长于平均的字段被惩罚——同样出现 3 次某词,出现在一篇万字长文里,远不如出现在一句标题里有分量。b控制惩罚强度。 - 逆文档频率 IDF:含某词的文档数
n越大,IDF越小。"的""是"这种到处都有的词权重被压到接近 0,区分度高的稀有词权重高。
BM25 自 Lucene 6 / ES 5(2016)起是默认打分。它替换的经典 TF-IDF 有两处粗糙:词频近似线性、无上界——一个词刷 100 次,分就涨 100 倍,容易被关键词堆砌骗到;而且对字段长度的归一也更原始。BM25 的词频饱和 + 长度归一让多词匹配的排序更贴近"人觉得哪条更相关"。
词频饱和让"把关键词塞进一个短字段"成为操纵排序的手段。一篇真正相关的长文,关键词密度被字段长度归一稀释;而一条把同样关键词硬塞进短标题的低质文档,fieldLen 小、惩罚轻,反而能压过长文排在前面。这不是 bug,是 b 旋钮的设计后果——3.5 节末的进阶挑战会让你拿这一点去排查"短文档总排前面"。
3.4分布式检索两阶段,与相关性偏差
一次检索分两阶段在所有分片上跑——先各自返回 top-K 的 id+score 归并,再只对胜出的 K 条去取原文。
3.1 说过数据被切成固定数量的分片。于是一次 search 不可能只问一个分片——它要在每个分片上跑,再把结果归并。默认策略叫 query_then_fetch,顾名思义两个阶段:
_source 都搬回来。阶段一(query):协调节点把查询发到每个分片,每个分片在自己的数据上算分、只返回本地 top-K 的 doc id + score(不含原文)。协调节点收齐所有分片的候选,归并出全局 top-K。阶段二(fetch):只对这 K 条,去它们各自所在的分片取完整 _source。这样网络上搬运的,第一阶段是轻量的 id+score、第二阶段只有最终 K 条原文,而不是每个分片的全部候选文档。
两个内容一模一样的文档,恰好被路由到了不同分片。同一个查询打过来,它们的 _score 会相等吗?
展开答案(先停 10 秒再点)
不一定相等,而且小集群上经常不等。原因在 BM25 的 IDF:IDF = log((N−n+0.5)/(n+0.5)) 里的 N(文档总数)和 n(含该词的文档数)是每个分片用自己本地的统计算的——阶段一各分片独立打分,谁也不知道全局词频。两个分片若文档数不同、或某词在两边的分布不同,同一个词的 IDF 就不同,于是两条内容相同的文档拿到不同的分。
这正是 dfs_query_then_fetch 要补的:它在阶段一之前多加一个 DFS 预阶段,先从所有分片收集全局词频,再让每个分片用这套统一的统计打分。代价是多一轮网络往返;收益是相同文档得到一致、更准确的分。
这是检索路径上最反直觉的一点:IDF 是每个分片各算各的。文档总量小、或路由不均(某些分片文档明显偏多偏少)时,同一个 term 在不同分片的 IDF 能差出可见的幅度,导致"两条相同文档分数不同""同一文档换个分片分布就排名跳动"。dfs_query_then_fetch 用一轮额外往返收集全局词频来消除它。好消息是:当每个分片都装满、词频统计趋于收敛,per-shard 的差异会自然变小——所以这个问题在小索引、测试数据集上最刺眼,在生产规模的大索引上往往可以忽略。
3.5doc_values 与聚合:为什么 text 不能直接聚合
聚合和排序要"按文档读字段值",方向和倒排索引正相反——doc_values 是写入时就建好的列式磁盘结构,专门提供这个反方向。
倒排索引解决的是"给一个 term,找出哪些文档含它"——方向是 term → docs。但聚合和排序问的是反方向的问题:"给一个文档,它这个字段的值是多少"——doc → value。要对 price 求平均、按 created_at 排序,都需要快速地按文档拿到字段值,而倒排索引天生不擅长这个方向。
term→docs(左),聚合 / 排序需要 doc→value(右);ES 为后者单独建了一份列式结构 doc_values,而不是在查询时硬把倒排索引反转。历史上这个反方向是用 fielddata 解决的:查询时把倒排索引临时反转成"按字段排列的数组",整个加载进 JVM 堆。字段一大,这个数组几十 GB,触发长时间 GC,严重时直接 OOM 把节点打挂。
doc_values 把这件事提前到写入时做。它在文档落盘、生成 segment 时就把每个字段写成一份列式磁盘结构(同一字段的所有值连续存放)。查询时通过操作系统的文件缓存(堆外、mmap 映射)读取,不占 JVM 堆。这就是为什么聚合 / 排序密集的负载能在一个不大的堆上跑——数据的重量压在堆外的文件缓存里,由操作系统按需换页,而不是全堆驻留。
这正是 01 章 text / keyword 分水岭的底层原因
回到 01 章那条最高频的分水岭——text 与 keyword。现在能看清它在检索路径上的后果了:
text默认没有 doc_values(它被分词、为全文检索而生,列式存原文意义不大)。所以直接对一个text字段做聚合或排序,会退回到老的 fielddata 路径——在堆上临时反转倒排索引,正是 01 章提到的那个易 OOM 的陷阱。ES 默认还把 text 的 fielddata 关掉,逼你显式开启或改用 keyword。keyword默认有 doc_values(它整体存原值、不分词)。于是它天生能聚合、能排序,走的是堆外列式读取,安全又高效。
"一个字段该用 text 还是 keyword"这道 01 章的选择题,底层就是"它要参与全文检索(走倒排)还是聚合 / 排序(走 doc_values)"。需要两者兼顾时,常见做法是一个字段配两个映射:text 主体 + 一个 .keyword 子字段。
| 维度 | doc_values | fielddata(旧) |
|---|---|---|
| 存储位置 | 堆外 · 磁盘 + OS 文件缓存(mmap) | JVM 堆内 |
| 何时构建 | 写入时(落盘进 segment) | 查询时临时反转倒排索引 |
| 主要风险 | 占磁盘 + 文件缓存,堆压力小 | 大字段几十 GB,长 GC / OOM |
| 默认开启的字段 | keyword / 数值 / 日期等 | text(且默认关闭,需显式开) |
doc_values 同时踩在两根支柱上:它是写入时建好的(02 章"段不可变"——既然 segment 不变,索引期建好的列式结构就一直有效),又是聚合在分片本地完成的基础(3.4 的两阶段里,每个分片用自己的 doc_values 算局部聚合,coordinator 再归并)。检索路径上看似无关的几处行为,回收到的是同两条公理。
§本章 self-check
先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。
- 用一句话说清"主分片数为什么创建后不能改",要点到它在路由公式里的角色。
- filter 缓存能"免失效",靠的是哪一章的哪条公理?为什么打分查询的结果缓存不了?
- BM25 的词频饱和,是一道"硬上限"还是一条"渐近线"?这个区别在排序上意味着什么?
- (设计层)什么场景下你愿意为
dfs_query_then_fetch多付一轮网络往返?什么场景下不值得?
答案(先做完再展开)
- 主分片数 N 是路由公式
hash(_id) % N里的除数;改了 N,所有已存在文档的余数都会变,旧数据再也定位不到——所以它创建后不可变。要改只能 reindex / split / shrink。 - 靠 02 章的"段不可变":bitset 按 segment 缓存,segment 永不改动,故"哪些文档命中此 filter"的答案恒定、无需失效。打分查询的输出是依赖词项组合的浮点排名、不是固定文档集合,压不成可复用的位图,所以缓存不了。
- 是渐近线,不是硬上限。词频越高,每多一次出现加的分越少,但永远不会"封顶为零增量"。意味着关键词堆砌的边际收益急剧递减,多词命中、稀有词命中比单词刷量更能拉高排名。
- 当索引文档量小、或分片间路由明显不均、且相关性精度要紧(如要给用户展示"最相关"少量结果、或做相关性回归测试)时,值得用 dfs 消除 per-shard IDF 偏差。当索引已是生产规模、每个分片都装满、词频统计趋于收敛时,偏差本就很小,多一轮往返不划算——直接用默认
query_then_fetch。
短文档总排在前面,给出两个排查方向
一个搜索结果里,几条明显不相关的短文档(标题里硬塞了查询关键词)总是压过真正相关的长文排在最前。只用本章讲过的 BM25 三个旋钮 + 分片 IDF,给出两个独立的排查 / 调整方向。
提示(卡住再展开)
方向一落在 b(字段长度归一)上:短文档之所以占便宜,正是因为 fieldLen 小、惩罚轻。想一想调高还是调低 b 会加重 / 减轻这种偏向,以及它的副作用。
方向二落在分片 IDF 上:如果数据量不大或分片不均,某些词在个别分片的 IDF 被局部抬高,会让本地的短文档分数虚高。想一想 3.4 哪个检索策略能把打分统一到全局词频,从而排除"分片偏差在作祟"这一项。