🔥

ElasticSearchのあれこれ(用語・クエリなど)

2024/09/27に公開

ElasticSearchを使用したので、使用した内容のまとめ
(間違えてたらすみません。)

用語解説

インデックス(index)

ドキュメントを保存する場所
RDBのデータベースみたいなもの

シャード(Shard)

インデックスを分割したもので、データを複数のノードに分散して保存するために使用

ドキュメント(Document)

インデックス内に保存される基本的な情報の単位。
JSON形式で表現される。
RDBでいうrow

{
 'id': 1,
 'name': '織田信長',
 'age': 45
}

フィールド(Field)

ドキュメント内のデータを構成する基本的な要素
key:valueの組です。
keyをフィールド名
valueをフィールド値
とします。
先程の例だと、id, name, ageがフィールド名
1, 織田信長, 45がフィールド値です。

フィールド値には様々なデータ型があります。

データ型

  • text
    • 文字列を格納する
    • 全文検索が可能
    • 文字列が単語分割される
    • 部分一致や曖昧検索に適している
     { 'message': 'Elasticsearchの使い方' }
    
    Elasticsearchの単語で検索が可能
  • keyword
    • textと同様で文字列を格納する
    • 単語分割されない
    • 正確な値の検索に使用(完全一致)
     { 'name': '織田信長' }
    
    織田信長で検索が可能
    メールアドレスなど完全一致で検索する時に便利
  • numeric(integer, floatなど)
    • 数値
    • 数値範囲の検索や集計が可能
  • date
    • 日付
    • 日付範囲の検索や集計が可能。異なる日付フォーマットを指定できる
  • object
    • valueがネストした構造
    { 'address': { 'zip_code': '100-0001', 'prefecture': '東京' } }
    
  • nested
    • objectと同様でvalueがネストした構造
    { 'address': { 'zip_code': '100-0001', 'prefecture': '東京' } }
    
objectnestedの違い
  • object
    • 内部のサブフィールドは親ドキュメントと同じレベルでインデックス化される
    • 複数のオブジェクトがある場合、サブフィールド間の関連性を考慮しない
    • そのため意図しない結果が返ることがある
  • nested
    • 個別のドキュメントとして扱われる
    • サブフィールド間の関係を保持する

objectでは単純な構造を保存するのに適する
データが深くネストしておらず、サブフィールド間の関係性を考慮しない場合
nestedはサブフィールドを個別に検索・集計する場合に適切

{
  "name": "佐藤花子",
  "purchases": [
    {
      "item": "ノートパソコン",
      "price": 150000,
      "date": "2023-10-01"
    },
    {
      "item": "スマートフォン",
      "price": 80000,
      "date": "2023-10-05"
    }
  ]
}

上記のようなデータがあった場合
itemがノートパソコンかつ、priceが150000のデータを取得したい場合には、nestedだと個別に検索・集計ができるので適切なデータの取得ができる

マッピング(Mapping)

ドキュメント内のフィールドとそのデータタイプ(文字列、数値、日付など)を定義する
RDBでいうスキーマ

マッピングの更新

既存のindexのマッピングを変更したい場合は、既存のindexに対して直接マッピングの更新ができません。そのためindexを再作成する必要があります。
流れとしては以下になります。

  1. 新しいマッピングでindexを作成
  2. 既存のデータを新しく作成したindexに移行
  3. エイリアスの切り替え
  4. 古いindexの削除

indexの作成

新しいマッピングでindexを作成します。
この時元のindex名は使用できないので、別名で作成する必要があります。

データの移行

既存のindexから新しく作成したindexに対してデータの移行を行います。
データを成形する必要がなく、そのままコピーするだけでいい場合は_reindexが使用できます。

POST /_reindex
{
  "source": {
    "index": "old_index_name"
  },
  "dest": {
    "index": "new_index_name"
  }
}
  • 注意点
    • データ量が多いと時間がかかります
    • 途中で更新されると、データ不整合が発生する可能性がある

エイリアスの切り替え

新しいindexに対してエイリアスを設定

POST /_aliases
{
  "actions": [
    { "add": { "index": "new_index_name", "alias": "index_alias" } }
  ]
}

indexに対してエイリアスを使用している場合は切り替える必要があります。

POST /_aliases
{
  "actions": [
    { "remove": { "index": "old_index_name", "alias": "index_alias" } },
    { "add":    { "index": "new_index_name", "alias": "index_alias" } }
  ]
}

インデックス(index)のコピー

バックアップなどで、indexをそのままコピーしたい場合
マッピングも同じになります。
一部設定を変更することも可能です。

POST /<元のインデックス名>/_clone/<新しいインデックス名>
{
  "settings": {
    // 必要に応じて設定を変更
  }
}
  • 注意点
    • clone元のindexをcloseしてからでないとcloneできない
    • 元のindexをcloseにすると、読み書きができなくなるので一部動作が止まる可能性がある

クエリ(query)

Elasticsearchのクエリは、検索条件を定義するJSONオブジェクト

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

