Elasticsearch データアグリゲーション

📢 この記事は gemini-2.5-flash によって翻訳されました

Elasticsearch シリーズ

内容リンク
Elasticsearch 基礎操作https://blog.yexca.net/archives/226
Elasticsearch クエリ操作https://blog.yexca.net/archives/227
RestClient 基礎操作https://blog.yexca.net/archives/228
RestClient クエリ操作https://blog.yexca.net/archives/229
Elasticsearch データアグリゲーションこの記事
Elasticsearch 自動補完https://blog.yexca.net/archives/232
Elasticsearch データ同期https://blog.yexca.net/archives/234
Elasticsearch クラスターhttps://blog.yexca.net/archives/235

アグリゲーション (aggregations) を使うと、データの統計、分析、演算がすごく簡単にできちゃうんだ。例えばこんな感じ:

  • どんなブランドのスマホが一番人気?
  • これらのスマホの平均価格、最高価格、最低価格は?
  • これらのスマホの月ごとの販売状況はどうなってる?

アグリゲーションの種類

よく使われるのは3種類だよ:

  • バケット (bucket) アグリゲーション:ドキュメントをグループ分けするのに使うんだ。
    • TermAggregation:ドキュメントのフィールド値でグループ分けするよ。例えばブランド別、国別とかね。
    • Date Histogram:日付の区切りでグループ分けするんだ。例えば1週間ごととか1ヶ月ごととか。
  • メトリック (metric) アグリゲーション:最大値、最小値、平均値などを計算するのに使うよ。
    • Avg:平均値
    • Max:最大値
    • Min:最小値
    • Stats:最大値、最小値、平均値、合計などをまとめて求めるんだ。
  • パイプライン (pipeline) アグリゲーション:他のアグリゲーションの結果をベースにして、さらにアグリゲーションを行うんだ。

アグリゲーションに参加するフィールドは、keyword、日付、数値、ブーリアン型である必要があるよ。

DSL アグリゲーションクエリ

bucket

全データの中でホテルのブランドが何種類あるか数える、つまりブランドごとにデータをグループ分けするよ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# bucket term
GET /hotel/_search
{
  "size": 0, // sizeを0に設定すると、結果にドキュメントは含まれず、アグリゲーション結果だけになるよ。
  "aggs": {
    "brandAgg": { // アグリゲーション名
      "terms": { // アグリゲーションタイプ
        "field": "brand", // アグリゲーション対象フィールド
        "size": 20 // 取得するアグリゲーション結果の数
      }
    }
  }
}

アグリゲーション結果のソート

デフォルトでは、バケットアグリゲーションはバケット内のドキュメント数を数えて(これをcountと呼ぶよ)、countの降順でソートされるんだ。order属性を指定すれば、ソート方法をカスタマイズできるよ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
GET /hotel/_search
{
  "size": 0,
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20,
        "order": { // ソート
          "_count": "asc"
        }
      }
    }
  }
}

アグリゲーション範囲の限定

デフォルトではインデックス内の全ドキュメントに対してアグリゲーションが実行されるんだけど、実際にはユーザーが検索条件を入力するから、アグリゲーションは検索結果に対して行われるべきだよね。だから、アグリゲーションには条件を追加する必要があるんだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# bucket query
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 200 // 価格が200未満のドキュメントのみをアグリゲーションする。
      }
    }
  },
  "size": 0,
  "aggs": {
    "brandAggQuery": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

Metric

さっきのbucketアグリゲーションでブランドごとにグループ分けしたけど、今度は各ブランドのユーザー評価の最小値、最大値、平均値を取得したいな。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# metric
GET /hotel/_search
{
  "size": 0,
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20
      },
      "aggs": { // bucketのサブアグリゲーションで、グループ分けされた各グループに対して演算を行うよ。
        "scoreStats": { // アグリゲーション名
          "stats": { // アグリゲーションタイプ
            "field": "score" // アグリゲーションフィールド
          }
        }
      }
    }
  }
}

