[Elasticsearch]Nestedなデータ構造を定義して検索・ソートする


概要

業務でElasticsearch(以降、ESと表記)を使うとき、Nestedなデータ構造の良しなな検索・ソート方法があまり分かってなかった。ので、自分の扱ったデータ構造・検索・ソートについてまとめる。

「入れ子なデータを扱いたいけど、良いデータ構造の持たせ方や検索・ソート方法がイマイチ掴めてない」みたいな過去の自分が読みたかった記事を、なるべく噛み砕いて書いていきます。

前置き

業務ではRailsでESを扱ったが、Railsプロジェクトへの導入方法や導入時の細かい設定、テスト周りについては今回扱いません。
objectnestedタイプの違いや、スコア計算・ソート周りのカスタマイズについて知見が欲しい方を想定しています。

構成

  1. おさらい
  2. やりたいこと
  3. ESのマッピング定義
  4. 検索クエリ
  5. ソートクエリ

簡単なおさらい

ESは強力な全文検索エンジン。
世の中ではワード検索する時の変換候補表示や、金融機関で資産運用情報を検索する用途にも使われてるらしい。
ワード検索以外でも、検索結果の優先順位を決めるスコア周りの計算も色々カスタマイズできたりする。
ESでは、RDBにおけるデータベースがindex, テーブルがmapping type, カラムがfield, レコードがdocumentに相当するというイメージ。
c.f. データベースとしてのElasticsearch#用語

やりたいこと

ここから先は以下のケースを想定して実際にデータ構造を定義し、検索&ソートするクエリを書いていきます。
⚠今回取り上げるケースは業務で扱ってる実ケースを架空のものに置き換えたものです。ご了承ください。

私の会社では元々、世のニュース記事をレコメンドするサービスを扱っている。
今回はそのサービスを使うユーザーが読みたいだろう「今日のニュース記事3選」をESから検索してオススメ記事として出すシステムを作りたい。
ES側はニュース記事一つひとつをdocumentとして持っていて、記事URLとタイトル、本文を持つ。
同時に、このニュースを読む他のユーザーのトラッキング情報から、「どんなユーザーに過去何回読まれたか」のデータも関連させて持たせたい。
具体的には↓の配列データを各記事が持つようなイメージ。

[
  {'20代女性': 500}, {'30代女性': 300}, {'20代男性': 900}, {'30代男性': 400}
]

こうすることで、例えば
「この記事は"20代男性"のスコアが最も高い(20代男性に最も見られている)から、20代男性ユーザーにレコメンドしよう」
と判断できるようにするのが狙い。

ESへのデータの持たせ方

まずはRails上でニュース検索用モジュールNewsSearchableを作って、以上で書いたデータ構造をES側で扱えるようにマッピングしていく。
ESのフィールド定義(RDBでいうカラム定義に相当)については、下記のsetings do ~ end内で行っている。

# ニュース検索用モジュール
module NewsSearchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    include Elasticsearch::Model::Callbacks

    index_name 'news_index'

    settings do
      mappings dynamic: 'false' do
        indexes :news_id, type: 'long'      # 記事ID
        indexes :url, type: 'keyword'       # 記事URL
        indexes :title, type: 'text'        # タイトル
        indexes :content, type: 'text'        # 本文
        indexes :target_counts, type: 'nested' do
          indexes :target, type: 'keyword'  # 記事のターゲット層(e.g. "20代男性")
          indexes :count, type: 'long'      # ターゲットの閲覧回数(e.g. 900)
        end
      end
    end
  end
end

indexes :news_id ~ indexes :contentまではわりと素直に把握できると思う。記事IDを整数値で扱いたいのでtypeをlongにしたり、タイトル・本文を文字列で扱いたいのでtypeをtextとして指定している。

なお、記事URLはtypeがkeyword指定されてるが、ここのtextkeywordの違いを簡単に触れておく。

textkeywordの違い

ESでは全文検索の際、テキストを単語に分割して転置インデックスを貼り、単語レベルで検索可能な形にすることが可能で、そうしたい場合にはtypeをtext指定する必要がある。
例えば今回は、ニュース記事のタイトルや本文も扱うので、それらの中で「単語"美容"が含まれてたら女性向けに記事を出したい」みたいなユースケースが将来出てくるのはけっこう容易に想定できると思う。(なので、それらはtextと指定)
一方で記事URLみたいな完全一致で検索したいフィールドについては、単語分割して転置インデックスを貼る必要性は特に無いと思うので、こうしたフィールドはtypeをkeywordと指定する。
他にも「タグの文字列(Qiita記事のタグみたいなもの)」だとか「ユーザーのメールアドレス」だとかはkeyword指定が一般的らしい。

