はてだBlog(仮称)

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

検索エンジンでのルックアップ検索型方式に関する講釈とdis_max、constant_score(Elasticsearchを題材に)

はじめに

検索エンジンのスコアリング・チューニングは「関連度」がキモ...だが...

このブログの前の記事で、「BM25」のような情報検索における関連度について、「使わせていただく立場」として載自分なりの講釈を述べてみました。

itdepends.hateblo.jp

関連度やその延長線上にある「評価・ランキング」といったものは、いわばこの分野の華でありいろいろ研究・研鑽が進んでいるんだろうなと思われます。

一方、現場レベルでは、サービス/業務ドメインによっては、関連度以外の方法でスコアリングする方がうまく機能する場合があると感じています。

いや、関連度が機能しないというのは少し違うかもしれません。

過去のデータ管理・運営などで積み上げられたデータ管理結果やデータ整備の運用の努力にうまく乗っかることで、手間をかけずに*1ルールベースの方法でも効果を得られるケースも多くあるのではと考えています。

↓こういうのが基本かもしれないが、、、

f:id:azotar:20200115020601p:plain

↓実はこういうモデルに帰着できるものも多いのでは?

f:id:azotar:20200115020618p:plain

ルックアップ検索モデルと(「関連度」を用いない)定数スコアリングによる検索方法・対象フィールドの序列化

もう少し具体的に言います。

当たり前といえば当たり前なのですが、ある程度データ整備されているドキュメント群においては、関連度に相当するものがあらかじめ整備されています(整備されていることが期待できます)。

この場合、特定の検索ワードでドキュメントのインデックスを検索するよりも、ドキュメントを直接検索する前に、そもそもユーザーの入力ワードを元に、ユーザーが探しているであろう情報カテゴリ自体を検索(「ルックアップ」と呼ぶことにします)し、その後、確定したカテゴリで確実にドキュメントをフィルタリング検索するというやり方も考えられます。

検索の前に検索?、となると一見まどろっこしいです。しかし、膨大なドキュメント検索を、より小さなドメインの検索に置き換えてやるので、もちろんドキュメントの整備され具合に依存はしますが、ルックアップ用の辞書の作り方次第で、ちょっとした工夫の効果が出やすいと思われます。

このような、ルックアップ方式においては、検索時に関連度は用いないものの、検索エンジン分かち書きやフィールドごとの重み付けの制御などスコアリングによる並び順制御の仕掛けはしっかり活用します。 *2

前置きが長くなりましたが、このようなルックアップ検索においては、Elasticsearchでの例でいうと、dis_maxクエリと、constant_scoreクエリが使い勝手が良いと感じているので、この記事では、これらのの紹介がてら、あらためて講釈をたれてみました。

※Elasticsearchのバージョンは6.4で確認しています。

※講釈は不要だ、クエリのシンタックス例をみたいというかたは前半部分をスルーして後半部分をご参照ください。

ルックアップ用の辞書の基本フォーマット

あくまで私の経験の範囲ですが、ルックアップ用の辞書は次のような構造が使いやすいと感じています。また、同じような手法で対応できそうな場合の定番形式として私は推しているものになります。

ルックアップについては、正式名称1つを1ドキュメントと見立てて、「正式名称」フィールドそのものも含めて、様々な検索条件で網をかけて、ユーザーの検索語から導ける、おすすめの「カテゴリ(の正式名称)」を取得するというやり方になります。 よって、正式名称は、この辞書データのユニークキーであり、ルックアップの戻り値の必須項目です。

f:id:azotar:20200115020833p:plain

f:id:azotar:20200115021006p:plain

正式名称のカテゴリの同義語、連想語(関連語)などを、ドメインエキスパートからのヒアリングや検索ログなどを元に、メンテナンスしていけば良いでしょう。

なお、このようなルックアップ機構自体を検索エンジンを用いることの隠れたメリット(?)ですが、、RDBのSELECTで厳格に辞書をルックアップする方式に比べて、辞書の管理がかなり気軽になります。

