Elasticsearchの"Aggregations"を使って SQLの"Group by"を置き換える

#はじめに

[Elasticsearchのv1.0.0](http://www.elasticsearch.org/blog/1-0-0-released/) が無事リリースされ、Aggregations APIの利用が可能になりました。

Elasticsearchはこれまで、検索結果を集約して解析する仕組みとしてFacets APIを提供していましたが、実質SQLのGroup byでのcountに相当する機能しかなかったため、maxやavgといった複雑な条件で集約を行いたい場合、クエリを分けて集約したり解析したりするしかありませんでした。

v1.0.0でAggregations APIが提供されたことで、maxやavgはもちろん、日付でヒストグラムを取得するといった複雑な集約も楽に行えるようになり、SQL型検索エンジンからの置き換えも行いやすくなってきたのではないかと思います。


#内容
Aggregationsで何ができるのか、SQLのGroup byとの置き換えを例に検証してみましたので、その共有です。

##Aggregations
詳しい説明は公式に書いてありますので省きますが、Facets APIに代わる次世代データ分析クエリという位置づけで、以下のような集約や複雑なフィルターができます。
将来、Facets APIが非推奨になるかどうかはまだ決まってないようです。

|Aggregationクエリ|内容|
---|---
|min|最小値|
|max|最大値|
|sum|合計値|
|avg|平均値|
|stats|count,max,sum,avg 全部| 
|terms|値の種類ごとに集約|
|range|範囲ごとに集約|
|histogram|一定範囲ごとに集約|
|...||

[公式URL] http://www.elasticsearch.org/guide/en/elasticsearch/reference/1.x/search-aggregations.html

##検証環境
```
$ cat /etc/redhat-release
centos release 6.4 (final)
```

##データフォーマット
(株)ドワンゴと国立情報学研究所さんのニコニコデータセットを利用しました。
http://www.nii.ac.jp/cscenter/idr/nico/nicocomm-apply.html	

Elasticsearchのデータは、Fluentdのtail inputで一括投入すると楽に作れます。
その辺りの記事もElasticsearchタグでいずれ書きます。

##Aggregationsを使ったSQL Group byとの置き換えの例

###1. 集計関数の為にGROUP BYを実行する

* SQLの場合

movie_type毎の件数・再生時間(最長・最短・平均)を取得するSQL

```
SELECT
	movie_type, COUNT(movie_type), MAX(length), MIN(length), AVG(length)
FROM
	nicodata
GROUP BY
	movie_type;
```

* ESの場合

```
curl -XGET http://localhost:9200/nicodata/_search?pretty -d '
{
  "aggs": {
    "my_movie_type_term": {
      "terms": {
        "field": "movie_type",
        "order": { "_count" : "desc" },
        "size": 3
      },
      "aggs": {
        "my_max_length": {
          "max": { "field": "length" }
        },
        "my_min_length": {
          "min": { "field": "length" }
        },
        "my_avg_length": {
          "avg": { "field": "length" }
        }
      }
    }
  }
}'
```

__解説__
"aggs"は"aggregations"の略でどちらでも使えます。
"my_xxx" はSQLのAS(別名)のようなもので、一つのaggregationクエリを表します。

最初のaggregationクエリは"terms"によってmovie_typeごとに集約したカウントを取得しています。
更に"aggs"の入れ子と"max","min","avg"によって、movie_typeごとに最大値や平均値を取得しています。

_countは結果のdoc_count順の並べ替えです。代わりにアルファベット順並べ替えを行う場合は、order: _termとするとよいでしょう。

__結果__

```
{
  ...
  
  "aggregations" : {
    "my_movie_type_term": {
      "buckets" : [ {
        "key" : "mp4",
        "doc_count" : 426778,
        "my_max_length" : {
          "value" : 37600.0
        },
        "my_min_length" : {
          "value" : 1.0
        },
        "my_avg_length" : {
          "value" : 711.5485451452512
        }
      }, {
        "key" : "flv",
        "doc_count" : 11848,
        "my_max_length" : {
          "value" : 9313.0
        },
        "my_min_length" : {
          "value" : 1.0
        },
        "my_avg_length" : {
          "value" : 664.2181802835921
        }
      }, {
        "key" : "swf",
        "doc_count" : 9672,
        "my_max_length" : {
          "value" : 7379.0
        },
        "my_min_length" : {
          "value" : 1.0
        },
        "my_avg_length" : {
          "value" : 246.87065756823822
        }
      } ]
    }
  }
}
```

###2. 月別集計の為のGROUP BYを実行する

* SQLの場合

月毎の投稿件数を取得するSQL

```
SELECT
	DATE_FORMAT(upload_time, '%Y-%m') AS upload_time, COUNT(*)
FROM
	nicodata
GROUP BY
	DATE_FORMAT(upload_time, '%Y-%m');
```

* ESの場合

```
curl -XGET http://localhost:9200/nicodata/_search?pretty -d '
{
  "aggs" : {
    "my_upload_time_histogram" : {
      "date_histogram" : {
        "field" : "upload_time",
        "interval" : "1M",
        "format" : "yyyy-MM-dd"
      }
    }
  }
}'
```

__解説__
"date_histogram"は先程まで使っていた"terms"と同じAggregationクエリの一つです。"histogram"のdate版で、日時範囲ごとに集約を行います。

"field"は対象としたいindexのfield名、"interval"は範囲で"1d","24h"..のように指定します。
"format"は結果のタイムスタンプを加工して"key_as_string"として取得したいフォーマットを指定します。

__結果__

```
{
  ...
  
  "aggregations" : {
    "my_upload_time_histogram" : {
      "buckets" : [ {
        "key_as_string" : "2012-04-01",
        "key" : 1333238400000,
        "doc_count" : 10651
      }, {
        "key_as_string" : "2012-05-01",
        "key" : 1335830400000,
        "doc_count" : 153742
      }, {
        "key_as_string" : "2012-09-01",
        "key" : 1346457600000,
        "doc_count" : 113462
      }, {
        "key_as_string" : "2012-10-01",
        "key" : 1349049600000,
        "doc_count" : 170443
      } ]
    }
  }
}
```

0件の月も結果に含めるには、"min_doc_count"を使うとよいでしょう。

```
curl -XGET http://localhost:9200/nicodata/_search?pretty -d '
{
  "aggs" : {
    "my_upload_time_histogram" : {
      "date_histogram" : {
        "field" : "upload_time",
        "interval" : "1M",
        "format" : "yyyy-MM-dd",
        "min_doc_count" : 0
      }
    }
  }
}'
```

__結果__

```
{
  ...
  
  "aggregations" : {
    "my_upload_time_histogram" : {
      "buckets" : [ {
        "key_as_string" : "2012-04-01",
        "key" : 1333238400000,
        "doc_count" : 10651
      }, {
        "key_as_string" : "2012-05-01",
        "key" : 1335830400000,
        "doc_count" : 153742
      }, {
        "key_as_string" : "2012-06-01",
        "key" : 1338508800000,
        "doc_count" : 0
      }, {
        "key_as_string" : "2012-07-01",
        "key" : 1341100800000,
        "doc_count" : 0
      }, {
        "key_as_string" : "2012-08-01",
        "key" : 1343779200000,
        "doc_count" : 0
      }, {
        "key_as_string" : "2012-09-01",
        "key" : 1346457600000,
        "doc_count" : 113462
      }, {
        "key_as_string" : "2012-10-01",
        "key" : 1349049600000,
        "doc_count" : 170443
      } ]
    }
  }
}
```

###3. サブクエリとしてGROUP BYを実行する　~~⇒未解決~~

[追記]
サブクエリを置き換える形ではありませんが、Elasticsearchのバージョンアップによって、例示したSQLの置き換えは可能になりました。

v1.3.0で追加された [Top Hits Aggregation](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-aggregations-metrics-top-hits-aggregation.html) により、グループ毎の並べ替えやメタ情報の取得が可能になり、置き換えられるSQLの幅がより広がったのではないかと思います。

※追記前とデータが変わっているので、取得内容はニコニコデータセットと一致しません。

* SQLの場合

動画種別(flv,mp4,etc)毎に、もっともIDが古い動画のメタ情報を取得するSQL

```
SELECT
	n.video_id, title, thumbnail_url, movie_type
FROM
	nicodata n,
	(SELECT MIN(video_id) AS video_id FROM nicodata GROUP BY movie_type) n1
WHERE
	n.video_id = n1.video_id;
```

* ESの場合

```
curl -XGET http://localhost:9200/nicodata/_search?pretty -d '
{
  "aggs": {
    "my_movie_type_term": {
      "terms": {
        "field": "movie_type"
      },
      "aggs": {
        "my_top_hit": {
          "top_hits": {
            "sort": [{ "video_id": { "order": "asc" } }],
            "size": 1
          }
        }
      }
    }
  }
}'
```

__解説__
"terms"は、グループ化されたデータ（Elasticsearchでは、bucketと呼ばれている）を作る役割があり、"top_hits"は、それぞれのbucketから並べ替えた上位のドキュメントを選び取る役割を持っています。

後者のようにbucketから条件や値に基づいて抽出を行うAggregationクエリを、Elasticsearchではmetric aggregationと呼んでいます。

__結果__

```
{
  ...
  
  "aggregations": {
    "my_movie_type_term": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "flv",
          "doc_count": 11848,
          "my_top_hit": {
            "hits": {
              "total": 2,
              "max_score": null,
              "hits": [
                {
                  "_index": "nicodata",
                  "_type": "video",
                  "_id": "sm16",
                  "_score": null,
                  "_source": {
                    "video_id": "sm16",
                    "movie_type": "flv",
                    "thumbnail_url": "http://tn-skr1.smilevideo.jp/smile?i=16",
                    "title": "LIVE! THE IDOLM@STER アイドルマスター relations 演歌ver"
                  },
                  "sort": [
                    "sm16"
                  ]
                }
              ]
            }
          }
        },
        {
          "key": "mp4",
          "doc_count": 426778,
          "my_top_hit": {
            "hits": {
              "total": 1,
              "max_score": null,
              "hits": [
                {
                  "_index": "nicodata",
                  "_type": "video",
                  "_id": "so20753557",
                  "_score": null,
                  "_source": {
                    "video_id": "so20753557",
                    "movie_type": "mp4",
                    "thumbnail_url": "http://tn-skr2.smilevideo.jp/smile?i=20753557",
                    "title": "【聞いてみたシリーズ】吉本彩編"
                  },
                  "sort": [
                    "so20753557"
                  ]
                }
              ]
            }
          }
        },
        {
          "key": "swf",
          "doc_count": 9672,
          "my_top_hit": {
            "hits": {
              "total": 1,
              "max_score": null,
              "hits": [
                {
                  "_index": "nicodata",
                  "_type": "video",
                  "_id": "nm2522142",
                  "_score": null,
                  "_source": {
                    "video_id": "nm2522142",
                    "movie_type": "swf",
                    "thumbnail_url": "http://tn-skr3.smilevideo.jp/smile?i=2522142",
                    "title": "初心者による初心者のための初心者のNMM使い方講座"
                  },
                  "sort": [
                    "nm2522142"
                  ]
                }
              ]
            }
          }
        }
      ]
    }
  }
}
```

# おわりに

現状最新はv1.4.0ですが、この時点でAggregationsにはfromに相当する機能がない為、ページングをするようなケースでは思ったように運用ができないこともありそうです。

ただ、ページングが必要になるほどbucketsを分割するようなケースでは、parent/childを利用したほうがよいと思われますので、件数に応じて使い分けるとよいでしょう。

最後に、ここで紹介した以外にも、バージョンアップのたびに新しいAggregationクエリは増えてきているので、ぜひ公式ドキュメントも参照してみてください。
http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/search-aggregations.html