3、ES高级查询:布尔查询(bool)、过滤上下文(filter)、评分机制(TF-IDF/BM25)、聚合分析(terms、avg、cardinality)

好,咱们今天聊点硬核的。ES 的查询,说白了就两大块:查得准不准查得快不快。而布尔查询、过滤上下文、评分机制和聚合分析,正是解决这些问题的核心武器。我在项目中见过太多人只会用 matchterm,一遇到复杂业务就抓瞎。嗯,今天咱们就把这些坑填上。

3.1 布尔查询(bool):组合查询的瑞士军刀

布尔查询,就是把你多个查询条件组合起来。它不像 SQL 里用 ANDOR 那么简单粗暴,而是提供了四种更精细的关系:mustshouldmust_notfilter

我个人习惯把 bool 查询当作主查询的外壳。比如你要搜“价格低于 100 且评分高于 4.0 的电子产品”,用 bool 组合就非常清晰。

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "category": "电子产品" } }
      ],
      "filter": [
        { "range": { "price": { "lte": 100 } } },
        { "range": { "rating": { "gte": 4.0 } } }
      ]
    }
  }
}

你想想看,must 里的条件会参与评分,而 filter 里的条件只负责过滤,不参与评分。这有什么好处?我待会讲过滤上下文时会细说。

小技巧: 如果你确定某个条件不需要影响相关性排序,比如“状态=已上架”,就把它扔进 filter 里。这样查询更快,评分也更纯粹。

3.2 过滤上下文(filter):不评分的精准打击

过滤上下文,说白了就是“只要结果,不要分数”。ES 在处理 filter 时,会做两件聪明的事:

  • 不计算评分:省去了 TF-IDF 或 BM25 的计算开销。
  • 缓存结果:相同的过滤条件,第二次查询直接走缓存。

我曾经在一个日志分析项目里,用 filter 替代了所有 must 里的范围查询,查询速度提升了将近 40%。为什么?因为日志查询里,时间范围这种条件根本不需要评分,你只需要“在不在这个时间段内”。

注意: 别把 filtermust_not 搞混了。must_not 也是过滤,但它会检查文档是否匹配,然后排除掉。而 filter 只是保留匹配的文档。两者都不影响评分,但语义不同。

3.3 评分机制:TF-IDF 与 BM25 的较量

ES 默认的评分算法,从 5.0 版本开始就换成了 BM25。为什么?因为 TF-IDF 有个明显的缺陷:词频过高时,分数会无限增长。你想想看,一篇文章里“的”字出现 100 次,难道它就更相关吗?显然不是。

BM25 引入了两个关键参数:k1b。简单说:

  • k1:控制词频对评分的影响。值越大,词频影响越大。默认 1.2。
  • b:控制文档长度对评分的影响。值越大,短文档越占便宜。默认 0.75。

我记得有一次做电商搜索,发现长标题的商品总是排在前面,因为关键词出现次数多。后来我把 b 调到了 0.3,短标题的商品终于有了出头之日。

PUT /my_index/_settings
{
  "index": {
    "similarity": {
      "default": {
        "type": "BM25",
        "k1": 1.2,
        "b": 0.75
      }
    }
  }
}
核心观点: 评分不是玄学,它是可调、可解释的。如果你发现搜索结果不符合预期,先别急着改数据,试试调 BM25 参数。

3.4 聚合分析:从数据中挖出金矿

聚合分析,就是 ES 里的 GROUP BY。但它比 SQL 的 GROUP BY 强大得多,因为它支持嵌套、过滤、甚至脚本计算。

3.4.1 terms 聚合:分组统计

最常用的聚合,就是按某个字段的值分组,统计每个分组的文档数。比如统计每个分类下的商品数量:

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

这里有个坑:terms 聚合默认只返回前 10 个分组。如果你有 100 个分类,只看到前 10 个,很容易误判。我建议在测试时把 size 设大一点,或者用 order 排序。

3.4.2 avg 聚合:平均值计算

算平均值,比如每个分类下的平均价格:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category.keyword",
        "size": 10
      },
      "aggs": {
        "avg_price": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}

你看,聚合可以嵌套。先按分类分组,再在每个分组里算平均价格。这种嵌套结构,是 ES 聚合的精髓。

3.4.3 cardinality 聚合:去重计数

去重计数,类似 SQL 里的 COUNT(DISTINCT)。比如统计有多少个不同的品牌:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "unique_brands": {
      "cardinality": {
        "field": "brand.keyword"
      }
    }
  }
}
性能警告: cardinality 聚合在数据量很大时,会消耗较多内存。ES 默认使用 HyperLogLog 算法,精度可调。如果你不需要精确值,可以设置 precision_threshold 来平衡精度和性能。

3.5 实战组合:布尔查询 + 聚合分析

最后,咱们来个综合案例。假设你要分析“价格低于 200 元的电子产品”中,每个品牌的平均评分和商品数量:

GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "range": { "price": { "lte": 200 } } },
        { "term": { "category": "电子产品" } }
      ]
    }
  },
  "size": 0,
  "aggs": {
    "by_brand": {
      "terms": {
        "field": "brand.keyword",
        "size": 20
      },
      "aggs": {
        "avg_rating": {
          "avg": {
            "field": "rating"
          }
        }
      }
    }
  }
}

这个查询,先用 boolfilter 快速过滤出符合条件的文档,然后对结果进行聚合分析。整个过程,过滤不评分,聚合不扫描全表,效率非常高。

我的经验: 在实际项目中,我通常会把 queryaggs 分开写。先调试 query 确保结果正确,再添加 aggs。这样排查问题更快。

好了,这一章的内容就到这里。布尔查询、过滤上下文、评分机制、聚合分析,这四个东西组合起来,基本能覆盖 90% 的搜索推荐场景。下一章,咱们聊聊更高级的玩法——嵌套查询与父子文档。到时候见。