マッチクエリ(Match Query)

全文検索を行う際に使用する

例: messageフィールドに「Elasticsearch」という単語を含むドキュメントを検索

GET /articles/_search
{
  "query": {
    "match": {
      "message": "Elasticsearch"
    }
  }
}

タームクエリ(Term Query)

正確な値一致の検索をする
フィールドがkeyword型に適している

例: statusフィールドが「published」のドキュメントを検索

GET /articles/_search
{
  "query": {
    "term": {
      "status": "published"
    }
  }
}

レンジクエリ(Range Query)

数値や日付などの範囲検索を行う

例: publish_dateが2023年1月1日から2023年10月31日までのドキュメントを検索

GET /articles/_search
{
  "query": {
    "range": {
      "publish_date": {
        "gte": "2023-01-01",
        "lte": "2023-10-31"
      }
    }
  }
}

ブールクエリ(Bool Query)

複数のクエリを組み合わせて、論理的な条件(AND、OR、NOT)を構築する

例: contentに「Elasticsearch」を含み、authorが「山田太郎」のドキュメントを検索

GET /articles/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "content": "Elasticsearch" } },
        { "term": { "author": "山田太郎" } }
      ]
    }
  }
}

ワイルドカードクエリ(Wildcard Query)

パターンに一致する値を検索
*?が使用できる
*は0文字以上の任意の文字列にマッチ
?は任意の1文字にマッチ

例: titleが「入門」で始まるドキュメントを検索

GET /articles/_search
{
  "query": {
    "wildcard": {
      "title": "入門*"
    }
  }
}

例: titleに「織田信」が含まれるドキュメントを検索

GET /articles/_search
{
  "query": {
    "wildcard": {
      "title": "織田信?"
    }
  }
}

ネストクエリ(Nested Query)

ネストされたオブジェクト内のフィールドを検索

例: commentsというネストフィールド内で、authorが「佐藤花子」でtextに「ありがとう」を含むコメントを持つドキュメントを検索

GET /articles/_search
{
  "query": {
    "nested": {
      "path": "comments",
      "query": {
        "bool": {
          "must": [
            { "match": { "comments.author": "佐藤花子" } },
            { "match": { "comments.text": "ありがとう" } }
          ]
        }
      }
    }
  }
}

クエリの組み合わせ

クエリは複数組み合わせることで、複雑な条件で検索ができる

例: contentに「Elasticsearch」または「検索エンジン」を含み、tagsに「deprecated」を持たないドキュメントを検索

GET /articles/_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "content": "Elasticsearch" } },
        { "match": { "content": "検索エンジン" } }
      ],
      "must_not": {
        "term": { "tags": "deprecated" }
      }
    }
  }
}

フィルター(filter)

ドキュメント検索時に絞り込み条件として指定

例: contentに「Elasticsearch」を含み、statusが「published」のドキュメントを検索(statusはフィルターとして使用)

GET /articles/_search
{
  "query": {
    "bool": {
      "must": {
        "match": { "content": "Elasticsearch" }
      },
      "filter": {
        "term": { "status": "published" }
      }
    }
  }
}

スクリプト(Script)

スクリプトを使用すると、検索や値を柔軟にカスタマイズできる
ドキュメントのフィールド値を動的に操作したり、一括で更新できたりする
例えば、税込の値段で返したい場合など、スクリプトを用いると税込の計算結果を返すことが可能

※スクリプトを使用すると処理に時間がかかる恐れがあるので要注意

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

スクリプトフィールド

スクリプトを用いて、検索結果に動的に計算したフィールドを追加

例: 消費税を含めた値段を取得
商品データのインデックスproducts、価格のフィールドがprice(税抜)だとする
検索結果に税込価格を含めたい場合

GET /products/_search
{
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "price_with_tax": {
      "script": {
        "lang": "painless",
        "source": "doc['price'].value * 1.10"
      }
    }
  }
}

script_fields: 検索結果に計算フィールドを追加する
price_with_tax: 追加されるフィールド名
script: 計算内容を記述する部分
lang: 使用するスクリプト言語(painlessが推奨らしい。。)
source: スクリプト本体
 doc['price'].valuepriceフィールドの値を取得し、1.10(消費税10%)倍する

取得結果

{
  "hits": {
    "hits": [
      {
        "_source": {
          "product_name": "商品A",
          "price": 1000
        },
        "fields": {
          "price_with_tax": [1100.0]
        }
      },
      // 他の商品ドキュメント
    ]
  }
}

スクリプトクエリ

条件をスクリプトでカスタムして検索が可能

例: 在庫数と価格を組み合わせた条件で検索
商品データのインデックスproducts、価格のフィールドがpriceで在庫数のフィールドがstockの場合
・在庫数が50以上
・価格が在庫数の2倍以下

GET /products/_search
{
  "query": {
    "script": {
      "script": {
        "lang": "painless",
        "source": "doc['stock'].value >= 50 && doc['price'].value <= doc['stock'].value * 2"
      }
    }
  }
}

