数据库基础体系 · 第 41/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Elasticsearch 查询与聚合:Query DSL、相关性、分页和统计

本文讨论 Elasticsearch 官方稳定版本中公开、常用的搜索语义。示例使用 Elasticsearch 8.x 风格 REST API 和 JSON Query DSL;请求通过 HTTP 发送到一个已连接的 Elasticsearch 集群。Elasticsearch 不提供关系数据库意义上的跨文档事务,示例中的写入、刷新和搜索可见性遵循其近实时模型。

一、先建立整体模型

一次 Elasticsearch 搜索通常包含以下部分:

GET /products/_search
{
  "query": { ... },
  "sort": [ ... ],
  "from": 0,
  "size": 10,
  "aggs": { ... }
}

它们分别解决不同问题:

  • query:哪些文档匹配,以及匹配文档的相关性分数是多少。
  • sort:结果按照什么顺序返回。
  • fromsize:返回哪一页、多少条文档。
  • aggs:对匹配文档进行分组、计数、求和、平均值等统计。
  • track_total_hits:是否精确计算匹配总数。
  • search_after、PIT:在深分页和一致遍历场景中控制位置与快照。

搜索过程并不是“在一个全局表上执行一次查询”。对于含有多个主分片的索引,协调节点通常会:

  1. 接收客户端请求;
  2. 将请求转发到相关分片;
  3. 每个分片本地执行查询、排序和聚合;
  4. 协调节点合并各分片的结果;
  5. 返回最终文档、总命中数、聚合结果和耗时等信息。

因此,相关性分数、分页边界、聚合计数和结果一致性都必须结合“分片级执行、协调节点合并”来理解。

1. 近实时,而不是事务立即可见

文档写入后,数据首先进入事务日志和内存缓冲区。经过 refresh 后,新的 Lucene segment 才能被搜索看到。默认情况下,索引通常是近实时的,而不是每次写入都立即可见。

可以在测试中显式刷新:

POST /products/_refresh

这适合测试验证,不应简单地把每次写入后的强制 refresh 当作生产方案,因为频繁 refresh 会增加 segment 创建和合并压力。

单次写入也可以使用:

PUT /products/_doc/1?refresh=wait_for
{
  "name": "Elasticsearch 实战",
  "category": "book",
  "price": 88
}

refresh=wait_for 会等待下一次 refresh 后再返回,便于需要“写入后立即搜索”的请求,但它仍然不是跨文档事务,也不会把多个写入变成原子操作。


二、Query DSL:用 JSON 表达搜索条件

Query DSL 是 Elasticsearch 的 JSON 查询语言。一个查询通常是一个查询对象,例如:

{
  "match": {
    "title": "分布式搜索"
  }
}

查询可以嵌套组合,形成复杂条件:

{
  "bool": {
    "must": [
      {
        "match": {
          "title": "Elasticsearch"
        }
      }
    ],
    "filter": [
      {
        "term": {
          "status": "published"
        }
      }
    ]
  }
}

要正确理解 Query DSL,必须先区分字段类型、分词和查询方式。

1. textkeyword

一个典型 Mapping 可能如下:

PUT /products
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "category": {
        "type": "keyword"
      },
      "description": {
        "type": "text"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "published_at": {
        "type": "date"
      },
      "sales": {
        "type": "integer"
      }
    }
  }
}

这里有两个不同用途的字段:

  • titletext,用于全文检索,会经过 analyzer 分词;
  • title.keyword 是多字段中的 keyword,保留完整字符串,适合精确过滤、排序和聚合;
  • categorykeyword,适合精确值匹配;
  • pricesales 是数值字段;
  • published_at 是日期字段。

例如,文档中的:

{
  "title": "Elasticsearch 查询基础",
  "category": "database"
}

title 可能被分析为若干词项,而 title.keyword 通常仍是完整值 "Elasticsearch 查询基础"。实际词项取决于 Mapping 中配置的 analyzer、语言分析器和版本行为,不能仅凭原始字符串猜测。

可以用 _analyze 检查分析结果:

POST /products/_analyze
{
  "analyzer": "standard",
  "text": "Elasticsearch 查询基础"
}

