株式会社CINC 開発本部 エンジニアブログ

ビッグデータ取得・自然言語処理・人工知能(AI)開発を軸に、マーケティングソリューションの開発や、DX推進を行っている、株式会社CINCの開発本部です。

Amazon OpenSearch Service ( ElasticSearch ) で Terms Aggregation のパーティショニング

こんにちは、株式会社CINC開発本部です。

前回・前々回に引き続き、Amazon OpenSearch Service ( ElasticSearch ) 関連のTipsです。

↓前々回の記事↓ cincdevteam.hatenablog.com

↓前回の記事↓ cincdevteam.hatenablog.com

はじめに

Amazon OpenSearch Service の Terms Aggregation は、SQLのGroup By句のような集計を実現できます。

ですが、パフォーマンス上の制約から、 1 回のリクエストで取得できる件数の最大値は 10,000 件までという制限があります。

参考:Terms aggregation | Elasticsearch Guide [8.16] | Elastic

そのため、大量のデータを集計して取得する用途には利用できない、と考えていました。

実は、パーティショニングを用いることで、 10,000 件を超えるデータを複数のリクエストに分割しつつ取得することが可能なことを発見しました。

以下で、その具体的な実装方法について説明します。

パーティショニングの指定方法

Amazon OpenSearch Service でのパーティショニングは以下の3つの要素を指定することで、利用ができます。

要素名 説明
partition分割したパーティションを特定するための番号
num_partitions分割したパーティションの総数
size1つのパーティションに含める terms の件数

実装例

例として

200,000 未満 ユーザー分のデータがある、ユーザログのドキュメントから、ユーザーごとの最終アクセス日付を取得するクエリを考えてみます。

※「未満」と書いた理由は後述します

GET /_search
{
   "size": 0,
   "aggs": {
      "expired_sessions": {
         "terms": {
            "field": "account_id",
            "include": {
               "partition": 0,      <= ①
               "num_partitions": 20 <= ②
            },
            "size": 10000,          <= ③
            "order": {
               "last_access": "asc"
            }
         },
         "aggs": {
            "last_access": {
               "max": {
                  "field": "access_date"
               }
            }
         }
      }
   }
}

account_id を Term とし、 Aggregation にて Term ごとに最大の access_date を取得します(これを last_access とする)

その際に、10,000 件ごと(③)のパーティションを 20 作成(②)し、そのパーティションの 0 番目(①)を取得する、というクエリです。

このクエリを使って、パーティションごとに 20 回リクエストすることで、合計 200,000 件の Term を取得できます。

※ 実際は partition の指定を 0〜19 と変えてリクエストします

取得したいTerm数と、設定値の関係

取得したいTerm数と、設定値の関係は以下のとおりです。

 \text{取得したいTerm数} \lt \text{size} \times \text{num_partitions}

注意事項

この手法を使うにあたり、以下の注意事項があります。

  • partition は、順に指定する必要はありません。
    • 初めに partition = 3 のリクエストを発行した後、 partition = 1 のリクエストを発行しても問題ないです。
    • 並行処理で取得することも可能です。
  • 取得したい term数 = num_partitions × size としている場合、データが欠落する可能性があります。(これが 未満 とした理由です)
    • 1つのパーティションで取得できるterm数は size の値より小さくなる場合があるため、より厳密に取得したい場合は、取得したいterm数より大きい数にする必要があります。
  • ソート指定は不可です。
    • Termのソートを行うことはできないため、ソートした大量データの一部のみ分割して取得するといったことはできません。

まとめ

今回は、 Aggregate 結果が大量データになった時に、パーティショニングを用いて全データを取得する方法について発見しました。 この方法を使うことで、10,000 件を超えるデータを取得する際にも、効率的にデータを取得することができます。

以上、Amazon OpenSearch Service のパーティショニングについてのTipsでした。

参考

Amazon OpenSearch Service でのページング方法と、Terms Aggregationを行う際のページング方法

こんにちは、株式会社CINC開発本部です。

前回に引き続き、Amazon OpenSearch Service ( ElasticSearch ) 関連のTipsです。

↓前回の記事↓ cincdevteam.hatenablog.com

今回は

今回は、クエリの結果をページングしたい時の手法と、 Terms Aggregation での集計時にページングをする方法を記載します。 この情報が同じように悩んでいる方々の参考になればと思います。

概要

Amazon OpenSearch Service では、クエリの結果をページングする方法がいくつか用意されています。

ここでは、これらのページング方法についての概要と、Sort resultを利用したTerms Aggregation におけるページング方法について説明します。

Paginate results の概要

Paginate results では、fromsize を指定することで検索結果のサブセットを取得する事が可能です。 検索結果が10,000件以内の場合にのみ利用可能な手法です。

GET shakespeare/_search
{
  "from": 0,
  "size": 10,
  "query": {
    "match": {
      "play_name": "Hamlet"
    }
  }
}

Paginate results の注意事項

  • 10,000件のデータに対するサブセットに限定されています。
  • from および size はステートレスなので、一貫性のないページネーションが発生する可能性があります。
  • Terms Aggregation では使用できません。