入れ子データはnestedで持たせる

続いて、記事を読むユーザーが「どんな年齢層で男性・女性どちらか」と、各ターゲット層の閲覧回数も、記事と関連づけて扱いたい。

[
  {'20代女性': 500}, {'30代女性': 300}, {'20代男性': 900}, {'30代男性': 400}
]

上記のような配列(再掲)を各記事に持たせたいので、こういう時にはnested指定して、下位indexにそれぞれのフィールドを記述すればok。

indexes :target_counts, type: 'nested' do
      indexes :target, type: 'keyword'  # 記事のターゲット層(e.g. "20代男性")
      indexes :count, type: 'long'      # ターゲットの閲覧回数(e.g. 900)
end

こうマッピングすることで、各記事は以下のようなデータを持つことが出来る。

[
  {target: '20代女性', count: 500},
  {target: '30代女性': count: 300},
  {target: '20代男性': count: 900},
  {target: '30代男性': count: 400}
]

今回はnestedを指定したが、ここのtypeにobjectと指定することも出来る。ただしそうした場合は検索した時に意図しない結果が返ってくるので注意が必要。

例えばtarget: 20代女性count: 800以上, 1000以下の条件で検索したなら、上記の配列を持つ記事であればヒットしてはならないはず。({target: '20代女性', count: 500}を持つ記事なので。)
でも、object指定したケースだとこの記事もヒットしてしまう。

なぜなら「targetフィールドとcountフィールドは"関連しない別物"として扱われ、target_countsフィールド内にtarget: 20代女性targetを含み、かつどのtargetでも気にしないのでcount: 800以上, 1000以下countも含む記事を探し出してくれ」と解釈されてしまうから。

今回のケースであれば、このtargetcountはセットな(ひとカタマリの)データとして扱ってほしい。それを実現するためにobjectではなくnestedtypeを指定してる。

object, nestedtypeの違いや使い分けについてより詳細を知りたければ、以下の記事が個人的には分かりやすかったです。

検索クエリ

ここまでで記事のデータ構造をESにマッピング出来たので、続いて検索するクエリについて検討してみる。
今回は「年齢層別のカウントで範囲検索を行う」という検索クエリを例として見ていく。
結論から言うと、Rubyの場合は下記の書き方でやりたい検索ができる。

# 検索クエリ
target_count_qr_1 = { nested: { path: 'target_counts', query:
                      { bool: { must: [
                        { term: {'target_counts.target': '20代女性' } },
                        { range: { 'target_counts.count': { gte: 100, lte: 500 } }
                      }
                    ] } } } }
target_count_queries = [target_count_qr_1]
query = { bool: {must: target_count_queries} }
documents = (NewsSearchable.search size: 3,
                                   query: query
            ).results

この場合だと「20代女性に100回以上、500回未満見られている記事を検索せよ」というクエリになっていて、最終的にはdocumentsに最大3件(sizeに最大取得件数を指定, デフォルトは10件)の記事データ(ESのドキュメントデータ)が格納される。

なお実際には、NewsSearchableをincludeしてあるようなES上のデータを更新・検索等ができるmodelクラスNewsを作って、News.searchを実行するとかが一般的な実装だと思うが、説明を簡易にするためその辺は省略している。

複数の範囲検索

例えばここから「さらに"30代女性の閲覧回数"も範囲指定した検索がしたい」, 「さらに色んなターゲット別の閲覧回数も個別で加味した検索がしたい」みたいなニーズも将来出てきそう。
そうした場合、上記のtarget_count_qr_1と同じような感じで必要に応じてtarget_count_qr_2, target_count_qr_3に相当するクエリを作り、target_count_queriesにそらを格納して検索クエリに含めれば、意図通りの検索が出来るようになる。

ソートクエリ

先ほどは検索"結果"に関わるクエリを見たが、最後に検索の"並び順(ソート)"に関わるクエリを見ていく。
通常、特にソート条件を指定しない場合はES側が独自のスコア付けをして、スコアが高い順(つまり、ESが検索クエリとの合致度合いが高いと判断した順)で検索結果を返してくれる。

