Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

RailsでElasticsearchを使った全文検索の実装方法を徹底解説

Elasticsearchは、現在最も人気のある検索エンジンのひとつです。Netflix、Medium、GitHubといった大手企業もこよなく愛用し、本番環境で積極的に活用しています。

Elasticsearchは非常に強力なツールで、主なユースケースとしては全文検索リアルタイムログ分析セキュリティ分析などが挙げられます。

残念ながら、RailsコミュニティではElasticsearchがあまり注目されていません。そこで本記事では、次の2つを目標にその状況を変えていきます。Elasticsearchの基本概念を読者に紹介することと、Ruby on Railsでの使い方を実践的に示すことです。

本記事で構築するサンプルプロジェクトのソースコードは公開されています。コミット履歴はおおむね記事のセクション順に対応しているので、学習の進捗に合わせて参照すると理解が深まります。

はじめに:Elasticsearchとは

広い視点で見ると、Elasticsearchは次のような特徴を持つ検索エンジンです。

  • Apache Luceneをベースに構築されている
  • JSONドキュメントを効率的に保存・インデックス化できる
  • オープンソースである
  • 操作のための一連のREST APIを提供している
  • デフォルトではセキュリティ機能がない(誰でも公開エンドポイント経由でクエリ可能)
  • 水平スケーリングに非常に優れている

まずは基本概念を簡単に確認しておきましょう。

Elasticsearchでは、ドキュメントを「インデックス」に格納し、そのインデックスに対してデータを問い合わせます。

インデックス(Index):リレーショナルデータベースにおけるテーブルのようなものです。後からクエリできるドキュメント(行)を格納する保管場所です。

ドキュメント(Document):フィールドの集合体です(リレーショナルデータベースの行に相当)。

マッピング(Mapping):リレーショナルデータベースのスキーマ定義に相当します。マッピングは明示的に定義することもできますし、挿入時にElasticsearchが推測することも可能ですが、事前に明示的に定義しておくのが常にベストプラクティスです。

概念が押さえられたところで、早速環境をセットアップしていきましょう。

Elasticsearchのインストール

macOSにElasticsearchをインストールする最も簡単な方法はbrewを使うことです。

brew tap elastic/tap
brew install elastic/tap/elasticsearch-full

あるいは、Docker経由で起動する方法もあります。

docker run \
  -p 127.0.0.1:9200:9200 \
  -p 127.0.0.1:9300:9300 \
  -e "discovery.type=single-node" \
  docker.elastic.co/elasticsearch/elasticsearch:7.16.2

その他のインストール方法については、公式ドキュメントを参照してください。

Elasticsearchはデフォルトでポート9200番でリクエストを受け付けます。以下のシンプルなcurlコマンド(またはブラウザでアクセス)で、正常に起動しているか確認できます。

curl https://localhost:9200

APIの概要

Elasticsearchは、あらゆる種類のタスクに対応できる一連のREST APIを提供しています。たとえば、JSON形式のコンテンツタイプでPOSTリクエストを送ると、ドキュメントを作成できます。

curl -X POST https://localhost:9200/my-index/_doc \
  -H 'Content-Type: application/json' \
  -d '{"title": "Banana Cake"}'

この場合、my-indexはインデックス名です(存在しない場合は自動的に作成されます)。また、_docはシステムルートです(システムルートはすべてアンダースコアで始まります)。

APIとのやり取りには複数の方法があります。

  1. コマンドラインからcurlを使用する(jqがあると便利です)
  2. JSONを見やすく整形する拡張機能を入れたブラウザからGETクエリを実行する
  3. KibanaをインストールしてDev Toolsコンソールを使う(筆者のお気に入りの方法)
  4. 優秀なChrome拡張機能を利用する

ただし、本記事ではどれを選んでも問題ありません。そもそもREST APIを直接叩くことはせず、内部でREST APIと通信するgemを利用するためです。

新しいRailsアプリの作成

ここでは、26,000曲以上の楽曲を含む公開データセットを使って、歌詞検索アプリケーションを作成します。各楽曲にはタイトル、アーティスト、ジャンル、歌詞テキストのフィールドがあり、Elasticsearchを使って全文検索を実装していきます。

まずはシンプルなRailsアプリケーションを作成しましょう。

rails new songs_api --api -d postgresql

今回はAPIとしてのみ使用するため、--apiフラグを付けてミドルウェアのセットを絞り込んでいます。

続いてscaffoldで雛形を生成します。

bin/rails generate scaffold Song title:string artist:string genre:string lyrics:text

マイグレーションを実行してサーバーを起動します。

bin/rails db:create db:migrate
bin/rails server

その後、GETエンドポイントが動作するか確認してみましょう。

