はてだBlog(仮称)

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

Elasticsearch のFunction score queryで得られたスコアに後付けで細工する

はじめに

何度目かのElasticsearchのオレオレスコアリング論まとめです。

この記事をまとめるきっかけとして、Elasticsearch ver7系におけるScript score queryなるもので、今までできなかった(?)BM25などから得られた関連度に細工ができるようで、これは知識をアップデートせねばと思ったものの、結局6.x系までの話に終始しています

オレオレ主張はともかく、記事の後半で、スコアの「加点」を制御するFunction scoreの中で、painless scriptにより、検索クエリから直接得られるQuery score(≒ Relevance score 関連度)に細工するサンプル例を示していますので、もしそれらのキーワードでこの記事を訪れた方は後半部分をご参照ください。

前提(scoreの制御)

先述のとおり、ver7系で導入された(らしい)Script score queryでは、もっときめ細やかに制御ができるようですが、それはさておき、scoreの制御はおおよそこんな感じです。 (図の丸付き数字のあたりが、横から差し金して最終評価のスコアを調整できる箇所です。)

今回は、図の丸付き数字4のfunction_scoreの部分で、Query scoreとして得られた値を「_score」という変数で取得できるので、これを使ってどのような遊びができるか確認してみます。

f:id:azotar:20200128123710p:plain

関連リンク

脱線しますが、Elasticsearchの公式リファレンスの最新版は、なかなか日本のGoogleではヒットしません。

本題に入る前に、本記事で関連するファンクションについての公式へのリンクをまとめてはっておきます。

www.elastic.co

www.elastic.co

および

この記事では使っていませんが、Script score ↓

Script score query | Elasticsearch Reference [7.5] | Elastic

および ちょっと似た話として、

itdepends.hateblo.jp

Function score queryの中でscore値に細工する

モチベーション・背景

検索クエリで得られる関連度〜Query scoreについては、Elasticsearchのデフォルトでは、BM25が用いられています。

そのBM25(BM25を分かった気になるかもしれない邪道な解説(?) - はてだBlog(仮称))についてはIDFという項がありますが、こいつが対数:logを用いた関数になっているため、小数を含む数字になる傾向があるとともに、Elasticsearchの分散検索の仕様に伴い、検索しにくシャードによって多少値の傾向が異なるということが発生してしまいます。

これ自体はBM25やElasticsearchがもちろんそういうコンセプトなので正しいのですが、目的によってはいくつかやっかいなことがあります。

よく言われるやっかいごと(並び順が安定しないように見える,etc.)についてはこの記事の本題ではないため、キーワードとして、Search after、Scroll API、DFS Query Then Fetch... といったものをあげておくにとどめます。

この記事で問題にする別のやっかいごとですが、シャードごとのちょっとした偏りにともなって、実際は大して本来のスコアに差がない、競合するような検索語・ドキュメントにおいて、スコア差が発生してしまうということです。

そのような、本来はどんぐりの背比べのリストの検索結果の場合は、サービス提供側でオススメかつ次善のソート条件を足しこんで並べてやることで良い感じにしてやることができる...ハズなのですが、実際は上記のとおり、小数で値が(僅差ながらも)ばらけてしまうため、思ったように機能しません。

考え方

前述のような、それ自体は正しいものの、用途や要件によってはちょいと都合がよろしくないスコアリング傾向に対してですが、

「近似的な演算ではなく厳格な条件で計算すると同じ値になるようなもののハズなので、値をまるめてやる(正規化・標準化する)ことで、値をならしてやり、誤差をある種塗りつぶしてやること」

を考えます。

ミソは、ある程度うまい正規化を定めてやれば、本来の特徴に応じて差が出るべきものには正規化してもその差が現れるし、そうでなければそれほどの特徴の差異だったと考えることです。

と、偉そうに言いましたが、ここで言う正規化については過度に複雑にするのもかえって分かりづらいので、「得られたスコアの上一桁を有効数字とする」という方法で試してみます。

