VisasQ Dev Blog

ビザスク開発ブログ

ElasticsearchのSigmoidを使って正規化した数値をスコアに反映させる

ElasticsearchのSigmoidを使って正規化した数値をスコアに反映させる
内部向けに企業名サジェスト機能を提供しているのですが、”いい感じ”の企業が上位に来るために試行錯誤しており、今回「マッチスコア」と「登録アドバイザー数」という値のスケールが異なる値同士を組み合わせたスコアを試してみました

この記事では、Elasticsearch (ES)の仕様を確認しつつ script_score の sigmoid (シグモイド関数) を使ったランキングについて考えます (最初は仕様の整理なので、ご存じの方は 3章まで飛んでもらってOKです)

1. ランキング(ソート) の基本仕様を整理

未指定時のランキング

指定しない場合は、 BM25アルゴリズムによる検索ワードのマッチスコア(_score)の降順でソートされた結果が返ってきます

date/ interger フィールドによるランキング

  • ES の search API では sort パラメータによって指定できます
GET /bank/_search
{
  "query": { "match_all": {} },
  "sort": { "balance": { "order": "desc" } }
}

www.elastic.co

  • 複数指定した場合は、1つ目に指定されたものでソートしたうえで、同値だった場合は2つ目に指定したものでソートします
    • ちなみに、同値で第2ソートの指定がない場合でも、 _doc 値を使ってソートされるので一意に決まります
    • サンプルの sort の中に "user" (keyword フィールド) が含まれていますが、ソート値的には null を返していたので意図はわかりませんでした
GET /my-index-000001/_search
{
  "sort" : [
    { "post_date" : {"order" : "asc", "format": "strict_date_optional_time_nanos"}},
    "user",
    { "name" : "desc" },
    { "age" : "desc" },
    "_score"
  ],
  "query" : {
    "term" : { "user" : "kimchy" }
  }
}

www.elastic.co

複合条件検索におけるスコア計算

www.elastic.co

  • bool query
    • must (AND検索)、 should (OR検索)、 must_not (NOT 検索)、 filter (フィルター) を使うESにおける基本形です
    • must と should でマッチしたものは _score の計算の対象となります
  • boosting query
    • positivenegative を指定できて、 negative でマッチした場合には _score の計算時に減衰されます
    • 減衰度合いは negative_boost パラメータで指定します
  • constant_score query
    • 固定値 ( boost で指定した値) のスコアを返します
    • これ単体で使うとすべて同じスコアになってしまうので、他のものと組み合わせて使います
    • こちらのブログに使用例があったので参考までに https://itdepends.hateblo.jp/entry/2020/01/15/023136
  • dis_max query
    • bool queryのshould (OR検索) で複数条件を指定した場合、条件にたくさんマッチした方がスコアが高くなりますが、 dis_max では queries の複数条件の内、最もスコアが高かった条件のスコアがそのドキュメントのスコアとして計算されます
    • 弊社の現行の企業名サジェストでも使っていて、別名や旧名などが複数登録されているので、たくさん登録されている企業がスコアが高くなるのではなくドンピシャな名前を持つもののスコアを高くしたい意図で使っています
    • tie_breaker オプションが 0の時にその効果が最大になり、1.0 を指定した場合は bool query の should と同じスコア計算になります
    • 他のブログで分かりやすい説明があったので引用させてもらいます https://www.greptips.com/posts/1314/
 スコア =
  最もマッチしたフィールドのスコア +
  1.0 * 他のマッチしたフィールドその1のスコア +
  1.0 * 他のマッチしたフィールドその2のスコア +
  ...
  • function query
    • 複雑なのでここでは概要だけで、詳細は後述します
    • boost_mode は query のスコアと functions のスコアの合わせる方法を定義します
    • score_mode は functions 内の複数のスコアの合わせる方法を定義します
      • max_boost は functions のスコア値の上限を定義します
      • weight は functions 内の各スコア計算のブースト値です

スコアのデバッグについて

_search API の実行時に explain=true の GET パラメータを付けることで最終スコア計算までのロジックを追うことができます

/_search?explain=true
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "smart visasq ntt enginner",
          "operator": "OR",
          "fields": [
            "new_topic^30",
            "new_job^30",
            "all_topic",
            "all_job"
          ]
        }
      },
      "functions": [{
        "filter": {
          "multi_match": {
            "query": "smart visasq ntt enginner",
            "operator": "OR",
            "fields": [
              "new_topic",
              "new_job"
            ]
          }
        },
        "weight": 10
      }],
      "score_mode": "multiply",
      "boost_mode": "multiply",
      "max_boost": 5
    }
  }
}

※ 分かりやすいようにスコアの部分だけ取り出しています