Sort results の概要

Sort results では、以下のパラメータを指定することで検索結果のサブセットを取得できます。 特にデータが大きい時に利用する方法です。

  • sort
    • 検索結果の順序を指定します。(複数のフィールドを指定可能)
    • このパラメータを指定するとhitsの各要素にソート情報(下記)が含まれます。
      • 検索時に指定したフィールドの値が配列で格納されます。
  • size
    • サブセットの件数を指定します。
  • search_after
    • 取得する下限値を指定します。
    • hits の配列末尾に含まれるソート情報を指定します。
GET shakespeare/_search
{
  "query": {
    "term": {
      "play_name": {
        "value": "Henry IV"
      }
    }
  },
  "sort": [
    {
      "line_id": {
        "order": "desc"
      }
    },
    {
      "speech_number": {
        "order": "desc"
      }
    }
  ],
  "search_after": [
    "3202",
    "8"
  ]
}

Sort results の注意事項

  • Terms Aggregation では使用できません。
  • 特定のページにダイレクトにアクセスはできません。(1ページ目から順にアクセスする必要があります。)

Terms Aggregationでのページング

OpenSearchでは、Terms Aggregation でのページング機能が用意されていませんが、上記の「Sort Results」と同様の処理を実装することで、ページングを行うことができます。

Terms Aggregation での Sort Results の処理の流れ

  1. Termの並び順が一意となるように sort を指定します。
  2. termsの size に1ページで取得するアイテム数を指定します。
  3. 検索を実施します。(1ページ目の情報を取得)
  4. Termsの検索結果における末尾の要素から sort に指定したフィールドの情報を取得します。
  5. 取得したフィールドの情報を filter クエリに指定して、検索を実施します。(2ページ目の情報を取得)
  6. (以降、取得結果を filter クエリに指定して検索を繰り返すことで、順にページ情報を取得可能です。)

ソートに複数のフィールドを指定する場合の注意事項

次のようなデータ・クエリを例として説明します。

データ

line_id speech_number
1 1
1 2
1 3
2 2
2 3
3 4
3 5
3 6
3 7
4 1
4 8

クエリ

GET shakespeare/_search
{
  "query": {
    "term": {
      "play_name": {
        "value": "Henry IV"
      }
    }
  },
  "sort": [
    {
      "line_id": {
        "order": "asc"
      }
    },
    {
      "speech_number": {
        "order": "asc"
      }
    }
  ]
}

1ページのアイテム数を7件とした場合、2ページ目はデータ表の8〜11行目となります。

2ページ目のデータを取得するための filter クエリは以下のように指定する必要があります。

  • 抽出条件 - ORで指定
    • 抽出条件(第1ソートの境界値に関する条件)- ANDで指定
      • line_id = 3
      • speech_number > 5
    • 抽出条件(その他の条件)
      • line_id > 3

安易に以下の条件を指定すると、10行目 (line_id=4 speech_number=1) が除外されてしまうため注意が必要です。

  • line_id >= 3
  • speech_number > 5

参考

Amazon OpenSearch Service ( ElasticSearch )のクエリで、排他的に抽出対象を絞り込む方法

どうしてこの記事を書こうと思ったのか

こんにちは、株式会社CINC開発本部です。

最近よく業務で Amazon OpenSearch Service ( ElasticSearch ) を利用しています。

今回は、SQLのサブクエリのような絞り込みが bucket_selector を使って実現できました。

Amazon OpenSearch Service ( ElasticSearch ) について基本的なクエリに関する情報は、公式ドキュメントはもちろん、いくつかのサイトでも見つけられましたが、今回のような高度な使い方に関しては、具体的な情報があまり見当たりませんでした。

そこで、自分が試行錯誤しながら辿り着いた解決策を備忘録として残したいと思いました。 この情報が同じように悩んでいる方々の参考になればと思います。

概要

特定の条件で絞り込んだ対象について、その中でも「グループAとそれ以外」という様な排他的な集計を行うクエリを書く際に、RDBであればサブクエリを利用します。
Amazon OpenSearch Service ( ElasticSearch ) では、現時点でサブクエリを実現する機能が提供されていないのですが、今回 bucket_selector を使うことで相当のデータ取得を行いましたので、その実装方法を記載します。

事例

次のような構造のインデックスにデータが入っている時

  • キーワードID(kw_id)
  • ドメイン(host)
  • 検索Vol(sv)

対象インデックスに対して

という条件で抽出を行いたい場合に

RDBであればサブクエリを利用する事で、それぞれの条件での抽出を行う事が可能です。
それに対して、 Amazon OpenSearch Service ( ElasticSearch ) でどの様に抽出したかをまとめていきます。

RDBでの実現方法

下記の様なクエリで抽出を行っています。

SELECT
    t02.host ,
    COUNT(t02.cnt_own) AS cnt_own ,
    sum(t02.sv_own) AS sv_own
