はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

Elasticsearchアナライズ時に派生型のプラグインを使う場合の雑図解(関連:edge_ngramやmultiplexer)

はじめに

私なぞは、検索時とインデックス時のアナライザーは同じものにしとく(つまりデフォルト)方が検索エンジンが賢いのでよしなにやってくれる派(という名のモグリ)なのですが、 edge_ngramやmultiplexerのような「トークン複数派生」的なアナライザーのプラグインを使う場合に、それを使いこなす上で、トークンに対して暗黙のORマッチング?的なところの当たり前といえば当たり前な挙動を雑にでも書き出しておきたいと考えてここにチラ裏記事を投稿します。

注意事項:

  • 検索エンジンを自作するような人には今更何をという内容です。
  • ※ Elasticsearchのanalyzerと各プラグインの設定を、処理フローというよりは、宣言的に解釈している方は実はそれで十分だと思います。そのような方は良い意味でこの記事はかえって紛らわしいことを言っているように聞こえると思いますので、用法にはご注意ください。
  • 題名で図解といいましたが、図解というほど図にしてません。

アナライザーのプラグインは変換型と派生型がある

私の他記事でもそうですが検索とインデックス時のアナライザーは同じで良い、少なくとも検討の最初はそれで良いと我思うというところの背景は次の図に考え方によります。

f:id:azotar:20210627225701p:plain

アナライザーの目的の大きなところは表記揺れ等を吸収して、同義と見なす語でマッチングさせるというものと言って良いと思われますが、その手法の根幹の一つは、同義と見なす語のグループは最終的に同じワード(見出し語)に終着させるというところがポイントになっています。

事前のインデックス時の見出し語終着と同じ手順(アナライザー)で検索時も見出し語を終着させれば、同じ結果が得られますよねというところです。

もちろん、一つの見出し語に無理に終着させなくとも、検索時かインデックス時に終着に向かうものとおおよそ逆の手順で派生してやれば、反対の方はアナライズは最小限ですみそうです(下記の図A、図B)。 ただ、それでもひとつの見出し語に片寄するのは、多分、これがもろもろの空間効率が良い方式だからだと思います。

f:id:azotar:20210627225919p:plain

ということで、細かいところはボロが出るのでこれ以上は深追いしませんが、検索時とインデックス時のアナライザーは同じ...でスタートして不都合があれば、別のことを考えましょうがヒューリステックなのかと思います。

が、実のところ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"
      }
    }
  }
}

Elasticsearch のmatch_phraseで多少のあいまい度を許容する姑息なアイディア例

はじめに

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というパラメータはあるものの、語順が合っていれば、複数の検索語のうちいくつかは無視しても良いよというパラメータはないようです。

とした時に、次のあたりのいくつかのアナライズプラグインを織り交ぜて、かつ検索クエリの形を整えることで、不恰好かもしれませんが、それっぽいことができるかもという紹介になります。

www.elastic.co

www.elastic.co

www.elastic.co

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では 次のようなビルトイン変数が使えるようです。

www.elastic.co

なお、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
    }
  ]
}

少々雑な方式なので、このままの例では、ノイズの方が多くなってしまうと思いますが、それほど大掛かりでない工夫の範囲で、なんとかなりそうな気がしないでもないです。

この項 了

N-Gramは部分一致っぽく当たるがそうでもないこともあるのは使い方次第(Elasticsearchを例にしてなんとなく説明)

Elasticsearchに限らずですが、検索エンジン案件ぽい話の際に、SQL/RDB界隈の部分一致検索のメタファーでN-Gramでの検索を語ることになりつつも、 結果、「部分一致」の先入観からかえってわかりにくくなる面もあって悩ましいということもなくはない...ので、このようなケースの 語り部記事を投稿いたします。

mapping

前提とするmapping設定はこちらです。 mappingおよび以下の検索確認は、Elasticsearch 6.8で行なっていますが、7系でも稼働するようなものになっていると思います。

PUT xgram
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "2g": {
          "type": "ngram",
          "min_gram":2,
          "max_gram":2,
          "token_chars": [
            "letter",
            "digit"
          ]
        },
        "3g": {
          "type": "ngram",
          "min_gram":3,
          "max_gram":3,
                    "token_chars": [
            "letter",
            "digit"
          ]
        },
        "23g": {
          "type": "ngram",
          "min_gram":2,
          "max_gram":3,
           "token_chars": [
            "letter",
            "digit"
          ]
        }
      },
      "analyzer": {
        "2g": {
          "type": "custom",
          "tokenizer": "2g",
          "filter":["ja_stop"]
        },
        "3g": {
          "type": "custom",
          "tokenizer": "3g",
          "filter":["ja_stop"]
        },
        "23g":{
          "type": "custom",
          "tokenizer": "23g",
          "filter":["ja_stop"]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "g": {
            "match_mapping_type": "string",
            "mapping": {
              "fields":{
                "2g":{
                  "type":"text",
                  "analyzer":"2g"
                },
                                "3g":{
                  "type":"text",
                  "analyzer":"3g"
                },
                "23g":{
                  "type":"text",
                  "analyzer":"23g"
                }
              }
            }
          }
        }
      ]
    }
  }
}

※雑に解説すると、2-Gram、3-Gram、2,3-Gramです。whitespaceで強制トークン分割を期待する設定(token_charsのところです。whitespaceという単語は出てきませんが、消去法です。)です。

サンプルデータ(1件だけ!)

ここにひとまず、ベンチマーク(というには件数が少ないですが)として次の1件のドキュメントをインポートします。

POST xgram/_doc
{ "t":"東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"}

以下では、このドキュメントが、それぞれの検索語・match系検索方法で当たるのか当たらないのかの事例をなぞって温度感を探ってみたいと思います。 (それとなく結論や主張は匂わせていますが、厳密な議論は筆者のパワーの問題もあって難しいので、雰囲気説明です。ご了承ください。)

なお、このドキュメント-フィールドのanalyze例は次のとおり。適宜ご参照ください。

2-Gram

GET xgram/_analyze
{ "analyzer": "2g",
 "text":"東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"
}

