Closed5

[Elasticsearch]Function Score クエリを学ぶ

fujimotoshinjifujimotoshinji

Elasticsearch はランキング用途に検索リクエストとヒットしたドキュメントに対して _score(以下、スコア)を持たせることができます。
デフォルト動作では query の条件との類似度を算出した数値をスコアに利用します。
それによりキーワード検索などで検索したキーワードにより近しいドキュメントのスコアが大きくなり、スコアの降順でソートすることで近しいほど上位表示できます。
一方で類似度じゃない、もしくは類似度だけじゃないスコアを算出したいケースもあります。
例えば、ドキュメントのフィールドが持つ数値を利用して独自のロジックで算出した数値だったり、類似度スコアに加えて日付を加味して調整した数値だったり。

こういう時に Elasticsearch の Function Score クエリを利用することで柔軟なスコアを算出できます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html

Function Score は大きく分類して以下のような機能を持ちます。

  • Script score : スクリプトによる任意のロジックでスコアを算出
  • Field Value factor : フィールドが持つ値を利用してスコアを算出
  • Decay functions : フィールドが持つ値との近さでスコアを算出

その他に Weight、Random といった機能もありますが、あまり使いみちがわかっていないのでここでは割愛します。

fujimotoshinjifujimotoshinji

Script score

Script score は elasticsearch が持つ painless 言語を利用して、任意のロジックでスコアを算出できます。

たとえば、ブログ記事を扱うインデックスで人気順のロジックを閲覧数、お気に入り数によって算出する場合。
↓はお気に入り数の重みを 100、閲覧数の重みを 1とした場合のクエリ。

POST blog/_search?filter_path=hits.hits
{
  "query": {
    "function_score": {
      "query": {"match_all": {}},
      "functions": [
        {
          "script_score": {
            "script": "doc['view_count'].value + doc['favorite_count'].value * 100"
          }
        }
      ]
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_index" : "blog",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 4000.0,
        "_source" : {
          "title" : "aaa",
          "view_count" : 1000,
          "favorite_count" : 30
        }
      },
      {
        "_index" : "blog",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1100.0,
        "_source" : {
          "title" : "bbb",
          "view_count" : 100,
          "favorite_count" : 10
        }
      }
    ]
  }
}

painless は四則演算に限らず多くの標準関数を備えているため、ほとんどの計算は関数を利用することで容易に実装できます。

https://www.elastic.co/guide/en/elasticsearch/painless/master/painless-api-reference.html

柔軟である一方、処理コストが高いため、性能、コストへの注意が必要です。

fujimotoshinjifujimotoshinji

Field Value factor

Field Value factor はドキュメントのフィールドを利用して、スコアを算出できます。

個人的には大きく 2つの使い方があるかな、と思いました。

  • Script score の簡易版
  • Child ドキュメントのフィールドの値を取得

Script score の簡易版

前述した Script score は処理コストが高いという話をしました。Script score ほど柔軟ではありませんが、前述の要件であれば Field Value factor で実現ができ、Script を利用しないので低処理コストとなります。

POST blog/_search?filter_path=hits.hits
{
  "query": {
    "function_score": {
      "functions": [
        {
          "field_value_factor": {
            "field": "view_count"
          }
        },
        {
          "field_value_factor": {
            "field": "favorite_count",
            "factor": 100
          }
        }
      ],
      "score_mode": "sum"
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_index" : "blog",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 4000.0,
        "_source" : {
          "title" : "aaa",
          "view_count" : 1000,
          "favorite_count" : 30
        }
      },
      {
        "_index" : "blog",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 1100.0,
        "_source" : {
          "title" : "bbb",
          "view_count" : 100,
          "favorite_count" : 10
        }
      }
    ]
  }
}

Child ドキュメントのフィールドの値を取得

フィールドの値によって sort する場合、ドキュメントのフィールドや nested フィールドは指定可能ですが、join 先(has_child で取得したドキュメント)のフィールドは指定できません。
Function Score クエリを利用することで join 先のフィールドの値をスコアにセットすることで sort 可能となります。

たとえば、先ほどのインデックスで親をブログデータ、子を集計データに変更して、スコアを算出する場合

