dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Elasticsearchで多様なフォーマットのレシピ検索体験を実現する

こんにちは!ウェブ版クラシルの開発を担当しているサーバサイドエンジニアの福島と申します。

今回は、ElasticsearchのMultiModel検索を使って、多様なフォーマットのレシピコンテンツ検索を実現したことについて書こうと思います。 MultiModel検索とはどのようなものか、なぜこの機能が必要だったか、また開発を通しての経験についてご紹介します。

MultiModel検索ってなに?

まずは、ElasticsearchのMultiModel検索機能について簡単に説明します。
従来の単一インデックスに対する検索機能とは違い、複数のインデックスからデータを検索することができる機能です。

(※Elasticsearchでは"Index"が、DBテーブルに相当する概念です。)

例えばユーザーが「鶏肉 レシピ」と検索した時に、Index A, B, Cそれぞれに格納されている異なる構造のデータに対して、任意の条件で検索を行い、並べ替え、データを返すような仕組みです。

ただ単に、「IndexAに対してクエリ検索を行い、その次にIndexBに対して検索して、その次IndexCに.... 」みたいな処理をしていては、パフォーマンスも良くないしコードの可読性も落ちてしまいそうですよね。
それを一発のクエリで取得できて、しかも異なる構造のデータに対して横断的にスコアリングして並べ替えられるのがMultiModel検索の便利なところだと思います。

なぜこの機能が必要だったのか?

クラシルでは、2年ほど前からCGM機能の開発を行なっており、クリエイターさんの素敵なコンテンツが投稿されています。

クラシルで内製している公式レシピは、Elasticsearchを活用した検索機能が既に実装されてましたが、
CGMコンテンツの検索機能の開発にはまだ着手できておらず、検索結果に表示されていないのが現状でした。

「クリエイターさんの素敵なコンテンツを検索できる状態にしたい」
「公式レシピではカバーできない検索クエリに対してもコンテンツを提供できるようにしたい」
という思いがあり、今回の機能開発に乗り出しました。

そして、この機能の実現方法として、ElasticsearchのMultiModel検索を採用したのですが、その理由は以下の点です。

  • 膨大な数のCGMコンテンツに対して、高パフォーマンスの全文検索を行う必要がある
  • 細かなスコアリングで適切なコンテンツが上位に表示されるようにしたい
  • すでに公式レシピの検索にElasticsearchのアーキテクチャが構築されており、それを拡張する形で比較的簡単に実現できそう

MultiModel検索の実装

ここでは、MultiModel検索を実現するための大まかな流れのみ紹介するので、細かい設定などは公式ドキュメントを読んでいただくのが良いかと思います。 https://www.elastic.co/guide/en/elasticsearch/reference/master/search-multiple-indices.html

また、クラシルのサーバサイドはRailsを使っており、Elasticsearchの利用には"elasticsearch-rails"というgemを使っています。
https://github.com/elastic/elasticsearch-rails

この上で、既に実装されていた単一テーブルの検索機能から、どうやってMultiModel検索に拡張するのかを説明します。

まず前提として、Elasticsearch + Railsでの検索機能の実現には、
①データマッピングの定義、②データの格納、③データ検索、④実データの取得
の4つの要素が必要です(実際はもう少し複雑ですが。。。)

  1. データのマッピング定義 ... Elasticsearchに新たにインデックスを作成し、それはどのようなデータ構造なのかを定義する
  2. データの格納 ... ①で作成したマッピングに基づき、データ(ドキュメント)を格納していく
  3. データ検索 ... Elasticsearchに対して、独自で定義したクエリをリクエストし、適切なデータ(ドキュメント)一覧を取得する
  4. 実データの取得 ... Elasticsearchから取得したデータ(ドキュメント)をもとに、ActiveRecordでマッピングされたDBに格納されているレコードを取得する

(※Elasticsearchでは"ドキュメント"が、DBレコードに相当する概念です)

これによって、高速で正確な検索が実現されています。 このステップに従い、今回に開発で行ったのは、

  1. 新たにCGMコンテンツのインデックス作成、およびそれぞれのマッピング定義
  2. Elasticsearchにドキュメントをインポート(格納)する処理の実装
  3. MultiModel検索のロジック作成
  4. MultiModel検索によって取得したドキュメント一覧をもとに、レコード一覧を取得してくる処理の実装

です。

①②に関してはそこまで難しいことはやってなくて、それぞれテーブルごとに個別に定義すれば済みます。

③MultiModel検索のロジック作成、に関しては、それぞれ異なるフォーマットに対して、「どうやって適切なスコアリングを行うか?」が難しいです。
フォーマットが違えば、それぞれの指標となるデータフィールドに対する重みづけも考慮しなければなりません。
この部分はウェブ開発チームで検証を行い、フィードバックをもらいながら精度の向上に努めました。

▼MultiModel検索のコードのサンプルを紹介します。

def search_merged_contents(query = '', options = {})
  # 検索対象のデータフィールドを定義
  # インデックスによっては存在しないデータフィールドを指定しても問題ないです
  fields = [
    'title',
    'introduction',
    : 
    : 
  ]
  # スコアリングロジックを定義
  search_functions = merged_contents_search_basic_function(query)
  # 検索対象のインデックスを配列で指定
  model = [IndexA, IndexB, IndexC]

  # 実際に発行する検索クエリを生成
  query = build_search_query(
             query: query,
             functions: search_functions,
             fields: fields
           )

  # MultiModel検索を実行(第二引数に検索対象のインデックス一覧を渡す)
  search_with_timeout do
    Elasticsearch::Model.search(
      query,
      model,
      size: options[:size] || DEFAULT_SIZE
    )
  end
end

④の実レコード取得に関しては、実はgemに搭載されている機能で簡単にできます。
詳しくはgemのソースコードを参照ください。
https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-model/lib/elasticsearch/model/adapters/multiple.rb

しかしクラシルでは、以下の条件からこの機能を使えず、自前で実装する必要がありました。

  • Elasticsearchのインデックスをダウンタイムなしで再構築するような仕組みにしている
  • そのため、それぞれユニークなインデックス名を定義している
  • これが原因で、gemに実装されている処理では、うまく該当のDBレコードを取得できない

ここは将来的に改善したい...!!

「ダウンタイムなしでインデックスを再構築」に関してご存知ない方は、こちらの記事が分かりやすかったです。
https://qiita.com/ainame/items/5ef2c2aa3c204cb23733

MultiModel検索の機能開発を通して

今回の機能開発は、ウェブチームにとっても重要な施策でした。
自分はこれまでElasticsearchをほぼ触ったことがなかったので、開発を進める上で色々と調べて試行錯誤して、時には他のエンジニアメンバーの力も借りながら開発を行いました。

結果的に、まだカバーできてなかった検索クエリに対してもコンテンツを表示できるようになり、ユーザーにより良い検索体験を提供できるようになったと思います。

▼「ルーローハン」のレシピ検索

最後に

今回は、ElasticsearchのMultiModel検索機能についてご紹介しました。 ウェブチームでは、この他にもまだまだやりたいことがたくさんあり、日々様々な開発を行っています。 これからもっと強い開発組織を作っていくためにも、一緒に働いてくれるメンバーをお待ちしてます:)