はてだBlog(仮称)

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

Elasticsearchの検索スコアリングの見定めに関する考察

はじめに

この記事は次の記事のアナザーバージョン(切り口は少し違うが同じことを別の表現で表したもの)です。

itdepends.hateblo.jp

itdepends.hateblo.jp

とあるタイプの検索サイトのElasticsearchを使ったサービス設計などに関する私見(2019年改訂版) - はてだBlog(仮称)

記事執筆にあたり、Elasticsearch6.4で試していますが、それ以上のバージョンでも動作する範囲にとどめています。

この記事の概要

検索エンジンによる検索サービスは、実際のサービスに照らし合わせると、文書検索としてのピュアな指標であるBM25やTF-IDFなどの客観的な適合度相当だけでは推し量れない面があります。

標準のスコアリング以外に、サービスに合わせた加点をしてやる必要もあるでしょう。というか大半がそのような例になるのではないでしょうか。

例えば、スポンサー型の検索サービスの場合はスポンサーをなんらか優遇(※もちろん利用者に役立つ範囲でですが)したり、ユーザーの性別や想定される嗜好や知識に合わせて、全文検索のスコア以外の要素を織り込んで、上位表示するものをサイト側で調整してやることで、利用者の求める検索結果あるいはそれ以上のものを(無い袖はふれないものの)提供できればなお良いでしょう。

ということで、この記事では次のようなものについて私の考察を述べてみます。

ただし、ふんわり考察であり、スキだらけの甘いロジックなのはご容赦ください。

  • 「適合度」を下敷きにしつつ、Elasticsearchのスコアリングを操作して、利用者向けあるいは検索サイト側の見せたいものを意識したスコアリングを調整する。
  • Elasticsearchの様々なスコアリング調整方法の中でも、筆者が使い勝手が良いと思ったクエリの紹介。
  • 上記を見い出すための、検討プロセスの例。目の付け所など。

目次

★1 あえて実例を最初にあげる

実例を見て察する方が早いかもしれないので、この記事では、コピペしていくとなんとなく動作するという例を最初に示します。

なお、Elasticsearchはひとまず動作していて、Kibanaで接続できていることとします。

この記事ではどんなサービスをイメージしたか、検索か?

  • サービス: 何かの観光情報などを紹介するサイトとします。
  • 検索シーン: ある観光情報の記事を探すにあたり、「スポット」名を検索して、そのスポットや周辺情報を芋づる式に辿っていくというような導線です。
  • 検索文書: スポット情報そのものです。スポットの表現方法は様々で施設を示すランドマークや(旅行や観光の探索の起点となる)駅、地域を表す「XX県YY市」のようなものが雑多に詰め込みされた半構造化データです。(後述)

データの仕込み

mapping設定
PUT myidx
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer": {
          "type": "kuromoji_tokenizer",
          "mode": "search"
        }
      },
      "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"
          ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true,
              "store": true,
              "fields": {
                "raw": {
                  "type": "keyword"
                }
              }
            }
          }
        }
      ]
    }
  }
}


注: kibanaのDevToolにコピペして実行できるスタイルとしました。

今回は形態素解析とかその辺は紙面の都合で直接の主題ではありませんので、後述の事例を扱う範囲の雑な設定です。

データのPOST

こんなデータを用意します。ちょっとだけ後で解説します。

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


注: KibanaのDevToolにコピペして実行できるスタイルとしました。

冒頭に、観光情報がなんとか...と大きく出ましたが、全国いろんなところに「中央」と入っているスポットデータがあるという体裁のデータです。

住所っぽいものがいくつか入っていますが、架空の例です。長崎県中央市なんてのは存在しないですね。

検索クエリの例

AフィールドもしくはBフィールドに「中央」と入っているスポットデータを検索します。

ここで、Cフィールドにサイト側のおすすめ順に相当するスポット情報の序列・階級を表す持ち点が設定されているとして、ここが高いものほど上位にきます。 さらに、Dフィールドに、「SPORTS」という指定を含むものを持ち上げます。

POST /myidx/_search
{
  "query": {
    "function_score": {
      "boost": "1",
      "boost_mode": "sum",
      "score_mode": "sum",
      "functions": [
        {
          "field_value_factor": {
            "field": "C",
            "factor": 1,
            "missing": 1
          }
        },
        {
          "filter": {
            "term": {
              "D.raw": "SPORTS"
            }
          },
          "weight": 10000
        }
      ],
      "query": {
        "multi_match": {
          "query": "中央",
          "fields": [
            "A^5",
            "B^0.01"
          ]
        }
      }
    }
  }
}

ちょっとだけ解説。

