こんにちは。@katsuhisa__ です。
本記事は、「Elastic stack (Elasticsearch) Advent Calendar 2017 」の12/18(月)分です。

前置き

今年の10月に、第21回Elasticsearch 勉強会にてログ解析基盤としてElastic Stack を導入した際のお話を発表したのですが、プレゼンの最後で、「次は全文検索エンジンとしてElasticsearch を使います!」という誰向けか分からない謎の宣言をしました。
残念ながらまだ実務で使うには至っていませんが、布石を着実に打っていきたいと思い、少し前にこちらの記事を参考に、Rails でElasticsearch を動かす一通りの流れを自分の手を動かして実装しました。

というわけで今回は、Rails でElasticsearch を動かすためにやることをあらためて整理します。

実装したときのコードは以下に置いていますので、必要であればどうぞ。
https://github.com/katsuhisa91/rails_es_study
※基本的には、前述したこちらの記事とほぼすべて同じ中身で、使っているRails とElasticsearch のバージョンによって異なる書き方がある部分だけ、アップデートされた状態です。

対象読者

  • DB 以外を使用した検索機能の実装経験が一度もない方
  • 自分にもElasticsearch って使えるんだろうか・・・と、不安な方
  • 実装の全体観だけをまずは知りたい方

全文検索エンジンの実装経験者が読んでも、得るものはあまりないかなあ、と思います。
逆に、ぼくが「全文検索エンジンを使うと検索機能が充実できて良さそう・・・」とぼんやり感じていたくらいの時に、当たり前すぎて誰も教えてくれなかったような内容からブレークダウンしながら書くので、超初心者の方にとっては役立つ記事になっているかもしれません。


バージョン

Rails 5.1.4
Elasticsearch 5.6.3

※elasticsearch-rails のgem のブランチが5系までしか生えてなかったので、Elasticsearch は6系ではなく5系を使いました。。6系にチャレンジすればよかったな、と少し後悔しています。
※ちなみに冒頭で紹介した記事は、2年前の記事なので、Rails 4.2.3, Elasticsearch 1.7.2 でした。

全文検索エンジンを使う考え方

  1. 全文検索エンジンの中に検索対象のデータが入っている
  2. アプリケーション側で検索すると、検索エンジンにクエリが投げられ、結果が返ってくる
  3. アプリケーション側で検索対象のデータが更新されると、ちゃんと検索エンジンの中のデータにも更新が反映される

超初心者にとっては、この考え方の地図が頭の中にある方が、以降の理解がスムーズになると思います。
ぼくの頭が悪いだけかもしれないですが、自分の経験の浅い実装をする際には、細部に入り込むと自分がどこの実装をやっているか迷子になりがちです。なので、ぼくは常にこういうアホみたいなことを実装の前に頭のなかで整理して、先にどこかにメモするように心がけています。(この地図が詳細に完成していると、実装がすでに8割終わった印象があります。あとは実際に手を動かすだけで、多少ハマろうがゴールにたどり着けることが大半です。)

Rails でElasticsearch を動かす

さて、ここからは、先ほどの1〜3をブレークダウンしながら具体的に解説します。もちろん、「アプリケーション = Rails アプリケーション」, 「全文検索エンジン = Elasticsearch 」とお考え下さい。
また、今回は、既存のRails アプリケーションにElasticsearch を組み込む、という前提で考えてみます。

1.全文検索エンジンの中に検索対象のデータが入っている

まずは、検索対象のデータを入れて管理するハコを用意しないといけないですね。RDB で言うところのデータベースは、Elasticsearch では、インデックスという概念があたります。というわけで、まずはElasticsearch にインデックスをつくりましょう

Elasticsearch にインデックスをつくる・・・ための準備

前述した通り、今回は既存のアプリケーションが出来上がっている前提ですので、Rails のModel をElasticsearch でも扱いたいですよね。うんうん。

と・・・このような我々の要望に対して(?)、Elastic 社の人たちが、Rails エンジニアがこうできれば嬉しいな、と思っていることを実現するためのgem を公開してくれています。
https://github.com/elastic/elasticsearch-rails

Gemfile に以下を書いて、bundle install してください。

gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'
gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'

ん?gem が2つ・・・?と気になる方は、ぜひREADME のUsage をどうぞ。今回は詳細解説しません。

Elasticsearch にインデックスをつくる

今度こそElasticsearch にインデックスをつくりましょう。

検索対象としたいModel の中に、Elasticsearch::Model をinclude しましょう。
※elasticsearch-rails, elasticsearch-model のサンプルコードでは、Model はArticle となっているようです。本記事でも、説明で使うサンプルコードのModel はArticle で統一しようと思います。

class Article < ActiveRecord::Base
  include Elasticsearch::Model
end

これで、Model でElasticsearch を扱う準備ができました。ということで、インデックスを作成してみましょう。インデックスは以下のようなコードで作成できます。

Article.__elasticsearch__.create_index! force: true

このへんの挙動の詳細が知りたい場合は、elasticsearch-modelのREADME を見ると詳しく解説されています。
https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#index-configuration

ちなみにNOTE に書かれてある通り、

Elasticsearch will automatically create an index when a document is indexed, with default settings and mappings.