查询时使用的分析过程必须和索引时的字段设计相匹配。一个字段如果被设计成全文字段,却使用 term 查询原始长句,往往查不到结果。


三、全文查询与精确查询

1. match:对输入文本进行分析

match 是常用的全文查询:

GET /products/_search
{
  "query": {
    "match": {
      "title": "分布式 搜索"
    }
  }
}

对于 text 字段,Elasticsearch 会对查询文本执行相应的搜索 analyzer,然后根据得到的词项查找倒排索引。默认情况下,多个词项通常按 OR 逻辑参与匹配;也可以明确要求全部词项匹配:

{
  "match": {
    "title": {
      "query": "分布式 搜索",
      "operator": "and"
    }
  }
}

operator: and 表示文档必须同时包含分析后的各个词项,但不等于短语查询。词项可以出现在不同位置。

2. match_phrase:要求词项按相邻顺序出现

GET /products/_search
{
  "query": {
    "match_phrase": {
      "title": "分布式 搜索"
    }
  }
}

它要求分析后的词项以相应顺序、相邻位置出现,除非设置 slop 允许一定距离:

{
  "match_phrase": {
    "title": {
      "query": "分布式 搜索",
      "slop": 2
    }
  }
}

match_phrase 不是字符串的字节级相等判断。它仍然受 analyzer、词项位置和字段类型影响。

3. term:不分析查询值

term 查询用于精确匹配一个已存在的索引词项:

GET /products/_search
{
  "query": {
    "term": {
      "category": "database"
    }
  }
}

对于 keyword、数值、日期等字段,term 通常适合过滤。对 text 字段使用 term 是常见错误:

{
  "term": {
    "title": "Elasticsearch 查询基础"
  }
}

如果 title 建立倒排索引时已经被拆成多个词项,那么完整字符串通常不是一个词项,这个查询可能返回 0 条结果。若需要匹配完整原始值,应使用 title.keyword

{
  "term": {
    "title.keyword": "Elasticsearch 查询基础"
  }
}

大小写也会影响 term 查询。term 不会替你执行通常意义上的全文分析、大小写归一化或分词。

4. 范围查询

数值、日期和可排序字段可以使用 range

GET /products/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 50,
        "lt": 100
      }
    }
  }
}

边界含义如下:

  • gte:大于等于;
  • gt:大于;
  • lte:小于等于;
  • lt:小于。

日期示例:

{
  "range": {
    "published_at": {
      "gte": "now-30d/d",
      "lt": "now"
    }
  }
}

日期数学表达式的解析依赖字段类型和请求时间。需要可重复测试时,应尽量使用固定时间,而不是直接依赖 now


四、Bool 查询:把条件组合成逻辑树

bool 查询是 Query DSL 中最重要的组合结构之一:

{
  "bool": {
    "must": [],
    "filter": [],
    "should": [],
    "must_not": [],
    "minimum_should_match": 1
  }
}

四类子句含义不同。

1. must

must 中的查询必须匹配,并参与相关性评分:

{
  "bool": {
    "must": [
      {
        "match": {
          "title": "Elasticsearch"
        }
      }
    ]
  }
}

2. filter

filter 中的查询必须匹配,但不参与相关性评分:

{
  "bool": {
    "must": [
      {
        "match": {
          "title": "Elasticsearch"
        }
      }
    ],
    "filter": [
      {
        "term": {
          "category": "database"
        }
      },
      {
        "range": {
          "price": {
            "lte": 100
          }
        }
      }
    ]
  }
}

这个查询表达的是:

匹配文档=全文匹配 titlecategory = databaseprice100\text{匹配文档} = \text{全文匹配 title} \land \text{category = database} \land \text{price} \le 100

其中,只有 title 的全文查询影响 _score。分类和价格只是资格条件。

把稳定的结构化条件放在 filter 中,通常更符合语义,也避免让价格、状态等条件改变文本相关性。缓存是否生效、如何生效由 Elasticsearch 和底层实现决定,不能把 filter 简化为“必然缓存”。

3. must_not

must_not 中的查询不能匹配:

{
  "bool": {
    "must_not": [
      {
        "term": {
          "status": "deleted"
        }
      }
    ]
  }
}

must_not 是过滤性质的排除条件,不用于给文档增加正向相关性分数。

4. should

should 表示可选的、通常用于提高相关性的条件:

{
  "bool": {
    "must": [
      {
        "match": {
          "description": "搜索"
        }
      }
    ],
    "should": [
      {
        "match": {
          "title": {
            "query": "搜索",
            "boost": 3
          }
        }
      },
      {
        "term": {
          "title.keyword": {
            "value": "搜索",
            "boost": 5
          }
        }
      }
    ]
  }
}

此时文档只要满足 must 就可能匹配,满足 should 的文档会获得额外分数。

should 是否变成必选条件,取决于它和 mustfilter 的组合方式。工程上不要假设“只要写了 should 就必须满足”。如果必须至少满足若干个 should 条件,应明确设置:

{
  "bool": {
    "should": [
      { "term": { "category": "database" } },
      { "term": { "category": "search" } }
    ],
    "minimum_should_match": 1
  }
}

如果 bool 中已经存在 mustfiltershould 默认通常是可选的;需要强制约束时,显式写 minimum_should_match 更清楚。


五、相关性:_score 是如何产生的

相关性是搜索结果“为什么排在前面”的机制。全文查询通常会给每个匹配文档计算一个 _score,再按分数降序返回。

1. BM25 的基本形式

Elasticsearch 默认常见的文本相似度算法是 BM25。可以将单个查询词项对文档的贡献近似表示为:

score(q,d)=IDF(q)f(q,d)(k1+1)f(q,d)+k1(1b+bdavgdl)\text{score}(q,d) = \operatorname{IDF}(q) \cdot \frac{f(q,d)(k_1+1)} {f(q,d)+k_1\left(1-b+b\cdot\frac{|d|}{\operatorname{avgdl}}\right)}

其中:

  • qq:查询词项;
  • dd:文档;
  • f(q,d)f(q,d):词项 qq 在文档 dd 中出现的次数;
  • d|d|:文档长度,通常指字段中的词项数量;
  • avgdl\operatorname{avgdl}:该字段所有文档的平均长度;
  • k1k_1:词频饱和参数;
  • bb:文档长度归一化参数;
  • IDF(q)\operatorname{IDF}(q):逆文档频率,越稀有的词通常越重要。

直觉分三步:

  1. 一个词在文档中出现,文档获得分数;
  2. 出现次数增加会提高分数,但收益逐渐递减;
  3. 在较长文档中出现同样次数,贡献通常会因长度归一化而降低;
  4. 只在少数文档中出现的词比出现在大量文档中的词更有区分度。

多个查询词的得分通常会组合起来。精确组合公式会受查询类型、协调方式、是否使用短语、boost 等因素影响,因此不能把 _score 当作概率或百分制相关度。

2. 一个完整算例

假设字段中有三个文档,分析后得到以下词项:

文档 词项
A elasticsearch, query, query
B elasticsearch, query, aggregate, index
C database, index

查询词为 query elasticsearch

第一步,匹配集合是 A 和 B,C 不包含这两个词项。

第二步,统计文档频率:

  • query 出现在 A、B,共 2 篇;
  • elasticsearch 出现在 A、B,共 2 篇;
  • 总文档数为 3。

因此,两者的 IDF 都低于只在一篇文档中出现的词,例如 aggregate

第三步,比较词频和长度:

  • A 中 query 出现 2 次;
  • B 中 query 出现 1 次;
  • B 的字段更长,长度归一化可能使其单次词项贡献降低。

A 可能因为 query 的更高词频获得更高分,但 BM25 的词频收益是饱和的,并不意味着出现两次就获得两倍分数。实际数值还受 analyzer、相似度配置、分片局部统计和查询结构影响。

3. 分片会影响相关性统计

倒排索引在每个分片上独立维护。默认搜索时,每个分片可能基于自己的文档频率计算 IDF。某个词在分片 1 很稀有、在分片 2 很常见时,同一个词可能在不同分片得到不同分数。

