Elasticsearch 入門。その2

2021.08.16

Elasticsearch 初学者の中村です。 入門その2では、 Bulk APIや検索方法について学んだことを書いていきます。

その1はこちら

Bulk API

前回の記事でCRUD処理を行うAPIを紹介しましたが、大量のドキュメントを処理するのに1件ずつAPIを実行していては時間やリソースの無駄使いな為、Elasticsearchでは一括処理用のAPIが用意されています。

https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html

使い方

Bulk APIでは、複数のドキュメントの登録、削除、更新等が1回のAPI呼び出しで実行可能です。

POST /<index>/_bulk にJSONL(NDJSON)フォーマットで操作したいドキュメント情報を指定します。

POST /shop/_bulk
{"index" : {"_id" : "shop_001"}}
{"name" : "shop name", "address" : "Saitama-ken xxx"}
{"delete" : {"_id" : "shop_002"}}
{"update" : {"_id" : "shop_001"}}
{"doc" :{ "address" : "Tokyo-to xxxxx"}}

一括でリクエストすることにより1件ずつ処理するよりもオーバーヘッドが少なくなり、負荷軽減や速度向上が見込めます。

ファイル指定

JSONLファイルを指定指定することも可能です。

https://www.elastic.co/guide/en/kibana/7.6/tutorial-build-dashboard.html#load-dataset

公式で配布されているサンプルデータのaccounts.jsonをダウンロードし、以下のcurlコマンドを実行します。

% curl -H 'Content-Type: application/x-ndjson' -XPOST 'localhost:9200/account/_bulk?pretty' --data-binary @accounts.json

  "took" : 327,
  "errors" : false,
  "items" : [
    {
      "index" : {
        "_index" : "bank",
        "_type" : "account",
        "_id" : "1",
        "_version" : 1,
        "result" : "created",
        "_shards" : {
          "total" : 2,
          "successful" : 1,
          "failed" : 0
        },
        "_seq_no" : 0,
        "_primary_term" : 1,
        "status" : 201
      }
    },
    ...

検索

上記で登録したaccounts.jsonのドキュメントを少し修正して色々な検索を試します。 Elasticsearchではインデキシングしたフィールドに対してSearch APIで検索を行うことが出来ます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html

GET /<index>/_search
POST /<index>/_search

Search APIではGETとクエリパラメータやリクエストボディの組み合わせで検索も可能ですが、本稿ではPOSTとリクエストボディで検索条件を指定する形で記載いたします。

Match query

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html

match queryは検索条件に指定したフィールドの値が等値のドキュメントを返してくれます。

POST account/_search
{
  "query": {
    "match": {
      "firstname": "Amber"
    }
  }
}

上記検索条件は firstname フィールドに Amber という値を持つドキュメントを検索して、Elasticsearchは該当のドキュメントを返してくれました。

Elasticsearchでは検索結果をレスポンスJSON .hits.hits[] 配下に含めて返してくれます。ヒットした件数は .hits.total.value になります。

AND検索

match queryはデフォルトでは OR検索を行います。 試しに先程の検索条件を変更して検索します。

"address": "880 Holmes Lane"

検索結果をみると、 address880,Holmes,Lane何れかが含まれたドキュメントが返されます。

AND検索を行いたい場合は operatorandを指定します。

POST account/_search
{
  "query": {
    "match": {
      "address": {
        "query": "880 Holmes Lane",
        "operator": "and"
      }
    }
  }
}

operator を指定することにより、 880,Holmes,Lane が全て含まれたドキュメントのみ検索が出来ます。

minimum_should_match

実際に検索サービスを運用する際、ORでは緩くANDでは厳しすぎるケースがあります。 その場合には minimum_should_match を指定することで、最低限含まれる単語を指定することが出来ます。

POST account/_search
{
  "query": {
    "match": {
      "address": {
        "query": "880 Holmes Lane",
        "minimum_should_match": 2
      }
    }
  }
}

この場合は、 880,Holmes,Lane の何れか2つが一致するドキュメントが返ります。

Match phrase query

match queryでは条件に一致するドキュメントを検索できましたが、語順は考慮されていませんでした。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html

POST account/_search
{
  "query": {
    "match": {
      "address": {
        "query": "Kings Place", 
        "operator": "and"
      }
    }
  }
}

上記match queryではKings Place が語順関係なくヒットしています。2件目では Kings Holmes Place と間に別の語句が入っていてもヒットします。

match_phrase を使うことで語順も加味した検索を行うことが出来ます。

POST account/_search
{
  "query": {
    "match_phrase": {
      "address" : "Kings Place"
    }
  }
}

match_phrase では、検索条件の単語が全て含まれていて、それぞれの単語が近くに存在すること。を検索するためコストが非常に高いので注意です。

slopパラメータ

match_phrase では厳しすぎるケースに対応するため緩めるために slop パラメータを指定可能です。

POST account/_search
{
  "query": {
    "match_phrase": {
      "address" : {
        "query": "Kings Place",
        "slop": 1
      }
    }
  }
}

"slop" : 1 を指定することにより、検索条件の単語間がどれくらい離れていいかを指定ができます。 上記を実行すると先程の条件ではヒットしなかったドキュメントがヒットするようになります。

Range query

range queryでは値を範囲指定して検索することが出来ます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html

以下では、 age フィールドに20以上30以下の値を持つドキュメントを検索しています。

POST account/_search
{
  "query": {
    "range": {
      "age": {
        "gte": 20,
        "lte": 30
      }
    }
  }
}

rangeは日付型のフィールドにも有効で、Elasticsearchでは検索用に様々な表現が用意されています。 下記クエリ例ではタイムスタンプフィールドが昨日から今日までの値を持つドキュメントを検索します。

POST <index>/_search
{
  "query": {
    "range": {
      "timestamp": {
        "gte": "now-1d/d",
        "lt": "now/d"
      }
    }
  }
}

Bool query

実際のシステムの検索条件は複雑で、複数の条件で検索することが多くなると思います。その際にはbool queryを利用します。

bool queryは以下の組み合わせで条件を構成していきます。

  • must
    • 条件が一致しなければならない。スコアに影響する
  • must_not
    • 条件が一致してはいけない。スコアに影響しない
  • should
    • 条件が一致してもしなくてもよい。スコアに影響する
  • filter
    • 条件が一致しなければならない。スコアに影響しない

mustとfilterが同じに見えますが、スコア算出の箇所が違います。ここに関しては後述いたします。

must

下記では、 genderMに一致するドキュメントを検索します。

POST account/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "gender": "M"
          }
        }
      ]
    }
  }
}

