Elasticsearch のアナライザをカスタマイズする

この記事は Elasticsearch で検索を行った際にバックエンドとして動作しているアナライザの仕組みとそのカスタマイズ方法についてまとめたものです。
確認している環境は Elasticsearch 7.6.2、Kibana 7.6.2 です。

目次

blog.linkode.co.jp

Elasticsearchのスコア計算を紐解くでは当たり前のように

検索テキストを単語単位で分解して――

と記載していましたが、この検索したテキストを単語単位に分解する処理は Elasticsearch のアナライザで行われています。
自然言語処理の分野では形態素解析と呼ばれる作業になります。

Elasticsearch のリファレンスを確認すると、何も指定しないときデフォルトでは Standard analyzer が適応される、とあります。

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-standard-analyzer.html

Example Output を確認すると、text で指定された文章からスペースやハイフン、ピリオドを取り除いて分割し、大文字が小文字に変換されているいることがわかります。

また、同ページの Definition には、
Standard analyzer
Standard Tokenizer という1つの Tokenizer と Lower Case Token FilterStop Token Filter という2つの Token Filters で構成されている、とあります。

アナライザの構成

Elasticsearch におけるアナライザは

  1. 0個以上Character filters
  2. 1個かつ必須Tokenizer
  3. 0個以上Token filters

これら3つをまとめたパッケージになっていて、順番に処理が行われます。

Character filters形態素解析の前処理
Tokenizer形態素解析
Token filters形態素解析の後処理
と考えることが出来そうです。

詳細はリファレンス Anatomy of an analyzer の通りですが、例えば

  1. HTML要素を除去する Character filter
  2. 空白で分割する Tokenizer
  3. 大文字を小文字に変換する Token filter

これらを行うアナライザの動作イメージを図示すると次のようになるかと思います。

f:id:ken01-linkode:20200702161446p:plain

アナライザを構成する Character FiltersTokenizerToken Filters は、自由に組み合わせることが可能です。

例示した3つの工程はビルトインの html_stripwhitespacelowercase で表現できるので、Kibana のDevTools から次のようにリクエストするとアナライザの動作を確認することができます。

GET _analyze
{
  "text": "<html><body>Quick Brown Fox!</body></html>",
  "char_filter": ["html_strip"],
  "tokenizer": "whitespace",
  "filter" : ["lowercase"]
}

Elasticsearch からのレスポンス

{
  "tokens" : [
    {
      "token" : "quick",
      "start_offset" : 12,
      "end_offset" : 17,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "brown",
      "start_offset" : 18,
      "end_offset" : 23,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "fox!",
      "start_offset" : 24,
      "end_offset" : 28,
      "type" : "word",
      "position" : 2
    }
  ]
}

指定した3つの工程を行った結果がレスポンスされますが、確認したい工程をひとつだけリクエストすることで、より詳細な動作確認も行えます。

日本語に対応したアナライザ

ここまでで、Elasticsearch のアナライザの構造と動作確認の方法を紹介してきました。
ただ対象となる言語がスペース等で区切られることのない日本語になると、ビルトインのアナライザだけでは残念ながら有効な検索結果が得られません。
例えば Standard analyzer で日本語をアナライズすると一文字ずつ分割されたトークンになってしまいます。
そこで、プラグインの導入を行います。

サービスの目的によって、ベストな検索の仕方(アナライザの構成)は様々かと思いますが、今回は紹介も兼ねて日本語で検索する際に必要となりそうな、主だった機能を含んでいる、次のプラグインを使用します。

インストール方法についてはリンク先を参照してください。
アナライザに含まれる全ての機能を使用するのではなく、必要になるものを組み合わせて使用します。
またあわせて、Synonym token filter というビルトインの Token filter を使用します。

  • Character Filters

    名称 用途 プラグイン
    icu_normalizer 文字の正規化 ICU Analysis Plugin
    kuromoji_iteration_mark 踊り字の正規化 Japanese (kuromoji) Analysis Plugin
  • Tokenizer

    名称 用途 プラグイン
    kuromoji_tokenizer トークン化(形態素解析) Japanese (kuromoji) Analysis Plugin
  • Token filters

    名称 用途 プラグイン
    kuromoji_part_of_speech 不要な品詞の除去 Japanese (kuromoji) Analysis Plugin
    kuromoji_stemmer 長音の除去 Japanese (kuromoji) Analysis Plugin
    kuromoji_baseform 原形化 Japanese (kuromoji) Analysis Plugin
    kuromoji_readingform 読み仮名付与 Japanese (kuromoji) Analysis Plugin
    synonym 同義語 (ビルトインの Synonym token filter)