143.02211 (_id:2)
  28.604422 (max of)
    0.2572701 (sum of)
      0.2572701 (all_topic:enginner)
    7.5207644 (sum of)
      7.5207644 (new_topic:enginner)
    0.9752057 (sum of)
      0.20309238 (all_job:visasq)
      0.7721133 (all_job:ntt)
    28.604422 (sum of)
      5.957041 (new_job:visasq)
      22.64738 (new_job:ntt)
  5.0 (min of)
    10.0 (multiply)
      10.0 (function1)
        1.0 (hit)
    5.0 (maxBoost)

2. Elasticsearchにおけるscriptの用途を整理

  • ランキングの話は一旦忘れて ES における script について整理します
  • 対応言語は、 painless や expression などがあります

    • 複雑な処理をするケースは少ないので初めてでも困ることは少ないです www.elastic.co
  • ここでは、ES における script の用途を3つ紹介します www.elastic.co

動的にフィールドを生成 (script_fields)

  • 検索結果のレスポンスに加工した値を合わせて返します
  • ES 上ではドルで格納して、取り出すときにその時の為替レートを掛けて円で返す場合などで利用できそうです
GET /_search
{
  "query": {
    "match_all": {}
  },
  "script_fields": {
    "yen_price": {
      "script": {
        "lang": "painless",
        "source": "doc['price'].value * params.factor",
        "params": {
          "factor": 150
        }
      }
    }
  },
  "_source": ["name"]
}

{
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "hits" : [
      {
        "_id" : "1",
        "_source" : {
          "name" : "ice cream"
        },
        "fields" : {
          "yen_price" : [
            15000
          ]
        }
      },
      {
        "_id" : "2",
        "_source" : {
          "name" : "candy"
        },
        "fields" : {
          "yen_price" : [
            30000
          ]
        }
      },
      {
        "_id" : "3",
        "_source" : {
          "name" : "chocolate"
        },
        "fields" : {
          "yen_price" : [
            45000
          ]
        }
      }
    ]
  }
}

www.elastic.co

フィルターを動的に追加 (script query)

  • 複雑な条件のフィルターを script を使って行うことができます
  • この script が false を返すものは結果から除外されます
GET /_search
{
  "query": {
    "bool": {
      "filter": {
        "script": {
          "script": """
            double amount = doc['amount'].value;
            if (doc['type'].value == 'expense') {
              amount *= -1;
            }
            return amount < 10;
          """
        }
      }
    }
  }
}

www.elastic.co

ソートのスコアを動的に変更 (script_score)

  • 検索結果の _score 値を変更します
  • フィールドの値や _score 値を組み合わせてソートしたい値を生成します
  • 予め関数が用意されているのでそれを使うこともできます

www.elastic.co

3. script_score を使ってみよう

ランキングの基本、スクリプトの基本をおさらいしたところでそれらを組み合わせた話について詳しく見ていきます

script_score vs function_score

  • 以前から、 function_score は使えていました www.elastic.co

  • ES 7.x からは script_score が使えるようになりました

    • 紛らわしいですが、function_score で使える機能の1つである script_score とは別のものです www.elastic.co
  • これらの使い分けについて、公式のGithub の issue で議論されていたのでこれを元に整理します

    1. 基本的には、 script_score の方がシンプルに書けるので推奨します
    2. しかし、いくつかの機能は(現状) function_score にしかないので (例えば score_modefirst とか) その場合は、 function_score を使うことになります
  • script_score の何が変わったのか?リリース当時の release note を覗いてみます
    • ここでは「Function score 2.0」として紹介されていますね (なので、先の issue の議論にもある通り置き換えるつもりなんだと思います)

7.0では、レコードごとにランク付けスコアを生成するための、よりシンプルでモジュール式、そしてより柔軟な方法を提供する

新しいモジュール構造により、ユーザーは一連の算術関数と距離関数を組み合わせて任意の関数のスコア計算を構築し、結果のスコア付けとランク付けをより詳細に制御できます

  • つまり 2.0 (script_score) になって変わったポイントは以下の2点です
    1. function score では「script_score」「weight」「random_score」「field_value_factor」「decay functions」と別れていたものが、モジュール化したことで script_score 内で自由に呼び出せるようになった
    2. 「Saturation」「Sigmoid」といった算術関数が新たに使えるようになった

今回使うデータの紹介

企業名と所属アドバイザーの数が入った index です

PUT /test-company
{
  "settings": {
    "analysis": {
      "analyzer": {
        "bigram_analyzer":{
          "tokenizer": "ngram" 
        }
      }
    }
  }, 
  "mappings": {
    "properties": {
      "name": { "type": "text", "analyzer": "bigram_analyzer" },
      "advisor_num": {"type": "integer"}
    }
  }
}
  • 「天丼」と「カツカレー」で引っかかりそうな企業を入れています
  • 今回一番上に来てほしいのは「天丼ホールディングス株式会社」と「カツカレージャパン合同会社」になります
