概要
業務でElasticsearch(以降、ESと表記)を使うとき、Nestedなデータ構造の良しなな検索・ソート方法があまり分かってなかった。ので、自分の扱ったデータ構造・検索・ソートについてまとめる。
「入れ子なデータを扱いたいけど、良いデータ構造の持たせ方や検索・ソート方法がイマイチ掴めてない」みたいな過去の自分が読みたかった記事を、なるべく噛み砕いて書いていきます。
前置き
業務ではRailsでESを扱ったが、Railsプロジェクトへの導入方法や導入時の細かい設定、テスト周りについては今回扱いません。
object
とnested
タイプの違いや、スコア計算・ソート周りのカスタマイズについて知見が欲しい方を想定しています。
構成
- おさらい
- やりたいこと
- ESのマッピング定義
- 検索クエリ
- ソートクエリ
簡単なおさらい
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
指定されてるが、ここのtext
とkeyword
の違いを簡単に触れておく。
text
とkeyword
の違い
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
も含む記事を探し出してくれ」と解釈されてしまうから。
今回のケースであれば、このtarget
とcount
はセットな(ひとカタマリの)データとして扱ってほしい。それを実現するためにobject
ではなくnested
typeを指定してる。
object
, nested
typeの違いや使い分けについてより詳細を知りたければ、以下の記事が個人的には分かりやすかったです。
検索クエリ
ここまでで記事のデータ構造を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
(オススメ指数)というlong
typeのフィールドが新たに追加され、「同率の閲覧回数の記事についてはさらにオススメ指数が高い方を優先して取り上げたい」といったケースだ。
その場合は以下のように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では、元々デフォルトで扱われている独自のスコアや、自分でソート条件として設定したスコアに、別のフィールドの値を掛けたり引いたりした数値を基準にソートするといったことも出来る。
今回のケースだと、例えば「long
typeの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"だよっていうのを最近知りました:)
〜原稿の固有名詞チェック〜
— suin❄️ TypeScriptが好き (@suin) October 29, 2020
「JavascriptでななくJavaScript」
ふむ。
「WebsocketはWebSocket」
なるほど。
「ElasticacheはElastiCache」
そうだよね。
僕: ElasticSearch……🖋カキカキ
「Elasticsearchです」
WHY DEV PEOPLE!!?