2026年6月28日
2026年6月28日
ElasticsearchのAggregationでデータを集計する方法(terms・date_histogram・composite)
ElasticsearchのAggregation(集計)を使うと、検索と同じリクエストの中で「カテゴリ別の売上合計」「日別の件数」「ユニーク顧客数」などをまとめて計算できます。この記事では、集計に向いたマッピングの作り方から、terms・date_histogram・メトリクス集計・composite によるページングまでを、実際のクエリ例とつまずきやすい点を添えて解説します。読み終えるころには、手元のインデックスに合わせて集計クエリを組み立て、結果の誤差や上限も判断できるようになります。
Aggregationの3つの種類を押さえる
集計は大きく3種類に分かれます。どれを組み合わせるかで、作れるレポートが決まります。
- バケット集計:ドキュメントを条件ごとの「入れ物」に振り分けます。
terms(値ごと)、date_histogram(期間ごと)、range(数値の範囲ごと)、filter/filters(条件ごと)などがあります。 - メトリクス集計:数値を計算します。
sum、avg、min、max、value_count、cardinality(ユニーク数)、percentiles(パーセンタイル)など。 - パイプライン集計:ほかの集計の結果をさらに加工します。累積合計の
cumulative_sum、前のバケットとの差分のderivative、バケットの並べ替えや切り詰めを行うbucket_sortなどです。
基本形は「バケット集計の中にメトリクス集計を入れ子にする」です。たとえば「カテゴリごと(バケット)の売上合計(メトリクス)」という形になります。
本記事のクエリは Elasticsearch 8系・9系の構文を前提にしています(2026年10月現在)。古い 6系・7系の一部では date_histogram の書き方が異なるため、運用中のバージョンの公式ドキュメントもあわせて確認してください。OpenSearch も集計の構文は多くが共通ですが、細部は別製品として確認が必要です。
集計に向いたマッピングを用意する
集計でいちばん多いつまずきは、文字列フィールドを text 型のまま集計しようとしてエラーになるケースです。text は全文検索用に単語へ分割されるため、そのままでは集計に使えません(fielddata を有効にすれば動きますが、メモリを大きく消費するため推奨されません)。集計したい文字列は keyword 型にします。
PUT /orders
{
"mappings": {
"properties": {
"order_date": { "type": "date" },
"category": { "type": "keyword" },
"customer_id": { "type": "keyword" },
"product_name": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
},
"amount": { "type": "scaled_float", "scaling_factor": 100 },
"quantity": { "type": "integer" }
}
}
}
商品名のように「検索にも集計にも使いたい」フィールドは、text 本体に keyword のサブフィールドを持たせます。集計では product_name.keyword を指定します。動的マッピングで文字列を投入した場合も、多くは同じ形(.keyword 付き)で作られています。GET /orders/_mapping で実際の型を先に確かめておくと、エラーの原因探しが短くなります。
なお、既存フィールドの型は後から変更できません。型を変えたいときは新しいインデックスを作り、_reindex でデータを移す流れになります。本番インデックスで作業する前に、スナップショットを取得しておいてください。
terms集計でカテゴリ別に合計する
直近30日の注文を対象に、カテゴリごとの売上合計と平均単価を求め、売上の多い順に10件返す例です。
GET /orders/_search
{
"size": 0,
"query": {
"range": { "order_date": { "gte": "now-30d/d" } }
},
"aggs": {
"by_category": {
"terms": {
"field": "category",
"size": 10,
"order": { "total_sales": "desc" }
},
"aggs": {
"total_sales": { "sum": { "field": "amount" } },
"avg_amount": { "avg": { "field": "amount" } }
}
}
}
}
ポイントは3つです。
- **
"size": 0**:検索ヒット(ドキュメント本体)を返さず、集計結果だけを受け取ります。不要な転送が減り、応答も速くなります。 - **
queryで対象を絞る**:集計は query に一致したドキュメントだけが対象です。期間やステータスの絞り込みは query 側に書きます。 - **
orderでサブ集計を指定**:total_salesのようにサブ集計の名前を書くと、その値で並べ替えられます。
結果は aggregations.by_category.buckets に配列で返り、各バケットに key(カテゴリ名)、doc_count(件数)、total_sales.value が入ります。
terms集計の「誤差」を理解する
terms 集計は、各シャードで上位の値を集めてから統合する仕組みです。そのため、シャードが複数あるとき、件数が「おおよそ」になることがあります。レスポンスの次の値で状況を判断できます。
doc_count_error_upper_bound:件数の誤差の上限の目安sum_other_doc_count:返されたバケットに入らなかったドキュメント数
誤差が気になる場合は、shard_size を大きくすると各シャードから多めに候補を集めるため精度が上がります(その分、負荷も増えます)。値の種類が少ないフィールドなら、size を種類数以上にすれば取りこぼしは起きにくくなります。
date_histogramで日別・月別に集計する
時系列のレポートには date_histogram を使います。日別の売上、ユニーク顧客数、累積売上をまとめて求める例です。
GET /orders/_search
{
"size": 0,
"query": {
"range": { "order_date": { "gte": "now-30d/d" } }
},
"aggs": {
"sales_per_day": {
"date_histogram": {
"field": "order_date",
"calendar_interval": "day",
"time_zone": "Asia/Tokyo",
"min_doc_count": 0
},
"aggs": {
"daily_sales": { "sum": { "field": "amount" } },
"unique_customers": { "cardinality": { "field": "customer_id" } },
"running_total": { "cumulative_sum": { "buckets_path": "daily_sales" } }
}
}
}
}
間隔の指定には2種類あります。calendar_interval は「日・週・月・四半期・年」のように暦に沿った間隔で、月ごとの日数の違いやタイムゾーンを考慮します。fixed_interval は 30m や 6h のような固定長の間隔です。月別集計なら "calendar_interval": "month"、5分ごとの監視データなら "fixed_interval": "5m" のように使い分けます。
time_zone を省略すると UTC で区切られるため、日本時間で見ると日付の境目が9時間ずれます。「日別の件数が管理画面の数字と合わない」という相談の多くはこれが原因です。
近似値になる集計に注意する
cardinality(ユニーク数)と percentiles は、大量データでも高速に計算するために近似アルゴリズムを使います。少量のデータでは正確な値になることが多いものの、件数が増えると誤差が出ます。請求金額の確定など厳密な数字が要る用途には、データベース側の集計と突き合わせるなど別の確認手段を用意してください。cardinality は precision_threshold で精度とメモリのバランスを調整できます。
compositeで全バケットをページングする
terms 集計の size を極端に大きくすると、メモリを圧迫したり、クラスタ設定 search.max_buckets の上限に当たってエラーになったりします。全カテゴリ・全顧客のように「すべてのバケットを取り出したい」場合は composite 集計でページングします。
GET /orders/_search
{
"size": 0,
"aggs": {
"all_customers": {
"composite": {
"size": 500,
"sources": [
{ "customer": { "terms": { "field": "customer_id" } } }
]
},
"aggs": {
"total_sales": { "sum": { "field": "amount" } }
}
}
}
}
レスポンスに含まれる after_key を、次のリクエストの composite に "after": { "customer": "前回のafter_keyの値" } として渡すと続きが取得できます。after_key が返らなくなるか、バケットが空になったら終了です。composite は並び順がキーの順に固定されるため、「売上上位10件」のようなランキングには terms 集計、全件の書き出しには composite、と役割を分けると扱いやすくなります。
サーバーやアプリから実行するときの注意
Kibana の Dev Tools で動作を確かめたら、アプリやバッチから同じ JSON を送ります。curl で試す場合は、パスワードをコマンドラインに直接書くと履歴に残るため、API キーを使い、値は環境変数などから読み込むのが安全です。
curl -s --cacert /path/to/http_ca.crt \
-H "Authorization: ApiKey ${ES_API_KEY}" \
-H "Content-Type: application/json" \
"https://localhost:9200/orders/_search" \
-d @aggs_by_category.json
本番クラスタで重い集計を試すときは、対象期間を短くして応答時間を確かめてから広げてください。高カーディナリティのフィールド(顧客IDなど)への terms 集計や、細かすぎる fixed_interval は、バケット数が急増して負荷の原因になります。
WordPress で Elasticsearch を使う場合は、10up が開発している ElasticPress プラグインが代表的です。検索結果の絞り込み(ファセット)などで集計の仕組みが使われています。機能名や設定画面の表記はバージョンによって変わるため、導入時はプラグインの公式ドキュメントで確認し、ステージング環境で試してから本番に反映してください。
まとめ
- 集計は「バケット集計+メトリクス集計」の入れ子が基本で、必要に応じてパイプライン集計で加工します。
- 集計する文字列は
keyword型にします。text型のままではエラーになります。 "size": 0でヒットを省き、対象の絞り込みは query に書きます。- terms 集計はシャードをまたぐと件数が近似になることがあり、
doc_count_error_upper_boundとshard_sizeで確認・調整します。 date_histogramはcalendar_intervalとfixed_intervalを使い分け、日本時間で区切るならtime_zoneを指定します。- 全バケットの取得は composite 集計の
after_keyでページングします。