Elasticsearchで検索結果に多様性を持たせる

Elasticsearchで表示順序を「良い感じ」にするためのアプローチ

Soudai Sasada

Soudai Sasada

なにをしたいのか

Elasticsearchを使って検索機能を提供する場合、結果を表示するページのコンテンツには検索条件の有無に関わらずElasticsearchの検索結果を用いるのではないでしょうか。

ここで特定のユーザーが1つ以上のコンテンツを投稿できるサイトを考えてみましょう。 このサイトでは上述のようにコンテンツ一覧ページにおいてElasticsearchを使用しておりユーザーのコンテンツを投稿日時の降順で並べることで「賑わい」を演出しています。

ところがある日、特定のユーザーが連続で投稿するとそのユーザーのコンテンツばかりが表示されてしまうことに気が付きました。これではコンテンツ一覧ページを見た人からは特定のユーザーばかりが投稿しているように見えてしまい意図したとおりの賑わいを演出できません。

事業戦略やデザインによってはあえてそうすることもありますが、ここでは「結果を偏らないようにしより多くのユーザーに閲覧機会を提供する」ことを目的とし、そのためにどのようなアプローチが取れるかを検討します。

複数のプロパティでソートする

まずはじめに思いつくアプローチが複数のプロパティによるソートとなります。

Elasticsearchでは検索結果のソートができます。ソートは同時に複数のプロパティでソートを指定でき、検索ワードとの適合度合いを示すスコア( _score )によるソートのほか、数値や論理値、日付や時刻での並び替えはもちろん、特定地点からの地理的な距離によるソートやプロパティに対する演算結果を求めた値に対するソートなど様々な種類のソートが可能です。ソートは指定した順序で適用されます。

https://www.elastic.co/guide/en/elasticsearch/reference/7.9/sort-search-results.html

はじめにソートをするため「投稿日時」ではなく「投稿した日」(または「投稿した週」か「投稿した月」)の単位でソートし、その結果に対し別の条件で並び替えをします。

このアプローチにより一定の期間(日、週または月)単位での最新コンテンツを並べつつ、次のソートで「特定ユーザーが連続して投稿してもコンテンツが連続して並ばない」ような順序を指定することが可能となります。

どのような期間で区切るかはコンテンツの量によります。コンテンツが多ければ日単位でも良いですし日や週単位で投稿されるコンテンツが少なければ月単位でソートするのが良いでしょう。

PUT /contents
{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword",
      },
      "name": {
        "type": "keyword"
      },
      "post_week": {
        "type": "weekyear_week"
      },
      "stars": {
        "type": "integer"
      },
      "user": {
        "properties": {
          "id": {
            "type": "keyword"
          },
          "name": {
            "type": "keyword"
          },
          "age": {
            "type": "keyword"
          }
        }
      }
    }
  }
}
GET /contents/_search
{
  "sort" : [
    "_score",
    { "post_week" : { "order" : "desc" }}
    { "stars" : { "order" : "desc" }},
  ]
}

この例ではまずはじめに _score によるソートを指定しています。これにより検索条件があった場合はその関連度を優先し、検索条件がなかった場合に「投稿した週」による降順でのソート、同じ週の投稿に対して「スター数」のソートを適用しています。

結果をランダムに並べたい場合

上記の例では「スター数」というプロパティを用いて並べ替えましたが、最適なプロパティがない場合はどのようにすれば良いでしょう?

こうした場合は「ランダムな値で並べる」というアプローチが取れます。
スクリプトによるソートを活用しランダムな値を求めそれを用いて並べ替えを行います。

GET /contents/_search
{
  "sort": [
    "_score",
    { "post_week" : { "order" : "desc" }},
    {
      "_script": {
        "script": "Math.random() * 200000",
        "type": "number",
        "params": {},
        "order": "asc"
      }
    }
  ]
}

このアプローチの欠点は「順序に再現性がない」点です。検索し直すと順序が変わってしまうことに注意しなければなりません。
では順序を並べ替えつつ再現性のある結果を求めたい場合にはどうすれば良いでしょうか?