辞書といっても、大辞林広辞苑ではありませんので、語彙間の関係を厳密に定義する必要もなく、強く関係しそうな単語を同義語として設定し、そうでもない単語や利用者によっては検索に使われそうな簡易な自然文などを連想語としてスペース区切りで登録しておけば良いのです。

なお、この記事の主旨とは違いますが、同義語といえば、Elasticsearchの標準で、同義語(synonym)機構がありますので、ここまでの話で、オレは正攻法が知りたいという方向けに、標準機能についてのリンクを引用しておきます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-synonym-tokenfilter.html

f:id:azotar:20200115021243p:plain

ルックアップ用途に向いている(とあなたが思う)検索方法の棚卸し

次に、analyze方法と組み合わせた上で、Elasticsearchでの検索方法を以下におさらいしてみます。

これがElasticsearchで検索できる検索方法全てではありませんが、私の中では定番の検索方法(≒ルックアップの名人芸を検討する際にまずは考えるもの)は次のとおりです。

f:id:azotar:20200115021334p:plain

なお、脱線しますが、ご自身の中で、各検索方法とその検索方法の場合にどのような検索の編みかけがなされる傾向があるかの定性的な特徴を棚卸ししておくと、ルックアップ用途に限らず、検索チューニングの際に頭が整理されて検討しやすくなりますので、おすすめです。

ルックアップ処理モデルの見極め

続いて、ルックアップ用の辞書の各フィールドとそれぞれのフィールドに対する検索方法を組み合わせて、それらのどの検索方法の優先度(スコア: すなわちboost値設定)を高めにすると、もっともらしい「カテゴリ」が得られるかを考察します。

例えば、以下のようなまとめを行ってみます。

f:id:azotar:20200115021503p:plain

対象ドメインにより異なりますが、基本は「正式名称」にぴったり一致するほど優先し、そうでないものは優先度を落とすことになります。肉と入れられて、ステーキハウスを連想するか、焼肉屋を連想するかは悩ましいですが、ハンバーガーと入れられた場合は、ハンバーガー屋カテゴリをまずは第一優先で連想することになると思います。

ここで、私の経験則・主観ですが、edge ngramでanalyzeしたものを(全文)検索(図中のE)と全文検索(token化以外の各種filter適用)(図中のF)の間に、「適合率」と「再現率」の壁があるように感じています。

Eより上は、うまくやればうまくやるほど、納得度の高いの検索結果が得られます。本記事のルックアップの例で言いうとルックアップで得られるカテゴリの尤もらしさが向上します。一方、その分だけ「再現率」が下がってしまうので、当たらないよりはマシでしょという発想で、F以下の検索方法で補ってやるという考え方です。もちろん、あくまで保険なのでFより下の検索方法は、それより上のものよりかなり評価は低めです。

ルックアップ検索モデルと定数スコアリング/検索方法と検索フィールドの序列化

あとは、この優先順に従い、検索方法とフィールドごとの重み付けでのboost設定を行った検索(ルックアップ)をしてやることになります。

... というような考察の結果、前述の図表のとおり、検索方法と検索対象のフィールドの組み合わせ(◯:この組み合わせの検索を実施、△:検索PoCの状況見合いでルックアップ対象としてみる)と優先度(図中の矢印。boost値の重み付け順イメージ)を見定めることになるのですが、だいたいどの例でもこのパターンにおちつく気がします。

さて、ここでは、「ルックアップ検索」のような小さい問題に検索モデルを置き換えることで、関連度のように曲線的なスコアリング(設計上のおおよその優先度はあるもののオーバーラップもありうることで要件に対応している方式)ではなく、設計上の優先順に確実に並べるような、実質ルールベースに近い「決定型」のスタンスでの、確実な制御・チューニングを行うことになります。

ということで、長くなりましたが、やっと、次項でElasticsearchのdis_maxクエリとconstant_scoreクエリおよびこれらの組み合わせのクエリのシンタックス紹介です。

dis_maxクエリとconstant_scoreクエリ

dis_maxクエリ

www.elastic.co