平均値でソート

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
GET /hotel/_search
{
  "size": 0,
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20,
        "order": {
          "scoreStats.avg": "desc" // 平均値の降順
        }
      },
      "aggs": {
        "scoreStats": {
          "stats": {
            "field": "score"
          }
        }
      }
    }
  }
}

RestAPI アグリゲーション

構文

アグリゲーション条件はqueryと同じ階層だから、request.source() を使ってアグリゲーション条件を指定するよ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Test
public void testAggTerm() throws IOException {
    SearchRequest request = new SearchRequest("hotel");

    request.source().size(0);
    request.source().aggregation(
            AggregationBuilders
                    .terms("brandAgg")
                    .field("brand")
                    .size(20)
    );

    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
}

レスポンス処理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
public void testAggTerm() throws IOException {
    SearchRequest request = new SearchRequest("hotel");

    request.source().size(0);
    request.source().aggregation(
            AggregationBuilders
                    .terms("brandAgg")
                    .field("brand")
                    .size(20)
    );

    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    // アグリゲーション結果をパースする。
    Aggregations aggregations = response.getAggregations();
    // 名前でアグリゲーション結果を取得する。
    Terms term = aggregations.get("brandAgg");
    // バケットを取得する。
    List<? extends Terms.Bucket> buckets = term.getBuckets();
    // イテレートする。
    for (Terms.Bucket bucket : buckets) {
        // キーを取得する。
        String name = bucket.getKeyAsString();
        System.out.println(name);
    }
}

実装要件

フロントエンドページの都市、星評価、ブランドは選択肢が固定されていて、検索入力によって変わらないんだ。

でも、「東方明珠」で検索したら、都市は上海だけになるべきで、他の都市は表示されるべきじゃないよね。

つまり、選択可能な都市などは検索入力の内容に応じて変わるべきなんだ。そのためには、フロントエンドは内容に基づいて選択可能な都市をリクエストする必要があるよ。インターフェースはこんな感じを想定:

  • リクエスト方式:POST
  • リクエストパス:/hotel/filters
  • リクエストパラメータ:RequestParams、検索ドキュメントのパラメータと同じ
  • 戻り値の型:Map<String, List<String>>

Controller

1
2
3
4
@PostMapping("/filters")
public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
    return hotelService.getFilters(params);
}

Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public Map<String, List<String>> getFilters(RequestParams params) {
    // リクエスト
    SearchRequest request = new SearchRequest("hotel");
    // DSL
    basicQuery(params, request);
    // sizeを設定
    request.source().size(0);
    // アグリゲーション
    request.source().aggregation(
            AggregationBuilders
                    .terms("brandAgg")
                    .field("brand")
                    .size(100)
    );
    request.source().aggregation(
            AggregationBuilders
                    .terms("cityAgg")
                    .field("city")
                    .size(100)
    );
    request.source().aggregation(
            AggregationBuilders
                    .terms("starAgg")
                    .field("starName")
                    .size(100)
    );
    // リクエスト実行
    try {
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);

        // レスポンスをパース
        Map<String, List<String>> result = new HashMap<>();
        Aggregations aggregations = response.getAggregations();
        // ブランド
        List<String> brandList = getAggName(aggregations, "brandAgg");
        result.put("ブランド", brandList);
        // 都市
        List<String> cityList = getAggName(aggregations, "cityAgg");
        result.put("都市", cityList);
        // 星評価
        List<String> starList = getAggName(aggregations, "starAgg");
        result.put("星級", starList);
        return result;
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

private static List<String> getAggName(Aggregations aggregations, String name) {
    // ブランドを取得
    Terms brand = aggregations.get(name);
    // バケットを取得
    List<? extends Terms.Bucket> buckets = brand.getBuckets();
    // イテレート
    List<String> brandList = new ArrayList<>();
    for (Terms.Bucket bucket : buckets) {
        // キーを取得
        String key = bucket.getKeyAsString();
        brandList.add(key);
    }
    return brandList;
}

Visits Since 2025-02-28

Hugo で構築されています。 | テーマ StackJimmy によって設計されています。