ひとつずつ、KibanaのDevToolsからの動作確認の例と、Elasticsearchのレスポンスを交えながら見ていきたいと思います。

icu_normalizer

Unicodeを正規化するために、ICU Analysis Plugin の icu_normalizer Character filter を使用します。

icu_normalizer の例

GET _analyze
{
  "text": "㈱Linkodeはソフトウェアの開発をしています。",
  "char_filter": ["icu_normalizer"]
}
{
  "tokens" : [
    {
      "token" : "(株)linkodeはソフトウェアの開発をしています。",
      "start_offset" : 0,
      "end_offset" : 25,
      "type" : "word",
      "position" : 0
    }
  ]
}

環境依存文字(株)に展開され、アルファベットは半角小文字に統一、半角カタカナは全角になっていることがわかります。

Unicodeの正規化された結果の形式を、正規化形式 (Normalization Forms) といい、Unicode® Standard Annex #15では、4種類の正規化形式を規定しています。
種類の詳細に関しては、リンク先をご確認ください。

icu_normalizer は、namemodeというオプションを指定することができ、この4種類の正規化形式を指定できます。
どちらも文字列で、指定できる値は次の通りです。

  • name: nfc, nfkc, nfkc_cf
  • mode: compose, decompose

4種類の正規化形式を namemode の組み合わせで表現すると次の通りです。

正規化形式 name mode
NFC nfc compose
NFD nfc decompose
NFKC nfkc compose
NFKD nfkc decompose

nfkc_cf は、nfkc に加えて、大文字小文字をすべて小文字に統一する、という指定になります。
Token filter の lowercase と同様です。

オプションは何も指定しないときは、name=nfkc_cfmode=composeとなっています。
実際の挙動は、Kibanaから以下のようにリクエストして、text やオプションを書き換える事で簡単に確認できるかと思います。

GET _analyze
{
  "text": "㋐㋑㋒㋓㋔カキクケコザジズゼゾ",
  "char_filter": {
    "type": "icu_normalizer",
    "name": "nfkc",
    "mode": "compose"
  }
}

また、Japanese (kuromoji) Analysis Plugin にも全角↔半角変換が行える CJK width token filter という Token filter が含まれていますが、icu_normalizer で行える正規化に含まれるため、今回は使用しません。

kuromoji_iteration_mark

日本語特有の踊り字の正規化には kuromoji_iteration_mark を使用します。

kuromoji_iteration_mark の例

GET _analyze
{
  "text": "刻々、すゞめ、シヾミ",
  "char_filter": ["kuromoji_iteration_mark"]
}
{
  "tokens" : [
    {
      "token" : "刻刻、すずめ、シジミ",
      "start_offset" : 0,
      "end_offset" : 10,
      "type" : "word",
      "position" : 0
    }
  ]
}

kuromoji_tokenizer

アナライザにひとつ、かつ、必須になっている Tokenizer には kuromoji_tokenizer を使用します。

kuromoji_tokenizer の例

GET _analyze
{
  "text": "今日は蒸し暑い。",
  "tokenizer": "kuromoji_tokenizer"
}
{
  "tokens" : [
    {
      "token" : "今日",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "蒸し暑い",
      "start_offset" : 3,
      "end_offset" : 7,
      "type" : "word",
      "position" : 2
    }
  ]
}

Elasticsearch のプラグインとして、日本語の形態素解析が行えるものは他にも Neologd Pluginワークス徳島人工知能NLP研究所Sudachi等があるようです。

kuromoji_part_of_speech