本来はいろいろあると思いますが、俗に言うと、複数の検索条件のOR検索です。 dis_max.queriesプロパティの配列に、ElasticsearchのリーフDSLクエリを列挙できます。 列挙されたリーフDSLクエリのそれぞれで検索されて、どれか一つでも該当すれば、該当したドキュメントが検索結果として戻ってきます。

dis_max部分をbool、queries部分をshouldに置き換えた検索クエリでも同じ検索結果ドキュメントの集合が得られますが、bool-should版が、それぞれの検索条件の加算のスコアリングになることに比べて、dis_maxでは、検索条件のうちもっとも単体スコアが高いものが、そのまま総合スコアとして採用される違いがあります。

f:id:azotar:20200115022731p:plain

加算型のモデルは関連度を意識した検索サービスの場合は、関連度が強ければ強いほど高評価という感覚に合うのでその点では良いですが、今回のように関連度は用いず検索対象のフィールドや検索方法から序列を決めてやろうとしている例の場合は、過剰評価になるきらいがあります。 その点、dis_maxは単体スコアの最高点=総合評価スコアなので、どの検索条件でどのフィールドに合致したかによって、スコアの序列を制御しやすいです。

constant_scoreクエリ

www.elastic.co

constant_scoreクエリは、boost指定された値をそのままスコアに使います。BM25などの関連度はスコアに関与しません。

dis_maxとconstant_scoreの組み合わせ

実際のシンタックスの例として、dis_maxとconstant_scoreの合わせ技の検索クエリ例を示します。

今回のルックアップの件にそった意味の例にはなっていないです。ご注意ください。

GET /20191231/_search
{
  "query": {
    "dis_max": {
      "queries": [
        {
          "constant_score": {
            "filter": {
              "terms": {
                "A.raw": [
                  "東京都","川崎市"
                ]
              }
            },
            "boost": 1000
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A": {
                  "query":"荒川区",
                  "operator":"and"
                }
              }
            },
            "boost": 10
          }
        }
      ]
    }
  }
}

この例だと、次のようになります。

  1. Aフィールドに、東京都,荒川区 という値のドキュメント →スコアが1000で戻る
  2. Aフィールドに、東京都,川崎市,荒川区 という値のドキュメント →スコアが1000で戻る
  3. Aフィールドに、荒川区 という値のドキュメント →スコアが10で戻る
  4. Aフィールドに、品川区 という値のドキュメント →検索結果に含まれない

FYI

長くなったのでやや尻切れトンボ気味ですが、本論はここまでで終わりです。

以下、関連情報+付録です。

Elasticsearchのpercolator

今回は述べていませんが、Elasticsearchには、今回述べたような「ルックアップ」あるいは類似ドキュメントを元にした「分類」相当を、検索エンジンの機構ならではの仕組みで実現するpercolatorという仕掛けがあります。

これは非常に面白いしかけです。

このブログの過去記事で紹介していますので、過去記事を参照ください。 (過去記事では、公式リファレンスへのリンクも行っています。)

itdepends.hateblo.jp

私はルックアップ用途以外はmulti_match推しです(宣伝)

今回はルックアップを例にしたため、dis_maxとconstant_scoreをピックアップしました。 しかし、私は、基本は「multi_match」と対象とする検索サービスの基本の検索パターンから逆算した、「bool」クエリでのmustとshouldの組み合わせの型を定めてその中で動くモデルを好んでいます。ルックアップモデルではうまく行かないような検索要件の場合はもちろんBM25などの関連度を活かした検索モデルを検討することになるのですが、このような例の考察については、こちらの過去記事で講釈の述べています。もしよろしければご参照ください。

itdepends.hateblo.jp

itdepends.hateblo.jp

なお、ここまで言っておいてなんですが、dismax自体は、luceneの世界でDisjunctionMaxQueryとして歴史があるものです。この記事では「関連度」を用いない「ルックアップ」用途にクエリの見栄えも含めてしっくりくる...という私の所見でdismax推しですが、「関連度」を意識した検索に不向きという意味ではありません。ご注意ください。

付録

settingおよびmapping設定

今回のルックアップでは、検索対象のフィールドの数は多くないのですが、同じフィールドを様々な検索方法で検索します。 前述の検索方法のパターンに対応したanalyzeパターンの設定の例を以下に示します。