source: 在庫数と価格の関係を条件式で記述

スクリプト更新

ドキュメントのフィールドを動的に更新

例: 価格を一括で10%値上げ
商品データのインデックスproducts、価格のフィールドがprice

POST /products/_update_by_query
{
  "script": {
    "lang": "painless",
    "source": "ctx._source.price *= 1.10"
  },
  "query": {
    "match_all": {}
  }
}

スクリプト内でのパラメータの使用

スクリプト内で変数を使用する場合、パラメータを渡すことができる

GET /products/_search
{
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "price_with_tax": {
      "script": {
        "lang": "painless",
        "source": "doc['price'].value * params.tax_rate",
        "params": {
          "tax_rate": 1.10
        }
      }
    }
  }
}

params: スクリプト内で使用するパラメータを定義
params.tax_rate:スクリプト内でパラメータを参照

パラメータで日付を渡すと文字列になる

日付で絞り込む場合にはrangeの使用が便利だが、rangeで対応できなく、scriptを使用してパラメータで日付を渡す場合、パラメータで渡した値は文字列となる
そのため、日付型に変換する必要がある

また、日付フィールドをスクリプト内で使用するとエポックミリ秒となるため、Instant型に変換することで日付扱いできる

例: signup_dateフィールドが指定した日時以降のユーザーを検索するスクリプトクエリを作成します。

GET /users/_search
{
  "query": {
    "script": {
      "script": {
        "lang": "painless",
        "source": """
          Instant docDate = Instant.ofEpochMilli(doc['signup_date'].value);
          Instant paramDate = Instant.parse(params['from_date']);
          return docDate.isAfter(paramDate) || docDate.equals(paramDate);
        """,
        "params": {
          "from_date": "2023-10-01T00:00:00Z"
        }
      }
    }
  }
}

スクリプトメトリック(scripted_metric)

標準の集計機能では対応できない複雑な計算や、特殊なビジネスロジックを適用した集計を行いたい場合に使用する
https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-scripted-metric-aggregation.html

scripted_metricは以下の4つで構成される

  • init_script
    • 各シャードで集計処理を開始する際に、一度だけ実行
    • 集計の初期化処理をおこなう
  • map_script
    • 各ドキュメントに対して実行される
    • ドキュメントの値を集計用の変数に追加したり、計算を行う
  • combine_script
    • 各シャード内でmap_scriptの結果をまとめる
    • シャードごとの部分集計結果を生成する
  • reduce_script
    • すべてのシャードから集められた部分集計結果を最終的な結果にまとめる

例: 購入金額の合計と平均を計算
各顧客が行った購入の合計金額と平均購入金額を計算する
customer_id: 顧客ID
amount: 購入金額

GET /sales/_search
{
  "size": 0,
  "aggs": {
    "customers": {
      "terms": {
        "field": "customer_id"
      },
      "aggs": {
        "custom_metrics": {
          "scripted_metric": {
            "init_script": "state.transactions = []",
            "map_script": "state.transactions.add(doc['amount'].value)",
            "combine_script": "return state.transactions",
            "reduce_script": """
              double total = 0;
              int count = 0;
              for (s in states) {
                for (t in s) {
                  total += t;
                  count += 1;
                }
              }
              double avg = total / count;
              return ['total_amount': total, 'average_amount': avg];
            """
          }
        }
      }
    }
  }
}

init_script: state.transactionsという空のリスト初期化
map_script: 各ドキュメントのamountstate.transactionsに追加
combine_script: シャード内のstate.transactionsをそのまま返す
reduce_script: すべてのシャードから集められたtransactionsを合計し、平均を計算。最終的な結果として、合計金額と平均金額を返す

結果例

{
  "aggregations": {
    "customers": {
      "buckets": [
        {
          "key": "customer_1",
          "doc_count": 5,
          "custom_metrics": {
            "value": {
              "total_amount": 1500.0,
              "average_amount": 300.0
            }
          }
        },
        // 他の顧客の集計結果
      ]
    }
  }
}

nested型ではないobject型のデータの集計をしたいときなどに利用できる

その他

クエリ実行時にはサイズに注意!

クエリを実行する時にサイズを指定しないとデフォルトの10件になる
全件取得して、データを成形して、更新、みたいな処理の時にサイズの指定を忘れると10件しか更新されないので注意!
デフォルトの最大値は10000件

例: sizeの指定をしない場合(デフォルト10件)

GET /products/_search
{
  "query": {
    "match_all": {}
  }
}

例: sizeの指定した場合(50件)

GET /products/_search
{
  "size": 50,
  "query": {
    "match_all": {}
  }
}

おわり

ElasticSearchについて自分で調べたことをまとめたので、間違っている箇所があるかもです。。

object型に対してscripted_metricを用いて、無理やり計算することがあって、今回色々調べる機会がありました。
最終的にobject型より便利なnested型の存在に気がついて、マッピング更新して対応することにしました。
また更新しようとおもいます

Discussion