はじめに
私なぞは、検索時とインデックス時のアナライザーは同じものにしとく(つまりデフォルト)方が検索エンジンが賢いのでよしなにやってくれる派(という名のモグリ)なのですが、 edge_ngramやmultiplexerのような「トークン複数派生」的なアナライザーのプラグインを使う場合に、それを使いこなす上で、トークンに対して暗黙のORマッチング?的なところの当たり前といえば当たり前な挙動を雑にでも書き出しておきたいと考えてここにチラ裏記事を投稿します。
注意事項:
- ※検索エンジンを自作するような人には今更何をという内容です。
- ※ Elasticsearchのanalyzerと各プラグインの設定を、処理フローというよりは、宣言的に解釈している方は実はそれで十分だと思います。そのような方は良い意味でこの記事はかえって紛らわしいことを言っているように聞こえると思いますので、用法にはご注意ください。
- 題名で図解といいましたが、図解というほど図にしてません。
アナライザーのプラグインは変換型と派生型がある
私の他記事でもそうですが検索とインデックス時のアナライザーは同じで良い、少なくとも検討の最初はそれで良いと我思うというところの背景は次の図に考え方によります。
アナライザーの目的の大きなところは表記揺れ等を吸収して、同義と見なす語でマッチングさせるというものと言って良いと思われますが、その手法の根幹の一つは、同義と見なす語のグループは最終的に同じワード(見出し語)に終着させるというところがポイントになっています。
事前のインデックス時の見出し語終着と同じ手順(アナライザー)で検索時も見出し語を終着させれば、同じ結果が得られますよねというところです。
もちろん、一つの見出し語に無理に終着させなくとも、検索時かインデックス時に終着に向かうものとおおよそ逆の手順で派生してやれば、反対の方はアナライズは最小限ですみそうです(下記の図A、図B)。 ただ、それでもひとつの見出し語に片寄するのは、多分、これがもろもろの空間効率が良い方式だからだと思います。
ということで、細かいところはボロが出るのでこれ以上は深追いしませんが、検索時とインデックス時のアナライザーは同じ...でスタートして不都合があれば、別のことを考えましょうがヒューリステックなのかと思います。
が、実のところElasticsearchでも、トークン(この場合は見出し語とおおよそ同義)を複数派生させることができるプラグインがあります。代表的なところでいうと、edge_ngramです。
複数派生プラグインの挙動(edge_ngramを例にして)
edge_ngramでは、
あいうえおかきくけこ
という語であれば、min_gram:1、max_gram:50としたセッティングの場合、
あ あい あいう あいうえ あいうえお あいうえおか あいうえおかき あいうえおかきく あいうえおかきくけ あいうえおかきくけこ
と派生されます。
ちなみに、
あいうれろ
という語の場合、
あ あい あいう あいうれ あいうれろ
となります。
ここで、
あいうえお
という語が検索語で、ここにmin_gram:1、max_gram:50とした先と同じセッティングのアナライズをかけると
↓
あ あい あいう あいうえ あいうえお
で派生されます。
そうです。
派生されて、
"あ "で始まる "あい "で始まる "あいう "で始まる "あいうえ "で始まる "あいうえお "で始まる
のいずれかのものを検索しにいくことになります。
よって、
この場合、
検索語、
あいうえお
は
あいうえお あいうえおかも あいうえおです
はもちろん、
"あ "で始まる "あい "で始まる "あいう "で始まる "あいうえ "で始まる "あいうえお "で始まる
ような
あいう あいう***... あいうえ***...
みたいなものもヒットすることになります。
edge_ngramで派生した転置インデックスと同じ条件で派生した検索語で検索される場合、ある意味それは意図通りとは言え、最初の何文字かが一致するものを幅広く当ててしまうことになります。
ということで、他のアナライズの例が検索時とインデックス時で同じアナライザーを用いる組み合わせがおのずと大半となるのに対し、edge_ngramでは次のようなところを戦略的に決めておく度合いが高くなると思います。
1) 検索時とインデックス時のアナライザーは同じにする(上記を狙い通りとする) ※prefix検索にはない特徴を得られると考えた用法
2) トーカナイザーにwhitespace(など何もしない検索時アナライザー)を使う
3) 検索時のアナライザーでは、トーカナイザーにkuromojiなどを使い、かつトークンフィルターで2トークン目を読み捨てるなどする
4) 最小gramが大きめのedge_ngramを使うことで、加減する (検索語"あいうえお"で、"あいうけ","あいうせそ"はヒットさせないが、"あいうえと"はヒットすると嬉しい範囲とする。)
なお、ここまでの話で出てきたedge_ngramはどちらかと言えば、tokenizerの方のedge_ngramです。
実際のところ、同じことは、token filter系である、multiplexerなどでもあてはまります。もちろん、token filterのedge_ngramでも当てはまります。
また、ngram系でなくとも、multiplexerでは、いくつか別のfilterを用いた、複数トークンを派生させることができますが、派生させた複数トークンに対して「このうちのどれか」というマッチングになります。
というか、書いていて気づきましたが、token filter、tokenizerどちらでedge_ngramを使う場合でも、またtoken filterであるmultiplexerでも、いずれも最終的に同じpositionのトークンが派生されるという意味では、違いがなくて当然ですね。やや回りくどい説明になってしまったかもしれません。
とりとめもなく書き下してしまいましたが、この記事以上です。
【付録】いろいろ試す用の例
特に解説等はしませんが、上記等を試すためのmapping設定の例などです。
確認はElasticsearch v6.8ですが、以下の範囲であれば、7.x系でも動作すると思います。
(なお、マッチしたトークン数を把握しやすくするために、similarityを独自定義しています。本記事テーマそのものの設定として必要かというとそうではありません。)
egde_ngram版
PUT en { "settings": { "similarity": { "cnt": { "type": "scripted", "script": { "source": "return query.boost * doc.freq;" } } }, "analysis": { "analyzer": { "a1": { "type": "custom", "similarity": "cnt", "tokenizer": "kuromoji_tokenizer" }, "a2":{ "type":"custom", "similarity":"cnt", "tokenizer":"en" } }, "tokenizer":{ "en":{ "type":"edge_ngram", "min_gram":1, "max_gram":20, "token_chars":["letter"] } } } }, "mappings": { "mapping": { "properties": { "text_a1": { "type": "text", "analyzer": "a1", "similarity": "cnt" }, "text_a2": { "type": "text", "analyzer": "a2", "similarity": "cnt" } } } } }
POST en/_doc/1 {"text_a1":"会社のお金の話" } POST en/_doc/2 {"text_a2":"会社のお金の話" }
GET en/_search { "query": {"match": { "text_a2": { "query":"会社が", "analyzer":"a2" } }} }
multiplexer版
PUT mp { "settings": { "similarity": { "cnt": { "type": "scripted", "script": { "source": "return query.boost * doc.freq;" } } }, "analysis": { "analyzer": { "a1": { "type": "custom", "similarity": "cnt", "tokenizer": "kuromoji_tokenizer", "filter": [ "mp1" ] }, "a2":{ "type":"custom", "similarity":"cnt", "tokenizer":"kuromoji_tokenizer" } }, "filter": { "mp1": { "type": "multiplexer", "filters": [ "kuromoji_readingform", "k2h", "roma" ], "preserve_original": true }, "k2h": { "type": "icu_transform", "id": "Katakana-Hiragana" }, "roma":{ "type":"kuromoji_readingform", "use_romaji": true } } } }, "mappings": { "mapping": { "properties": { "text_a1": { "type": "text", "analyzer": "a1", "similarity": "cnt" }, "text_a2": { "type": "text", "analyzer": "a2", "similarity": "cnt" } } } } }
POST mp/_doc/1 {"text_a1":"こんにちは サヨウナラ"} POST mp/_doc/2 {"text_a1":"コンニチハ さようなら"} POST mp/_doc/3 {"text_a1":"こんにちは さようなら"} POST mp/_doc/4 {"text_a1":"コンニチハ サヨウナラ"} POST mp/_doc/5 {"text_a2":"こんにちは サヨウナラ"} POST mp/_doc/6 {"text_a2":"コンニチハ さようなら"} POST mp/_doc/7 {"text_a2":"こんにちは さようなら"} POST mp/_doc/8 {"text_a2":"コンニチハ サヨウナラ"} POST mp/_doc/9 {"text_a2":"konnichiha"}
GET mp/_search { "query":{ "match": { "text_a1":{ "query": "こんにちは さようなら", "operator": "and", "analyzer": "whitespace" } } } }