www.elastic.co

  1. field_value_factor: あるフィールドの値そのものをスコア値として使います。
  2. script_score: 複数のフィールドの値を使うなどfield_value_factorの高度版と私は捉えることにしています。それ以上のことも工夫次第ではできそうな気がします。 https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-score-context.html ただし、今回は使っていません。
  3. filter + 検索クエリDSL: 検索クエリDSLで指定の検索条件にマッチする場合、加点するというものです。
  4. 全文検索の重み付け(ここではmulti_matchを使っている): multi_matchはfields指定で、複数の対象フィールドを指定できますが、加えて重みを指定できます。ここでは、フィールドAを5、Bを0.01としているので、 フィールドAにヒットすると、フィールドBにヒットする
  5. boost_modeとscore_modeともにsum(加算)にしてあります。他にもいくつかありますが、「加算」モデルで要件を満たせそうな場合は、「加算」をベースに考えるとわかりやすいので個人的にはおすすめです。

検索結果(のスコアリングによる並び順)の例

前項のクエリの結果の最初の1件です。

中央スポーツセンターというのが上位ヒットしました。

{
  "hits": {
    "total": 10,
    "max_score": 10100.82,
    "hits": [
      {
        "_index": "myidx",
        "_type": "_doc",
        "_id": "2f4WYWsBSVSNflQF4HH1",
        "_score": 10100.82,
        "_source": {
          "A": "中央スポーツセンター(秋田県秋田市)",
          "C": 100,
          "D": "SPORTS"
        }
      },
      {
        "_index": "myidx",
        "_type": "_doc",
        "_id": "2P4WYWsBSVSNflQF4HHh",
        "_score": 1100.8427,
        "_source": {
          "A": "中央駅(神奈川県横浜市)",
          "C": 1100
        }
      },
explain表示(抜粋)

前述のクエリに、"explain": true をつけて検索すると、スコアリングの過程が見て取れます。(以下参照)

ボロが出るので解説はしませんが、出てくる数字をなぞって見ると、100 + 10000 + 0.82 という合計として、10100.82というスコアリングがされていそうです。

    "hits": [
      {
        "_score": 10100.82,
        "_source": {
          "A": "中央スポーツセンター(秋田県秋田市)",
          "C": 100,
          "D": "SPORTS"
        },
        "_explanation": {
          "value": 10100.82,
          "description": "sum of",
          "details": [
            {
              "value": 0.8201082,
              "description": "max of:",
              "details": [
                {
                  "value": 0.8201082,
                  "description": "weight(A:中央 in 1) [PerFieldSimilarity], result of:",
                  "details": [
                    {
                      "value": 0.8201082,
                      "description": "score(doc=1,freq=1.0 = termFreq=1.0\n), product of:",
                      "details": [
                        {
                          "value": 5,
                          "description": "boost",
                          "details": []
                        },
                        {
                          "value": 0.18232156,
                          "description": "idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from:",
                          "details": [
                            {
                              "value": 2,
                              "description": "docFreq",
                              "details": []
                            },
                            {
                              "value": 2,
                              "description": "docCount",
                              "details": []
                            }
                          ]
                        },
                        {
                          "value": 0.8996283,
                          "description": "tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from:",
                          "details": [
                            {
                              "value": 1,
                              "description": "termFreq=1.0",
                              "details": []
                            },
                            {
                              "value": 1.2,
                              "description": "parameter k1",
                              "details": []
                            },
                            {
                              "value": 0.75,
                              "description": "parameter b",
                              "details": []
                            },
                            {
                              "value": 5.5,
                              "description": "avgFieldLength",
                              "details": []
                            },
                            {
                              "value": 7,
                              "description": "fieldLength",
                              "details": []
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ]
            },
            {
              "value": 10100,
              "description": "min of:",
              "details": [
                {
                  "value": 10100,
                  "description": "function score, score mode [sum]",
                  "details": [
                    {
                      "value": 100,
                      "description": "field value function: none(doc['C'].value?:1.0 * factor=1.0)",
                      "details": []
                    },
                    {
                      "value": 10000,
                      "description": "function score, product of:",
                      "details": [
                        {
                          "value": 1,
                          "description": "match filter: D.raw:SPORTS",
                          "details": []
                        },
                        {
                          "value": 10000,
                          "description": "product of:",
                          "details": [
                            {
                              "value": 1,
                              "description": "constant score 1.0 - no function provided",
                              "details": []
                            },
                            {
                              "value": 10000,
                              "description": "weight",
                              "details": []
                            }
                          ]
                        }
                      ]
                    }
                  ]
                },
                {
                  "value": 3.4028235e+38,
                  "description": "maxBoost",
                  "details": []
                }
              ]
            }
          ]
        }
      },

★2 考察というかうんちく編

ここからは、考察というかうんちく編としてつれづれ述べてみます。

検索チューニングにもいろいろある

主にfunction_scoreクエリに条件をぶら下げることで、スコアリングの味付けができるということが見て取れたと思います。

Elasticsearchは高機能のため、他にもありますし、もっと高度のなこともできるのはそれとなく見てとれたのではないでしょうか。

ここで、ひとえにスコアリングといっても、上記の簡単な例だけでもこのような観点があると言えます。

  1. ワードに対する、文書の適合度
  2. あるセクション・フィールドのどこに入力ワードがヒットするかによる重みづけ
  3. 文書自体があらかじめ持つ序列、階級 ※ヒットしなければ序列や階級がどれほど高いものでも検索結果に現れないが、ヒットしてしまいさえすれば、上位表示すべきというもの。
  4. ユーザー側のコンテキストに応じたパーソナライズに伴う、加点(通常この例だと、序列や階級の追い越し、飛び級が発生する。)

1は興味深い世界ではあるのですが、俗な(必ずしも悪い意味ではない)世界では、1以外の観点での味付けがサービスの体感の鍵を握ることが多いように思います。

1自体および2について、私の場合は、multi_matchの複数フィールド間の配点バランスでざっくり最初の世界観を決めて、1以外のスコアリングの味付けがおおよそ定まってきてから、検索フィールド間の配点のバランスを見たり、対象フィールドを増やしたり減らしたりということを行います。

逆に言うと、1はある程度、検索エンジンエンジニア主導で整理していくものの、1以外の観点の味付けの部分は、そのサイトオーナーの「要望」やそのデータドメインに関する「知見」やユーザーにサイトをどう見せたいかが現れる部分なんだろうなーと思う次第です。

スコアリングの味付けを見極める

前項でいうところの、1以外、2はハザマの観点だとして、3、4などはどうやって見極めていくのが良いのかな〜といつも考えています。

私は次のようなものを心の片隅において、チームの皆やこのドメインに詳しい人にヒアリング〜ハンドリングすることを心がけています。

f:id:azotar:20190617204503p:plain

味付けとスコアリング方針、サービスの整合性確認

続いて、スコアリングの味付け具合とサービスの整合性を確かめます。

整合性といってもそんなかしこまったものではなく、スコアリング寄りの視点で検索サービス部分を絵にしたり、シナリオをかいてみたりということを行います。

実際は前項の営みと行ったり来たりしながら、試行錯誤の過程です。

冒頭に述べたスポット名を起点にした観光情報サイトのスポット検索導線であれば次のような感じです。

f:id:azotar:20190617204606p:plain

RDBの検索と違うのは、駅と住所とランドマークとといった多少異質なものも同じスポット情報のくくりにして検索できてしまうのが面白いですね。

なお、あまり具体的なことを書くと何かの事例をお漏らししてしまった扱いになってしまう可能性があるので、ここでは架空の例で、かつ若干強引な例になっています。ご了承ください。

(i) (ii)の視点の補足

f:id:azotar:20190617204945p:plain

(iii)の視点の補足

f:id:azotar:20190617204956p:plain

(iv)の視点の補足

f:id:azotar:20190617205009p:plain

続いて、どのようなインデックスデータを作ると良いか、インデクサーで計算した方が良いのか、検索時に計算するのか・加点するのかといったことを見極めます。

私は、断言こそできませんが、「序列や階級」にあたるところは、データ収集やメンテナンスの時点でしっかり意識した方が良いということもあり、インデックス時には複数の条件を織り込んで、合計値を持ち点フィールドに配置して、ユーザーコンテキストに合うかといった、検索時にしかできないことを検索時につまりクエリで調整する派です。

STEP1: 検索(のスコアリング)に必要なフィールドを見極める

◆検索&スコアリングに使うフィールド棚卸し

f:id:azotar:20190617205059p:plain

観点として何があるか、何をフィールドとして用意すれば良いのかというところを見定めましょう。

RDBの正規化を突き詰めていく方向とはやや異なりますね。あえて正規化を崩すのさらにもう一歩先の検索で使う、スコアリングで使うフィールドとして何があれば都合が良いかというところを洗い出すことになります。

STEP2: 検索対象のフィールドとフィールド間の序列、利用クエリを確認

f:id:azotar:20190617205254p:plain

STEP3: インデクサー時に設定するフィールドとその序列・階級の表現の検討

f:id:azotar:20190617205733p:plain

駅を高めに配置するかな〜、でもサービスの性質からいうと地方の駅とたまたま名称の似た都市部の地域だと後者を優先したいよね〜といった序列と階級を見定めてやります。

ちなみに、例を先にあげましたが、まさに、前述の「Cフィールド」に持ち点設定しました。

また、今回は、階級越えは無いとしました。

STEP4: 検索時の加点事項について検討

f:id:azotar:20190617205422p:plain

ユーザーがスポーツ好きというプロフィールの持ち主の場合は、同じ「中央」でも中央スポーツセンターをヒットさせてやります...という話。

この類の例でいま一番よく利用されそうな観点としては、現在地に近いものを優先するロジックですかね。

GEO系のクエリもしくはGEO系の距離ソートが活かせそうです。

★3 さいごに

もうちょっとしっかり説明しなければと思いつつかなりボリューミーになったのと少々息切れしたので、尻すぼみ気味ですが、ここまでとします。

途中うっすら触れたboost_modeとscore_modeについて、アナログなこだわりがあるのでそのうち、自分の考え整理がてらまとめてみたいところです。 (と書いておけば、いつかやるゾという自分の気合いのため。)