POST /test-company/_doc/1
{
  "name": "天丼ABC株式会社",
  "advisor_num": 0
}

POST /test-company/_doc/2
{
  "name": "天丼デバイスPLUS株式会社",
  "advisor_num": 40
}

POST /test-company/_doc/3
{
  "name": "天丼ホールディングス株式会社",
  "advisor_num": 50
}

POST /test-company/_doc/4
{
  "name": "有限会社カツカレー",
  "advisor_num": 0
}

POST /test-company/_doc/5
{
  "name": "株式会社カツカレー",
  "advisor_num": 10
}

POST /test-company/_doc/6
{
  "name": "カツカレージャパン合同会社",
  "advisor_num": 100
}
  • 普通に検索すると以下のようになります (カッコ内は _score の値)
    • 文字数の関係でどうしても一番上には来ません
GET test-company/_search
{
  "query": {
    "match_phrase": {
      "name": "カツカレー"
    }
  }
}

有限会社カツカレー (5.479126)
株式会社カツカレー (5.479126)
カツカレージャパン合同会社 (4.7315)

GET test-company/_search
{
  "query": {
    "match_phrase": {
      "name": "天丼"
    }
  }
}

天丼ABC株式会社 (7.3327675)
天丼デバイスPLUS株式会社 (6.294567)
天丼ホールディングス株式会社 (6.294567)

Sigmoid (シグモイド関数)

  • Sigmoid(シグモイド関数) は、あらゆる入力値を 0.0~1.0 の範囲の数値に変換して出力してくれるそうです
  • 数学的な素養はないので適切な数値はわかっていないですが、公式の例で示されている値を入れます
  • 確かに、イメージするような値が出てきましたね
    • ただし、カツカレージャパン合同会社 (100) が 0.98 に対して 株式会社カツカレー (10) が 0.83 と思ったより大きい値が出てしまいました
▼ アドバイザー数に適用
GET test-company/_search
{
  "query": {
    "script_score": {
      "query": {
        "match_phrase": {
          "name": "カツカレー"
        }
      },
      "script": {
        "source": "sigmoid(doc['advisor_num'].value, 2, 1)"
      }
    }
  }
}

カツカレージャパン合同会社 (0.98039216)
株式会社カツカレー (0.8333333)
有限会社カツカレー (0.0)

天丼ホールディングス株式会社 (0.96153843)
天丼デバイスPLUS株式会社 (0.95238096)
天丼ABC株式会社 (0.0)

▼ _score に適用
GET test-company/_search
{
  "query": {
    "script_score": {
      "query": {
        "match_phrase": {
          "name": "カツカレー"
        }
      },
      "script": {
        "source": "sigmoid(_score, 2, 1)"
      }
    }
  }
}

有限会社カツカレー (0.73258907)
株式会社カツカレー (0.73258907)
カツカレージャパン合同会社 (0.7028894)

天丼ABC株式会社 (0.7857013)
天丼デバイスPLUS株式会社 (0.7588783)
天丼ホールディングス株式会社 (0.7588783)

query の _score との組み合わせ

  • 2つの条件で試してみましたが、今回の例だとどちらの場合も期待する結果が得られました

スコアに シグモイド関数の値(0~1) を掛けた場合

GET test-company/_search
{
  "query": {
    "script_score": {
      "query": {
        "match_phrase": {
          "name": "カツカレー"
        }
      },
      "script": {
        "source": "_score * sigmoid(doc['advisor_num'].value, 2, 1)"
      }
    }
  }
}

カツカレージャパン合同会社 (4.6387258)
株式会社カツカレー (4.5659385)
有限会社カツカレー (0.0)

天丼ホールディングス株式会社 (6.0524683)
天丼デバイスPLUS株式会社 (5.994826)
天丼ABC株式会社 (0.0)

スコア値も シグモイド関数を通して足した場合

GET test-company/_search
{
  "query": {
    "script_score": {
      "query": {
        "match_phrase": {
          "name": "カツカレー"
        }
      },
      "script": {
        "source": "sigmoid(_score, 2, 1) + sigmoid(doc['advisor_num'].value, 2, 1)"
      }
    }
  }
}

カツカレージャパン合同会社 (1.6832815)
株式会社カツカレー (1.5659224)
有限会社カツカレー (0.73258907)

天丼ホールディングス株式会社 (1.7204168)
天丼デバイスPLUS株式会社 (1.7112592)
天丼ABC株式会社 (0.7857013)

まとめ

  • 今回は、 script_score の sigmoid (シグモイド関数) を使って、値スケールが異なるものをスコアに反映させることをやってみました
  • 実際には、アドバイザー数に引っ張られすぎないようにしたり企業の売上データなども使ったりしながらチューニングして進めていくことになると思います
  • 今回紹介しませんでしたが、他にも rank_feature など Elasticsearch には色々用意されているので調べてみると面白いです

www.elastic.co