可以使用:

GET /products/_search?search_type=dfs_query_then_fetch
{
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  }
}

这个搜索类型会先收集更全面的词项统计,再执行查询,以改善分片间相关性统计的一致性,但会增加一次协调阶段和网络成本。它不是所有相关性问题的解决方案,也不能替代合理的分片设计。

4. boostconstant_scorefunction_score

boost 用于表达业务上的相对偏好:

{
  "match": {
    "title": {
      "query": "Elasticsearch",
      "boost": 2
    }
  }
}

它改变的是相关性组合中的权重,不是“增加 2 分”的固定含义。

如果只需要“满足条件即可”,可以使用 constant_score

{
  "constant_score": {
    "filter": {
      "term": {
        "status": "published"
      }
    },
    "boost": 1
  }
}

所有匹配文档获得相同的基础分数。若查询中只有过滤条件,使用:

{
  "bool": {
    "filter": [
      { "term": { "status": "published" } }
    ]
  }
}

通常更直接。

如果需要把数值字段、衰减函数或自定义函数加入相关性,可使用 function_score。例如按评分排序时,仍应确认数值字段的业务含义、缺失值和函数对极端值的影响,而不能把“业务权重”与文本相关性混成一个未经验证的黑盒分数。

5. 相关性诊断:_explain 和 Profile

针对单个文档查看匹配原因:

GET /products/_explain/1
{
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  }
}

响应会说明:

  • 文档是否匹配;
  • 总分如何由子查询组成;
  • 哪些词项命中了;
  • 词频、文档频率等因素如何影响分数。

_explain 适合单文档诊断,不适合对大批量文档循环调用。

分析查询执行阶段可以使用:

GET /products/_search
{
  "profile": true,
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "Elasticsearch" } }
      ],
      "filter": [
        { "term": { "category": "database" } }
      ]
    }
  }
}

Profile 输出的是查询执行细节和耗时分解。它本身会增加执行开销,且测量环境与生产环境可能不同,不能直接将 Profile 数值当作端到端用户延迟。


六、分页:from/sizesearch_after 与 PIT

分页不仅是“跳过几条数据”,还涉及排序、分片协调、数据变化和资源消耗。

1. fromsize

最简单的分页:

GET /products/_search
{
  "from": 0,
  "size": 20,
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  },
  "sort": [
    { "_score": "desc" },
    { "_id": "asc" }
  ]
}

第二页:

{
  "from": 20,
  "size": 20
}

在分布式搜索中,每个分片不能只返回最终页面的 20 条。假设协调节点需要全局第 21~40 条,那么每个分片至少要提供足够多的候选结果,协调节点才能合并排序。因此,from + size 越大,分片侧候选集和协调开销通常越大。

Elasticsearch 对普通深分页设置了 index.max_result_window 限制,默认常见值为 10000。修改这个限制只是扩大资源消耗上限,不会消除深分页的代价。

2. 排序必须稳定

只按 _score 排序时,多个文档可能分数相同;只按字段排序时,也可能存在相同值。分页时如果排序键不唯一,文档可能在页与页之间重复或遗漏。

可以增加唯一的 tie-breaker:

"sort": [
  { "published_at": "desc" },
  { "_id": "asc" }
]

但要注意,_id 虽然通常可用于排序和提供稳定次序,但在大规模场景中,专门建立一个具有 doc_values 的唯一关键字段通常更适合作为排序字段。排序字段必须在索引 Mapping 和查询设计阶段准备好。

3. search_after

search_after 不使用页码,而是使用上一页最后一条文档的排序值作为游标:

第一页:

GET /products/_search
{
  "size": 20,
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  },
  "sort": [
    { "published_at": "desc" },
    { "_id": "asc" }
  ]
}

假设最后一条结果的 sort 值为:

["2025-01-20T10:00:00.000Z", "product-123"]

下一页:

GET /products/_search
{
  "size": 20,
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  },
  "search_after": [
    "2025-01-20T10:00:00.000Z",
    "product-123"
  ],
  "sort": [
    { "published_at": "desc" },
    { "_id": "asc" }
  ]
}