PUT an
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer": {
          "type": "kuromoji_tokenizer",
          "mode": "search"
        },
        "my_ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit",
            "symbol"
          ]
        },
        "my_4gram_tokenizer": {
          "type": "ngram",
          "min_gram": 4,
          "max_gram": 4,
          "token_chars": [
            "letter",
            "digit",
            "symbol"
          ]
        },
        "my_e_ngram_tokenizer": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 10
        }
      },
      "analyzer": {
        "my_ja_default_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "kuromoji_baseform",
            "kuromoji_part_of_speech",
            "ja_stop",
            "lowercase",
            "kuromoji_number",
            "kuromoji_stemmer"
          ]
        },
        "my_ja_analyzer1": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "lowercase",
            "kuromoji_stemmer"
          ]
        },
        "my_ja_analyzer2": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "kuromoji_baseform",
            "lowercase",
            "kuromoji_stemmer"
          ]
        },
        "my_ja_readingform_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "kuromoji_readingform",
            "lowercase",
            "hiragana_2_katakana",
            "kuromoji_stemmer"
          ]
        },
        "my_ngram_analyzer": {
          "type": "custom",
          "tokenizer": "my_ngram_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "html_strip"
          ],
          "filter": []
        },
        "my_4gram_analyzer": {
          "type": "custom",
          "tokenizer": "my_4gram_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "html_strip"
          ],
          "filter": []
        },
        "my_e_ngram_analyzer": {
          "type": "custom",
          "tokenizer": "my_e_ngram_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "html_strip"
          ],
          "filter": []
        }
      },
      "filter": {
        "hiragana_2_katakana": {
          "type": "icu_transform",
          "id": "Hiragana-Katakana"
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "location": {
          "type": "geo_point"
        }
      },
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true,
              "store": true,
              "fields": {
                "ja1": {
                  "type": "text",
                  "analyzer": "my_ja_analyzer1"
                },
                "ja2": {
                  "type": "text",
                  "analyzer": "my_ja_analyzer2"
                },
                "rf": {
                  "type": "text",
                  "analyzer": "my_ja_readingform_analyzer"
                },
                "ng": {
                  "type": "text",
                  "analyzer": "my_ngram_analyzer"
                },
                "4g": {
                  "type": "text",
                  "analyzer": "my_4gram_analyzer"
                },
                "e_ng": {
                  "type": "text",
                  "analyzer": "my_e_ngram_analyzer"
                },
                "raw": {
                  "type": "keyword"
                }
              }
            }
          }
        }
      ]
    }
  }
}


クエリビルダー

本記事でテーマにした、「ルックアップ」ですが、dis_maxクエリに職人芸で様々な判定・編みかけ用のクエリをほおりこみます。 真面目にクエリを作っていたら手間ですので、例えば、次のようなクエリビルダー(Pythonです)みたいなものを考えて、検索方法と対象フィールドとboost値をコンフィグ設定(プログラム中のQUERY_CONFリスト)から生成してやるようにすると良いでしょう。

import json

def terms(f,b,q):
    dsl = {"constant_score": {"filter":
        {"terms": {
            f: q.split()
        }},
        "boost":b
    }}
    return dsl

def match(f, b, q):
    dsl = {"constant_score": {"filter":
        {"match": {
            f: {
                "query": q,
                "operator":"and"
            }
        }}},
        "boost": b
    }
    return dsl

T = terms
M = match

QUERY_CONF = [
    [T, 'A.raw', 100],
    [M, 'A.ja1', 10]
    # こんな感じで追加... 
]

QS = "東京都  川崎市  ".replace(' ',' ')

queries = []

for i in QUERY_CONF:
    queries.append(i[0](i[1],i[2],QS))

es_query = {"query": {"dis_max": {"query":
        queries
    }}}

print(json.dumps(es_query,ensure_ascii=False,indent=2))

*1:正確にはすでにある程度かかっている手間を有効活用することということになります。

*2:活用することで、grepRDB/SQLのLIKEなどによる検索/ルックアップよりももう一段うまい効果が得られることが期待できます。