先日、Elasticsearchの使い方という記事を書きましたが、ここに書ききれなかったTipsやユースケースについて紹介しようと思います。

Elasticsearch version: 7.11

文字列を扱うtextとkeywordの違い

  • text型
    • index登録時にアナライザーによって処理され、トークンに分割されて保存される
    • アナライザーは以下の3つで構成される
      • Char Filter
        • トークンを認識せずに行える機械的な変換処理
        • 大文字、小文字変換、特殊文字正規化、正規表現による抽出など
      • Tokenizer
        • 文字列をトークンに分割する(分かち書きする)
        • 形態素解析、N-gram、特定文字でsplitなど
      • Token Filter
        • トークンに対しての変換処理
        • ストップワード除去、ステミングなど
    • フレーズ検索ができる
      • いわゆる「全文検索」ができるということ
      • トークンに分かれているので、複数の連続するトークン(=フレーズ)と、入力文字列をアナライズした結果の一連のトークン(=フレーズ)を一致させる、フレーズ検索ができる
    • デフォルトだと、集計やソートには使えない
      • fielddata: trueを設定することで使えるようになる
      • メモリに乗るので、ヒープ使用状況を確認する必要がある
      • Cerebroを使っていれば、管理ツールで確認できる(APIもあります)
  • keyword型
    • index登録時にノーマライザーによって処理され、トークンに分割されずに保存される
    • ノーマライザーは以下の二つで構成される(Tokenizerは存在しない)
      • Char Filter
        • アナライザーにおける Char Filter とおなじ
      • Filter
        • アナライザーにおける Token Filter と同じ
        • 文字列全体に対して適用される
    • フレーズ検索ができない
      • トークンに分かれてないため、文字列全体に対するmatch検索しかできない
    • 集計やソートに使える

インデックスへのエイリアス設定

Elasticsearchでは既存のインデックスを直接変更できないので、新しいインデックスを作って切り替える必要があります。
とはいえ、インデックス名が変わるとインデックス利用箇所を全て変更していく必要があり大変です。
そうならないように、事前にインデックスに対してエイリアスを設定しておくことをお勧めします。

  • 事前にエイリアスを設定しておく
    • warloads_202102 <= warloads(エイリアス)
  • 新しくインデックスを追加する
  • エイリアスを付け替える
    • warloads_202102
    • warloads_202103 <= warloads(エイリアス)

例えば、 warloads というインデックスを作成したい場合、変更に備えてインデックス名を warloads_202102 としておき、warloadsというエイリアスを設定しておきます。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/_aliases' -d '{
  "actions": [
    {
      "add": {
        "index": "warloads_202102",
        "alias": "warloads"
      }
    }
  ]
}'

インデックスの変更が必要になった場合は、新しく warloads_202103 という名前のインデックスを作成して、エイリアスを貼り替えます。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/_aliases' -d '{
  "actions": [
    {
      "remove": {
        "index": "warloads_202102",
        "alias": "warloads"
      }
    },
    {
      "add": {
        "index": "warloads_202103",
        "alias": "warloads"
      }
    }
  ]
}'

こうしておけば、Elasticsearchを利用する側は常に warloads でアクセスできるので、安心です。

インデックス変更

まず前提として、Elasticsearchでは基本的には一度作成したインデックスを変更することはできないので、意外に大変な作業になります。

汎用的な方法

もっとも汎用的な方法は、Create Index APIを使って、新しいインデックスを作成しておいて、データを一から登録しなおすという方法です。
これは単純なのですが、件数が多くなってくると実行時間もかかるし、元データが同一であることを担保するのが大変なのでできれば避けたいところです。

というわけで、Elasticsearchの中だけで完結するケースをいくつか紹介しようと思います。

フィールド名を変更したいだけのケース

https://stackoverflow.com/questions/43120430/elasticsearch-mapping-rename-existing-field

例えば、以下のようにwarloads_202102というインデックスにおいて
フィールド名を age => death_age に変更するケースを考えます。

{
  "warloads_202102": {
    "mappings": {
      "properties": {
        "age": { // ここを death_age に変更する
          "type": "long"
        }
      }
    }
  }
}

まず、データ取り込み用の Ingest Node Pipeline を作成します。