search_after 数组必须与 sort 字段和顺序对应。第一条请求的排序定义不能改变,否则游标没有明确含义。

它适合:

  • 无限滚动;
  • 导出大量结果;
  • 顺序遍历结果集;
  • 避免 from 深分页的候选集增长。

它不适合天然的“跳到第 500 页”,因为游标依赖上一页的排序值。

4. PIT:固定搜索视图

如果分页期间有文档新增、更新或删除,只使用 search_after,不同请求可能看到不同的索引状态。结果可能发生变化。

PIT(Point in Time)用于在一段时间内固定一个搜索视图:

POST /products/_pit?keep_alive=2m

响应会返回一个 id。之后搜索:

GET /_search
{
  "pit": {
    "id": "返回的 PIT ID",
    "keep_alive": "2m"
  },
  "size": 20,
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  },
  "sort": [
    { "published_at": "desc" },
    { "_shard_doc": "asc" }
  ]
}

下一页继续使用响应中的 sort 值:

GET /_search
{
  "pit": {
    "id": "返回的 PIT ID",
    "keep_alive": "2m"
  },
  "size": 20,
  "search_after": [
    "2025-01-20T10:00:00.000Z",
    12345
  ],
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  },
  "sort": [
    { "published_at": "desc" },
    { "_shard_doc": "asc" }
  ]
}

使用 PIT 时,响应里的 PIT ID 可能发生变化,客户端应使用最新返回的 ID。遍历结束后主动关闭:

DELETE /_pit
{
  "id": "当前 PIT ID"
}

PIT 会保留相关搜索视图和 segment,长时间、大量并发 PIT 会增加文件句柄、磁盘和 segment 生命周期压力。keep_alive 不是越长越好,应按一次遍历的预计时间设置,并处理 PIT 过期错误。

PIT 和 search_after 解决的是搜索视图与游标问题,不是事务快照。它不会让外部数据库、多个索引或后续写入获得原子一致性。


七、总命中数与结果数量

搜索响应通常包含:

"hits": {
  "total": {
    "value": 10000,
    "relation": "gte"
  },
  "max_score": 1.42,
  "hits": []
}

total 有两种重要状态:

  • relation: "eq":返回值是精确总数;
  • relation: "gte":返回值是“至少有这么多”,达到统计上限后不再继续精确计数。

可以要求精确统计:

GET /products/_search
{
  "track_total_hits": true,
  "query": {
    "term": {
      "category": "database"
    }
  }
}

也可以设置数值上限:

{
  "track_total_hits": 10000
}

这表示最多精确计算到 10000;超过后可以返回 gte

精确总数不是免费信息。对于只需要展示“有很多结果”的页面,限制计数通常比强制精确计算更合适。size: 0 只是不返回文档本身,不代表查询和聚合没有成本。


八、聚合:对匹配文档做统计

聚合(Aggregation)是在搜索结果集合上执行的统计框架。一个请求可以同时返回文档结果和聚合:

GET /products/_search
{
  "query": {
    "match": {
      "description": "搜索"
    }
  },
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category"
      }
    },
    "price_stats": {
      "stats": {
        "field": "price"
      }
    }
  }
}

默认情况下,聚合针对 query 匹配的文档执行。size 只控制返回多少条文档,不控制聚合输入集合。

若只需要统计,不需要文档:

GET /products/_search
{
  "size": 0,
  "query": {
    "term": {
      "status": "published"
    }
  },
  "aggs": {
    "price_stats": {
      "stats": {
        "field": "price"
      }
    }
  }
}

1. Metrics 聚合

Metrics 聚合计算数值或统计指标。

minmaxavgsum

{
  "aggs": {
    "average_price": {
      "avg": {
        "field": "price"
      }
    },
    "total_sales": {
      "sum": {
        "field": "sales"
      }
    },
    "highest_price": {
      "max": {
        "field": "price"
      }
    }
  }
}

对于没有该字段值的文档,数值聚合通常不会把它作为有效数值参与计算。avg 的分母是有值文档数,而不是所有命中文档数。

stats