デフォルトのインデックス設定とマッピングを利用する場合、明示的にインデックスはつくらなくてもよいです。
今回は、明示的にインデックスをつくる操作を一度説明したほうが理解が促進されると思い、敢えて書きました。(逆に混乱させてしまったら申し訳ありません。)

Elasticsearch にドキュメントをいれる

次に、Elasticsearch のインデックスの中にデータを入れてみましょう。ここでいうデータは、もちろん検索対象にしたいデータのことであり、Elasticsearch では、ドキュメントと呼びます。(RDB でいうレコードの概念に相当)

では、Elasticsearch にドキュメントをインポートしてみましょう。

Article.import

シンプルで分かりやすいですね。ここまでで、Elasticsearch のインデックスの中に、ドキュメントが登録されました。

(参考)https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#importing-the-data

2.アプリケーション側で検索すると、検索エンジンにクエリを投げ、結果が返ってくる

次に、Rails アプリケーション側で検索すると、Elasticsearch にクエリを投げるようにしましょう。

以下のように書けば、Rails からElasticsearch にクエリを投げることができます。

response = Article.search 'fox dogs'

つまり、Rails からElasticsearch にクエリを投げる実装をするためには、クエリパラメータに、アプリケーション側の検索文字列を単に突っ込んであげればよいですね。

def index
  @articles = Article.search(params)
end

これで終わりです。
ちなみに取得したデータは、こんな感じで扱うことができます。

response.took
# => 3

response.results.total
# => 2

response.results.first._score
# => 0.02250402

response.results.first._source.title
# => "Quick brown fox"

3.アプリケーション側で検索対象のデータが更新されると、ちゃんと検索エンジンの中のデータにも更新が反映される

ここまでで、Elasticsearch を検索エンジンとして使うところまではできました。しかし、実際のサービスで運用することを考えると、もちろん検索対象のデータが更新されれば、Elasticsearch の中のドキュメントも更新されなければなりませんね。
ということで、最後に、Elasticsearch のドキュメントをRails から更新する方法について紹介します。

さて、まずは単にElasticsearch のドキュメントを更新するには、以下のようなコードで実装できます。

Article.first.__elasticsearch__.update_document

他にもdelete_document というメソッドがあるので、これをつかえばドキュメントの削除もできます。

さて、では、これらのメソッドをレコード更新する処理の前後に都度実装すればよいのでしょうか?もちろんそんな必要はありません。
elasticsearch-model では、Elasticsearch::Model::CallbacksをModel にinclude しておくと、レコードの更新をした際にElasticsearch のドキュメントを更新するクエリを投げてくれます。

class Article
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks
end

非同期でドキュメント更新する

しかし、これだけではよくないですね。データベーストランザクション中にもHTTPリクエストぼんぼこ投げていたら、きっと運用ポリスに怒られます。
そうです、非同期にしたいですね。

ということで、非同期にする方法を最後に紹介して記事を締めくくります。
以下のように書けば非同期で処理できます。こちらの例では、sidekiq を使用しています。
(超初心者向けの内容からはずれるので解説はスキップします。)

class Article
  include Elasticsearch::Model

  after_save    { Indexer.perform_async(:index,  self.id) }
  after_destroy { Indexer.perform_async(:delete, self.id) }
end
class Indexer
  include Sidekiq::Worker
  sidekiq_options queue: 'elasticsearch', retry: false

  Logger = Sidekiq.logger.level == Logger::DEBUG ? Sidekiq.logger : nil
  Client = Elasticsearch::Client.new host: 'localhost:9200', logger: Logger

  def perform(operation, record_id)
    logger.debug [operation, "ID: #{record_id}"]

    case operation.to_s
      when /index/
        record = Article.find(record_id)
        Client.index  index: 'articles', type: 'article', id: record.id, body: record.__elasticsearch__.as_indexed_json
      when /delete/
        Client.delete index: 'articles', type: 'article', id: record_id
      else raise ArgumentError, "Unknown operation '#{operation}'"
    end
  end
end

https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#asynchronous-callbacks

まとめ

以上で、Rails で全文検索エンジンとしてElasticsearch を動かすための考え方(超初心者向け)を締めくくります。

本記事では、全文検索エンジンをアプリケーションに組み込む際に必要なことを3つに分け、その上でRails×Elasticsearch に限定した実装方法をご説明しました。

お付き合いいただきありがとうございました。

何か気になることがあれば、いつでもどうぞ!(ちゃんと答えられるか分かりませんが!)
@katsuhisa__

他の「Elastic stack (Elasticsearch) Advent Calendar 2017 」も楽しみにしています。

余談

Elasticsearch の用語とRDB の用語は、それぞれが完全なサブセットではないです。なので、Elasticsearch の用語で分からないことがあれば、都度調べるほうが良いと思います。そのほうが、「RDB でできる◯◯をElasticsearch でもできるんだよね?」という変な期待や勘違いをしなくて済むのかなあ、と初心者ながらに思っています。

ぼくはビビリなので、分からないことがあればドキュメント読みます・・・
Elasticsearch は公式ドキュメントめちゃくちゃ充実していて、今回も自分の手で実装しながら、ハマったところ(バージョンによってFilterd query の書き方が異なっていた)については、ちゃんと公式ドキュメントに変更ポイントの解説が書いてました。