この記事は Elasticsearch で検索を行った際にバックエンドとして動作しているアナライザの仕組みとそのカスタマイズ方法についてまとめたものです。
確認している環境は Elasticsearch 7.6.2、Kibana 7.6.2 です。
目次
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 Filter
、Stop Token Filter
という2つの Token Filters で構成されている、とあります。
アナライザの構成
Elasticsearch におけるアナライザは
- 0個以上の Character filters
- 1個かつ必須の Tokenizer
- 0個以上の Token filters
これら3つをまとめたパッケージになっていて、順番に処理が行われます。
Character filters
は形態素解析の前処理
Tokenizer
は形態素解析
Token filters
は形態素解析の後処理
と考えることが出来そうです。
詳細はリファレンス Anatomy of an analyzer の通りですが、例えば
- HTML要素を除去する Character filter
- 空白で分割する Tokenizer
- 大文字を小文字に変換する Token filter
これらを行うアナライザの動作イメージを図示すると次のようになるかと思います。
アナライザを構成する Character Filters
、Tokenizer
、Token Filters
は、自由に組み合わせることが可能です。
例示した3つの工程はビルトインの html_strip
、whitespace
、lowercase
で表現できるので、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 は、name
とmode
というオプションを指定することができ、この4種類の正規化形式を指定できます。
どちらも文字列で、指定できる値は次の通りです。
- name:
nfc
,nfkc
,nfkc_cf
- mode:
compose
,decompose
4種類の正規化形式を name
と mode
の組み合わせで表現すると次の通りです。
正規化形式 | name | mode |
---|---|---|
NFC | nfc | compose |
NFD | nfc | decompose |
NFKC | nfkc | compose |
NFKD | nfkc | decompose |
nfkc_cf
は、nfkc
に加えて、大文字小文字をすべて小文字に統一する、という指定になります。
Token filter の lowercase
と同様です。
オプションは何も指定しないときは、name
=nfkc_cf
、mode
=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_analyzer
、my_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 ではドキュメントのフィールドに対して非常に細かくアナライザをカスタマイズできることがわかりました。
その分、サービスに応じたベストな組み合わせを最初から確定させることが難しいのではないかと思います。
実際にはサービスを稼働しながら更新していけるように、観測と更新が簡単に行える環境の構築が大事になるのかも、と感じました。