Elasticsearch 聚合搜索
当用户使用搜索引擎完成搜索后,在展示结果中需要进行进一步的筛选,而筛选的维度需要根据当前的搜索结果进行汇总,这就用到了聚合技术。
环境准备
- Elasticsearch 服务(单机或集群)
- Kibana 服务
如果对ES不了解或没有上述环境,可以看下我之前的博客。
Elasticsearch查看集群信息,设置ES密码,Kibana部署
数据准备
json
PUT /course
{
"mappings": {
"properties": {
"title": {
"type": "text"
},
"teacher_name": {
"type": "text"
},
"price": {
"type": "double"
},
"create_time": {
"type": "date",
"format": [
"yyyy-MM-dd HH:mm:ss"
]
},
"tags": {
"type": "keyword"
},
"publish": {
"type":"boolean"
},
"comment_info":{
"properties": {
"favourable_num":{
"type":"integer"
},
"negative_num":{
"type":"integer"
}
}
}
}
}
}
PUT /_bulk
{"index":{"_index":"course","_id":"1"}}
{"title":"Python编程基础","teacher_name":"张三","price":99.99,"create_time":"2023-04-01 10:00:00","tags":["编程","Python"],"publish":true,"comment_info":{"favourable_num":150,"negative_num":3}}
{"index":{"_index":"course","_id":"2"}}
{"title":"Java高级开发","teacher_name":"李四","price":129.5,"create_time":"2023-06-08 15:30:00","tags":["Java","后端开发"],"publish":true,"comment_info":{"favourable_num":200,"negative_num":7}}
{"index":{"_index":"course","_id":"3"}}
{"title":"数据结构与算法","teacher_name":"王五","price":88.88,"create_time":"2023-03-15 14:45:00","tags":["算法","数据结构"],"publish":false,"comment_info":{"favourable_num":50,"negative_num":0}}
{"index":{"_index":"course","_id":"4"}}
{"title":"Web前端开发入门","teacher_name":"赵六","price":79.9,"create_time":"2023-05-20 09:15:00","tags":["HTML","CSS","JavaScript"],"publish":true,"comment_info":{"favourable_num":88,"negative_num":2}}
{"index":{"_index":"course","_id":"5"}}
{"title":"机器学习实战","teacher_name":"孙七","price":159,"create_time":"2023-02-25 11:00:00","tags":["机器学习","人工智能"],"publish":true,"comment_info":{"favourable_num":120,"negative_num":5}}
{"index":{"_index":"course","_id":"6"}}
{"title":"数据库原理与设计","teacher_name":"周八","price":66.6,"create_time":"2023-01-10 13:30:00","tags":["数据库","SQL"],"publish":false,"comment_info":{"favourable_num":30,"negative_num":1}}
{"index":{"_index":"course","_id":"7"}}
{"title":"Android应用开发","teacher_name":"吴九","price":119.88,"create_time":"2023-04-20 17:45:00","tags":["Android","移动开发"],"publish":true,"comment_info":{"favourable_num":105,"negative_num":4}}
{"index":{"_index":"course","_id":"8"}}
{"title":"深度学习探索","teacher_name":"郑十","price":299,"create_time":"2023-05-05 16:00:00","tags":["深度学习","神经网络"],"publish":true,"comment_info":{"favourable_num":180,"negative_num":6}}
{"index":{"_index":"course","_id":"9"}}
{"title":"UI/UX设计精髓","teacher_name":"钱十一","price":55.55,"create_time":"2023-03-20 12:15:00","tags":["UI设计","用户体验"],"publish":true,"comment_info":{"favourable_num":75,"negative_num":2}}
{"index":{"_index":"course","_id":"10"}}
{"title":"云计算技术基础","teacher_name":"孙十二","create_time":"2023-07-01 18:30:00","tags":["云计算","AWS"],"publish":false,"comment_info":{"favourable_num":45,"negative_num":0}}
基础聚合
json
# 聚合指令
"aggs":{
// 指定聚合内容
"value_count_price":{
// 指定方法
"value_count": {
//指定聚合字段
"field": "price"
}
}
}
基础聚合方法
- 平均值 avg
- 最大 max
- 最小 min
- 计数 value_count
- 求和 sum
- 统计聚合 stats
注意: 上述基础聚合,非空值不会参与计算
例如:十个文档数据,其中有一个文档中字段为空,对其求平均值,结果应该是: 字段不为空的9个文档的平均值。
对于空值:可以使用missing 字段,指定 将空值替换为某个值 来参与计算
桶聚合(分组聚合)
在ES中,MYSQL 中的 Group by 分组 被称为 桶聚合
单维度桶聚合
单维度桶聚合 就是 按照一个维度对文档 进行分组聚合
在桶聚合时匹配方式
- terms
terms聚合是按照字段的实际完整值进行匹配和分组的,它使用的维度字段必须是keyword、bool、keyword数组等适合精确匹配的数据类型,因此不能对text字段直接使用terms聚合,如果对text字段有terms聚合的需求,则需要在创建索引时为该字段增加多字段功能。
- ranges
ranges聚合也是经常使用的一种聚合。它匹配的是数值字段,表示按照数值范围进行分组。用户可以在ranges中添加分组,每个分组用from和to表示分组的起止数值。注意该分组包含起始数值,不包含终止数值。
- filter
filter聚合使用和搜索时使用方法一样,用来过滤出一批数据,一般用于 不影响查询条件的前过滤器
json
GET /course/_search
{
// 桶聚合默认 计算每个桶对应的文档数
"size": 0,
"aggs":{
"agg_terms_tags":{
// 默认只返回十个桶,即size为10
"terms": {
"field": "tags"
}
},
"agg_terms_publish":{
"terms": {
"field": "publish",
"size": 10
}
},
"agg_range_price":{
"range": {
"field": "price",
"ranges": [
//不指定from (-∞,80)
{
"to": 80
},
// 左闭右开 [80,100)
{
"from": 80,
"to": 100
},
//不指定to [100,+∞)
{
"from": 100
}
]
}
},
"agg_filter_price": {
"filter": {
"match": {
"publish": "true"
}
}
}
}
}
注意: 这里多了一个 key_as_string 字段。
如果桶字段类型不是keyword类型,ES在聚合时会将桶字段转换为Lucene存储的实际值进行识别。true在Lucene中存储为1,false在Lucene中存储为0。而key_as_string 则用来表示原始值的字符串形式。
在默认情况下,进行桶聚合时如果不指定指标,则ES默认聚合的是文档计数,该值以doc_count为key存储在每一个bucket子句中。
返回的doc_count 是近似值,并不是一个准确数,
因此在聚合外围,ES给出了两个参考值doc_count_error_upper_bound
和 sum_other_doc_count
doc_count error_upper
表示被遗漏的文档数量可能存在的最大值。
sum other doc count
表示除了返回给用户的文档外剩下的文档总数。
多维度桶聚合
通常,在一些复杂业务中,单维度的桶聚合无法满足需求。
往往需要引入多维度的嵌套桶聚合。
例如:分别获取发布和未发布状态 价格 在 (-∞,80),[80,100),[100,+∞) 的 价格平均值
分析上述 需求,按照关系数据库的思路,其实就是 按照 发布状态 和 这三个价格区间进行分组 求每个组的平均价格
对应ES 中的桶聚合,可以转化为 求两个维度的桶聚合,
- 按照 发布状态 进行分组聚合
- 在聚合的结果中,按照价格区间进行分组聚合
- 在最新的聚合桶中 计算平均价格
转化为DSL如下:
json
GET /course/_search
{
"size": 0,
"aggs": {
// 第一层分组桶:按照发布状态分组
"publish_group": {
"terms": {
"field": "publish"
},
"aggs": {
// 第二层分组:按照价格区间分组
"price_range_group": {
"range": {
"field": "price",
"ranges": [
{
"to": 80
},
{
"from": 80,
"to": 100
},
{
"from": 100
}
]
},
"aggs": {
// 聚合计算平均值,也可以算一层
"ans_avg": {
"avg": {
"field": "price"
}
}
}
}
}
}
}
}
第一个桶 publish 为 true 时,内部嵌套了 价格区间的桶,每个价格区间内又有计算的 平均值 ans_avg
第二个桶 publish 为 false 时,内部嵌套了 价格区间的桶,每个价格区间内又有计算的 平均值 ans_avg
聚合方式
ES支持灵活的聚合方式,它不仅支持聚合和查询相结合,而且还可以使聚合的过滤条件不影响搜索条件,并且还支持在聚合后的结果中进行过滤选。
直接聚合
直接聚合指的是聚合时的DSL没有query子句,是直接对索引内的所有文档进行聚合。
前面的案例都是使用直接聚合方式
先查询后聚合
与直接聚合相对应,这种查询方式需要增加query子句,query子句和普通的query查询没有区别,参加聚合的文档必须匹配query查询。
对应的 就是 SQL 语言中的 Where语句
例如:查询发布状态的 课程 在 价格区间 (-∞,80),[80,100),[100,+∞) 中的平均价格
json
GET /course/_search
{
"query": {
"match": {
"publish": "true"
}
},
"size": 0,
"aggs": {
"price_range_group": {
"range": {
"field": "price",
"ranges": [
{
"to": 80
},
{
"from": 80,
"to": 100
},
{
"from": 100
}
]
},
"aggs": {
"ans_avg": {
"avg": {
"field": "price"
}
}
}
}
}
}
前过滤器
有时需要对聚合条件进一步地过滤,但是又不能影响当前的查询条件。
例如:查询全部课程,并计算已经上架的课程的平均价格
因为 未上架的课程 也不能买,所以其平均价格没有意义
这时 需要用到 filter 关键字,在聚合前进行过滤,但不影响query数据
SQL 中没有 前过滤器 关键字,但可以通过 case when end 实现相似的效果
对应DSL语句为:
json
GET /course/_search
{
"size": 0,
"aggs": {
"my_aggs": {
"filter": {
"term": {
"publish": "true"
}
},
"aggs": {
"ans_avg": {
"avg": {
"field": "price"
}
}
}
}
}
}
后过滤器
在有些场景中,需要根据条件进行数据查询,但是聚合结果不受影响。
例如:求全部数据的平均值,但只需输出下架的课程
这时 需要使用 post_filter 关键字 进行 聚合后的 后置过滤,但不影响aggs聚合
可以类比 SQL语句中的 having 关键字,分组后进行数据筛选
json
GET /course/_search
{
"size": 10,
"post_filter": {
"term": {
"publish": "false"
}
},
"aggs": {
"ans_avg": {
"avg": {
"field": "price"
}
}
}
}
注意:
ans_avg 还是计算的 query 查询出来的数据的平均值
而 post_filter 作用于 聚合计算后,再过滤数据,所以 只输出了三个 publish 为false 的数据
聚合排序
按照文档计数排序
可以使用_count 来进行文档计数排序
json
GET /course/_search
{
"size": 0,
"aggs": {
"agg_terms_publish1": {
"terms": {
"field": "publish",
"order": {
"_count": "asc"
}
}
}
}
}
按照聚合指标排序
可以使用具体的聚合指标名称 来进行排序
json
GET /course/_search
{
"size": 0,
"aggs": {
"agg_terms_publish1": {
"terms": {
"field": "publish",
"order": {
"avg_aggs": "desc"
}
},
"aggs": {
"avg_aggs": {
"avg": {
"field": "price"
}
}
}
}
}
}
按照分组Key排序
在聚合排序时,业务需求可能有按照每个分组的组名称排序的场景。此时可以使用 key来引用分组名称。
按照分组Key的自然顺序升序排列
json
GET /course/_search
{
"size": 0,
"aggs": {
"agg_terms_publish1": {
"terms": {
"field": "publish",
"order": {
"_key": "asc"
}
}
}
}
}
聚合分页
ES支持同时返回查询结果和聚合结果,前面介绍聚合查询时,查询结果和聚合结果各自封装在不同的子句中。
但有时我们希望聚合的结果按照每组选出前N个文档的方式进行呈现,最常见的一个场景就是电商搜索,如搜索苹果手机6S,搜索结果应该展示苹果手机6S型号中的一款手机即可,而不论该型号手机的颜色有多少种。
另外,当聚合结果和查询结果封装在一起时,还需要考虑对结果分页的问题,此时前面介绍的聚合查询就不能解决这些问题了。
ES提供的Top hits聚合和Collapse聚合可以满足上述需求,但是这两种查询的分页方案是不同的。
Top hits 聚合
Top hits聚合指的是聚合时在每个分组内部,按照某个规则选出前N个文档进行展示。
例如: 搜索 "开发"时,按照上下架分组,每组按照价格升序,展示最便宜的数据
json
GET /course/_search
{
"query": {
"match": {
"title": "开发"
}
},
"size": 0,
"aggs": {
"group_publish": {
"terms": {
"field": "publish"
},
"aggs": {
"top_aggs": {
"top_hits": {
"size": 1,
"sort": {
"price": {
"order": "asc"
}
}
}
}
}
}
}
}
Collapse 聚合
当在索引中有大量数据命中时,Top hits聚合存在效率问题,并且需要用户自行排序。
针对上述问题,ES推出了Collapse聚合,即用户可以在collapse子句中指定分组字段,匹配query的结果按照该字段进行分组,并在每个分组中按照得分高低展示组内的文档。
当用户在query子句外指定from和size时,将作用在Collapse聚合之后,即此时的分页是作用在分组之后的。
json
GET /course/_search
{
"query": {
"match": {
"title": "开发"
}
},
"from": 0,
"size": 2,
"collapse": {
"field": "price"
}
}