結果に再現性を持たせたい場合は random_score が利用できます。

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

これはシードとフィールドを元に一定の幅の値を求めそれを _score に反映する機能です。 フィールドはドキュメント毎にユニークな値、シードにはサイトを閲覧するユーザーのセッションを用いることで「ユーザーによって異なるがそのユーザーにとっては一貫性のある結果」を提供できます。

GET /contents/_search
{
  "query": {
    "function_score": {
      "functions": [
        {
          "random_score": {
            "seed": 1546631034,
            "field": "id"
          }
        }
      ]
    }
  },
  "sort": [
    { "post_week" : { "order" : "desc" }},
    "_score"
  ]
}

この例では検索条件がなかった場合に random_score を求めその結果を _score に適用、 _score による並び替えを適用することで「ランダムな結果」を演出しています。
ただし _score に作用するため検索ワードを入力し一致度を求める場合においてはこのソートはむしろノイズとなってしまいます。あらかじめ辞書を整えておくことでElaticsearchが最適なアルゴリズムで適合したドキュメントを返してくれるので特に理由がなければそうしたケースでは random_score を使わないといった使い分けをした方が良いでしょう。

どちらのアプローチも「表示順序を変更する」ことには変わりはないですがユーザー単位による重複を排除したわけではないため「連続で並ぶ可能性」がなくなったわけではない点に注意が必要です。

検索結果をまとめる

Elasticsearchでは検索結果をまとめることができます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/collapse-search-results.html

ユーザーごとに結果をまとめることで同一ユーザーのコンテンツが並んでしまうことを回避することができます。

GET /contents/_search
{
  "collapse": {
    "field": "user.id",
      "inner_hits": [
      {
        "name": "most_recent",
        "size": 5,
        "sort": [ { "stars" : "desc" } ]
      }
    ]
  },
  "sort": [ { "post_week" : { "order" : "desc" }} ]
}

このアプローチでは結果がユーザー毎にまとめられます。ユーザー毎に最新の1件だけを並べううることで確実に「同一ユーザーのコンテンツが並ぶ」という事態を回避することができます。

一方で「コンテンツ一覧」としてすべてのコンテンツを表示したいケースにおいては不適切です。
この機能は「ユーザーをN件毎のコンテンツとセットで一覧表示する」といったユースケースに向いていそうです。

rank_featureを使う

これまでのアプローチでは _score とは別にプロパティを指定し並べ替えを行なっていましたが rank_feature という機能でプロパティの値をそのまま _score に適用する方法もあります。

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

このアプローチでは検索ワードがない場合でも rank_feature クエリにより _scorerank_feature クエリで指定したプロパティへの演算結果が適用されるため検索キーワードがない場合の順序はもちろん検索キーワードがある場合にもある程度結果をコントロールすることが可能となります。 rank_feature クエリは対象プロパティが rank_feature または rank_features 型である必要があります。

GET /contents/_search

{
  "query": {
    "bool": {
      "should": [
        { "rank_feature": { "field": "recommended_score", "boost": 1.5 } }
    }
  },
  "sort": ["_score"]
}

rank_feature 型で登録するプロパティには事前に表示順序として優先したい条件を決めておき、ドキュメントを登録する際に都度計算することで「良い感じ」の結果を演出することが可能です。

まとめ

今回は目的に対しドキュメントを一通り読みどのようなアプローチが取れるのかをまとめてみました。ここでは基本的に検索ワードの有無に関わらずソートの順序を固定する前提でのアプローチを紹介しましたが最適な結果を出すためには複雑性を許容して検索条件や検索者の属性によってソートの順序も柔軟に指定する方が良いかもしれません。Elasticsearchに関してはまだまだ知らないことが多いため他にどのようなアプローチが取れるか継続して調べるつもりです。

新たに分かったことがあれば記事を更新しようと思います。

この記事がお役に立てたら一杯のコーヒーを恵んで頂けると励みになります 😊

Buy Me A Coffee
© 2020, Soudai Sasada