{
  "tokens" : [
    {
      "token" : "東京",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "京は",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "は昨",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "昨日",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "日は",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "word",
      "position" : 4
    },
    {
      "token" : "は晴",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "word",
      "position" : 5
    },
    {
      "token" : "晴れ",
      "start_offset" : 6,
      "end_offset" : 8,
      "type" : "word",
      "position" : 6
    },
    {
      "token" : "れで",
      "start_offset" : 7,
      "end_offset" : 9,
      "type" : "word",
      "position" : 7
    },
    {
      "token" : "で今",
      "start_offset" : 8,
      "end_offset" : 10,
      "type" : "word",
      "position" : 8
    },
    {
      "token" : "今日",
      "start_offset" : 9,
      "end_offset" : 11,
      "type" : "word",
      "position" : 9
    },
    {
      "token" : "日は",
      "start_offset" : 10,
      "end_offset" : 12,
      "type" : "word",
      "position" : 10
    },
    {
      "token" : "は雨",
      "start_offset" : 11,
      "end_offset" : 13,
      "type" : "word",
      "position" : 11
    },
    {
      "token" : "雨だ",
      "start_offset" : 12,
      "end_offset" : 14,
      "type" : "word",
      "position" : 12
    },
    {
      "token" : "だが",
      "start_offset" : 13,
      "end_offset" : 15,
      "type" : "word",
      "position" : 13
    },
    {
      "token" : "埼玉",
      "start_offset" : 16,
      "end_offset" : 18,
      "type" : "word",
      "position" : 14
    },
    {
      "token" : "玉は",
      "start_offset" : 17,
      "end_offset" : 19,
      "type" : "word",
      "position" : 15
    },
    {
      "token" : "は昨",
      "start_offset" : 18,
      "end_offset" : 20,
      "type" : "word",
      "position" : 16
    },
    {
      "token" : "昨日",
      "start_offset" : 19,
      "end_offset" : 21,
      "type" : "word",
      "position" : 17
    },
    {
      "token" : "日も",
      "start_offset" : 20,
      "end_offset" : 22,
      "type" : "word",
      "position" : 18
    },
    {
      "token" : "も雨",
      "start_offset" : 21,
      "end_offset" : 23,
      "type" : "word",
      "position" : 19
    },
    {
      "token" : "雨だ",
      "start_offset" : 22,
      "end_offset" : 24,
      "type" : "word",
      "position" : 20
    }
  ]
}

3-Gram

GET xgram/_analyze
{ "analyzer": "3g",
 "text":"東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"
}

{
  "tokens" : [
    {
      "token" : "東京は",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "京は昨",
      "start_offset" : 1,
      "end_offset" : 4,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "は昨日",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "昨日は",
      "start_offset" : 3,
      "end_offset" : 6,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "日は晴",
      "start_offset" : 4,
      "end_offset" : 7,
      "type" : "word",
      "position" : 4
    },
    {
      "token" : "は晴れ",
      "start_offset" : 5,
      "end_offset" : 8,
      "type" : "word",
      "position" : 5
    },
    {
      "token" : "晴れで",
      "start_offset" : 6,
      "end_offset" : 9,
      "type" : "word",
      "position" : 6
    },
    {
      "token" : "れで今",
      "start_offset" : 7,
      "end_offset" : 10,
      "type" : "word",
      "position" : 7
    },
    {
      "token" : "で今日",
      "start_offset" : 8,
      "end_offset" : 11,
      "type" : "word",
      "position" : 8
    },
    {
      "token" : "今日は",
      "start_offset" : 9,
      "end_offset" : 12,
      "type" : "word",
      "position" : 9
    },
    {
      "token" : "日は雨",
      "start_offset" : 10,
      "end_offset" : 13,
      "type" : "word",
      "position" : 10
    },
    {
      "token" : "は雨だ",
      "start_offset" : 11,
      "end_offset" : 14,
      "type" : "word",
      "position" : 11
    },
    {
      "token" : "雨だが",
      "start_offset" : 12,
      "end_offset" : 15,
      "type" : "word",
      "position" : 12
    },
    {
      "token" : "埼玉は",
      "start_offset" : 16,
      "end_offset" : 19,
      "type" : "word",
      "position" : 13
    },
    {
      "token" : "玉は昨",
      "start_offset" : 17,
      "end_offset" : 20,
      "type" : "word",
      "position" : 14
    },
    {
      "token" : "は昨日",
      "start_offset" : 18,
      "end_offset" : 21,
      "type" : "word",
      "position" : 15
    },
    {
      "token" : "昨日も",
      "start_offset" : 19,
      "end_offset" : 22,
      "type" : "word",
      "position" : 16
    },
    {
      "token" : "日も雨",
      "start_offset" : 20,
      "end_offset" : 23,
      "type" : "word",
      "position" : 17
    },
    {
      "token" : "も雨だ",
      "start_offset" : 21,
      "end_offset" : 24,
      "type" : "word",
      "position" : 18
    }
  ]
}

2,3 Gram

GET xgram/_analyze
{ "analyzer": "23g",
 "text":"東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"
}