curl https://localhost:3000/songs

空の配列が返ってくるはずです。まだデータがないので当然の結果ですね。

Elasticsearchの導入

それではElasticsearchを組み込んでいきましょう。必要になるのはelasticsearch-model gemです。これは公式のElasticsearch gemで、Railsモデルときれいに統合されます。

Gemfileに以下を追加してください。

gem 'elasticsearch-model'

デフォルトではlocalhostの9200番ポートに接続しますが、これはそのまま使えます。変更したい場合は、次のようにクライアントを初期化します。

Song.__elasticsearch__.client = Elasticsearch::Client.new host: 'myserver.com', port: 9876

次に、モデルをElasticsearchでインデックス可能にするために、2つの作業が必要です。1つ目はマッピングの準備(要するにElasticsearchへデータ構造を伝えること)、2つ目は検索リクエストの構築です。幸い、使用するgemは両方に対応しているので、使い方を見ていきましょう。

Elasticsearch関連のコードは独立したモジュールに分けておくのが良い設計です。app/models/concerns/searchable.rbにconcernを作成し、以下を追加します。

# app/models/concerns/searchable.rb

module Searchable
  extend ActiveSupport::Concern

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

    mapping do
      # マッピング定義をここに書く
    end

    def self.search(query)
      # 検索の構築と実行
    end
  end
end

まだ骨組みだけですが、解説すべき点がいくつかあります。

最初に重要なのはElasticsearch::Modelです。ESとやり取りするための機能を追加します。Elasticsearch::Model::Callbacksモジュールは、レコードを更新した際にElasticsearch内のデータも自動的に更新されることを保証します。mappingブロックはElasticsearchのインデックスマッピングを記述する場所で、どのフィールドをElasticsearchに保存し、どんな型を持たせるかを定義します。最後に、実際にElasticsearchへ歌詞を検索させるためのsearchメソッドがあります。使用するgemはSong.search("genesis")のようなシンプルなクエリで使えるsearchメソッドを提供していますが、ここではクエリDSLで構築したより複雑な検索クエリを使います(詳細は後述)。

忘れずに、モデルクラスにこのconcernをincludeしておきましょう。

# /app/models/song.rb

class Song < ApplicationRecord
  include Searchable
end

マッピングの定義

Elasticsearchにおけるマッピングは、リレーショナルデータベースのスキーマ定義のようなものです。保存したいドキュメントの構造を記述します。典型的なリレーショナルデータベースと異なり、事前にマッピングを定義する必要はありません。Elasticsearchが型を推測してくれます。しかし、予期しない挙動を避けるためにも、ここでは明示的にマッピングを先に定義します。

マッピングはRESTエンドポイント(PUT /my-index/_mapping)で更新し、GET /my-index/_mappingで読み取れますが、elasticsearch gemがこれらを抽象化してくれるので、mappingブロックを書くだけで済みます。

# app/models/concerns/searchable.rb

mapping do
  indexes :artist, type: :text
  indexes :title, type: :text
  indexes :lyrics, type: :text
  indexes :genre, type: :keyword
end

artisttitlelyricsフィールドはtext型でインデックスします。これは全文検索の対象となる唯一の型です。genreにはkeyword型を使います。これは完全一致による絞り込み検索に最適な型です。

次にbin/rails consoleでRailsコンソールを起動し、以下を実行します。

Song.__elasticsearch__.create_index!

これでElasticsearch上にインデックスが作成されます。__elasticsearch__オブジェクトはElasticsearchの世界への入り口であり、やり取りに役立つ多くの便利なメソッドが詰め込まれています。

データのインポート

レコードを作成するたびに、データは自動的にElasticsearchへ送信されます。そこで、歌詞データセットをダウンロードしてアプリにインポートしましょう。まず、こちらのリンクからデータセット(Creative Commons Attribution 4.0 Internationalライセンス)をダウンロードします。このCSVファイルには26,000件以上のレコードが含まれており、以下のコードでデータベースとElasticsearchの両方にインポートします。

require 'csv'

class Song < ApplicationRecord
  include Searchable

  def self.import_csv!
    filepath = "/path/to/your/file/tcc_ceds_music.csv"
    res = CSV.parse(File.read(filepath), headers: true)
    res.each_with_index do |s, ind|
      Song.create!(
        artist: s["artist_name"],
        title: s["track_name"],
        genre: s["genre"],
        lyrics: s["lyrics"]
      )
    end
  end
end

Railsコンソールを開いてSong.import_csv!を実行してください(少し時間がかかります)。バルクインポートを使えばはるかに高速化できますが、今回はPostgreSQLデータベースとElasticsearchの両方に確実にレコードを作成したいので、この方法を採用しています。