FROM
    (
    SELECT
      host,
      kw_id ,
      max(cnt_own) AS cnt_own ,
      max(sv_own) AS sv_own
    FROM
      (
        SELECT
          host,
          kw_id,
          1 AS cnt_own,
          sv AS sv_own
        FROM
          data_table t01
        WHERE
          host = :host_own
          AND kw_id NOT IN (
            SELECT DISTINCT
              kw_id
            FROM
              data_table
            WHERE 
              host = :host_competitor_1
          )
      )
    GROUP BY
      host,
      kw_id
  ) t02
GROUP BY
  t02.host

サブクエリが入れ子になっていますが、自社が持つキーワードの中から、競合のホストの情報を持つkw_idをNOT IN で除外する事で、自社のみが持つキーワードを対象としています。

その後 host, kw_idでGROUP BYして自社のみが持つキーワードについての集計を行っています。
(競合についての集計を行う際には1, svとしている部分については、CASE文を利用して集計用のデータを作ることになります。)

OpenSearchでの実現方法

下記の様なクエリで対応しました。
(JSONにコメントは付けられないので、実行時には // の行は削除が必要です。)

{
  "size": 0,
  "from": 0,
  "_source": false,
  "query": {
    "bool": {
      "filter": [
        {
           // ① 全体の絞り込み
          "bool": {
            "minimum_should_match": 1,
            "should": [
              {
                "match": {
                  "host": "test1.com"
                }
              },
              {
                "match": {
                  "host": "test2.com"
                }
              }
            ]
          }
        }
      ]
    }
  },
  "aggs": {
    "kw_id_terms": {
      "aggs": {
         // ② キーワード単位でのグルーピング
        "terms":{
          "field": "kw_id",
          "size": 100
        },
        // ③ 自社の絞り込み
        "own_filter": {
          "filter": {
            "match": {
              "host": "test1.com"
            }
          }
        },
        // ③ 競合の絞り込み
        "competitors_filter": {
          "filter": {
            "bool": {
              "minimum_should_match": 1,
              "should": [
                {
                  "match": {
                    "host": "test2.com"
                  }
                }
              ]
            }
          }
        },
        // ④ キーワードの絞り込み
        "keyword_bucket_selector": {
          "bucket_selector": {
            "buckets_path": {
              "competitors": "competitors_filter>_count",
              "own": "own_filter>_count"
            },
            "script": "params.own > 0 && params.competitors == 0"
          }
        },
        "sv_max": {
          "max": {
            "field": "sv"
          }
        }

      }
    },
    "sv_sum_bucket": {
      "sum_bucket": {
        "buckets_path": "kw_id_terms>sv_max"
      }
    }
  }
}

以下で①〜④のポイントについて詳細に解説します。

①全体の絞り込み

"bool": {
      "filter": [
        {
          "bool": {
            "minimum_should_match": 1,
            "should": [
              {
                "match": {
                  "host": "test1.com"
                }
              },
              {
                "match": {
                  "host": "test2.com"
                }
              }
            ]
          }
        }
      ]
    }

filterの中でshouldを利用しtest1.com と test2.comのいずれかに一致している結果を対象としています。
minimum_should_matchに1を設定する事でいずれか一つ以上にヒットしているもののみが対象となります。
(minimum_should_matchはデフォルトでは0なので、指定しない場合に他のfilter条件があると、それにマッチしているだけで検索に引っかかってしまうためです。)

②キーワード単位でのグルーピング

        "terms":{
          "field": "kw_id",
          "size": 100
        },

termsでkw_id毎にバケットを作成しています。
(なお、例ではsize:100としているが、結果キーワード数が十分入るsizeにしておく必要があります。場合によってはパーティションを設定して複数回に分けての取得が必要です。)

③自社・競合の絞り込み

        "own_filter": {
          "filter": {
            "match": {
              "host": "test1.com"
            }
          }
        },
        "competitors_filter": {
          "filter": {
            "bool": {
              "minimum_should_match": 1,
              "should": [
                {
                  "match": {
                    "host": "test2.com"
                  }
                }
              ]
            }
          }
        },

termsバケット内に更に

  • 自社ドメインのみを抽出するフィルタ(own_filter)
  • 競合ドメインのみを抽出するフィルタ(competitors_filter)

を用意しています。

競合用の filter では 複数の競合を指定する事 もあるため should を利用しています。

④キーワードの絞り込み

        "keyword_bucket_selector": {
          "bucket_selector": {
            "buckets_path": {
              "competitors": "competitors_filter>_count",
              "own": "own_filter>_count"
            },
            "script": "params.own > 0 && params.competitors == 0"
          }
        },

bucket_selectorを利用することで 指定した条件に合致するバケットのみ を対象とする事が可能です。

例では「 自社ドメインを含まない(競合ドメインのみ) のフィルタのドキュメント数が0で、且つ 自社ドメインのみのフィルタのドキュメント数 が0より大きいバケット」を対象としています。
これにより termsでは、自社のみドキュメントがあるキーワードだけがバケットに含まれる形になり、 sv_sum_bucket では、条件を満たしたバケットの sv_max の合計を集計しているため 自社のみが含まれるキーワード のSV合計が算出できます。

参考

Bucket aggregations - OpenSearch Documentation