今まで紹介した検索条件とは構文が少し代わり .query.bool.must[] の下に詳細な条件を指定していきます。

must_not

先程の条件に stateIL一致しないという条件を追加します。

これにより、 genderM かつ stateIL ではない。という条件になります。

POST account/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "gender": "M"
          }
        }
      ],
      "must_not": [
        {
          "match": {
            "state": "IL"
          }
        }
      ]
    }
  }
}

should

should はヒット件数には影響しないけども、条件にヒットしたドキュメントのほうがスコアが高くなります。

先程の条件に30歳未満のshouldを追加してみます。

POST account/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "gender": "M"
          }
        }
      ],
      "must_not": [
        {
          "match": {
            "state": "IL"
          }
        }
      ],
      "should": [
        {
          "range": {
            "age": {
              "lt": 30
            }
          }
        }
      ]
    }
  }
}

先ほどとヒットした件数(.hits.total.value)の値は変わりませんが、ヒットしたドキュメントの順番が変わり、20台のドキュメントが先に返ってきていることが分かります。

filter

filter は条件に一致していないといけませんが、スコアには影響しません。

先程の条件にfilterにcityCoalmontの場合。という条件を追加します。

POST account/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "gender": "M"
          }
        }
      ],
      "must_not": [
        {
          "match": {
            "state": "IL"
          }
        }
      ],
      "should": [
        {
          "range": {
            "age": {
              "lt": 30
            }
          }
        }
      ],
      "filter": [
        {
          "match" : {
            "city" : "Coalmont"
          }
        }
      ]
    }
  }
}

ヒットした件数は1件に減り、ヒットしたドキュメントのスコア(.hits.hits._score)は先程と変わっていません。 filterは単純にドキュメントを絞りたい場合に利用します。

スコア

検索結果レスポンスのスコア(.hits.hits._score) は検索条件のクエリに、該当のドキュメントがどれくらい類似しているかの値になります。

スコアはヒットしたドキュメントそれぞれに対して計算されます。 ちなみに .hits.max_score にはヒットしたドキュメントの中で最大のスコアが入ります。

Elasticsearchでは類似度の計算にBM25というアルゴリズムをデフォルトで利用します。

Okapi BM25

BM25は以下の計算を行います。

  • TF(term frequency)
    • 検索単語の頻出頻度が多い程スコアが高くなる
  • IDF(inverse document frequency)
    • 検索単語がたくさんのドキュメントに存在するほど、スコアが低くなる
  • Field length
    • 該当フィールドの値の長さが平均より短いほうがスコアが高くなる

上2つはTF-IDFと呼ばれる手法です。

TFでは検索した単語がよく頻出するドキュメントはスコアが高くなります。 Elasticsearch で検索した場合、1回出現するドキュメントと30回出現するドキュメントでは、後者のほうが関連性が高いと判断されます。

IDFでは単語の希少度を判断しスコア付けします。 AWS Elasticsearch _score で検索した場合、AWSElasticsearch を含んだドキュメントは沢山あるのでスコアは低めですが、 _score は他の単語より出現頻度が少ない単語なので、含まれているドキュメントは関連性が高いと判断されます。

Field lengthはフィールドの値の長さが平均より低い場合にスコアが高くなります。 Elasticsearch で検索し、1ページと100ページのドキュメントがヒットし、それぞれ単語が1回しか出てこなかった場合、1ページのドキュメントの方が関連度が高いという判断になります。100ページに1度しか出現しないのであれば、 Elasticsearch に言及しているページでは無さそうですね。

アルゴリズムの詳細については、 Elastic社のブログに詳しく書いてあります。

最後に

今回は検索の方法について学びました。 RDBMSにも全文検索を実現するための機能は提供されていますが、利用するにはコストや負荷を気にしてしまいがちです。

Elasticsearchでは専用APIを利用することにより、簡単に低コストで検索することが出来るのが便利ですね。

次回はマッピングやアナライザーについて書いていこうと思います。