インポートが完了すれば、検索できる大量の歌詞データが手に入りました。

データの検索

elasticsearch-model gemは、インデックスされたすべてのフィールドを横断して検索できるsearchメソッドを追加します。searchable concernの中で使ってみましょう。

# app/models/concerns/searchable.rb

# ...
def self.search(query)
  self.__elasticsearch__.search(query)
end
# ...

Railsコンソールを開いてres = Song.search('genesis')を実行してみてください。レスポンスオブジェクトには多くのメタ情報(リクエストにかかった時間、使用されたノードなど)が含まれています。私たちが知りたいのはヒット情報なので、res.response["hits"]["hits"]を参照します。

次に、コントローラーのindexメソッドを変更して、代わりにESへクエリを投げるようにしましょう。

# app/controllers/songs_controller.rb

def index
  query = params["query"] || ""
  res = Song.search(query)
  render json: res.response["hits"]["hits"]
end

これでブラウザまたはcurlでhttps://localhost:3000/songs?query=genesisにアクセスして試せます。レスポンスは次のようになります。


[
  {
  "_index": "songs",
  "_type": "_doc",
  "_id": "22676",
  "_score": 12.540506,
  "_source": {
    "id": 22676,
    "title": "genesis",
    "artist": "grimes",
    "genre": "pop",
    "lyrics": "heart know heart ...",
    "created_at": "...",
    "updated_at": "..."
    }
  },
...
]

ご覧のとおり、実際のデータは_sourceキーの下に返され、その他のフィールドはメタデータです。中でも最も重要なのは_scoreで、特定の検索に対してそのドキュメントがどれだけ関連性が高いかを示します。これについてはすぐ後で触れますが、まずはクエリの作り方を学びましょう。

クエリDSL

ElasticsearchのクエリDSLを使うと、複雑なクエリを構築でき、Rubyコードからも利用できます。たとえば、アーティストフィールドのみを検索対象にするようsearchメソッドを修正してみましょう。

# app/models/concerns/searchable.rb

module Searchable
  extend ActiveSupport::Concern

  included do
    # ...

    def self.search(query)
      params = {
        query: {
          match: {
            artist: query,
          },
        },
      }

      self.__elasticsearch__.search(params)
    end
  end
end

