ElasticSearch の全文検索での analyzer について
前回の ElasitcSearch での文書検索についての記事の中で、ElasticSearch
の中核となる機能である全文検索クエリについて触れました。ElasticSearch
の全文検索クエリでは、文書を単語に分割する分かち書きという工程を経て、その単語が含まれる文書が検索されます。しかし、はたして何を単語とみなすかというのは難しい問題です。普段人間が使っている言葉の種類は国や言語、時代によって膨大な量になりますし、常に新しい言葉、失われていく言葉が存在しています。また同じ言葉でも文脈や使われる分野によって意味が変わったり、1 つの言葉なのに文字で表現しようとすると何通りも書き方があったりします。現時点ではあらゆる単語を分かち書きできるような技術は残念ながら存在していないので、全文検索を適用したい分野にあわせてどのように分かち書きしていくかを自分で設計していく必要があります。ElasticSearch
では単語分割は analyzer
によって処理されます。この analyzer
には単語分割に関わる言語処理機能を必要に応じて組みあわせて使えるようになっています。今回はこの analyzer
がどのように分かち書きを行っていくのか、ElasticSearch
の analyzer
による言語処理機能の具体例を見ていこうと思います。analyzer による単語分割の流れ
ElasticSearch
における analyzer
は、分かち書きする文字列中の各文字を追加/変更/削除する character filter
、character filter
から受け取った文字列を実際に単語に分割する tokenizer
、tokenizer
が分割した単語の列から単語単位で追加/変更/削除する token filter
の 3 つによって構成されています。analyzer
には必ず 1 つの tokenizer
が設定されていなければなりませんが、character filter
と token filter
はオプションとなっていて、任意の数を設定することができます。一応定義どおりに
analyzer
の構成要素を説明するとこうなるのですが、感覚としては、中心となる tokenizer
が単語分割しやすいように tokenizer
の前処理 / 後処理として必要なフィルターを設定していく、くらいで良いと思います。ElasticSearch
にあらかじめ組み込まれているビルトインの token filter
には、どちらかというと character filter
としてあるべきなのではと思うものもあったりします。また高機能な tokenizer
には、他のビルトインのフィルターと同等の機能を内包していたりするものもあります。character filter
character filter
は tokenizer
が分かち書きする文字列を単語に分割する前に、tokenizer
が単語分割しやすいように文字列中の文字を調整します。あらかじめ組み込まれているフィルターとしては、分かち書きする文字列から HTML タグを取り除く HTML Strip Char Filter、設定したマッピング表からある文字を別の文字に変換する Mapping Char Filter、Java の正規表現によって変換規則を設定できる Pattern Replace Char Filter があります。
実際に
Mapping Char Filter
を使って、前回も少しさわった Analyze API から単語分割の確認をしてみます。材料として以下のインデックスを作ってみました。$ curl -XPUT http://localhost:9200/char-filter-sample -H "Content-type: application/json" -d '{ "settings": { "analysis": { "analyzer": { "my_analyzer": { "tokenizer": "keyword", "char_filter": [ "my_char_filter" ] } }, "char_filter": { "my_char_filter": { "type": "mapping", "mappings": [ "零 => 0", "壱 => 1", "弐 => 2", "参 => 3", "肆 => 4", "伍 => 5", "陸 => 6", "漆 => 7", "捌 => 8", "玖 => 9" ] } } } } }'今回は Analyze API で
analyzer
の動作確認をするだけなので、インデックスのドキュメントタイプは書かず、インデックスの設定を書く settings
要素内に analyzer
の定義のみ記述しています。settings.analysis.char_filter
内の my_char_filter
というのが設定した Mapping Char Filter
の名前で、settings.analysis.analyzer
内の my_analyzer
というのが 今回利用する analyzer
の名前になります。Mapping Char Filter
を使うにはフィルターの type
値を mapping
とし、また mappings
値として変換する文字のマッピングを記載します。"壱 => 1" と書いた場合、壱
が 1
に変換されます。今回は数字の大字を半角数字にマッピングするようにしています。analyzer
には char_filter
値として適用する character filter
の名前を指定します。analyzer
には必ず 1 つの tokenizer
が必要になるので、Keyword Tokenizer を意味する keyword
を指定しています。Keyword Tokenizer
は、受け取った文字列をそのまま 1 単語に分かち書きする tokenizer
です。作成した
analyzer
を Analyze API にかけて、どのように分かち書きされるか確認してみます。POST する JSON オブジェクトの analyzer
値に先程インデックス内で定義した analyzer
の名前を、text
値に単語分割したい文字列を記載します。$ curl -XPOST http://localhost:9200/char-filter-sample/_analyze -H "Content-type: application/json" -d '{ "analyzer": "my_analyzer", "text": "玖頭龍閃" }'この Analyze API のレスポンスが以下のようになります。
Mapping Char Filter
のマッピングで設定したとおり、数字の大字の 玖
が半角数字の 9
に変換されました。{ "tokens": [ { "token": "9頭龍閃", "start_offset": 0, "end_offset": 4, "type": "word", "position": 0 } ] }ちなみに分割した単語が 1 つだけなのは、先程ふれたとおり
tokenizer
として Keyword Tokenizer
が使われているためです。Mapping Char Filter
の動作はシンプルにある文字を別の文字に変換することなので、character filter
をイメージするのにはわかりやすいかと思います。HTML Strip Char Filter
では、取り除く HTML タグが単語っぽく見えがちなので、tokenizer
の前処理であるという側面を無視すると token filter
っぽく見えてしまいます。 tokenizer
tokenizer
は文字列を実際に単語に分割し、分かち書きを行う役割を持ちます。公式ドキュメントには多数の組み込み tokenizer が記載されています。tokenizer
こそが単語分割の中核となるため、公式ドキュメントに記載されているものの他にも、サードパーティ製のプラグインとして多くの tokenizer
が公開されています。先程は文字列をそのまま 1 単語とする
Keyword Tokenizer
を使いましたが、ここではデフォルトで適用される Standard Tokenizer と Whitespace Tokenizer を試してみたいと思います。Analyze API で
tokenizer
を動作確認する場合には、インデックスを作る必要はありません。リクエスト URL にインデックス名を指定せず、/_analyze
エンドポイントに以下のような JSON オブジェクトを POST します。JSON オブジェクトの
tokenizer
値には Standard Tokenizer
を意味する standard
を、text
値には分かち書きする文字列を指定します。$ curl -XPOST http://localhost:9200/_analyze -H "Content-type: application/json" -d '{ "tokenizer": "standard", "text": "What is the不殺" }'そしてこちらが
Standard Tokenizer
による分かち書きの結果です。{ "tokens": [ { "token": "What", "start_offset": 0, "end_offset": 4, "type": "基本的には半角スペースによって単語に分割されていますが、単語『the不殺』内の漢字はさらに 1 字ずつに分割されています。この挙動は前回も見た通りです。", "position": 0 }, { "token": "is", "start_offset": 5, "end_offset": 7, "type": " ", "position": 1 }, { "token": "the", "start_offset": 8, "end_offset": 11, "type": " ", "position": 2 }, { "token": "不", "start_offset": 11, "end_offset": 12, "type": " ", "position": 3 }, { "token": "殺", "start_offset": 12, "end_offset": 13, "type": " ", "position": 4 } ] }
公式ドキュメントによると
Standard Tokenizer
は文法ベースで単語に分割すると書いてありますが、漢字を 1 字ずつ分割するというのにはさすがに文法ベースでの分割とは言えないと思います。続いて
Whitespace Tokenizer
を試してみます。Whitespace Tokenizer
を使うには、Analyze API に送る JSON オブジェクトの tokenizer
値には whitespace
を指定します。$ curl -XPOST http://localhost:9200/_analyze -H "Content-type: application/json" -d '{ "tokenizer": "whitespace", "text": "What is the不殺" }'そしてこちらが
Whitespace Tokenizer
による分かち書きの結果です。{ "tokens": [ { "token": "What", "start_offset": 0, "end_offset": 4, "type": "word", "position": 0 }, { "token": "is", "start_offset": 5, "end_offset": 7, "type": "word", "position": 1 }, { "token": "the不殺", "start_offset": 8, "end_offset": 13, "type": "word", "position": 2 } ] }
Whitespace Tokenizer
は単純にスペース文字によってのみ単語分割を行うので、Standard Tokenizer
では漢字毎に分割されていた the不殺
も、1 単語として分割されています。この結果だけ見ると
Standard Tokenizer
よりも Whitespace Tokenizer
の方が日本語の文書には向いているようにも見えますが、日本語文ではスペース文字によって単語を分割するというルールがないため、やはり Whitespace Tokenizer
は適していません。どちらにしても実用性がいまいちです。ElasticSearch
の組み込み tokenizer
としてスマートな解決策が用意されていないのは、特に表意文字を扱う言語は、それだけ分かち書きは難しいからだと思います。これについては世の中には N グラムや形態素解析という手法があるので、またの機会に詳しく扱いたいと思います。token filter
token filter
は tokenizer
が文字列を単語に分かち書きした後に、分割した単語を調整します。公式ドキュメントには多数の組み込み token filter が記載されています。わかりづらいですが、ページの右サイドメニューの Token Filters 以下に token filter
がいっぱい並んでいます。分割した単語中の小文字を大文字に変換する Uppercase Token Filter や、分割した単語の長さを一定長で切り詰める Truncate Token Filter など、本当にさまざまな
token filter
が用意されています。今回はその中から、全角英数文字を半角英数文字(ASCII 文字コード表の先頭 127 文字)に変換する ASCII Folding Token Filter を試してみたいと思います。材料として以下のインデックスを作ってみました。
$ curl -XPUT http://localhost:9200/token-filter-sample -H "Content-type: application/json" -d '{ "settings": { "analysis": { "analyzer": { "my_analyzer": { "tokenizer": "keyword", "filter": [ "my_asciifolding" ] } }, "filter": { "my_asciifolding": { "type": "asciifolding", `preserve_original` : true } } } } }'送信する JSON オブジェクトの
settings.analysis.filter
下に書いているのが、利用する ASCII Folding Token Filter
の名前とその設定です。type
値には ASCII Folding Token Filter
を意味する asciifolding
を、設定項目 preserve_original
値には false を指定しています。また、settings.analysis.analyzer.{analyzer 名}.filter
値には定義した ASCII Folding Token Filter
の名前 を記載しています。ASCII Folding Token Filter
は ASCII 文字の先頭 127 文字に丸める変換をしますが、preserve_original
値を true にしておくと、変換前のオリジナルの単語も別途返してくれるようになります。デフォルトでは preserve_original
値は false になります。さっそく Analyze API で動作を見てみます。
$ curl -XPOST http://localhost:9200/token-filter-sample/_analyze -H "Content-type: application/json" -d '{ "analyzer": "my_analyzer", "text": "HUTAE NO KIWAMI" }'こちらがレスポンスです。
{ "tokens": [ { "token": "HUTAE NO KIWAMI", "start_offset": 0, "end_offset": 15, "type": "word", "position": 0 }, { "token": "HUTAE NO KIWAMI", "start_offset": 0, "end_offset": 15, "type": "word", "position": 0 } ] }
ASCII Folding Token Filter
によって ASCII 文字が丸められた『HUTAE NO KIWAMI』と、preserve_original
値を true にしたことで返ってくるようになった『HUTAE NO KIWAMI』の 2 単語が返されました。preserve_original
値が false のままであれば、返される単語は変換された『HUTAE NO KIWAMI』のみになります。実運用で
ASCII Folding Token Filter
の preserve_original
値を true にすることはあまりない気もしますが、tokenizer
が分かち書きした後で token filter
が単語単位での追加/変更/削除を行う、という意味ではわかりやすいかと思って試してみました。インデックスに analyzer を設定する
ここまで動作確認として Analyze API を使ってきましたが、実際にマッピング内のフィールドに対してanalyzer
を設定してみます。マッピングで
analyzer
を定義する場合、フィールド毎に個別に適用する analyzer
を指定する方法と、個別の analyzer
が指定されていないフィールドに対して適用するデフォルトの analyzer
を指定する方法があります。材料として以下のようなインデックスを用意しました。
$ curl -XPUT http://localhost:9200/analyzer-sample -H "Content-type: application/json" -d '{ "settings": { "analysis": { "analyzer": { "default": { "tokenizer": "whitespace" }, "my_analyzer": { "tokenizer": "keyword" } } } }, "mappings": { "_doc": { "properties": { "text01": { "type": "text" }, "text02": { "type": "text", "analyzer": "my_analyzer" } } } } }'送信する JSON オブジェクトでは、
settings.analysis.analyzer
下の default
値として、デフォルトですべてのフィールドに適用される Whitespace Tokenizer
を指定しています。また、それとは別に名前つきの analyzer
として Keyword Tokenizer
を定義しました。また、マッピング定義では 2 つの
text
型フィールドを用意しています。text01
には特に analyzer
を指定していませんが、text02
には analyzer
として Keyword Tokenizer
をもつ名前付きの analyzer
を指定しています。これにより、text01
に対する分かち書きではデフォルト設定の Whitespace Tokenizer
が適用され、text02
に対する分かち書きではフィールドに個別設定された Keyword Tokenizer
が適用されます。このインデックスに以下のドキュメントを追加しておきます。
$ curl -XPOST http://localhost:9200/analyzer-sample/_doc -H "Content-type: application/json" -d ' {"text01": "緋村 剣心", "text02": "緋村 剣路" }'
それでは動作を確認してみます。まずはデフォルト設定の
Whitespace Tokenizer
によって分かち書きされたフィールドに全文検索してみます。$ curl -XGET http://localhost:9200/analyzer-sample/_search -H "Content-type: application/json" -d '{ "query": { "match" : { "text01" : "緋村" } } }'この全文検索クエリでは作成したドキュメントが返されてきます。全文検索クエリの対象フィールド
text01
にはデフォルト設定の Whitespace Tokenizer
が適用されているため、分かち書きされた単語としては『緋村』と『剣心』が保持されているからです。全文検索クエリの検索キーワードがフィールド値に含まれているため、このドキュメントはヒットします。一方で、以下の全文検索クエリでは、作成したドキュメントは返されません。
$ curl -XGET http://localhost:9200/analyzer-sample/_search -H "Content-type: application/json" -d '{ "query": { "match" : { "text02" : "緋村" } } }'全文検索クエリの対象フィールド
text02
には個別に Keyword Tokenizer
が適用されているため、分かち書きされた単語としては『緋村 剣路』のみが保持されているからです。これには全文検索クエリの検索キーワードがふくまれていません。もしこのフィールドに対する全文検索クエリで該当ドキュメントを返してほしいならば、検索キーワードは『緋村 剣路』にする必要があります。
ElasticSearch コメント (0) 2018/10/11 19:07:40