POST blog/_search?filter_path=hits.hits._score,hits.hits._source,hits.hits.inner_hits.summary.hits.hits._score,hits.hits.inner_hits.summary.hits.hits._source
{
  "query": {
    "has_child": {
      "type": "summary",
      "score_mode": "max", 
      "query": {
        "function_score": {
          "query": {"match_all": {}},
          "functions": [
            {
              "field_value_factor": {
                "field": "view_count"
              }
            }
          ]
        }
      },
      "inner_hits": {}
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_score" : 1000.0,
        "_source" : {
          "title" : "aaa",
          "join" : "blog"
        },
        "inner_hits" : {
          "summary" : {
            "hits" : {
              "hits" : [
                {
                  "_score" : 1000.0,
                  "_source" : {
                    "view_count" : 1000,
                    "favorite_count" : 30,
                    "join" : {
                      "name" : "summary",
                      "parent" : "blog-1"
                    }
                  }
                }
              ]
            }
          }
        }
      },
      {
        "_score" : 100.0,
        "_source" : {
          "title" : "bbb",
          "join" : "blog"
        },
        "inner_hits" : {
          "summary" : {
            "hits" : {
              "hits" : [
                {
                  "_score" : 100.0,
                  "_source" : {
                    "view_count" : 100,
                    "favorite_count" : 10,
                    "join" : {
                      "name" : "summary",
                      "parent" : "blog-2"
                    }
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}

一点注意点としては score は float の数値範囲となるため、float を超えた範囲の数値をスコアに利用すると丸められて想定した検索結果にならないことがあります。

fujimotoshinjifujimotoshinji

Decay functions

Decay functions(減衰関数)はある値とドキュメントのフィールドが持つ値の距離によってスコアを算出します。距離が遠いほどスコアは小さくなるのですが、リニア(直線)だけではなく柔軟に推移することができます。

サポートする減衰関数は以下の 3種類です。

  • gauss
  • exp
  • linear

それぞれ offsetscaledecay をオプションで指定できます。

3種類の減衰関数、および各オプションの挙動は公式ドキュメントの画像がわかりやすいです。

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#_supported_decay_functions

対応するフィールドのデータ型は数値、日時、緯度経度です。

使用例として公式ドキュメントでは宿泊施設の予約サイトを例に価格と距離からおすすめ順に表示する検索方法を考えます。
たとえば、価格は安い方がいい、距離は駅から近い方がいいとします。安くて駅から遠すぎるのも、駅から近くて高額すぎるのも困ります。いい感じに安くて、駅から近いのが望ましいです。それも直線的に決まるというよりは距離は 10m も 100m もそんなに変わらずだけど 500m超えるとしんどい。価格も 10,000円まではそんなに変わらずだけど 20,000円超えると手が出しづらかったりします。

こういういい感じのソートは自分で計算式を組み立てるのが難しいですが、Decay function を利用することで簡単に実装できます。

POST hotel/_search?filter_path=hits.hits._score,hits.hits._source
{
  "query": {
    "function_score": {
      "query": {"match_all": {}},
      "functions": [
        {
          "gauss": {
            "location": {
              "origin": [0, 0],
              "scale": "500m"
            }
          }
        },
        {
          "gauss": {
            "price": {
              "origin": 0,
              "scale": 10000,
              "offset": 3000
            }
          }
        }
      ]
    }
  }
}
  1. 駅から 150mの 20,000円のホテル
  2. 駅から 1,300mの 1,000円のホテル
  3. 駅から 400mの 8,000円のホテル

3 -> 1 -> 2 の順で返します。

ここでは少し雑に書いていますが、システムの要件に合わせて柔軟に順序を調整できます。

fujimotoshinjifujimotoshinji

ブログサイトで類似度スコアをベースとして、新着を上位表示する方法を考えてみる

ブログに以下のようなページがあるとします。

{"title": "elasticsearch の基本", "date": "2019-01-01", "content": "elasitcsearch とは・・・"}
{"title": "elasticsearch 8.5 の新機能紹介", "date": "2023-01-01", "content": "elasitcsearch 8.5 では・・・"}
{"title": "elasticsearch 7.9 の新機能紹介", "date": "2021-01-01", "content": "elasitcsearch 7.9 では・・・"}
{"title": "elastic stack の紹介", "date": "2020-01-01", "content": "elasitc stack とは elasticsearch、kibana・・・"}

「elasticsearch」で検索した時に 2 -> 1 -> 3 -> 4 の順で結果がほしいとします。ここでは content も検索しますが、content のスコアは無視します。

まずは単純に Multi Match クエリで「elasticsearch」で検索してみます。

POST blog/_search?filter_path=hits.hits._score,hits.hits._source
{
  "_source": "title", 
  "query": {
    "multi_match": {
      "query": "elasticsearch",
      "fields": ["title", "content"]
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_score" : 1.0304024,
        "_source" : {
          "title" : "elastic stack の紹介"
        }
      },
      {
        "_score" : 0.72615415,
        "_source" : {
          "title" : "elasticsearch の基本"
        }
      },
      {
        "_score" : 0.52583575,
        "_source" : {
          "title" : "elasticsearch 8.5 の新機能紹介"
        }
      },
      {
        "_score" : 0.52583575,
        "_source" : {
          "title" : "elasticsearch 7.9 の新機能紹介"
        }
      }
    ]
  }
}

4 -> 1 -> 2 -> 3 で返ってきました。

1, 2, 3 の優先度を上げるために title に重み付けします。

POST blog/_search?filter_path=hits.hits._score,hits.hits._source
{
  "_source": "title", 
  "query": {
    "multi_match": {
      "query": "elasticsearch",
      "fields": ["title^4", "content"]
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_score" : 2.9046166,
        "_source" : {
          "title" : "elasticsearch の基本"
        }
      },
      {
        "_score" : 2.103343,
        "_source" : {
          "title" : "elasticsearch 8.5 の新機能紹介"
        }
      },
      {
        "_score" : 2.103343,
        "_source" : {
          "title" : "elasticsearch 7.9 の新機能紹介"
        }
      },
      {
        "_score" : 1.0304024,
        "_source" : {
          "title" : "elastic stack の紹介"
        }
      }
    ]
  }
}

1 -> 2 -> 3 -> 4になりました。ここではまだ新着が優先されていません。
function score クエリを利用して、新着を優先します。

POST blog/_search?filter_path=hits.hits._score,hits.hits._source
{
  "_source": "title", 
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "elasticsearch",
          "fields": ["title^4", "content"]
        }
      },
      "functions": [
        {
          "gauss": {
            "date": {
              "origin": "now",
              "scale": "1000d"
            }
          }
        }
      ]
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_score" : 2.1005895,
        "_source" : {
          "title" : "elasticsearch 8.5 の新機能紹介"
        }
      },
      {
        "_score" : 1.3893639,
        "_source" : {
          "title" : "elasticsearch 7.9 の新機能紹介"
        }
      },
      {
        "_score" : 0.6049594,
        "_source" : {
          "title" : "elasticsearch の基本"
        }
      },
      {
        "_score" : 0.4189385,
        "_source" : {
          "title" : "elastic stack の紹介"
        }
      }
    ]
  }
}

新着記事が上位表示され 2 -> 3 -> 1 -> 4 となりました。function score では日付によってスコアの乗数が決まるため、2 と 3 の間に 1を持ってくることは難しいです。
function score でやると新着の重みが大きすぎるため、新着をあくまで若干の優位とする加算方式にしてみます。
bool クエリで全文検索と新着によるスコアをわけ、類似度スコア+新着によるスコアで算出します。

POST blog/_search?filter_path=hits.hits._score,hits.hits._source
{
  "_source": "title", 
  "query": {
    "bool": {
      "must": [
        {
          "function_score": {
            "query": {"match_all": {}},
            "functions": [
              {
                "gauss": {
                  "date": {
                    "origin": "now",
                    "scale": "1000d"
                  }
                }
              }
            ]
          }
        },
        {
          "multi_match": {
            "query": "elasticsearch",
            "fields": ["title^4", "content"]
          }
        }
      ]
    }
  }
}
{
  "hits" : {
    "hits" : [
      {
        "_score" : 2.7292237,
        "_source" : {
          "title" : "elasticsearch 8.5 の新機能紹介"
        }
      },
      {
        "_score" : 2.604599,
        "_source" : {
          "title" : "elasticsearch の基本"
        }
      },
      {
        "_score" : 2.39108,
        "_source" : {
          "title" : "elasticsearch 7.9 の新機能紹介"
        }
      },
      {
        "_score" : 1.5574603,
        "_source" : {
          "title" : "elastic stack の紹介"
        }
      }
    ]
  }
}

これで 2 -> 1 -> 3 -> 4 とすることができました。あくまでも今回のデータだとうまくいったという感じなのでデータが変わったり、類似ケースの他のデータでやると結果が変わってくることになります。

よくある検索キーワードを用意しておき、定期的に計測、モニタリングが必要なのかもしれません。

このスクラップは2023/02/13にクローズされました