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つです。

  1. **"size": 0**:検索ヒット(ドキュメント本体)を返さず、集計結果だけを受け取ります。不要な転送が減り、応答も速くなります。
  2. **query で対象を絞る**:集計は query に一致したドキュメントだけが対象です。期間やステータスの絞り込みは query 側に書きます。
  3. **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 でページングします。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