{
  "aggs": {
    "price_stats": {
      "stats": {
        "field": "price"
      }
    }
  }
}

结果包含:

  • count
  • min
  • max
  • avg
  • sum

例如有价格 102030,其中一个文档缺失价格,则:

count=3,sum=60,avg=20count=3,\quad sum=60,\quad avg=20

不是以全部四个文档作为分母。

cardinality

{
  "aggs": {
    "unique_categories": {
      "cardinality": {
        "field": "category"
      }
    }
  }
}

cardinality 用于估算去重后的值数量,通常基于近似算法,而不是对任意大集合执行精确集合计数。它适合高基数字段的统计,但需要理解精度和内存之间的取舍。不要把它默认当作精确的 COUNT(DISTINCT ...)

2. Bucket 聚合

Bucket 聚合把文档分到桶中,再统计每个桶的文档数或执行子聚合。

terms

GET /products/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category",
        "size": 10
      }
    }
  }
}

响应中可能包含:

"buckets": [
  { "key": "database", "doc_count": 120 },
  { "key": "search", "doc_count": 95 }
]

terms 适合对 keyword、数值等未分析字段分组。对 text 字段直接聚合通常会被禁止或不适合,因为全文字段被拆成词项,词项聚合不等于原始字符串分组。若确实需要按原始值分组,应使用 field.keyword 或专门的 keyword 字段。

terms 是分布式聚合。各分片先选取本地候选桶,再由协调节点合并。由于某个全局高频值可能在单个分片上并不突出,它可能未进入该分片的候选集合,因此返回的 doc_count 可能不是严格精确值。响应可能提供:

  • doc_count_error_upper_bound:文档数误差上界;
  • sum_other_doc_count:未返回桶中的其他文档总数。

增加 terms.shard_size 可以提高候选桶数量、改善准确性,但会增加分片侧和协调节点的内存与网络开销。size 控制最终返回桶数,shard_size 控制分片候选数量,两者不是同一个参数。

date_histogram

GET /products/_search
{
  "size": 0,
  "aggs": {
    "sales_per_day": {
      "date_histogram": {
        "field": "published_at",
        "calendar_interval": "day",
        "min_doc_count": 0
      },
      "aggs": {
        "daily_sales": {
          "sum": {
            "field": "sales"
          }
        }
      }
    }
  }
}

calendar_interval 按日历单位划分,例如天、月;fixed_interval 按固定时间长度划分。二者不能混为一谈,尤其在时区和夏令时切换环境中,日历日不一定等于固定的 24 小时。

时区可以明确指定:

{
  "date_histogram": {
    "field": "published_at",
    "calendar_interval": "day",
    "time_zone": "Asia/Shanghai"
  }
}

如果不指定业务时区,时间桶边界可能按默认时区计算,导致报表日期与业务日期不一致。

3. 嵌套聚合

聚合可以层层嵌套:

{
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category"
      },
      "aggs": {
        "avg_price": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}

执行逻辑是:

  1. category 建立桶;
  2. 将每个类别中的文档送入 avg_price
  3. 分别计算每个类别的平均价格。

这对应:

avg_price(c)=dcprice(d)#{dcprice(d) 存在}\operatorname{avg\_price}(c) = \frac{\sum_{d\in c} price(d)} {\#\{d\in c \mid price(d)\text{ 存在}\}}

它不是“所有文档平均价格”再按类别展示,而是每个桶独立计算。


九、过滤范围与聚合范围

1. query 同时影响文档结果和聚合

{
  "query": {
    "term": {
      "category": "database"
    }
  },
  "aggs": {
    "by_status": {
      "terms": {
        "field": "status"
      }
    }
  }
}

这里的 by_status 只统计 category=database 的文档。

2. post_filter 只影响返回文档

在筛选页面中,经常需要:

  • 聚合展示完整候选分布;
  • 文档列表应用用户当前选择的过滤条件。

可以使用 post_filter

GET /products/_search
{
  "query": {
    "match": {
      "description": "搜索"
    }
  },
  "post_filter": {
    "term": {
      "category": "database"
    }
  },
  "aggs": {
    "all_categories": {
      "terms": {
        "field": "category"
      }
    }
  }
}