kuromoji_part_of_speech で品詞を指定して不要なトークンを除去することが可能です。
何も指定しない場合は、プラグイン<ES_HOME>/plugins/analysis-kuromoji/lucene-analyzers-kuromoji-8.4.0.jar に含まれる、stoptags.txt で指定されている品詞が除去されます。
stoptags.txt を確認してみると、助詞記号 が除去されるように設定されているようです。

kuromoji_part_of_speech の例

GET _analyze
{
  "text": "大阪の天気は☀",
  "tokenizer": "kuromoji_tokenizer",
  "filter" : ["kuromoji_part_of_speech"]
}
{
  "tokens" : [
    {
      "token" : "大阪",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "天気",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 2
    }
  ]
}

kuromoji_stemmer

語幹を統一するために、kuromoji_stemmer で長音を除去します。

kuromoji_stemmer の例

GET _analyze
{
  "text": "プリンターとスキャナー",
  "tokenizer": "kuromoji_tokenizer",
  "filter" : ["kuromoji_stemmer"]
}
{
  "tokens" : [
    {
      "token" : "プリンタ",
      "start_offset" : 0,
      "end_offset" : 5,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "スキャナ",
      "start_offset" : 6,
      "end_offset" : 11,
      "type" : "word",
      "position" : 2
    }
  ]
}

kuromoji_baseform

トークン化された活用を原型化する kuromoji_baseform です。

kuromoji_baseform の例

GET _analyze
{
  "text": "わかりやすく伝える",
  "tokenizer": "kuromoji_tokenizer",
  "filter" : ["kuromoji_baseform"]
}
{
  "tokens" : [
    {
      "token" : "わかる",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "やすい",
      "start_offset" : 3,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "伝える",
      "start_offset" : 6,
      "end_offset" : 9,
      "type" : "word",
      "position" : 2
    }
  ]
}

検索の障害にならないようであれば、微妙な表記の揺れを吸収するために設定しておいた方が良さそうです。

kuromoji_readingform

kuromoji_readingform は読み仮名を付与します。

kuromoji_readingform の例

GET _analyze
{
  "text": "寿司、天ぷら、芸者",
  "tokenizer": "kuromoji_tokenizer",
  "filter" : ["kuromoji_readingform"]
}
{
  "tokens" : [
    {
      "token" : "スシ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "テンプラ",
      "start_offset" : 3,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "ゲイシャ",
      "start_offset" : 7,
      "end_offset" : 9,
      "type" : "word",
      "position" : 2
    }
  ]
}

デフォルトではカタカナで読み仮名が付与されますが use_romaji オプションでローマ字にすることもできます。

kuromoji_readingform(ローマ字) の例

# ローマ字の読み仮名を付与するアナライザを持ったインデックスを作成
PUT readingform-test-index
{
  "settings": {
    "analysis" : {
      "filter" : {
        "my_kuromoji_readingform" : {
          "type" : "kuromoji_readingform",
          "use_romaji" : true
        }
      },
      "analyzer": {
        "my_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "my_kuromoji_readingform"
          ]
        }
      }
    }
  }
}

# my_analyzer の動作確認
GET readingform-test-index/_analyze
{
  "text": "寿司、天ぷら、芸者",
  "analyzer" : "my_analyzer"
}
{
  "tokens" : [
    {
      "token" : "sushi",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "tempura",
      "start_offset" : 3,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "geisha",
      "start_offset" : 7,
      "end_offset" : 9,
      "type" : "word",
      "position" : 2
    }
  ]
}

filter をカスタマイズすることで、カタカナとローマ字両方の読み仮名で検索を行う、といったこともできそうです。

synonym

同義語(類義語)、またタイポによる検索に対応するために、Synonym token filter を使用します。
これは、プラグインではなく Elasticsearch のビルトインの機能になります。

同義語の設定は、アナライザの定義をする際に直接記述することもできますが、今回はより実用的な使用を想定して外部のテキストファイル synonym.txt から同義語の辞書を読み込むようにします。 テキストファイルのフォーマットは公式のリファレンスがわかりやすいかと思います。

Linkode会社 を同義語とみなす synonym.txt の例

Linkode, 会社

同義語の辞書ファイルは <ES_HOME>/config/相対パスで読み込まれるため、今回は
<ES_HOME>/config/analysis/synonym.txt として配置します。

アナライザの設定は以下の通りです。

PUT /synonym-test-index
{
  "settings": {
    "analysis" : {
      "filter" : {
        "my_synonym_filter" : {
          "type" : "synonym",
          "synonyms_path" : "analysis/synonym.txt",
          "updateable" : true
        }
      },
      "analyzer": {
        "my_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "my_synonym_filter" 
          ]
        }
      }
    }
  }
}

"updateable" : true としているのは、サービスを稼働したまま辞書の更新を有効にするためです。
synonym.txt を更新して、サービスを止めずに検索のアナライザに反映させたい場合は

POST /synonym-test-index/_reload_search_analyzers

とする事で、辞書の更新が行えます。

synonym の例

GET synonym-test-index/_analyze
{
  "text": "会社",
  "analyzer" : "my_analyzer"
}
{
  "tokens" : [
    {
      "token" : "会社",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "Linkode",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0
    }
  ]
}

マッピング

それではここまでのアナライザの工程をまとめた ja-test-index というインデックスを作成して、field01 というフィールドにマッピングしてみたいと思います。

PUT /jp-test-index
{
  "settings": {
    "analysis" : {
      "filter" : {
        "my_synonym_filter" : {
          "type" : "synonym",
          "synonyms_path" : "analysis/synonym.txt",
          "updateable" : true
        }
      },
      "analyzer": {
        "my_index_analyzer": {
          "type": "custom",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark"
          ],
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech",
            "kuromoji_stemmer",
            "kuromoji_baseform",
            "kuromoji_readingform"
          ]
        },
        "my_search_analyzer": {
          "type": "custom",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark"
          ],
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech",
            "kuromoji_stemmer",
            "kuromoji_baseform",
            "kuromoji_readingform",
            "my_synonym_filter" 
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "field01": {
        "type": "text",
        "analyzer": "my_index_analyzer",
        "search_analyzer": "my_search_analyzer"
      }
    }
  }
}

my_index_analyzermy_search_analyzer というふたつのアナライザを定義していますが、これはフィールドのインデックス時と検索時のアナライザをそれぞれ定義したものです。
ここでわざわざふたつに分けているのは、インデックス時には synonym の使用が禁止されているためです(マッピングしようとするとエラーになります)。

この状態でいくつかドキュメントをインデックスしてみます。

POST /jp-test-index/_bulk
{"index":{}}
{"field01" : "Linkode"}
{"index":{}}
{"field01" : "久久"}
{"index":{}}
{"field01" : "キロ"}

きちんと機能しているか確認してみます。

GET /jp-test-index/_search
{
  "query": {
    "match": {
      "field01": "久々に会社で体重を測ると5㌔も増加していました。"
    }
  }
}
{
  "took" : 45,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 0.9808291,
    "hits" : [
      {
        "_index" : "jp-test-index",
        "_type" : "_doc",
        "_id" : "dulFDnMBFiRGs0BIkq5D",
        "_score" : 0.9808291,
        "_source" : {
          "field01" : "Linkode"
        }
      },
      {
        "_index" : "jp-test-index",
        "_type" : "_doc",
        "_id" : "d-lFDnMBFiRGs0BIkq5D",
        "_score" : 0.9808291,
        "_source" : {
          "field01" : "久久"
        }
      },
      {
        "_index" : "jp-test-index",
        "_type" : "_doc",
        "_id" : "eOlFDnMBFiRGs0BIkq5D",
        "_score" : 0.9808291,
        "_source" : {
          "field01" : "キロ"
        }
      }
    ]
  }
}

強引な例文でしたが、インデックスしたドキュメントがすべて検索結果として得られました。

所感

Elasticsearch ではドキュメントのフィールドに対して非常に細かくアナライザをカスタマイズできることがわかりました。
その分、サービスに応じたベストな組み合わせを最初から確定させることが難しいのではないかと思います。
実際にはサービスを稼働しながら更新していけるように、観測と更新が簡単に行える環境の構築が大事になるのかも、と感じました。

参考ドキュメント・記事