最近在优化一个全文检索业务时,遇到了一个典型的排序场景:用户要求搜索结果首先按相关性排序,相关性相同时按创建时间倒序。听起来很简单对吧?但当我真正上手写 DSL 时,才发现 mustfiltershould 以及 sort 之间的微妙关系远比想象中复杂。

研究发现了 term 查询在 mustfilter 中的评分差异、terms 为何默认得分恒定、以及如何利用 bool + should + sort 实现“硬过滤 + 软评分”的混合排序。

问题背景

业务场景是文章搜索,核心需求如下:

  1. 硬性筛选:只返回状态为 published、且创建时间在某个时间点之后的文章。
  2. 相关性排序:根据用户输入的关键词(如标签、作者)计算匹配度,分数越高越靠前。
  3. 时间兜底:当两篇文章的相关性分数完全一致时,最新的文章排在前面。

直觉上,这个需求可以拆解为 filter 做硬过滤,should 做加分项,最后 sort_scorecreateTime 排序。但在实际测试中,发现 _score 的权重似乎“不听话”,甚至有时候加了 sort 后,_score 直接“失效”了。

核心机制梳理:评分与排序的底层逻辑

在解决问题之前,有必要先理清 Elasticsearch 的几个基础机制。

1. mustfilter:评分与不评分的分水岭

  • must:必须匹配,且参与评分。每个 must 子句都会基于 BM25 计算文档的相关性得分,所有得分相加得到最终 _score
  • filter:必须匹配,但不参与评分。它只做二值判断(匹配/不匹配),结果会被缓存,性能极高。

所以,如果需要用 term 做精确匹配且不关心它对排序的影响,优先用 filter

2. term vs terms:评分策略完全不同

  • term:计算 BM25 相关性分数。词频(TF)、逆文档频率(IDF)、字段长度都会影响得分。
  • terms:默认采用 常量分数(Constant Score),所有匹配文档得分均为 1.0。这是为了性能考虑,因为 terms 通常用于筛选(如 status in ["active", "pending"]),而非排序。

如果想让 termsterm 一样计算分数,需要设置 "rewrite": "scoring_boolean",将其改写为 bool + should 的形式。

3. sort 的“覆盖”效应

一旦在查询中显式指定 sort 字段,排序将完全由该字段决定_score 只被计算但被忽略。默认情况下,_score 甚至不会返回(显示为 null 或 0)。

如果需要在排序后仍然看到 _score,可以加 "track_scores": true,但这只影响返回值,不影响排序。

最终解决方案:filter + should + 二级排序

经过多次尝试,最终采用了以下结构,完美解决了需求:

{
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "published" } },
        { "range": { "createTime": { "gte": "2025-01-01" } } },
        { "terms": { "tags": ["elasticsearch", "search"] } },
        { "term": { "author": "john" } }
      ],
      "should": [
        { "term": { "tags": "elasticsearch" } },
        { "term": { "tags": "search" } },
        { "term": { "author": "john" } }
      ],
    }
  },
  "sort": [
    { "_score": { "order": "desc" } },
    { "createTime": { "order": "desc" } }
  ]
}

执行逻辑拆解

  1. filter:先过滤出符合条件的文档,这部分结果会被缓存,不参与评分。
  2. should:在过滤后的结果集上,计算每个文档匹配了多少个 should 条件,匹配越多,_score 越高。
  3. sort
    • 第一优先级:_score 降序,相关性高的排前面。
    • 第二优先级:createTime 降序,分数相同时新文章优先。

不同场景下的排序表现

匹配 should 数量 _score 范围 createTime 最终排名
3 个 最高 任意 第 1 名
2 个 中等 最新 第 2 名
1 个 较低 最新 第 3 名(分数优先于时间)
0 个 0 最新 最后(分数为 0,按时间倒序)

这个逻辑很好地满足了“相关性优先,时间辅助”的需求。

性能与最佳实践

  1. 能用 filter 就别用 must:对于状态、分类、ID 范围等硬性条件,用 filter 更高效,且不影响相关性。
  2. terms 默认不评分:如果希望 terms 参与评分,记得加 rewrite 参数,但要评估性能开销。
  3. sort 会覆盖 _score 排序:如果希望混合排序,要么用二级排序,要么用 function_score,不要试图在 sort 里同时兼顾。
  4. 调试时善用 explain:加 "explain": true 可以详细查看每个文档的分数计算过程,是排查评分问题的利器。

总结

这次排查的核心收获是:Elasticsearch 的排序和评分是两个独立的阶段。查询阶段计算出 _score,排序阶段决定最终顺序。理解 must / filter / should / sort 各自的职责边界,才能在复杂业务中写出既准确又高性能的查询。

最终采用的 filter + should + 二级排序方案,既保证了过滤条件的缓存效率,又实现了相关性优先的排序需求,同时兼顾了时间兜底。如果未来业务需要更复杂的“时间权重”融合,function_score 会是更灵活的选择。