今回はさらに指定したターゲット層の閲覧回数も加味して、降順にソートをかけたい。具体的には「30代男性の閲覧回数が高い順」でソートしてみる。Rubyの場合は下記の書き方で意図通りにソートできる。

# ソートクエリ
sort_query_1 = {
               "target_counts.count": {
                 order: 'desc',
                 nested: {
                   path: 'target_counts',
                   # ここでは `match`ではなく`term`を使っている。
                   # `match` を使うと対象ワードが単語分割されて検索実行されるので、
                   # クエリの文字列が完全一致でなくても
                   # 合致するドキュメントとして採用される恐れがある。
                   filter: { term: { "target_counts.target": '30代男性' } }
                 }
               }
             }
sort_queries = [sort_query_1]

# 検索クエリ
query = { bool: {must: target_count_queries} }
documents = (NewsSearchable.search size: 3,
                                   query: query,
                                   sort: sort_queries,
            ).results

なおコード中のコメントにも記載の通り、今回のケースではtargetの文字列が「30代男性」と完全一致したnestedフィールドにおけるcountを見たいので、termと指定する必要がある。
c.f. 初心者のためのElasticsearchその2 -いろいろな検索-

複数のソート条件の指定

上記の書き方ではソート条件を1つだけ指定しているが、複数のソート条件を指定することもできる。例えば将来的にrecommend_indicator(オススメ指数)というlongtypeのフィールドが新たに追加され、「同率の閲覧回数の記事についてはさらにオススメ指数が高い方を優先して取り上げたい」といったケースだ。

その場合は以下のようにsort_query_2をソートクエリに加えたら意図通りのソート結果が得られる。

# ソートクエリ
sort_query_1 = {
               "target_counts.count": {
                 order: 'desc',
                 nested: {
                   path: 'target_counts',
                   filter: { term: { "target_counts.target": '30代男性' } }
                 }
               }
             }
# 新たに追加したソートクエリ
sort_query_2 = {
                 { "recommend_indicator": { order: 'desc' } }
               }
sort_queries = [sort_query_1, sort_query_2]

# 検索クエリ
query = { bool: {must: target_count_queries} }
documents = (NewsSearchable.search size: 3,
                                   query: query,
                                   sort: sort_queries,
            ).results

なお、sort_queriesの配列にどちらのソートクエリを先に置くかで、どのクエリを優先的に考慮するかを指定していることになる。

さらにソート条件・スコアをカスタマイズしたい場合

ESでは、元々デフォルトで扱われている独自のスコアや、自分でソート条件として設定したスコアに、別のフィールドの値を掛けたり引いたりした数値を基準にソートするといったことも出来る。
今回のケースだと、例えば「longtypeのweight_indicator(重み指数)フィールドを新たに追加して、ターゲットの閲覧回数にこの"重み指数"を掛けた数値が降順になるようにソートしたい」みたいなケースだ。
こういったことはFunction ScoreクエリのScript Scoreを使って実装が可能らしいが、elasticブログ:検索順位を自在に操るにも記載の通り、計算コストが高くソート実行時間が長くなる傾向にあるらしい(ので、このブログ記事ではあまり推奨されていない)。

実際に多くElasticsearchユーザーが、このような方法を用いています。では、なぜ好ましくないのでしょうか。それは、Elasticsearchはスクリプトを実行するために、マッチクエリーで一致したドキュメント全ての、「入荷日(arrival_date)」フィールドと、「販促度(promotion)」にアクセスし、それぞれのドキュメントでスクリプトを用いて計算を行い、求められた値にしたがって検索順位を並べ替える必要があるからです。プロファイルAPIを用いて観察してみると、scoreに多くの時間(本例では267,863ナノ秒)が割かれていることがわかります。

記事にも書かれている通り、「ある指数も考慮に入れて、より高い(もしくは低い)ドキュメントの順位が優先的に高くなるようにしてほしい」というビジネス要件であれば、わざわざ計算コストのかかる四則演算をScript Scoreを使って実装しなくても充分ニーズを満たせるケースは多いと思う。

今回はソート条件を複数設けて「このフィールドの値も考慮に入れてね」というビジネス要件を満たすような実装を取り上げた。が、例えばFunction ScoreクエリのDecay関数を使えば、「そのフィールドの値について、設定した基準値から遠ざかれば遠ざかるほどスコアを下げる」ような実装も出来るので、これを使っても同じようにニーズを満たせそうに思う。

最後に

ESはElasticSearchでもelastic searchでもなく"Elasticsearch"だよっていうのを最近知りました:)