curl -H 'Content-type: application/json' -XPUT 'http://localhost:9200/_ingest/pipeline/warloads_rename_age_to_death_age_pipeline' -d '{
  "processors": [
    {
      "rename": {
        "field": "age",
        "target_field": "death_age",
        "ignore_missing": true
      }
    }
  ]
}'
  • 今回はフィールド名を変更するのでrenameプロセッサーを利用
  • 変更するage以外のフィールドについては、特に指定する必要なし
  • ignore_missingは今回は指定しなくても問題ないが、Elasticsearchは値がない場合、フィールド自体が存在しないので、そのようなフィールドをリネームする場合は必要になる

先ほど定義したパイプラインを指定して、Reindex API を使って、新しいインデックスを作成します。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/_reindex' -d '{
  "source": {
    "index": "warloads_202102"
  },
  "dest": {
    "index": "warloads_202103",
    "pipeline": "warloads_rename_age_to_death_age_pipeline"
  }
}'

新しく作成されたインデックスの定義は以下の通りで、ちゃんと項目名が変更されました。
もちろん他の項目はそのままで、match_allクエリで検索してもちゃんと全項目データが入ってました。

{
  "warloads_202103": { // インデックス名が変更されている
    "mappings": {
      "properties": {
        "death_age": { // フィールド名が変更されている
          "type": "long"
        }
      }
    }
  }
}

フィールドの定義を変更したいだけのケース

例えば、以下のようにwarloads_202102というインデックスにおいて、
introductionsフィールドのアナライザーを デフォルトのstarndard analyzer => kuromoji analyzer
に変更するケースを考えます。

{
  "warloads_202102": {
    "mappings": {
        "introduction": {
          "type": "text",
          // ここに、"analyzer": "kuromoji" の設定を追加する
        }
     }
  }
}

kuromoji プラグインのインストールについては、記事の後半に記載しています。
kuromoji プラグインがインストールが完了したら、まずは変更後のwarloads_202103インデックスを作成します。

curl -H 'Content-type: application/json' -XPUT 'http://localhost:9200/warloads_202103' -d '{
  "mappings": {
    "properties": {
      "introduction": {
        "type": "text",
        "analyzer": "kuromoji" // アナライザーに,kuromojiを指定
      },
      // 他のフィールドは省略
    }
  }
}'

あとは、Reindex API を使って、warloads_202102からwarloads_202103にデータを流し込めば大丈夫です。
フィールド名が変更されなければ、単純に新しい定義でデータがコピーされます。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/_reindex' -d '{
  "source": {
    "index": "warloads_202102"
  },
  "dest": {
    "index": "warloads_202103"
  }
}'

これで、warloads_202103インデックスにデータが登録されました。
実際にmatch検索すると、日本語の単語を意識した検索ができていました。

既存のフィールドから導出可能なフィールドを追加するケース

例えば、以下のようにwarloads_202102というインデックスにおいて、
introductionというフィールドからその文字長を導出し、introduction_lengthというフィールドを追加するケースを考えます。

{
  "warloads_202102": {
    "mappings": {
        "introduction": {
          "type": "text",
        },
        // 以下を追加する
        // "introduction_length": {
        //   "type": "long",
        // }
     }
  }
}

まずは、フィールド追加後のwarloads_202103インデックスを作成します。

curl -H 'Content-type: application/json' -XPUT 'http://localhost:9200/warloads_202103' -d '{
  "mappings": {
    "properties": {
      "introduction_length": {
        "type": "long"
      },
      // 他のフィールドは省略
    }
  }
}'

次に、データ取り込み用の Ingest Node Pipeline を作成します。

curl -H 'Content-type: application/json' -XPUT 'http://localhost:9200/_ingest/pipeline/warloads_introcuce_set_pipeline' -d '{
  "processors": [
    {
      "set": {
        "field": "introduction_length",
        "copy_from": "introduction"
      }
    }
  ]
}'
  • 今回は値を再設定するだけなので、Setプロセッサーを利用
  • fieldで設定先のフィールド名を、copy_fromでコピー元のフィールド名を指定する

最後に、Reindex API を使って、warloads_202102からwarloads_202103にデータを流し込めば大丈夫です。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/_reindex' -d '{
  "source": {
    "index": "warloads_202102"
  },
  "dest": {
    "index": "warloads_202103"
  }
}'

これで、warloads_202103インデックスにデータが登録されました。
実際にmatch検索すると、日本語の単語を意識した検索ができていました。

ハイライト

検索時に指定したワードを、検索結果内でハイライトさせることができます。
https://www.elastic.co/guide/en/elasticsearch/reference/7.11/highlighting.html