f:id:azotar:20200128191510p:plain

実例

インポートデータの例

少々乱暴ですが、kibanaのDevToolsで貼り付けてください。 データ内容はあまり意味がありません。

POST /myidx2/_doc/                                                            
{    "A": "東京都中央区"                         ,"C":    200                 }
POST /myidx2/_doc/                                                            
{    "A": "東京都港区中央町"                           ,"C":    110                 }
POST /myidx2/_doc/                                                            
{    "A": "東京都荒川区西日暮里中央"                           ,"C":    100                 }
POST /myidx2/_doc/                                                            
{    "A": "長崎県中央市"                         ,"C":    100                 }
POST /myidx2/_doc/                                                            
{    "A": "鹿児島中央駅(鹿児島県鹿児島市)"                           ,"C":    1000                    }
POST /myidx2/_doc/                                                            
{    "A": "中央駅(神奈川県横浜市)"                           ,"C":    1100                    }
POST /myidx2/_doc/                                                            
{    "A": "中央スポーツセンター(秋田県秋田市)"                         ,"C":    100 ,"D":    "SPORTS"         }
POST /myidx2/_doc/                                                            
{    "A": "中央周辺エリア"                          ,"C":    101 ,"D":    ["OUTDOORS","ACTIVE"]           }
POST /myidx2/_doc/                                                            
{    "A": "千葉県千葉市XXX区YYY町" ,"B":    ["△△駅","中央駅"]                   ,"C":    100                 }

クエリ例1(得られたQuery score(「_score」)を10の累乗の数で割ったりかけたりする))

GET /myidx2/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "A": {
            "query": "中央",
            "boost": 10000
          }
        }
      },
      "score_mode": "sum",
      "boost_mode":"replace",
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """    
              int keta = String.valueOf((long)_score).length();
              double p = Math.pow(10,keta -1 );
              double q = Math.floor(_score/p) * p;
              return q ;
              """
            }
          }
          }
      ]
    }
  }
}

クエリ例2(文字列操作で対応)

こっちの方が無難かな...

GET /myidx2/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "A": {
            "query": "中央",
            "boost": 10000
          }
        }
      },
      "score_mode": "sum",
      "boost_mode":"replace",
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """
              int keta = String.valueOf((long)_score).length();
              String d = String.valueOf(_score).substring(0,1);
              return  Math.pow(10,keta -1 ) * Integer.parseInt(d);
              """
            }
          }
        }
      ]
    }
  }
}

クエリ例3(例2 に加えて「doc['XXX'].value」を使う)

GET /myidx2/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "A": {
            "query": "中央",
            "boost": 10000
          }
        }
      },
      "score_mode": "sum",
      "boost_mode":"replace",
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """
              int keta = String.valueOf((long)_score).length() ;
              String d = String.valueOf(_score).substring(0,1);
              return  Math.pow(10,keta -1 ) * Integer.parseInt(d) + doc['C'].value;
              """
            }
          }
        },
       {"weight": 250}
      ]
    }
  }
}

戻り値イメージ

前述のクエリ(例2)の戻り値のイメージです。

Query scoreとして得られた10470.967から、「0470.967」部分を除いて 「10000」に丸めた値が最終スコアとなっていますね。 (紛らわしいのですが、検索クエリの「boost」と結果的に同じ値になっています。別物ですのでご注意ください。→ 画像の2枚目が2レコード目ですが、こちらは9400.072が9400に正規化されている。)

f:id:azotar:20200128123907p:plain

f:id:azotar:20200128124159p:plain

まとめ

Function score queryでpainless scriptを使うと、Query scoreの結果の値を元に、後から調整ができます。

また、doc[フィールド名].valueでフィールドの値も取れることと、if文なども記載できるので、必要なら細やかな制御も可能です。

もちろん、Field values score など、Function score queryのより手軽なショートハンドで済むならそれでも良しですが、Function score queryに足を踏み入れるならいっそ、painless scriptを使ったこの記事の方式に慣れておくのも悪くないかと思います。