执行语义是:

  1. query 决定基础匹配集合;
  2. 聚合基于基础匹配集合统计;
  3. post_filter 只过滤最终返回的文档命中;
  4. 因此聚合不会被 post_filter 缩小。

如果把 category 放进 query.bool.filter,聚合也会只看到该类别。二者的结果不同,这是筛选导航中最容易误用的边界之一。

3. global 聚合

如果需要在聚合中比较“当前查询结果”和“全索引基线”,可以使用 global

{
  "query": {
    "term": {
      "category": "database"
    }
  },
  "aggs": {
    "all_products": {
      "global": {},
      "aggs": {
        "by_status": {
          "terms": {
            "field": "status"
          }
        }
      }
    }
  }
}

global 桶会忽略顶层 query,统计整个索引范围内的文档;其内部仍可继续使用过滤聚合缩小范围。


十、过滤、查询和排序如何共同工作

考虑以下请求:

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "Elasticsearch 查询",
            "fields": [
              "title^3",
              "description"
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "status": "published"
          }
        },
        {
          "range": {
            "price": {
              "lte": 100
            }
          }
        }
      ]
    }
  },
  "sort": [
    "_score",
    {
      "published_at": "desc"
    },
    {
      "_id": "asc"
    }
  ],
  "size": 10
}

逐步解释:

  1. multi_matchtitledescription 中搜索文本;
  2. title^3 让标题匹配相对于描述匹配具有更高权重;
  3. statusprice 只是资格条件,不改变文本分数;
  4. 首先按 _score 降序;
  5. 分数相同时按发布时间降序;
  6. 仍相同时按 _id 升序,形成更稳定的次序。

如果改成:

"sort": [
  { "published_at": "desc" }
]

那么 _score 不再是主要排序依据。结果仍可能匹配全文查询,但“最相关”不再决定文档顺序。查询条件和排序条件是两个独立概念,不能因为使用了全文查询就假设结果一定按相关性排序。


十一、常见错误与诊断路径

1. term 查询 text 字段返回 0 条

失败表现:

GET /products/_search
{
  "query": {
    "term": {
      "title": "Elasticsearch 查询"
    }
  }
}

诊断顺序:

  1. 查看 Mapping:

    GET /products/_mapping
    
  2. 检查字段是否为 text

  3. 检查 analyzer 的词项:

    POST /products/_analyze
    {
      "field": "title",
      "text": "Elasticsearch 查询"
    }
    
  4. 如果需要全文搜索,改用 match

  5. 如果需要完整值匹配,使用 title.keyword

  6. 确认目标文档写入后已经 refresh。

2. 聚合结果缺失或报字段错误

常见原因:

  • text 字段聚合;
  • 字段在不同索引中 Mapping 不一致;
  • 字段不存在或值类型冲突;
  • 使用了错误的多字段路径;
  • 字段没有适合聚合的 doc_values

查看 Mapping:

GET /products/_mapping/field/category*

如果需要按标题原文分组,应在设计阶段建立 title.keyword,而不是在查询时试图把已经分析的词项重新拼回原文。

3. 分页重复或遗漏

可能原因:

  • 排序键有大量相同值;
  • 没有唯一 tie-breaker;
  • 分页期间发生写入、更新或删除;
  • 使用 _score,但分片局部统计变化;
  • search_after 与上一页的 sort 值不一致;
  • PIT 已过期。

处理方式:

  1. 固定完整的 sort
  2. 增加稳定且唯一的排序键;
  3. 长列表使用 search_after
  4. 需要遍历期间视图稳定时使用 PIT;
  5. 检查响应中的 sort 数组,不要自行重新计算游标。

4. 聚合总数与搜索命中总数不一致

需要区分:

  • hits.total 是匹配文档数量,可能受 track_total_hits 限制;
  • terms 的桶只返回前 size 个值;
  • sum_other_doc_count 表示未返回桶中的其他文档;
  • 分布式 terms 可能存在候选桶误差;
  • 聚合可能基于 query,也可能因 post_filterglobal 等结构而使用不同范围。