{
  "tokens" : [
    {
      "token" : "東京",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "東京は",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "京は",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "京は昨",
      "start_offset" : 1,
      "end_offset" : 4,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "は昨",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "word",
      "position" : 4
    },
    {
      "token" : "は昨日",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "word",
      "position" : 5
    },
    {
      "token" : "昨日",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 6
    },
    {
      "token" : "昨日は",
      "start_offset" : 3,
      "end_offset" : 6,
      "type" : "word",
      "position" : 7
    },
    {
      "token" : "日は",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "word",
      "position" : 8
    },
    {
      "token" : "日は晴",
      "start_offset" : 4,
      "end_offset" : 7,
      "type" : "word",
      "position" : 9
    },
    {
      "token" : "は晴",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "word",
      "position" : 10
    },
    {
      "token" : "は晴れ",
      "start_offset" : 5,
      "end_offset" : 8,
      "type" : "word",
      "position" : 11
    },
    {
      "token" : "晴れ",
      "start_offset" : 6,
      "end_offset" : 8,
      "type" : "word",
      "position" : 12
    },
    {
      "token" : "晴れで",
      "start_offset" : 6,
      "end_offset" : 9,
      "type" : "word",
      "position" : 13
    },
    {
      "token" : "れで",
      "start_offset" : 7,
      "end_offset" : 9,
      "type" : "word",
      "position" : 14
    },
    {
      "token" : "れで今",
      "start_offset" : 7,
      "end_offset" : 10,
      "type" : "word",
      "position" : 15
    },
    {
      "token" : "で今",
      "start_offset" : 8,
      "end_offset" : 10,
      "type" : "word",
      "position" : 16
    },
    {
      "token" : "で今日",
      "start_offset" : 8,
      "end_offset" : 11,
      "type" : "word",
      "position" : 17
    },
    {
      "token" : "今日",
      "start_offset" : 9,
      "end_offset" : 11,
      "type" : "word",
      "position" : 18
    },
    {
      "token" : "今日は",
      "start_offset" : 9,
      "end_offset" : 12,
      "type" : "word",
      "position" : 19
    },
    {
      "token" : "日は",
      "start_offset" : 10,
      "end_offset" : 12,
      "type" : "word",
      "position" : 20
    },
    {
      "token" : "日は雨",
      "start_offset" : 10,
      "end_offset" : 13,
      "type" : "word",
      "position" : 21
    },
    {
      "token" : "は雨",
      "start_offset" : 11,
      "end_offset" : 13,
      "type" : "word",
      "position" : 22
    },
    {
      "token" : "は雨だ",
      "start_offset" : 11,
      "end_offset" : 14,
      "type" : "word",
      "position" : 23
    },
    {
      "token" : "雨だ",
      "start_offset" : 12,
      "end_offset" : 14,
      "type" : "word",
      "position" : 24
    },
    {
      "token" : "雨だが",
      "start_offset" : 12,
      "end_offset" : 15,
      "type" : "word",
      "position" : 25
    },
    {
      "token" : "だが",
      "start_offset" : 13,
      "end_offset" : 15,
      "type" : "word",
      "position" : 26
    },
    {
      "token" : "埼玉",
      "start_offset" : 16,
      "end_offset" : 18,
      "type" : "word",
      "position" : 27
    },
    {
      "token" : "埼玉は",
      "start_offset" : 16,
      "end_offset" : 19,
      "type" : "word",
      "position" : 28
    },
    {
      "token" : "玉は",
      "start_offset" : 17,
      "end_offset" : 19,
      "type" : "word",
      "position" : 29
    },
    {
      "token" : "玉は昨",
      "start_offset" : 17,
      "end_offset" : 20,
      "type" : "word",
      "position" : 30
    },
    {
      "token" : "は昨",
      "start_offset" : 18,
      "end_offset" : 20,
      "type" : "word",
      "position" : 31
    },
    {
      "token" : "は昨日",
      "start_offset" : 18,
      "end_offset" : 21,
      "type" : "word",
      "position" : 32
    },
    {
      "token" : "昨日",
      "start_offset" : 19,
      "end_offset" : 21,
      "type" : "word",
      "position" : 33
    },
    {
      "token" : "昨日も",
      "start_offset" : 19,
      "end_offset" : 22,
      "type" : "word",
      "position" : 34
    },
    {
      "token" : "日も",
      "start_offset" : 20,
      "end_offset" : 22,
      "type" : "word",
      "position" : 35
    },
    {
      "token" : "日も雨",
      "start_offset" : 20,
      "end_offset" : 23,
      "type" : "word",
      "position" : 36
    },
    {
      "token" : "も雨",
      "start_offset" : 21,
      "end_offset" : 23,
      "type" : "word",
      "position" : 37
    },
    {
      "token" : "も雨だ",
      "start_offset" : 21,
      "end_offset" : 24,
      "type" : "word",
      "position" : 38
    },
    {
      "token" : "雨だ",
      "start_offset" : 22,
      "end_offset" : 24,
      "type" : "word",
      "position" : 39
    }
  ]
}

では、当たり具合、当たらな具合を見ていきます。

(1) これはヒットしてしかるべきでヒットするよねの例

対象ドキュメント(再掲): "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"

■ex.①A

GET xgram/_search
{"query": {
  "match": {
  "t.2g": { "query": "東京",
  "operator":"and",
  "analyzer":"2g"
  }}}}

ヒットする

■ex.①B

GET xgram/_search
{"query": {
  "match": {
  "t.2g": { "query": "埼玉",
  "operator":"and",
  "analyzer":"2g"
  }}}}

ヒットする

(2) こいつはどうでしょう(検索語(文)そのままはありませんが...)の例

対象ドキュメント(再掲): "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"

よく見るとそのままのセンテンスではないのにヒットする例です。

■ex.②A

GET xgram/_search
{"query": {
  "match": {
  "t.2g": { "query": "東京は昨日も雨だ",
  "operator":"and",
  "analyzer":"2g"
  }}}}

ヒットする

■ex.②B

GET xgram/_search
{"query": {
  "match": {
  "t.2g": { "query": "埼玉は昨日は晴れ",
  "operator":"and",
  "analyzer":"2g"
  }}}}

②Aはヒットしたくせに、こちらはヒットしない

これらは、当たって欲しくない派閥から見ると嫌な例(ヒットしてしまう)ですね。

ただし、これは上記のようなそれっぽいセンテンスに対して、本当にそんな検索ニーズあるんかなという例で検索したからこその事例というところもあります。

長文のフリーテキストフィールドを検索するならまだしも、お店の名前や小説・映画のタイトルといったフィールドに対して、少し気の聞いた部分一致検索をしたいというシーンでは、 この長さの検索語であれば、「こんなの部分一致じゃねーっ!」というところまで及ばない(実害はない)ケースも多々あるように思われます。

(3) ちょっと寄り道しますの例(Gramの刻み違い)

対象ドキュメント(再掲): "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"

2Gramの検索アナライズと3Gramのインデックスアナライズをぶつけてみます。

文字面だけ見るとヒットしそうですが、噛み合わせが悪くヒットしません。

■ex.③A

