Elasticsearchを使って日本語の全文検索機能を実装することはよくあるケースですね。
大量のドキュメントをインデックスに登録し運用していると、想定したドキュメントがヒットしない、関連性が低いドキュメントのスコアが高くなっている等、意図していない挙動になっている場合があると思います。
その時にデバッグする時に便利なAPIや使い方をまとめてみます。
Analyze API
実際に検索クエリがどのようにトークン分割されているか確認したい場合に使います。
アナライザを指定することで、アナライザごとにどのようにトークン分割されているか比較することが可能です。
standard analyzer
例えば、ビルドインのデフォルトアナライザであるStandard Analyzerを指定してみます。このアナライザはUnicode Text Segmentation algorithmに基づいてトークン分割されますが、テキストが日本語だと1文字ごとに分割されてしまいます。ひらがなや表示文字であることは認識しているようですね。
GET /_analyze {"analyzer": "standard","text": ["いちご大福"], "explain": false} { "tokens" : [ { "token" : "い", "start_offset" : 0, "end_offset" : 1, "type" : "<HIRAGANA>", "position" : 0 }, { "token" : "ち", "start_offset" : 1, "end_offset" : 2, "type" : "<HIRAGANA>", "position" : 1 }, { "token" : "ご", "start_offset" : 2, "end_offset" : 3, "type" : "<HIRAGANA>", "position" : 2 }, { "token" : "大", "start_offset" : 3, "end_offset" : 4, "type" : "<IDEOGRAPHIC>", "position" : 3 }, { "token" : "福", "start_offset" : 4, "end_offset" : 5, "type" : "<IDEOGRAPHIC>", "position" : 4 } ] }
simple analyzer
文字以外の文字(記号やスペースなど)をテキストの区切りと判断するsimpleアナライザを指定すると、以下のように認識されます。記号?を途中に挟むとその前後でトークン分割されていますね。
# いちご大福 GET /_analyze {"analyzer": "simple","text": ["いちご大福"], "explain": false} { "tokens" : [ { "token" : "いちご大福", "start_offset" : 0, "end_offset" : 5, "type" : "word", "position" : 0 } ] } # いちご?大福 GET /_analyze {"analyzer": "simple","text": ["いちご?大福"], "explain": false} { "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0 }, { "token" : "大福", "start_offset" : 4, "end_offset" : 6, "type" : "word", "position" : 1 } ] }
ビルドインプラグイン一覧はこちらに記載されてあります。
kuromoji analyzer
日本語のテキスト分析を行う場合は、kuromojiなどのプラグインをインストールします。
アナライザにkuromojiを指定すると、日本語の単語ごとにトークン分割されるようになります。
GET /_analyze {"analyzer": "kuromoji","text": ["いちご大福"], "explain": false} { "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0 }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1 } ] }
explainをtrueにすると、トークン属性や詳細情報も表示されます。
GET /_analyze {"analyzer": "kuromoji","text": ["いちご大福"], "explain": true} { "detail" : { "custom_analyzer" : false, "analyzer" : { "name" : "kuromoji", "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0, "baseForm" : null, "bytes" : "[e3 81 84 e3 81 a1 e3 81 94]", "inflectionForm" : null, "inflectionForm (en)" : null, "inflectionType" : null, "inflectionType (en)" : null, "keyword" : false, "partOfSpeech" : "名詞-一般", "partOfSpeech (en)" : "noun-common", "positionLength" : 1, "pronunciation" : "イチゴ", "pronunciation (en)" : "ichigo", "reading" : "イチゴ", "reading (en)" : "ichigo", "termFrequency" : 1 }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1, "baseForm" : null, "bytes" : "[e5 a4 a7 e7 a6 8f]", "inflectionForm" : null, "inflectionForm (en)" : null, "inflectionType" : null, "inflectionType (en)" : null, "keyword" : false, "partOfSpeech" : "名詞-一般", "partOfSpeech (en)" : "noun-common", "positionLength" : 1, "pronunciation" : "ダイフク", "pronunciation (en)" : "daifuku", "reading" : "ダイフク", "reading (en)" : "daifuku", "termFrequency" : 1 } ] } } }
表示する属性はattributesで絞り込むことも可能です。
GET /_analyze {"analyzer": "kuromoji","text": ["いちご大福"], "explain": true, "attributes" : ["keyword","pronunciation"]} { "detail" : { "custom_analyzer" : false, "analyzer" : { "name" : "kuromoji", "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0, "keyword" : false, "pronunciation" : "イチゴ" }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1, "keyword" : false, "pronunciation" : "ダイフク" } ] } } }
kuromoji_tokenizer
また、アナライザはカスタムすることが可能で、トークナイザやトークンフィルターなど要件に応じて指定することができます。
ここでは、kuromoji_tokenizerを使った例を示します。kuromoji_tokenizerのsearchモードを指定した場合、以下のようにテキストが分割されます。
kuromoji_tokenizerのモードはnormal, search, extendedの3種類があり、未知な単語や複合語をトークナイザがどのように扱うのかを決定するものです。
詳細は、こちらに記載されています。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer" }, "text": ["いちご大福が美味しい"] } { "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0 }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1 }, { "token" : "が", "start_offset" : 5, "end_offset" : 6, "type" : "word", "position" : 2 }, { "token" : "美味しい", "start_offset" : 6, "end_offset" : 10, "type" : "word", "position" : 3 } ] }
トークンフィルター
次にトークンフィルターを使ってみます。kuromoji_part_of_speechフィルターは、話し言葉で使われるような特定の品詞をストップタグとして除外してくれます。
デフォルトでどのような品詞が除外されるのかは、stoptags.txtに記載されています。
例えば、「いちご大福が美味しい」というテキストの場合、助詞の「が」が除外されているのがわかります。
# デフォルト GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer" }, "filter" : ["kuromoji_part_of_speech"], "text": ["いちご大福が美味しい"] } { "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0 }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1 }, { "token" : "美味しい", "start_offset" : 6, "end_offset" : 10, "type" : "word", "position" : 3 } ] }
デフォルトのストップタグを変更することも可能ですが、デフォルトで定義されていたストップタグを上書きしてしまい、元々除外されていたストップタグが出力されてしまうので、注意が必要です。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer" }, "filter" : [ { "type": "kuromoji_part_of_speech", "stoptags": [ "接頭詞-名詞接続" ] } ], "text": ["いちご大福が美味しい"] }
フィルターは、複数指定することも可能です。
以下では、漢数字を半角のアラビア数字に正規化するkuromoji_numberと動詞と形容詞を終止形に置き換えるkuromoji_baseformを追加してみた例です。
一〇〇〇
が1000
となり、食べたい
が食べる
に変換され出力されていますね。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer" }, "filter" : ["kuromoji_part_of_speech", "kuromoji_number", "kuromoji_baseform"], "text": ["いちご大福が一〇〇〇個食べたい"] } { "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0 }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1 }, { "token" : "1000", "start_offset" : 6, "end_offset" : 10, "type" : "word", "position" : 2 }, { "token" : "個", "start_offset" : 10, "end_offset" : 11, "type" : "word", "position" : 3 }, { "token" : "食べる", "start_offset" : 11, "end_offset" : 13, "type" : "word", "position" : 4 } ] }
辞書、シノニム
お団子食べたいというテキストを渡した時、「お、団子、食べる」の3トークンに分割されます。お団子は一単語として認識させたい場合、辞書登録する必要があります。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer" }, "filter" : [ "kuromoji_part_of_speech", "kuromoji_number", "kuromoji_baseform", "kuromoji_stemmer" ], "text": ["お団子食べたい"] } { "tokens" : [ { "token" : "お", "start_offset" : 0, "end_offset" : 1, "type" : "word", "position" : 0 }, { "token" : "団子", "start_offset" : 1, "end_offset" : 3, "type" : "word", "position" : 1 }, { "token" : "食べる", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 2 } ] }
kuromojiはデフォルトでMeCab-IPADIC辞書を用いています。この辞書を拡張したい場合、user_dictionary_rules
にCSVフォーマットで記載する必要があります。
お団子を辞書登録すると、無事「お団子、食べる」の2つのトークンに分割されるようになりました。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer", "user_dictionary_rules": ["お団子,お団子,オダンゴ,カスタム名詞"] }, "filter" : [ "kuromoji_part_of_speech", "kuromoji_number", "kuromoji_baseform", "kuromoji_stemmer" ], "text": ["お団子食べたい"] } { "tokens" : [ { "token" : "お団子", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0 }, { "token" : "食べる", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1 } ] }
お団子は一単語として認識させることができましたが、お団子、団子、おだんごの3パターンで検索しても同意語として扱いたい場合は、synonymトークンフィルターを使用します。synonymを定義するための記法は、SolrとWordNetの2種類があります。今回は、Solr記法を用いました。
「おだんご食べたい」というテキストを渡すとおだんごが団子とお団子の同意語として認識されているのが分かります。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer", "user_dictionary_rules": [ "お団子,お団子,オダンゴ,名詞", "おだんご,おだんご,オダンゴ,名詞" ] }, "filter" : [ "kuromoji_part_of_speech", "kuromoji_number", "kuromoji_baseform", "kuromoji_stemmer", { "type": "synonym", "lenient": false, "synonyms": [ "おだんご,お団子,団子" ] } ], "text": ["おだんご食べたい"] } { "tokens" : [ { "token" : "おだんご", "start_offset" : 0, "end_offset" : 4, "type" : "word", "position" : 0 }, { "token" : "お団子", "start_offset" : 0, "end_offset" : 4, "type" : "SYNONYM", "position" : 0 }, { "token" : "団子", "start_offset" : 0, "end_offset" : 4, "type" : "SYNONYM", "position" : 0 }, { "token" : "食べる", "start_offset" : 4, "end_offset" : 6, "type" : "word", "position" : 1 } ] }
展開方向の抑制というのは、以下のように⇒を使って、おたんこまたはたんこというテキストが渡ってきた場合、右側に記載されたおだんご、団子、お団子の3つに展開されるように抑制することです。つまり、お団子と検索した際はおたんこやたんこは同意語として認識させないようにできます。もし、表記揺れワードに対して展開方向を抑制しなければ、意図しないドキュメントがヒットしまう可能性があるため注意が必要です。
このように、表記揺れワードに対してもお団子として認識させたい場合は、シノニムを定義する際に展開方向の抑制をすることをおすすめします。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer", "user_dictionary_rules": [ "お団子,お団子,オダンゴ,名詞", "おだんご,おだんご,オダンゴ,名詞" ] }, "filter" : [ "kuromoji_part_of_speech", "kuromoji_number", "kuromoji_baseform", "kuromoji_stemmer", { "type": "synonym", "lenient": false, "synonyms": [ "おだんご,お団子,団子", "おたんこ,たんこ=>おだんご,お団子,団子" ] } ], "text": ["おたんこ食べたい"] } { "tokens" : [ { "token" : "おだんご", "start_offset" : 0, "end_offset" : 4, "type" : "SYNONYM", "position" : 0 }, { "token" : "お団子", "start_offset" : 0, "end_offset" : 4, "type" : "SYNONYM", "position" : 0 }, { "token" : "団子", "start_offset" : 0, "end_offset" : 4, "type" : "SYNONYM", "position" : 0 }, { "token" : "食べる", "start_offset" : 4, "end_offset" : 6, "type" : "word", "position" : 1 } ] }
synonymトークンフィルター、synonym graphトークンフィルター
上記の例では、synonymトークンフィルターを使用しましたが、同様のフィルターとしてsynonym graphトークンフィルターが存在します。
synonym graphトークンフィルターは、テキスト解析の過程で複数の単語で構成されたシノニムを含めて、synonymトークンフィルターと比較してシノニムをより簡単に扱うことが可能です。
これだけだと、従来のsynonymトークンフィルターと何が異なるのか分かりづらいと思うので、トークナイザの仕組みをみてみましょう。
トークナイザは、テキストをトークンのストリームに変換し、ストリーム内の各トークンの位置情報(position)、いくつのトークンにまたがっているのかを表す数(positionLength)を記録します。トークンのストリームのことをトークングラフ(Directed acyclic graph:有向非巡回グラフ)と言います。
Analyze APIでexplain=trueにすると各トークンのpositionとpositionLengthを確認することができます。
GET /_analyze { "tokenizer": { "mode": "search", "type": "kuromoji_tokenizer" }, "text": ["いちご大福が美味しい"], "explain": true, "attributes": ["position","positionLength"] } { "detail" : { "custom_analyzer" : true, "charfilters" : [ ], "tokenizer" : { "name" : "__anonymous__kuromoji_tokenizer", "tokens" : [ { "token" : "いちご", "start_offset" : 0, "end_offset" : 3, "type" : "word", "position" : 0, "positionLength" : 1 }, { "token" : "大福", "start_offset" : 3, "end_offset" : 5, "type" : "word", "position" : 1, "positionLength" : 1 }, { "token" : "が", "start_offset" : 5, "end_offset" : 6, "type" : "word", "position" : 2, "positionLength" : 1 }, { "token" : "美味しい", "start_offset" : 6, "end_offset" : 10, "type" : "word", "position" : 3, "positionLength" : 1 } ] }, "tokenfilters" : [ ] } }
トークンの中には、複数のトークンにまたがるものも存在します。例えば、DNSのシノニムとしてDomain Name Systemを持つ場合などです。この場合、DNSのpositionLengthは3になります。(position 0 のDomainからposition 3のSystemにまでトークンが広がっているため)
synonym graphトークンフィルターは、このように複数トークンにまたがる場合のpositionLengthを正確に記録してくれるそうです。(synonymトークンフィルターだとpositionLengthは1になってしまう)
ただし、Indexingする場合にはpositionLengthが無視されるので、synonym graphトークンフィルターはサポートされていないため注意が必要です。
詳しくはこちら:
Explain API
Elasticsearchで検索機能を実現していると、関連性の低いドキュメントがヒットしていたり、そのドキュメントが高いスコアになっているなど発生しうると思います。 そういった時に便利なAPIがExplain APIです。このAPIを使うと何故そのドキュメントがインプットされた検索クエリに対してヒットしたのか詳細を教えてくれます。Explain APIはElasticsearchが自動で算出するスコアをfunctionやweightを使って調整している場合に活躍すると思います。
今回は、ドキュメントのtitle
フィールドのweightを2倍、introduction
のweightを0.5倍にし、title
に基本
や簡単
というワードのどちらかが含まれていれば100点を加算するように指定しました。
GET <index_name>/_explain/<doc_id> { "query": { "function_score": { "score_mode": "sum", "boost_mode": "multiply", "query": { "bool": { "must": [ { "simple_query_string": { "query": "いちご大福", "fields": [ "title^2.0", "introduction^0.5" ], "analyzer": "kuromoji", "default_operator": "and", "boost": 1 } } ] } }, "functions": [ {"filter": {"match": {"title": {"query": "基本 簡単", "minimum_should_match": 1 }}}, "weight": 100} ] } } }
レスポンスは以下のようにどのような計算式でスコアが算出されているのか細かく教えてくれます。「いちご大福」は「いちご」と「大福」2つのトークンに分割されるのでそれぞれに対してスコアが計算されます。
ざっくり説明すると、以下のような計算がされたことになります。
queryスコア
- titileにいちご、大福が含まれる・・・30.19222点
- introductionににいちご、大福が含まれる・・・7.147559点
functionスコア
- 簡単が含まれている・・・100点
score_modeがsumでboost_modeがmultiplyなので、結果約3734点のスコアがついたことになります。
(30.19222 + 7.147559) * 100 = 3733.9778
queryスコアは、Elasticsearch側でtf-idfというドキュメントに含まれる単語の重要度を評価する手法を用いて計算されているそうです。
{ "_index" : "<index_name>", "_type" : "_doc", "_id" : "<doc_id>", "matched" : true, "explanation" : { "value" : 3733.9778, "description" : "function score, product of:", "details" : [ { "value" : 37.33978, "description" : "sum of:", "details" : [ { "value" : 30.19222, "description" : "sum of:", "details" : [ { "value" : 12.783758, "description" : "weight(title:いちご in 1956) [PerFieldSimilarity], result of:", "details" : [ { "value" : 12.783758, "description" : "score(freq=1.0), computed as boost * idf * tf from:", "details" : [ { "value" : 4.4, "description" : "boost", "details" : [ ] }, { "value" : 4.7500243, "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details" : [ { "value" : 387, "description" : "n, number of documents containing term", "details" : [ ] }, { "value" : 44789, "description" : "N, total number of documents with field", "details" : [ ] } ] }, { "value" : 0.6116599, "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details" : [ { "value" : 1.0, "description" : "freq, occurrences of term within document", "details" : [ ] }, { "value" : 1.2, "description" : "k1, term saturation parameter", "details" : [ ] }, { "value" : 0.75, "description" : "b, length normalization parameter", "details" : [ ] }, { "value" : 5.0, "description" : "dl, length of field", "details" : [ ] }, { "value" : 13.437027, "description" : "avgdl, average length of field", "details" : [ ] } ] } ] } ] }, { "value" : 17.408463, "description" : "weight(title:大福 in 1956) [PerFieldSimilarity], result of:", "details" : [ { "value" : 17.408463, "description" : "score(freq=1.0), computed as boost * idf * tf from:", "details" : [ { "value" : 4.4, "description" : "boost", "details" : [ ] }, { "value" : 6.4684134, "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details" : [ { "value" : 69, "description" : "n, number of documents containing term", "details" : [ ] }, { "value" : 44789, "description" : "N, total number of documents with field", "details" : [ ] } ] }, { "value" : 0.6116599, "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details" : [ { "value" : 1.0, "description" : "freq, occurrences of term within document", "details" : [ ] }, { "value" : 1.2, "description" : "k1, term saturation parameter", "details" : [ ] }, { "value" : 0.75, "description" : "b, length normalization parameter", "details" : [ ] }, { "value" : 5.0, "description" : "dl, length of field", "details" : [ ] }, { "value" : 13.437027, "description" : "avgdl, average length of field", "details" : [ ] } ] } ] } ] } ] }, { "value" : 7.147559, "description" : "sum of:", "details" : [ { "value" : 3.3820996, "description" : "weight(introduction:いちご in 1956) [PerFieldSimilarity], result of:", "details" : [ { "value" : 3.3820996, "description" : "score(freq=2.0), computed as boost * idf * tf from:", "details" : [ { "value" : 1.1, "description" : "boost", "details" : [ ] }, { "value" : 4.5432725, "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details" : [ { "value" : 476, "description" : "n, number of documents containing term", "details" : [ ] }, { "value" : 44789, "description" : "N, total number of documents with field", "details" : [ ] } ] }, { "value" : 0.67674476, "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details" : [ { "value" : 2.0, "description" : "freq, occurrences of term within document", "details" : [ ] }, { "value" : 1.2, "description" : "k1, term saturation parameter", "details" : [ ] }, { "value" : 0.75, "description" : "b, length normalization parameter", "details" : [ ] }, { "value" : 50.0, "description" : "dl, length of field (approximate)", "details" : [ ] }, { "value" : 68.668335, "description" : "avgdl, average length of field", "details" : [ ] } ] } ] } ] }, { "value" : 3.7654593, "description" : "weight(introduction:大福 in 1956) [PerFieldSimilarity], result of:", "details" : [ { "value" : 3.7654593, "description" : "score(freq=1.0), computed as boost * idf * tf from:", "details" : [ { "value" : 1.1, "description" : "boost", "details" : [ ] }, { "value" : 6.693357, "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", "details" : [ { "value" : 55, "description" : "n, number of documents containing term", "details" : [ ] }, { "value" : 44789, "description" : "N, total number of documents with field", "details" : [ ] } ] }, { "value" : 0.5114242, "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details" : [ { "value" : 1.0, "description" : "freq, occurrences of term within document", "details" : [ ] }, { "value" : 1.2, "description" : "k1, term saturation parameter", "details" : [ ] }, { "value" : 0.75, "description" : "b, length normalization parameter", "details" : [ ] }, { "value" : 50.0, "description" : "dl, length of field (approximate)", "details" : [ ] }, { "value" : 68.668335, "description" : "avgdl, average length of field", "details" : [ ] } ] } ] } ] } ] } ] }, { "value" : 100.0, "description" : "min of:", "details" : [ { "value" : 100.0, "description" : "function score, score mode [sum]", "details" : [ { "value" : 100.0, "description" : "function score, product of:", "details" : [ { "value" : 1.0, "description" : "match filter: (title:基本 Synonym(title:かんたん title:カンタン title:簡単))~1", "details" : [ ] }, { "value" : 100.0, "description" : "product of:", "details" : [ { "value" : 1.0, "description" : "constant score 1.0 - no function provided", "details" : [ ] }, { "value" : 100.0, "description" : "weight", "details" : [ ] } ] } ] } ] }, { "value" : 3.4028235E38, "description" : "maxBoost", "details" : [ ] } ] } ] } }
まだ便利な使い方はたくさんあるかと思いますが、一旦以上です。