因此不能仅看到一个 doc_count 就把它理解成“整个索引中该值的绝对精确总数”。

5. 相关性看起来“不合理”

诊断步骤:

  1. _analyze 检查索引和查询文本的词项;
  2. 检查字段是否选错,例如查询了 title.keyword
  3. _explain 查看单个文档的分数组成;
  4. 检查 bool 中是否错误地使用了 filter
  5. 检查 boostminimum_should_matchmulti_match 字段权重;
  6. 检查分片数是否导致词项统计差异;
  7. 需要时比较普通搜索和 dfs_query_then_fetch 的结果;
  8. 用 Profile 分析执行路径,而不是只看总耗时。

十二、生产边界与取舍

1. 查询语义依赖索引设计

Query DSL 不能补救错误 Mapping:

  • 需要全文检索的字段应设计为 text
  • 需要精确匹配、排序和聚合的字段应设计为 keyword 或数值类型;
  • 需要两种用途时使用多字段;
  • 需要嵌套对象独立匹配时,应评估 nested 类型,而不是默认使用普通对象;
  • analyzer 一旦影响已有倒排词项,通常需要重新索引才能使新设计生效。

2. 查询超时不是回滚

搜索请求可以设置超时:

GET /products/_search?timeout=2s
{
  "query": {
    "match": {
      "description": "搜索"
    }
  }
}

超时意味着请求可以提前返回部分结果或被中止,具体响应需要结合请求状态判断。它不是关系数据库事务中的回滚,也不撤销已经完成的分片计算或之前的写入。

3. 聚合需要控制桶数量

termssize 过大、层层嵌套高基数字段,可能造成较大的堆内存、网络传输和协调开销。对于需要遍历全部唯一值的场景,应评估 composite 聚合。它通过分页方式返回组合桶,适合批量处理,而不是一次性构造所有桶。

示例:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "by_category_and_status": {
      "composite": {
        "size": 100,
        "sources": [
          {
            "category": {
              "terms": {
                "field": "category"
              }
            }
          },
          {
            "status": {
              "terms": {
                "field": "status"
              }
            }
          }
        ]
      }
    }
  }
}

若响应包含 after_key,下一页将其作为 after

{
  "size": 0,
  "aggs": {
    "by_category_and_status": {
      "composite": {
        "size": 100,
        "after": {
          "category": "database",
          "status": "published"
        },
        "sources": [
          {
            "category": {
              "terms": {
                "field": "category"
              }
            }
          },
          {
            "status": {
              "terms": {
                "field": "status"
              }
            }
          }
        ]
      }
    }
  }
}

这解决的是“分页遍历聚合桶”,与文档结果的 search_after 是两种不同机制。

4. 一致性必须明确范围

Elasticsearch 搜索的一致性至少有几个边界:

  • refresh 之前,新写入可能不可搜索;
  • 多次独立搜索可能看到不同的索引状态;
  • PIT 可以固定一个搜索视图,但不是跨系统事务;
  • 聚合和命中文档在同一次搜索请求中按该请求看到的搜索视图执行;
  • 分片失败、超时或部分结果场景必须检查响应中的分片状态,而不能只读取 hits

在生产代码中,应处理 HTTP 错误、查询解析错误、字段类型错误、超时、PIT 过期以及分片失败,并记录实际 Query DSL、索引别名、排序条件和游标信息。否则出现“结果少了”“分页跳过数据”“统计不准”时,很难还原当时的执行条件。

Elasticsearch 查询的核心不是记住若干 JSON 片段,而是明确四个集合和两种顺序:

  1. 哪些文档满足查询;
  2. 哪些条件参与分数;
  3. 聚合看到哪些文档;
  4. 返回结果按什么稳定顺序排列;
  5. 总命中数是否要求精确;
  6. 多次请求之间是否需要固定搜索视图。

Query DSL 负责表达匹配逻辑,相关性负责衡量文本匹配质量,分页负责在有序结果中定位窗口,聚合负责在匹配集合上计算统计。把这四部分和 Mapping、分片执行、refresh 边界联系起来,才能正确解释 Elasticsearch 返回的每一个结果。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。