GET xgram/_search
{"query": {
  "match": {
  "t.3g": { "query": "埼玉は",
  "operator":"and",
  "analyzer":"2g"
  }}}}

ヒットしません。

■ex.③B

今度は反対。

GET xgram/_search
{"query": {
  "match": {
  "t.2g": { "query": "埼玉",
  "operator":"and",
  "analyzer":"3g"
  }}}}

ヒットしません。

深く考える必要はありませんが、この仕組みの本質のひとつでもあるので頭の片隅においておきましょう。

(4) 本題に戻ってきて、アナライザーを同じものにする、もしくは 小刻みな方で併用アナライザー(筆者の造語)のインデックスデータにぶつける

対象ドキュメント(再掲): "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"

■ex.④A

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "埼玉は",
  "operator":"and",
  "analyzer":"23g"
  }}}}

(いくつか前の方の例でヒットしなかった例と同じ検索語ですが、こちらは)ヒットします。

■ex.④B

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "埼玉",
  "operator":"and",
  "analyzer":"23g"
  }}}}

ヒットします。

■ex.④C

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "埼玉は",
  "operator":"and",
  "analyzer":"2g"
  }}}}

ヒットします。

■ex.④D

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "埼玉",
  "operator":"and",
  "analyzer":"2g"
  }}}}

ヒットします。

なんとなく、肌感覚を掴めてきたでしょうか。

(5) 当たらない方が正統派と思われますが、ひょっとしたらヒットするかも...いやこの例は実際はヒットしない例(間違い探し的にヒットする例)

対象ドキュメント(再掲): "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"

■ex.⑤A

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "埼玉は今日も雨",
  "operator":"and",
  "analyzer":"23g"
  }}}}

懸念がありましたが、無事(?)ヒットしませんね。

(余談ですが、多分事実としては正しいのですが...)

■ex.⑤B

では、さすがに、これは、当たって欲しいわけではないがあたっちゃうんじゃないの?の例

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "東京も昨日も雨だ",
  "operator":"and",
  "analyzer":"23g"
  }}}}

ヒットせず。 タネ明かし:「京も昨」がドキュメント中に存在しないので、この例はヒットせず。

■ex.⑤C

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "東京は昨日も雨だ",
  "operator":"and",
  "analyzer":"23g"
  }}}}

ついに敗北? ヒットしてしまいました。

流石に、今度はヒットしてしまいますね。

(東京の昨日の天気には触れていないにも関わらず...「東京は」「は昨日」「昨日も」「も雨だ」あたりが存在するため。)

(6) 定番ですがphrase型の検索にシフトチェンジします(部分一致に近づけるには...の例)

前項あたりの例(特に最後の例)を見せると、やはりこの類の挙動(実際はからくりはありますが)を不安定に感じて嫌がる人も多いですかね。

私などは、最後の例で善戦したところまでで十分ではと思う要件もなくはないという感想ですが...

いや許せん!

そんなあなたにオススメなのが、phrase型の検索です。 phrase型の検索の場合、検索語・検索センテンスの検索対象ドキュメントでの登場順と一致するかを考慮してくれます。

対象ドキュメント(再掲): "東京は昨日は晴れで今日は雨だが、埼玉は昨日も雨だ"

■ex.⑥A

GET xgram/_search
{"query": {
  "match_phrase": {
  "t.23g": { "query": "東京は昨日も雨だ",
  "analyzer":"23g",
  "slop":0
  }}}}

ヒットしなくなりましたね。

(当たって欲しくないし、実際に)当たらなくなった。

ここまで随分前置きが長くなったが、match_phraseのslop=0だとクラシカルな「部分一致」になると言えるでしょう。

※ヒットしない方の例を出しましたが、もちろん、「東京は昨日は晴れ」などであればヒットします。

ちなみに、slopを大きくしていくと...

■ex.⑥B

GET xgram/_search
{"query": {
  "match_phrase": {
  "t.23g": { "query": "東京は昨日も雨だ",
  "analyzer":"23g",
  "slop":30
  }}}}

ヒットします。

再び当たるようになった! (が、やっぱり流石に気持ち悪いか...)

(7) スペース区切りのキーワード複数(はどんな部分一致を期待?)

ここまではセンテンス風の検索語でしたが、スペース区切りのキーワード複数の検索ってWeb検索で見慣れてみんなやるよねの話です。

具体的には、"東京 昨日 雨だ"などと検索された場合の利用者の意図は? ・・・分かりません。

UIやそのサービスでどうしたいかのUX設計次第。

ただ、「東京」「昨日」「雨だ」を同時に含むものというところを大まかに期待しており、何かヒットすると嬉しいというところまでで、「東京は昨日雨だった...」というところまでは求めてないような気がしますね。

スペース区切りのワードごとの部分一致のAND検索となって欲しいのではと思い込むことにしましょう。

とした場合に、N-Gramというよりは、その前段で強制トークン分割する条件として、whitespaceをひとつの条件とする設定としてあるからこそですが、以下のとおり、多数派がそうなって欲しいという挙動になるかな...と筆者は感じました。

■⑦A

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "東京 昨日 雨だ",
  "operator":"and",
  "analyzer":"23g"
  }}}}

ヒットする。

■⑦B

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "東京 昨日 晴れ",
  "operator":"and",
  "analyzer":"23g"
  }}}}

ヒットする。

■⑦C

GET xgram/_search
{"query": {
  "match": {
  "t.23g": { "query": "東京 昨日 晴れだ",
  "analyzer":"23g",
  "operator":"and"
  }}}}

ヒットしない。「晴れだ」がマッチしないため。前の2つの例に比べて、「晴れ」よりも「晴れだ」で利用者の気持ちが入った検索なので、今回の例だとヒットしない(残念ながらあなたのお探しの例はありません)でも、そんなに悪くないと言えませんかね?