query-match構文により、特定のフィールド(この場合はartist)だけを検索できます。もう一度「genesis」で検索すると(https://localhost:3000/songs?query=genesisにアクセスして試してみてください)、タイトルに「genesis」を含む曲ではなく、「Genesis」というバンドの曲だけが返ってくるはずです。複数のフィールドを検索したい場合(これはよくあるケースです)は、multi-matchクエリを使えます。

# app/models/concerns/searchable.rb

def self.search(query)
  params = {
    query: {
      multi_match: {
        query: query, 
        fields: [ :title, :artist, :lyrics ] 
      },
    },
  }

  self.__elasticsearch__.search(params)
end

フィルタリング

たとえばロックの曲だけを検索対象にしたい場合はどうすればいいでしょうか?その場合、ジャンルでフィルタリングする必要があります!検索は少し複雑になりますが、心配いりません。ひとつずつ丁寧に説明していきます。

  def self.search(query, genre = nil)
    params = {
      query: {
        bool: {
          must: [
            {
              multi_match: {
                query: query, 
                fields: [ :title, :artist, :lyrics ] 
              }
            },
          ],
          filter: [
            {
              term: { genre: genre }
            }
          ]
        }
      }
    }

    self.__elasticsearch__.search(params)
  end

最初の新しいキーワードはboolです。これは単に複数のクエリを1つにまとめるための仕組みです。ここではmustfilterを組み合わせています。前者のmustはスコアに影響を与え、以前使ったのと同じクエリを含みます。後者のfilterはスコアに影響せず、その名の通りクエリにマッチしないドキュメントを除外するだけです。ジャンルでレコードを絞り込みたいので、termクエリを使用します。

重要なのは、filter-termの組み合わせは全文検索とは無関係だという点です。SQLのWHERE句(WHERE genre = 'rock')と同じように、完全一致値による通常のフィルタにすぎません。termフィルタの使い方を知っておくのは有益ですが、今回の用途では必須ではありません。

スコアリング

検索結果は_scoreによって並べ替えられます。これはアイテムが特定の検索にどれだけ関連しているかを示す指標で、スコアが高いほど関連性の高いドキュメントです。「genesis」で検索したとき、最初に表示されたのはGrimesの曲でしたが、本当はGenesisというバンドの方に関心があった、ということに気づいたかもしれません。では、アーティストフィールドをより重視するようにスコアリングの仕組みを変更できるでしょうか?答えはイエスです。ただしそのためには、まずクエリを少し調整する必要があります。

  def self.search(query)
    params = {
      query: {
        bool: {
          should: [
            { match: { title: query }},
            { match: { artist: query }},
            { match: { lyrics: query }},
          ],
        }
      },
    }

    self.__elasticsearch__.search(params)
  end

このクエリは本質的には前のものと同等ですが、複数のクエリを1つにまとめるboolキーワードを使用しています。ここではshouldを使い、3つのクエリ(フィールドごとに1つ)を個別に指定しています。これらは論理ORで結合されます。代わりにmustを使うと論理ANDで結合されます。なぜフィールドごとに個別のmatchが必要なのでしょうか?それは、boostプロパティを指定できるようになるからです。boostは特定のクエリからのスコアに掛け算される係数です。

  def self.search(query)
    params = {
      query: {
        bool: {
          should: [
            { match: { title: query }},
            { match: { artist: { query: query, boost: 5 } }},
            { match: { lyrics: query }},
          ],
        }
      },
    }

    self.__elasticsearch__.search(params)
  end

他の条件が同じであれば、クエリがアーティストにマッチした場合のスコアは5倍になります。もう一度https://localhost:3000/songs?query=genesisで「genesis」クエリを試してみてください。Genesisバンドの曲が上位に表示されるはずです。素晴らしいですね!

ハイライト表示

Elasticsearchのもうひとつの便利な機能は、ドキュメント内のマッチ箇所をハイライト表示できることです。これにより、ユーザーは特定の結果がなぜ検索に引っかかったのかをより理解しやすくなります。

HTMLにはそのための専用タグがあり、Elasticsearchは自動的にそのタグを追加してくれます。

searchable.rbのconcernを再度開き、新しいキーワードを追加しましょう。

def self.search(query)
  params = {
    query: {
      bool: {
        should: [
          { match: { title: query }},
          { match: { artist: { query: query, boost: 5 } }},
          { match: { lyrics: query }},
        ],
      }
    },
    highlight: { fields: { title: {}, artist: {}, lyrics: {} } }
  }

  self.__elasticsearch__.search(params)
end

新しく追加したhighlightフィールドは、どのフィールドをハイライトするかを指定します。ここではすべてのフィールドを選択しました。これでhttps://localhost:3000/query=genesisにアクセスすると、マッチしたフレーズがemタグで囲まれたドキュメントフィールドを含む「highlight」という新しいフィールドが表示されるはずです。

ハイライトについてさらに詳しく知りたい方は、公式ガイドを参照してください。

あいまい検索(Fuzziness)

さて、誤って「genesis」の代わりに「benesis」と入力してしまったらどうなるでしょうか?通常は何も結果が返りませんが、Elasticsearchに基準を緩めてあいまい検索(fuzzy search)を許可するよう指示すれば、「genesis」の結果も表示させられます。

やり方は簡単です。アーティストのクエリを{ match: { artist: { query: query, boost: 5 } }}から{ match: { artist: { query: query, boost: 5, fuzziness: "AUTO" } }}に変更するだけです。fuzzinessの細かい挙動は設定可能で、詳細は公式ドキュメントを参照してください。

次のステップへ

本記事を通じて、自明ではない検索機能を実装する必要があるときに、Elasticsearchが強力なツールであり、活用すべきものであると感じていただけたなら幸いです。さらに学びたい方のために、役立つリソースをまとめました。

参考リソース

  • Elasticsearch公式リファレンス
  • Ruby gem(elasticsearch-ruby)
  • Rails gem(elasticsearch-rails)
  • 実践的な知識が詰まった良書(Elasticsearch: The Definitive Guide)
  • オートコンプリート機能の構築ガイド

代替gem

  • Searchkick — シンプルなAPIで手軽に導入できる人気の検索gem
  • Chewy — 高度なカスタマイズ性を持つElasticsearchラッパー

  1. RailsでTailwind CSSを使う方法|導入から実践的なスタイリングまで徹底解説

    CSSは魔法のような存在ですが、同時に時間のかかる作業でもあります。美しく、機能的で、アクセシブルなサイトは使っていて心地よいものですが、自分でCSSを一から書くのは骨の折れる仕事です。近年はBootstrapをはじめとする多くのCSSフレームワークが登場し、その中でもTailwind CSSは特に注目を集めています。 RailsにはTailwindが標準搭載されていませんが、この記事では新しいRuby on RailsプロジェクトにTailwind CSSを追加する方法を解説します。これにより、デザイン実装にかかる時間を大幅に節約できるでしょう。さらに、Tailwindのユーティリティクラス

  2. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた