はじめに
Elasticsearchのmatch_phraseで語順を意識して検索したいけど、多少は外れてたやつも下位で良いのでヒットさせたいよねという例をサカナにanalyzeの頭の体操をしてみましたの例です。 確認はver6.8で実施しましたが、基本は7系でも動作すると思います。
match_phraseでminimum_should_match要件
さて、そのmatch_phraseですが、公式ドキュメント等の確認不足かもしれませんが、match系のクエリDSLで活躍する、minimum_should_matchやoperatorのような曖昧さや厳格さを調整する弁がありません。
なので、match_phraseでは、(1)「去年 東京 激しい 雨」で検索した場合に、(2)「去年の8月に東京で激しい雨が降った」はヒットしても、(3)「去年の8月に東京で優しい雨が降った」はヒットしません。
そりゃそうだろというところですし、この例だと「激しい雨」を探していることが透けて見えるので特に違和感もないのですが、(2)が上位ヒットすることを前提に(3)も下位で良いのでヒットさせてあげたいような事例もぼちぼちあるかと思います。
ですが、match_phraseには、トークン間の離れ具合の許容度を制御するslopというパラメータはあるものの、語順が合っていれば、複数の検索語のうちいくつかは無視しても良いよというパラメータはないようです。
とした時に、次のあたりのいくつかのアナライズプラグインを織り交ぜて、かつ検索クエリの形を整えることで、不恰好かもしれませんが、それっぽいことができるかもという紹介になります。
analyze設定
アイディアのミソは、特定のヒューリスティックで、検索語分かち書き後のトークンらから特定のトークンを除外するanalyzerを複数用意し、それらを併用して複数のmatch_phraseクエリを走らせることです。早速そのようなanalyze設定の例を示します。
PUT ese_msm { "settings": { "similarity": { "cnt": { "type": "scripted", "script": { "source": "return query.boost * doc.freq;" } } }, "analysis": { "analyzer": { "a1": { "type": "custom", "similarity": "cnt", "tokenizer": "kuromoji_tokenizer", "filter": [ "p1" ] }, "a2": { "type": "custom", "similarity": "cnt", "tokenizer": "kuromoji_tokenizer", "filter": [ "p2" ] }, "idx": { "type": "custom", "tokenizer": "kuromoji_tokenizer" } }, "filter": { "p1": { "type": "predicate_token_filter", "script": { "source": " return token.position % 2 === 1;" } }, "p2": { "type": "predicate_token_filter", "script": { "source": " return token.position % 2 === 0;" } }, "cond": { "type": "condition", "filter": [ "lowercase" ], "script": { "source": "token.getPosition() % 2 == 1" } }, "rep": { "type": "pattern_replace", "pattern": "^(.+)$", "replacement": "" } } } }, "mappings": { "mapping": { "properties": { "text": { "type": "text", "analyzer": "idx", "similarity": "cnt" } } } } }
filterのcond、repという設定は今回は使っていません。
p1とp2でそれぞれ、偶数個め、奇数個めのトークンを読み捨てるとしています。 (この他にも最後のトークンのみ捨てるといったことも可能でしょう。)
なお、predicated_token_filterでは 次のようなビルトイン変数が使えるようです。
なお、similarityを設定していますが、これは必須ではありません。目論見どおりヒットしているかを確かめやすくするために、docFreqを単純に数える計算式としています。
データインポート
POST ese_msm/_doc/ {"text": "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"}
検索確認
クエリの基本フォーマット
{ "query": { "dis_max": { "queries": [ { "match_phrase": { "text": { "query": "XXXXXXXXXXXXX", "analyzer": "idx", "slop": 10, "boost":1000 } } }, { "match_phrase": { "text": { "query": "XXXXXXXXXXXXX", "analyzer": "a1", "slop": 10, "boost":10 } } }, { "match_phrase": { "text": { "query": "XXXXXXXXXXXXX", "analyzer": "a2", "slop": 10, "boost":1 } } } ] } } }
検索時のアナライザーを明示指定して、検索語の全てのトークンを取り漏らさない検索を最高評価として、1個飛ばしの2バージョンの格を下げています。
確認(準備運動:参考比較用)
GET ese_msm/_search { "query": { "dis_max": { "queries": [ { "match_phrase": { "text": { "query": "東京 昨日 晴れ", "analyzer": "idx", "slop": 10, "boost":1000 } } }, { "match_phrase": { "text": { "query": "東京 昨日 晴れ", "analyzer": "a1", "slop": 10, "boost":10 } } }, { "match_phrase": { "text": { "query": "東京 昨日 晴れ", "analyzer": "a2", "slop": 10, "boost":1 } } } ] } } }
↓
約1000点のスコアで、先にインポートしたドキュメントがヒットします。
これは、1つめのDSLがヒットしたためです。「東京 昨日 晴れ」をこの順序で、マッチさせて、"東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"を捕まえます。
確認(本題:検索語読み捨てにより拡大)
つづいて、本題の例です。
「東京 埼玉 神奈川 昨日」で検索してみます。理想は「神奈川」も含むようなドキュメントがヒットするというところですが、残念ながら登録データにはそのようなものはないのですが、それ以外の単語を程よくチョイスして、ボチボチ良い例がヒットすると嬉しいね、というところです。
GET ese_msm/_search { "query": { "dis_max": { "queries": [ { "match_phrase": { "text": { "query": "東京 埼玉 神奈川 昨日", "analyzer": "idx", "slop": 10, "boost":1000 } } }, { "match_phrase": { "text": { "query": "東京 埼玉 神奈川 昨日", "analyzer": "a1", "slop": 10, "boost":10 } } }, { "match_phrase": { "text": { "query": "東京 埼玉 神奈川 昨日", "analyzer": "a2", "slop": 5, "boost":1 } } } ] } } }
↓
約20点のスコアで、先のドキュメントがヒットしました。match_phraseらしさをある程度残しつつ、次善の検索結果を取得できたといえそうです。
前後しますが、以下のようにトークンが間引かれています。
GET ese_msm/_analyze { "text":"東京 埼玉 神奈川 昨日", "analyzer":"a1"} ↓ { "tokens" : [ { "token" : "埼玉", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1 }, { "token" : "昨日", "start_offset" : 10, "end_offset" : 12, "type" : "word", "position" : 3 } ] }
少々雑な方式なので、このままの例では、ノイズの方が多くなってしまうと思いますが、それほど大掛かりでない工夫の範囲で、なんとかなりそうな気がしないでもないです。
この項 了