まとめ(ズルして上の方ではっきりと触れていない事項も含む):

  1. 部分一致という言い回し先行の要件を望むなら、Elasticsearchのmatch句系のoperatorはデフォルトではORであるため、ANDを明示指定する。
  2. AND指定しても、N-Gramはなんとなく使うと、大半のケースは素直に部分一致しているように見えるがそうでもないパターンが発生する。
  3. 部分一致しているように見えるがそうでもないパターンは、筆者自身は、RDBワイルドカード挟みのLIKE検索に比べほど良い忖度があって、実のところキライではない。
  4. 厳格な部分一致に近づけるなら、min_gramとmax_gramを違う値にしたN-Gramを設定する。max_gramを大きくすると当然、厳格になる。といいつつ、イビデンスは英語圏だけかもしれないが、minは2、maxは3で十分うまく行くらしい。
  5. N-Gram関係の検索は、search側で別のアナライズを行うことで、空間効率を高めることや当たり方の厳格度やユル度の微調整に近い効果が得られる場合がある。ただし、インデックス側のmax_gramより大きいNを検索側のアナライズに適用すると絶対にヒットしないので注意。ちなみに、この考え方で、あえてmin、maxを大きめにすることで、アソビのある部分一致とそこそこの厳密な部分一致を両立させることもできる(し、定性的に仕様を宣言(例. 検索語が4文字以上でないと部分一致させません!)できる。まあ、当たり前だが。)
  6. さらにより厳格な部分一致を極めていくなら、matchではなく、match_phraseを使う。特にslopを0にすると「隙間」無しとなるので、本当に「部分一致」になる。もちろん、今度は遊びがなくなるので、slopを0より大きな値にして文中で多少離れていてもその語順になってさえいれば良いという許容範囲に応じて微調整する、....あるいは、検索の意図の深読みはやめて、●項のような、アソビを残すのは手でしょう。

その他

上記で例にあげたものは、辞書によく出てくるような未知語も少なく、単語分割がはっきりしている例(晴れ、東京、埼玉...)でした。

このような例はそもそもN-Gramではなく、使える辞書を使って(つまり形態素解析分かち書きで)頑張れば上記のようなまどろっこしい例とはなりにくいことを今更ですが補足しておきます。

なお、「部分一致」じゃないかもというところからスタートしたコラムなので、大前提としてAND推しでしたが、全文検索の華は、OR検索のような気もしますので、 次のしかけ等をうまく使って検索体験を向上させるようなトリックとしていろいろ打つ手はありそうです! というところだけそれとなくご紹介して終わりにします。

  • minimum_should_match: AND検索に適度に近づけつつ、残念ながらそのワードではヒットしませんよというワードをほどほどに無視する。
  • shingle(トークンフィルター): 無印のmatch検索で、phrase検索を擬似実現。
  • トーカナイザーでN-Gramを使って、トークンフィルターでもN-Gram系を使う:

参考リンク

勉強させてもらいました↓

gihyo.jp

Elasticsearchで辞書で頑張らずにN-Gramでやりくりするちょっとした例(それがうまくいく利用シーンを選んだだけとも言う)

そろそろきりの良いエディション、バージョンでのElasticsearchに関する俺々ポエムを吐き出し切っておきたいと思う今日この頃です。

itdepends.hateblo.jp

さて、上記の過去記事あたりで「マイフェイバリット」としてAnalyze設定について講釈を述べましたが、形態素解析優遇のN-Gramとのハイブリッド推しとしていました。

しかし、実のところ未知語が多く形態素解析(kuromoji)のデフォルトの辞書では太刀打ちしづらく、なんらか辞書の仕掛けを用意することも難しい場合は、基本に立ち返って分かち書きN-Gram一本で頑張ることになると思います。

話やすくかつ腹落ちしやすい例かは自信がありませんが、「薬」の情報を「うろ覚えの薬名」で検索といったようなケースは形態素解析裏目にでることも多いので、形態素解析やそれとのハイブリッドはあきらめて、N-Gram中心に組み立てることになる一例としてあげられそうです*1

仕込み

では、さっそく例をあげてみたいと思います。

mapping設定

knというフィールドにちょっとだけトリックありの2,3-Gramを適用したアナライズをかける設定です。

PUT k
{
  "settings": {
    "similarity": {
      "scoring": {
        "type": "scripted",
        "script": {
          "source": "return query.boost * doc.freq + 1.0/(doc.length + 1.0)  + 0.00001/(term.docFreq + 2.0);"
        }
      }
    },
    "analysis": {
      "char_filter": {
        "m2b": {
          "type": "mapping",
          "mappings": [
            "ッ => ツ",
            "ョ => ヨ"
          ]
        }
      },
      "tokenizer": {
        "ng": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit",
            "symbol"
          ]
        }
      },
      "analyzer": {
        "anlz": {
          "type": "custom",
          "tokenizer": "ng",
          "char_filter": [
            "icu_normalizer",
            "m2b"
          ],
          "filter": [
            "icu_folding"
          ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "kn": {
          "type": "text",
          "analyzer": "anlz",
          "similarity":"scoring"
        }
      }
    }
  }
}

補足

  1. m2b というchar_filterを設定しています。小さいヨと大きいヨを同じとみなしましょうという意味合いです。うろおぼえ検索の場合は、発声の都合等で拗音の区別をあえてしないことで覚え間違いなどを救済するという狙いです。ここでは「ツ」と「ヨ」のみの例ですが、他にも全ての拗音を設定したり、すべての拗音を設定するのはノイズが増えるので1文字ずつではなく「ショウ => シヨウ」というように実際の薬名だとこういう傾向があるというものを意識して味付けしても良さそうです。なお、「救済」といっても、一方で美容院(ビヨウイン)と病院(ビョウイン)の区別が必要な場合は利用に注意が必要です。
  2. icu_normalizerもchar_filterに使っています。これは過去記事で紹介したマイフェイバリットでも使っているのですが、カタカナ・ひらがな・アルファベットおよび全角半角あたりのユニコード的標準化をしてくれるので、引き続き採用です。
  3. icu_foldingですが、ここでは濁音・半濁音を清音と区別しないという用途につかっています。アカデミック的に正しい用語の使い方かは自信がないですが、いわゆる「ミニマルペア」の混同をあえて許す検索スタイルといえるでしょう。もっとシンプルに説明すると「ベッドをベットと言い間違い」「バッグをバックと言い間違い」というようなケースの救済を意図しています。過去記事のマイフェイバリットではこのfilterは使わない方を推しているのですが今回は使います。
  4. similarityを独自設定していますが、これは後述。
  5. Elasticsearchのver 7系だと、おそらく"_doc"の層は不要ですが、それ以外は多分このままでエラーにならないと思います。

データのインポート

厚労省が公開しているこちらの医薬品マスター を使ってみます。

診療報酬情報提供サービス

ダウンロードするとy.csvが入っていますので、これをy.utf.csvという名前のUTF8に変換して、5項目目の薬名をElasticsearchのインデックスにバルクインポートしてみます。 ↓

*バルクロードするスクリプト(Python)

import pandas as pd
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk

df_ = pd.read_csv('y.utf.csv')
df_.columns = [str(i) for i in range(0,35)]
df =  df_[['4']]
df.columns=['kn']
endpoint = 'http://localhost:9200'
es = Elasticsearch(endpoint)
df['_index'] = 'k'
df['_type'] = '_doc'
bulk(client=es,actions=df.to_dict(orient='records'))

*ロード結果の確認

GET k/_search
{"query": {"match_all": {}}}

↓

  "hits" : {
    "total" : 21596,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "k",
        "_type" : "_doc",
        "_score" : 1.0,
        "_source" : {
          "kn" : "バリトップHD 99%"
        }
      },

ひとまずうまくインポートできてそう、21596件ですか。

検索例

クエリ

続いてクエリ例です。

想定検索シーンは、「正解は【バリトップ】という薬がよしなにヒットするで、そこに対してUIの検索窓で【ハリトツプ】で検索してみたら」のイメージです。

補足は後ろに書きます。

なお、薬ドメインに詳しくないので、この薬名がこの例の本来のベンチマークとしてはうまい例になっていないかもしれませんがご容赦ください。

GET k/_search?filter_path=*.*._explanation,*.*._source,*.*._score
{
  "query": {
    "dis_max": {
      "tie_breaker": 0.7,
      "boost": 1.2,
      "queries": [
        {
          "multi_match": {
            "query": "ハリトツプ",
            "fields": [
              "kn"
            ],
            "operator": "and",
            "boost":10000
          }
        },
        {
          "multi_match": {
            "type":"phrase", 
            "query": "ハリトツプ",
            "fields": [
              "kn"
            ],
            "operator": "or",
            "minimum_should_match": 2,
            "boost":100
          }
        },
        {
          "multi_match": {
            "query": "ハリトツプ",
            "fields": [
              "kn"
            ],
            "operator": "or",
            "minimum_should_match": 2
          }
        }
      ]
    }
  }
}

検索結果

{
  "hits" : {
    "hits" : [
      {
        "_score" : 84595.41,
        "_source" : {
          "kn" : "バリトップP 94.6%"
        }
      },
      {
        "_score" : 84595.17,
        "_source" : {
          "kn" : "バリトップHD 99%"
        }
      },
      {
        "_score" : 84594.766,
        "_source" : {
          "kn" : "バリトップCT 1.5%300mL"
        }
      },
      {
        "_score" : 84594.69,
        "_source" : {
          "kn" : "バリトップゾル150 150%"
        }
      },
      {
        "_score" : 3.9000025,
        "_source" : {
          "kn" : "バリトゲン 98.47%"
        }
      },
      {
        "_score" : 3.8307729,
        "_source" : {
          "kn" : "バリトゲンHD 98.6%"
        }
      },
      {
        "_score" : 3.800003,
        "_source" : {
          "kn" : "バリトゲンSHD 99.0%"
        }
      },
      {
        "_score" : 3.776473,
        "_source" : {
          "kn" : "バリトゲン-デラックス 97.98%"
        }
      },
      {
        "_score" : 3.7500029,
        "_source" : {
          "kn" : "バリトゲン消泡内用液2%"
        }
      },
      {
        "_score" : 2.5052636,
        "_source" : {
          "kn" : "ドンペリドン1%シロップ用"
        }
      }
    ]
  }
}

悪くないですね。

これだけで判断できるほどの十分な検証と言えるかはともかく。

クエリの補足

後述しますとしたsimilarityとそもそものクエリのコンセプトの補足です。

  1. operatorがANDのmatch、ORのmatch phrase(minimum should matchを2)、ORのmatch(minimum should matchを2)で、この順に確実な序列になるようにboostをかけて、dismaxで結んでいます。
  2. つまるところ、ヒューリスティックではありますが、検索語に近いものがよりもっともらしくヒットするようにしてあります。また、同じ検索語で欲張りで3種検索するところがこだわりです。
  3. mappingのfoldingなどでで揺らぎをカバーしているので、またANDだけでなく、ORでも次善の策として当てに行くので、N-Gramの狙いどおり、多少うろ覚えや正解に対する文字の欠損などもカバーできそうです。
  4. 今回の例ではそこまで重要ではないのですが、settingに(デフォルトのBM25ではなく)独自のsimilarity評価式を設定して、マッチしたトークンが多い方が単純に整数値で加点されよりストレートに反映するようにしています。dismaxの各クエリに明確な序列を設定したので少し埋もれていますが、スコアの左端の1桁目だけに注目すると切り刻んだトークンのうち何項目ぐらいが一致したかを比較的にストレートにあらわしているといえるかもしれません。【バリトップゾル】が"8"、【バリトゲン】が"3”で検索語の【ハリトツプ】から見ると、両者の間に納得感のある境界線が引けたようにも思います*2。Fuzzy検索のレーベンシュタイン距離のチューニングっぽく作用しているといいたいところです。
  5. similarityの計算式では、 1.0/(doc.length + 1.0) + 0.00001/(term.docFreq + 2.0) の項をつけていますが、前者は文字数が短いドキュメントほど検索語に対する密度が濃い(適合度が高い)、後者はドキュメント集合全体でレアなトークンであればわずかながら優先するという意図の項です。ただ、小数値を基本とし、最重視する整数値の評価が同点だった場合に限りそれとなく綺麗にならぶように意図したものです。ここではexplainの確認はしませんが、バリトップ兄弟のうち、バリトップPが最初で、バリトップゾルが最後なのはこの味付けの効果ですかね。
  6. 本例での独自similarityではBM25を(少なくともスコアの整数値部分で)使わない分、というかマッチした部分が多いほど整数値が素直に加算されていくタイプのスコアリングとすることで(またminimum should matchをひとまず2としたことで)、あくまで感覚的ですが、2点を超えるものが検索結果にヒットし、3点に満たないものは少しびみょうかも...といったことを定性的ながら定量的に見極めやすくなります。よって、通常は一旦計算したスコアの値で一定スコアに満たないものを間引くことはしないと思いますが、この例で言うような、ドンペリドンは微妙と感じる検証結果が他にも多く得られるようであれば、例外的にスコア閾値による選抜もいたずらにクエリを複雑にしないためのやり方として使えるかもしれません。

過去記事

itdepends.hateblo.jp

itdepends.hateblo.jp

*1:自信がない理由としては医療系のデータ検索等ではもっとガチでやらざるを得ないので、未知語がどうのこうのいっておられず辞書を作ることから始めることになるのでは?というところ。

*2:あくまでこの一例だけだとフェアではありませんが

ElasticsearchでBM25でなくPainless Scriptによるカスタム評価式で類似度スコアリング

はじめに

Elasticsearchでは、検索語に対してあるドキュメントのあるフィールドの類似度を評価してリストの並び順を制御します。

この評価関数はBM25がデフォルトだそうですが、BM25やその由来のTF/IDFではどうも高級すぎて少し使いづらい時があります(あるかもしれません)。

例えば、検索対象ドキュメントが、フリーテキスト中心ではなく、お店の名前やタグ、あるいは短文・単文のリード文などに限られる場合、TF/IDFのIDFの項のドキュメント全体の集合に対するこの検索語の重み(検索語の重要度、レア度)が少し煩わしく感じられる場合があります。

ストップワードもさほど含まれないし、TF/IDFのTF(ドキュメント中の検索語の出現回数)で単純に優劣を決めるだけで良いという場合もなくはないでしょう。

ここはさすがElasticsearch、mapping/setting〜インデクシングの時点で考慮する必要がありそうですが、JavaJavaScript風の書式(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をするのが好みです。

www.elastic.co

以上です。

参考:過去の似たテーマの記事

itdepends.hateblo.jp

itdepends.hateblo.jp

itdepends.hateblo.jp

*1:同じ検索語の出現回数でも、長めの文と短めの文では、後者の方が適合度が高いという評価

ゆるJOIN(JavaScriptでもmapやreduce、filter(手習いメモ) その3)

データのJOINと言えば、複数のデータセットをあるキーで完全一致するものですが、まれに、完全一致するようなレコードがあればそれと結合したいが、そのようなものがない場合でも、キーのフィールドの値のN文字以上の前方一致で最長の一致となるものがあればそのようなレコードと結合したいというケースに出くわすことはないでしょうか?

例えば、geohashでエリア名を規定している対応表に該当するデータセットがあるものの、対応表でメンテしているgeohashの粒度(精度)はエリアによってまちまちであるため、あるgeohash値でルックアップして、完全一致する値が対応表にあればそれを使うものの、そうでなければ、上位桁数に絞って一致する次善の対応値を使うというような例です。

件名のとおり、mapやreduce、filterを使うといい感じに記述できそうな気がしたので試してみた...というのがこの記事です。

考え方

2つのデータセットのうち、ルックアップされる側のデータを結合フィールドの最初の数文字からなるtrie風のデータにあらかじめ変換したものを用意して、これをハッシュとみなして検索し、得られたハッシュの中のtrieの内訳データを探索するやり方としました。

なお、実際のところ汎用化や一般化には至らなかったので、インプットデータ等に仮定をおいて取り扱う「問題」を限定してあります。 (インプットデータのところにもう少し「仮定」に関する考え方を補足してあります。)

この類の仮定を置いていいなら、また言語の機能を使うなどすればもっとスマートな方法がいくらでもありそうです。

今のところこんな感じのものどまりですが、ゼロから考えるよりは時間短縮になるのと記録しておけばいつかもっとスマートな方法に巡り会えるかもしれないのでここにメモっています。

なお、この記事は以下の記事の続きでもあるのですが、よく見たら、mapは使っているもののという例にすぎないかも。

itdepends.hateblo.jp

itdepends.hateblo.jp

インプットデータ(プログラム前半)

'use strict';

/*
2つのデータを「ゆるJOIN」するサンプル

*「ゆるJOIN」
    二つのデータセットをキーとなるフィールドで結合するが、前方一致で一致する部分が最長のもので結合する。
    データAとデータBがあり、Aの12345というキーを持つレコードを、Bに12345があればそれと結合するが、なければ1234*にあてはまるものを結合する。

* 汎用は辛いので、データセット等に次の仮定をおく。    
    1. Aを左結合とする演算に限定。
    2. キーとなるフィールドの桁数はAとBで同じ。また、桁数は固定とする。
    3. 最初のN桁目までが一致するようなものがなければ結合失敗とするような歯止めのNを要件として定めることができる。
        (言い換えると、BのキーフィールドのN桁目部分の集合は、Aの同様なものの集合を含んでいることが保証されているものを対象にしてある)

*/

//注:文字列か数字かということはポイントではないので、タイピング量を減らすことを優先、つまり型は雑に扱っている。

const a = ['12345','12344','67890','34567','345XX'];
const PRESIZE = 3;
const SIZE = 5;
// trie風のデータ。これを作るところはまた別途。
const b = {
    12345: { k: [''], p: [11111] },
    123: { k: ['45', '4',], p: [11111, 1111], default: 123 },
    67890: { k: [''], p: [22222] },
    678: { k: ['90', '9'], p: [22222, 2222], default: 678 },
    345: { k: ['66', '6'], p: [33333, 3333], default: 345 },
};

/*
↓ aとbを用いて得られる(得たい)出力のイメージ

[
    [ '12345', 11111 ],
    [ '12344', 1111 ],
    [ '67890', 22222 ],
    [ '34567', 3333 ],
    [ '345XX', 345 ]
]

*/


関数(プログラム後半)


/*
edgeNGram('abcde') ->
['abcde','abcd', 'abc', 'ab', 'a']
*/
const edgeNGram = str => {
    const it = [...Array(str.length).keys()].reverse();
    return it.map(i => str.substr(0, i + 1));
};

/* ゆるJOINロジック */
const aa = a.map(el => {
    if (b[el]) {
        return [el, b[el].p[0]];
    }
    const prfx = el.substr(0, PRESIZE);
    const sufx = el.substr(PRESIZE);
    const sufxs = edgeNGram(sufx);
    /*
    // これは例外をあげた方が良い類だが、今回は発生しえない扱い
    if (!b[prfx]) {
        console.log('failed');
        return null;
    }
    */
    const t = b[prfx];
    for (const sx of sufxs) {
        const idx = t.k.findIndex(x => x === sx);
        if (idx > -1) {
            return [el, t.p[idx]];
        }
    }
    return [el, t.default];
});
console.log(aa);


プログラム(hoge.jsと命名) 実行結果

$ node hoge.js 
[
  [ '12345', 11111 ],
  [ '12344', 1111 ],
  [ '67890', 22222 ],
  [ '34567', 3333 ],
  [ '345XX', 345 ]
]

JavaScriptでもmapやreduce、filter(手習いメモ) その2

次の記事のつづきです。 (このブログは全体的にそうですが、誰得でいうと私自身用って感じのスニペットですネ...)

itdepends.hateblo.jp

find

/* findIndex同様に、find って関数を引数に取れるので便利 */

//find初歩的な例
// (filterと異なり、最初にヒットしたものが得られる)
//
const data = [
    { id: '111', name: 'A', score: 100 },
    { id: '111', name: 'B', score: 50 },
    { id: '222', name: 'C', score: 100 },
    { id: '222', name: 'C', score: 50 },
    { id: '222', name: 'D', score: 70 },
];

const ffn = i => ['A', 'C'].includes(i.name);
const found1st = data.find(ffn)

console.log(found1st)


// > console.log(found)
// { id: '111', name: 'A', score: 100 }

配列を前方一致でグループ化できるか

/* sort */
// 配列の要素の全てに前方一致するようなある要素が存在するか
//  (思ったほどsortの例にはならなかったケド....。どちらかといえば、JavaScriptのmapは「src」が使えることの便利さの例かも。)
const true1 = ['1234', '12', '123', '12345']; // 12が該当
const false2 = ['234', '12', '123', '12345']; // 特に該当するもの無し
const hougan = ary => [...ary].sort().reduce((a, c) => c.startsWith(a) ? c : undefined, '');
const hougan2 = ary => [...ary].sort().map((c, i, src) => c.startsWith(src[0])).every(Boolean);

hougan2(true1);
hougan2(false2);

/*
> 
> hougan2(true1);
true
> hougan2(false2);
false
> 
>
*/

配列のintersect(順番維持)


// 順番維持版の2つのリストのintersect とその逆
const intersect = (a, b) => a.filter(el => b.includes(el));
const without = (a, b) => a.filter(el => !b.includes(el));

/*
>intersect([1, 2, 3, 4], [4, 5, 2])
[2, 4]
> without([1, 2, 3, 4], [4, 5, 2])
[1, 3]
*/

オブジェクト配列(順序はそれほど意味がない)を同等の形式のオブジェクトに変換


//オブジェクトの配列(各オブジェクトは実際は特定のプロパティで一意になっているようなもの)をそのプロパティの値をキーとする1つのオブジェクトに変換する
// 例えば、Elasticsearchの検索結果の一覧をドキュメントのIDをキーにしたオブジェクトに変換する... といった例をイメージした演算
let x = [{ id: 'a1', title: 'abc' }, { id: 'b2', title: 'def' }];

function keyBy(objArr, key) {
    const obj = {};
    objArr.forEach(i => {
        obj[i[key]] = i
    });
    return obj;
}

keyBy(x, 'id')

/*    
> keyBy(x, 'id')
{ a1: { id: 'a1', title: 'abc' }, b2: { id: 'b2', title: 'def' } }
*/

ポスティングリスト風のデータに対して外部から与えた関数で「スコアリング」

/* ポスティングデータ/ポスティングリスト([キー,TF/IDFなどの持ち点1,持ち点2,...]形式)のキーごとの合計スコアを計算 */
//   ↓

/* インプットデータ(仕込み) */
const parr = [
    // 検索条件Aに関するヒットの文書一覧
    [
        ['ID123', 900, 800], ['ID124', 9000, 8000], ['ID125', 90000, 80000], ['ID222', 5, 5]
    ],
    // 検索条件Bに関するヒットの文書一覧
    [
        ['ID111', 1, 1], ['ID123', 100, 200], ['ID124', 1000, 2000], ['ID125', 10000, 20000]
    ]
    // ... 実際は続いていく
];
const fnarr = [
    // 検索条件Aに関する個別の評価関数
    p => ({ key: p[0], score: p[1] + p[2] }),
    // 検索条件Bに関する個別の評価関数
    p => ({ key: p[0], score: p[1] * 2 + p[2] }),
]

/* スコア計算ロジック */
const DEFAULT_FUNC = x => ({ key: x[0], score: x[1] });
function score(parr, fnarr, strategyfn = (a, b) => a + b) {
    //
    if (!parr || parr.length === 0) {
        return Object.create(null);
    }
    //スコア評価関数の設定(引数で評価関数が引き渡されているならそれを使うが未指定の場合はデフォルト(持ち点1の値をそのまま使う))
    const fns = fnarr && fnarr.length > 0
        ? fnarr
        : Array(parr.length).fill(DEFAULT_FUNC);

    //スコアリング
    const scoreObj = Object.create(null);
    parr.forEach((p, i) => {
        p.forEach(el => {
            const { key, score } = fns[i](el);
            scoreObj[key] = strategyfn((scoreObj[key] || 0), score);
        });
    });
    return scoreObj;
}


/* スコア計算例 */
score(parr, fnarr)
/*
> score(parr, fnarr)
{
  ID123: 2100,
  ID124: 21000,
  ID125: 210000,
  ID222: 10,
  ID111: 3
}
*/