はじめに
Elasticsearchでは、検索語に対してあるドキュメントのあるフィールドの類似度を評価してリストの並び順を制御します。
この評価関数はBM25がデフォルトだそうですが、BM25やその由来のTF/IDFではどうも高級すぎて少し使いづらい時があります(あるかもしれません)。
例えば、検索対象ドキュメントが、フリーテキスト中心ではなく、お店の名前やタグ、あるいは短文・単文のリード文などに限られる場合、TF/IDFのIDFの項のドキュメント全体の集合に対するこの検索語の重み(検索語の重要度、レア度)が少し煩わしく感じられる場合があります。
ストップワードもさほど含まれないし、TF/IDFのTF(ドキュメント中の検索語の出現回数)で単純に優劣を決めるだけで良いという場合もなくはないでしょう。
ここはさすがElasticsearch、mapping/setting〜インデクシングの時点で考慮する必要がありそうですが、Java、JavaScript風の書式(Painless Script)で、設定レベルで類似度をカスタム評価式で独自定義できるようです。
www.elastic.co ※こちらで解説があります。特にページの後半あたり。
www.elastic.co ※パッと見、3種類しか選べなさそうだが、similarityモジュールへのリンクがある。
やってみる
similarityモジュールの公式Rでの説明によると、 1. settingで評価式を「similarity」というセクションで定義 2. mappingのpropertiesで「similarity」というところに、1で定義した評価式の名称を指定 すれば良さそうです。
では、さっそくやってみます。 (なお、記事にあたり、Elasticsearch 6.8 で動作確認しています。)
settings/mappings
検索語の出現回数をそのままスコアにする類似度設定↓
PUT hogehogehoge { "settings": { "similarity": { "freq": { "type": "scripted", "script": { "source": "return query.boost * doc.freq ;" } } }, "analysis": { "tokenizer": { "kuro": { "type": "kuromoji_tokenizer", "mode": "search" } }, "analyzer": { "jaWords": { "type": "custom", "char_filter": [ "icu_normalizer" ], "tokenizer": "kuro", "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_number", "kuromoji_stemmer" ] } } } }, "mappings": { "_doc": { "dynamic_templates": [ { "mySim": { "match_mapping_type": "string", "mapping": { "similarity": "freq", "analyzer": "jaWords", "type": "text" } } } ] } } }
※ 対象フィールド名を指定するのが手間でしたので、dynamic_templatesでワイルドカード風の指定にしてあります。 ご存知のとおり空間効率がよろしくないと思われますので、フィールド名がバチッと決まるなら、propertiesでそれぞれ明示的に指定した方が良いでしょう。
データをインポート
ひとまず1件だけですが... (しかも奇妙な文例ですね...)
POST hogehogehoge/_doc/ {"text1":"会員登録証/ご契約内容のご案内", "text2":["会員契約解除制度","記載内容をご確認ください"] }
検索してみる
検索クエリ
GET /hogehogehoge/_search { "explain":true, "query": { "multi_match": { "type":"most_fields", "fields": [ "text1^1" ], "query": "会員内容案内", "operator": "or" } } }
検索結果(explain)
{ "hits" : { "hits" : [ { "_source" : { "text1" : "会員登録証/ご契約内容のご案内", "text2" : [ "会員契約解除制度", "記載内容をご確認ください" ] }, "_explanation" : { "value" : 3.0, "description" : "sum of:", "details" : [ { "value" : 1.0, "description" : "weight(text1:会員 in 0) [PerFieldSimilarity], result of:", "details" : [ { "value" : 1.0, "description" : "score from ScriptedSimilarity(weightScript=[null], script=[Script{type=inline, lang='painless', idOrCode='return query.boost * doc.freq ;', options={}, params={}}]) computed from:", "details" : [ { "value" : 1.0, "description" : "weight", "details" : [ ] }, { "value" : 1.0, "description" : "query.boost", "details" : [ ] }, { "value" : 1.0, "description" : "field.docCount", "details" : [ ] }, { "value" : 7.0, "description" : "field.sumDocFreq", "details" : [ ] }, { "value" : 8.0, "description" : "field.sumTotalTermFreq", "details" : [ ] }, { "value" : 1.0, "description" : "term.docFreq", "details" : [ ] }, { "value" : 1.0, "description" : "term.totalTermFreq", "details" : [ ] }, { "value" : 1.0, "description" : "doc.freq", "details" : [ ] }, { "value" : 8.0, "description" : "doc.length", "details" : [ ] } ] } ] }, { "value" : 1.0, "description" : "weight(text1:内容 in 0) [PerFieldSimilarity], result of:", "details" : [ { "value" : 1.0, "description" : "score from ScriptedSimilarity(weightScript=[null], script=[Script{type=inline, lang='painless', idOrCode='return query.boost * doc.freq ;', options={}, params={}}]) computed from:", "details" : [ { "value" : 1.0, "description" : "weight", "details" : [ ] }, { "value" : 1.0, "description" : "query.boost", "details" : [ ] }, { "value" : 1.0, "description" : "field.docCount", "details" : [ ] }, { "value" : 7.0, "description" : "field.sumDocFreq", "details" : [ ] }, { "value" : 8.0, "description" : "field.sumTotalTermFreq", "details" : [ ] }, { "value" : 1.0, "description" : "term.docFreq", "details" : [ ] }, { "value" : 1.0, "description" : "term.totalTermFreq", "details" : [ ] }, { "value" : 1.0, "description" : "doc.freq", "details" : [ ] }, { "value" : 8.0, "description" : "doc.length", "details" : [ ] } ] } ] }, { "value" : 1.0, "description" : "weight(text1:案内 in 0) [PerFieldSimilarity], result of:", "details" : [ { "value" : 1.0, "description" : "score from ScriptedSimilarity(weightScript=[null], script=[Script{type=inline, lang='painless', idOrCode='return query.boost * doc.freq ;', options={}, params={}}]) computed from:", "details" : [ { "value" : 1.0, "description" : "weight", "details" : [ ] }, { "value" : 1.0, "description" : "query.boost", "details" : [ ] }, { "value" : 1.0, "description" : "field.docCount", "details" : [ ] }, { "value" : 7.0, "description" : "field.sumDocFreq", "details" : [ ] }, { "value" : 8.0, "description" : "field.sumTotalTermFreq", "details" : [ ] }, { "value" : 1.0, "description" : "term.docFreq", "details" : [ ] }, { "value" : 1.0, "description" : "term.totalTermFreq", "details" : [ ] }, { "value" : 1.0, "description" : "doc.freq", "details" : [ ] }, { "value" : 8.0, "description" : "doc.length", "details" : [ ] } ] } ] } ] } } ] } }
検索結果(explanation)の補足
会員内容案内は、今回のanalyzeだと「会員/内容/案内」にトークン化されるのですが、これらの「text1」での登場回数がのべ3回なので、スコアとして3.0として評価されているようです。
まとめ
評価式のPainless Scriptでは次の変数値が使えそうです。最初にリンクした公式Rのページの例にもありますが、自分でTF/IDFを再現することももちろんできそうです。 私は、当初に述べたとおりterm.docFreqはあえて捨てることはありますが、上記の「doc.freq」以外に、「doc.length」をからめた項を使って、ドキュメントの検索対象フィールドのトークン数が少ないほど密であるという評価の味付け*1をするのが好みです。
以上です。
参考:過去の似たテーマの記事
*1:同じ検索語の出現回数でも、長めの文と短めの文では、後者の方が適合度が高いという評価