今回は、 kuromoji でアナライズした、 warloads インデックスの introduction に対して検索して、検索結果をハイライトしたいと思います。

introduction戦国時代で検索して、結果をハイライトしています。
オプションは色々ありますが、fragment_sizeの設定だけ行なっています。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/warloads/_search' -d '{
  "query": {
    "match": {
      "introduction": "戦国時代"
    }
  },
  "highlight": {
    "fields": [
      {
        "introduction": {}
      }
    ],
    "fragment_size": 10
  }
}'

結果(ハイライト部分だけ抜粋)は、以下のようになりました。

"highlight": {
  "introduction": [
    "旧字体:德川 家康)は、<em>戦国</em><em>時代</em>",
    "から江戸<em>時代</em>初期にかけての",
    "武将・<em>戦国</em>大名[1]・",
    "<em>戦国</em><em>時代</em>に終止符を打ち"
  ]
}

以下のことが分かります。

  • 結果はフラグメントの配列として返ってくる
  • フラグメントとは、ハイライト対象のトークンの前後を含む文字列
  • <em>戦国</em><em>時代</em>のように対象のトークンが<em>タグで囲まれる

フラグメントは結果のデータ量を減らす意味では良いのですが、結果を画面に表示するような場合には、フィールドの全文に対して<em>タグで強調してくれた方が嬉しいです。
そのような場合は、"number_of_fragments": 0 オプションを追加することで、フラグメント分けされずに結果が返却されます。

"highlight": {
  "introduction": [
    "徳川 家康(とくがわ いえやす、旧字体:德川 家康)は、<em>戦国</em><em>時代</em>から江戸<em>時代</em>初期にかけての武将・<em>戦国</em>大名[1]・天下人。安祥松平家9代当主で徳川家や徳川将軍家、御三家の始祖。旧称は松平 元康(まつだいら もとやす)。<em>戦国</em><em>時代</em>に終止符を打ち、朝廷より征夷大将軍に任せられ、1603年、260年間続く江戸幕府を開いた[1]。三英傑のひとりである。"
  ]
}

あとは、query対象のフィールドしかハイライトすることができません。

集計

Elasticsearchでも集計を行うことができます。
https://www.elastic.co/guide/en/elasticsearch/reference/7.11/search-aggregations.html

集計の種類は大きく三つあるみたいです。

  • バケット集計
    • フィールドの値や範囲などを元に、ドキュメントをバケットにグルーピングする(Terms、Rangeなど)
    • RDBにおけるgroup byに相当する
  • メトリック集計
    • フィールドの値に対して計算する(Sum, Averageなど)
  • パイプライン集計
    • バケット集計 した結果を、さらに集計する

メトリック集計の例(Sum, Average)

ageの合計と平均を取得する集計方法は以下の通りです。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/warloads/_search' -d '{
  "size": 0,
  "aggs": {
    "age_sum": {
      "sum": {
        "field": "age"
      }
    },
    "age_avg": {
      "avg": {
        "field": "age"
      }
    }
  }
}'

結果は、以下の通りです。

{
  "aggregations": {
    "age_sum": {
      "value": 177
    },
    "age_avg": {
      "value": 59
    }
  }
}

バケット集計の例(Terms, Range)

例として、以下の二つをやってみようと思います。

  • 年齢の範囲集計
    • Range Aggregation を利用
    • rangesageを3段階(50未満、50以上70未満、70以上)で範囲指定
  • 単語の文書頻度集計
    • Terms Aggregation を利用
    • introduction に現れる単語頻度を指定
    • もともと introductiontext型のため、集計できなかったので、fielddata: true を指定してインデックスを再作成することで集計可能にした
curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/warloads/_search' -d '{
  "size": 0,
  "aggs": {
    "age_range": {
      "range": {
        "field": "age",
        "ranges": [
          { "to": 50 },
          { "from": 50, "to": 70 },
          { "from": 70 }
        ]
      }
    },
    "introduction_terms": {
      "terms": {
        "field": "introduction"
      }
    }
  }
}'

結果は以下の通りです。

{
  "aggregations": {
    "age_range": {
      "buckets": [
        {
          "key": "*-50.0",
          "to": 50,
          "doc_count": 1
        },
        {
          "key": "50.0-70.0",
          "from": 50,
          "to": 70,
          "doc_count": 1
        },
        {
          "key": "70.0-*",
          "from": 70,
          "doc_count": 1
        }
      ]
    },
    "introduction_terms": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 98,
      "buckets": [
        {
          "key": "三",
          "doc_count": 3
        },
        {
          "key": "大名",
          "doc_count": 3
        },
        {
          "key": "年",
          "doc_count": 3
        },
        {
          "key": "戦国",
          "doc_count": 3
        },
        {
          "key": "時代",
          "doc_count": 3
        },
        {
          "key": "武将",
          "doc_count": 3
        },
        {
          "key": "10",
          "doc_count": 2
        },
        {
          "key": "のぶ",
          "doc_count": 2
        },
        {
          "key": "人",
          "doc_count": 2
        },
        {
          "key": "代",
          "doc_count": 2
        }
      ]
    }
  }
}

「年齢の範囲指定」も「単語の文書頻度集計」も、概ね想定通りの結果が返ってきました。

パイプライン集計

バケット集計した結果を、さらにバケット集計やメトリック集計することで、複雑な集計を実現することができます。
バケット集計=>バケット集計=>...=>メトリック集計のように、集計を何階層も重ねることもできます。

今回のデータだと全く意味のない集計になってしまうのですが、
誕生日でバケット集計(date_histogram) => 名前でバケット集計(terms) => 年齢でメトリック集計(avg)
という集計をやってみました。

curl -H 'Content-type: application/json' -XPOST 'http://localhost:9200/warloads/_search' -d '{
  "query": {
    "match_all": {}
  },
  "aggs": {
    "group_by_birthday": {
      "date_histogram": {
        "field": "birthday",
        "calendar_interval": "year",
        "min_doc_count": 1
      },
      "aggs": {
        "group_by_name": {
          "terms": {
            "field": "name"
          },
          "aggs": {
            "average_age": {
              "avg": {
                "field": "age"
              }
            }
          }
        }
      }
    }
  }
}'

結果は以下の通りでした。
分かりにくいですが、ちゃんと集計できています。

"aggregations": { - 
  "group_by_birthday": { - 
    "buckets": [ - 
      { - 
        "key_as_string": "1523-01-01",
        "key": -14106009600000,
        "doc_count": 1,
        "group_by_name": { - 
          "doc_count_error_upper_bound": 0,
          "sum_other_doc_count": 0,
          "buckets": [ - 
            { - 
              "key": "武田 信玄",
              "doc_count": 1,
              "average_age": { - 
                "value": 53
              }
            }
          ]
        }
      },
      { - 
        "key_as_string": "1534-01-01",
        "key": -13758854400000,
        "doc_count": 1,
        "group_by_name": { - 
          "doc_count_error_upper_bound": 0,
          "sum_other_doc_count": 0,
          "buckets": [ - 
            { - 
              "key": "織田 信長",
              "doc_count": 1,
              "average_age": { - 
                "value": 49
              }
            }
          ]
        }
      },
      { - 
        "key_as_string": "1543-01-01",
        "key": -13474857600000,
        "doc_count": 1,
        "group_by_name": { - 
          "doc_count_error_upper_bound": 0,
          "sum_other_doc_count": 0,
          "buckets": [ - 
            { - 
              "key": "徳川 家康",
              "doc_count": 1,
              "average_age": { - 
                "value": 75
              }
            }
          ]
        }
      }
    ]
  }
}

プラグインインストール

日本語テキストの分析のために kuromoji プラグインをインストールするケースを考えます。
docker-compose を使って Elasticsearch を起動するケースを記載します。

まず、Dockerfile を作成します。

FROM docker.elastic.co/elasticsearch/elasticsearch:7.11.0
RUN elasticsearch-plugin install analysis-kuromoji

docker-compose.yml で、Dockerfileを利用するように変更します。
ファイル全体はこちらを参照ください。

version: '2.2'
services:
  es01:
    # image: docker.elastic.co/elasticsearch/elasticsearch:7.11.0  既存のイメージは利用しない
    build: . # 直下にあるDockerfileを元にイメージをビルドして利用するよう設定

あとは、Dockerコンテナを起動するだけです。

docker-compose up

さいごに

Elasticsearchは、RDBと違って文字列の扱いやインデックスの扱いが思ったより大変でした。
今回ある程度理解できた気がするのでよかったです。

今回のコードは一応以下で公開してます。
https://github.com/rinoguchi/elasticsearch_sample