こんにちは、スタメンの松谷です。 弊社は「TUNAG」という 社内SNS を提供しています。TUNAGではアプリケーション フレームワーク として、 Ruby on Rails を使用しています。TUNAGの主要機能に Facebook のニュースフィードに該当する「タイムライン」があり、社員同士のコミュニケーションや、会社からのお知らせが共有されます。 タイムラインに投稿が蓄積されるにつれ、過去の投稿を振り返りたいというニーズが増えたので、 全文検索 (Elasticsearch)を導入して検索を可能にしました。 導入に際して、 Rails から Elasticsearch を扱う方法をまとめました。これから Rails にElasticsearchを導入しようとしている方の参考になれば幸いです。 TL;DR 概要 Ruby on Rails で作られた TUNAG に Elasticsearch で、 全文検索 を導入した。 対象読者 これから Rails に作られたサービスに Elasticsearch を導入しようとしている方 動作環境 Ruby on Rails 5.1.6 Amazon Elasticsearch Service (Elasticsearch 6.0) なぜ Elasticsearchか? 以下の理由でElasticseachを選択しました。 形態素解析 や n-gram など 自然言語 的な解析をサービスに合わせて選択することができる ノードを増やすことで簡単にスケールアウトができる 検索が高速でパフォーマンスが高い 検索クエリの表現力が高い インデックスを複数もつことでマルチテナント対応が可能 スコアリングして検索順位のチューニングが可能 利用したgem 今回 Rails で 全文検索 を実現する上で以下の2つのgemを利用しました。 elasticsearch-model elasticsearch-dsl elasticsearch-model は、 Rails の MVC の「モデル」とElasticsearchの統合を簡素化することを目的としています。 このgemのモジュールを検索対象のクラスにincludeすることにより、 ActiveRecord を継承したモデルに対応するデータをインデックスにインポートする機能が用意されるなど、Elasticsearchに関連する機能が拡張されます。 elasticsearch- dsl は、Elasticsearchに対するクエリの作成と実行のサポートを目的としたgemです。表現豊かなElasticsearchのクエリを Ruby の DSL で感覚的に構築することができます。 運用上気にしたこと TUNAG のクライアント企業様毎にインデックスを分けてデータの分離を実現 サービス公開後でも、サービス停止することなくインデックスの変更を実現 実装の詳細 以下で Rails にElasticsearchを導入した際の全体的な流れを紹介していきます。 検索対象モデル 「タイムライン」は複数のモデルから構成されています。一部を紹介すると、 タイムラインへ投稿したユーザ(User) 投稿フィード(Feed) 投稿フィードに対するコメント(Comment) 投稿に関連するデータ(例: 利用した社内制度 Menu) などが存在します。 全文検索 では、これらのモデルのデータを検索対象とし、横断的に走査してキーワードに合致した投稿(Feed)を取得します。 # == Schema Information # Table name: feeds # # id :integer not null, primary key # content :text(65535) # status :integer default("active"), not null class Feed has_many :comments belongs_to :user belongs_to :menu end データのインポート ActiveRecord ::Base を継承したモデルに、elasticsearch-model の Elasticsearch::Model::Importing を Mix-In することで、 インスタンス のデータを Elasticsearch::Model::Importing#import で、Elasticsearch に インポートすることができます。 TUNAG では、Feed に関連するアソシエーションを含めてインデックスにインポートしたいので、 Elasticsearch::Model::Importing#import で中で、 JSON にシリライズする際に呼ばれる Elasticsearch::Model::Serializing#as_indexed_json をオーバーライドしています。 下記は、Feedクラスがincludeするモジュール Serchable を定義しています。(実際の実装を省略しています。) module Searchable extend ActiveSupport :: Concern <200b> included do include Elasticsearch :: Model <200b> def as_indexed_json (options = {}) as_json( only : [ :content , :status ], include : { comments : { only : [ :content ] }, menu : { only : [ :title ] }, user : { only : [ :name ] }, }) end end end Feed .first.as_indexed_json # => { # "content" => "お疲れ様でした!来週は新入社員歓迎会をします!", # "status" => "active", # "comments" => [ # { "content" => "参加します!" }, # { "content" => "最近会ってないですね。来週楽しみです。" }, # { "content" => "お疲れさまです!参加予定です。" } # ], # "menu" => { "title" => "イベント出欠" }, # "user" => { "name" => "松谷 勇史朗" }, # } インデックスの更新 初回のインデックスへのインポートが完了した後も、新しい投稿が発生したり、既存の投稿が編集されたタイミングで、該当のインデックスを更新させる必要があります。 ActiveRecord ::Base を利用したモデルの場合、after_commitコールバックを使用してインデックスの更新を行うことができます。TUNAGでは、ActiveJobを使用して、非同期でインデックス操作を行っています。 module Searchable extend ActiveSupport :: Concern <200b> included do after_commit ->(record) { Elasticsearch :: IndexerJob .perform_later( ' index ' , record.id, ElasticsearchIndex .alias_name(record.company))}, on : :create after_commit ->(record) { Elasticsearch :: IndexerJob .perform_later( ' index ' , record.id, ElasticsearchIndex .alias_name(record.company))}, on : :update after_commit ->(record) { Elasticsearch :: IndexerJob .perform_later( ' delete ' , record.id, ElasticsearchIndex .alias_name(record.company))}, on : :destroy end end class Elasticsearch :: IndexerJob < ApplicationJob def perform (operation, record_id, index_name) logger.debug [operation, " ID: #{ record_id }" ] case operation when / index / record = :: Feed .find(record_id) Elasticsearch :: Model .client.index index : index_name, type : ' feed ' , id : record.id, body : record.__elasticsearch__.as_indexed_json when / delete / Elasticsearch :: Model .client.delete index : index_name, type : ' feed ' , id : record_id else raise ArgumentError , " Unknown operation ' #{ operation } ' " end end end マッピング の定義 マッピング とは、Elasticsearch の インデックスにおいて、ドキュメントをどのような構造で表現するかを定義することです。 具体的には、フィールドとその型、Analyzerなどを設定します。 Elasticsearch::Model::Indexing.settings メソッドで設定することができます。 ここではFeed、Comment、Menu、Userの4つのテーブルそれぞれのフィールドに、それぞれAnalyzerとしてkuromojiを設定しています。 module Searchable extend ActiveSupport :: Concern included do settings index : { number_of_shards : 1 , number_of_replicas : 0 } do mappings dynamic : ' false ' do indexes :content , analyzer : ' kuromoji_analyzer ' indexes :status , type : ' keyword ' indexes :comments do indexes :content , analyzer : ' kuromoji_analyzer ' end indexes :menu do indexes :title , analyzer : ' kuromoji_analyzer ' end indexes :user do indexes :name , analyzer : ' kuromoji_analyzer ' end end end end end 複数テーブルの横断的検索 elasticsearch- dsl を使ってクエリを構築します。ElasticsearchのBoolクエリを使えば複雑な検索も可能になりますBoolクエリは以下の4種類あります。 must: AND条件 filter: AND条件 should: OR条件 must_not: NOT条件 例として、 (statusがactiveなFeed) && (FeedのcontentフィールドまたはCommnetのcontentフィールドまたはMenuのtitileフィールドまたはUserのnameフィールドにキーワードが含まれる)の条件をelasticsearch- dsl で構築しました。 multi_matchクエリは、複数フィールドへのマッチを許可するクエリです。Boolクエリを用いて「投稿したユーザから検索(例: 自分の投稿)」、「投稿したユーザの属性(例: 所属部署)による検索」など、複雑な検索を実現することができます。 このように、Elasticsearch側で 関連する複数のモデルを対象とした、複雑な検索が実現できるため、 Rails の Controller 等で検索結果をさらに絞り込む必要性が無く、シンプルな実装と高いパフォーマンスを得ることができます。 Elasticsearch :: DSL :: Search .search do sort do by :created_at , order : ' desc ' # 新しい順 end query do bool do must do term ' status ' : ' active ' # 有効なフィード end must do multi_match do query keyword fields %w( content comments.content menu.title user.name ) operator ' and ' end end end end end マルチテナント対応 Elasticsearchではインデックスを複数もつことが可能です。TUNAGでは、データ分離のためクライアント企業毎にインデックスを分割しており、検索結果に他クライアント企業の情報が紛れ込む可能性はありません。 class Feed < ApplicationRecord include Searchable belongs_to :company # クライアント企業毎にインデックス名を割り振る index_name = " company_ #{ company.id }" end インデックスの無停止再構築 サービスを運営していると、機能の仕様変更などで、モデルとElasticsearch インデックスの マッピング 定義の変更や、検索対象フィールドの変更が発生し、インデックスを再構築する必要性がでてきます。この場合、インデックスを0から再構築するのに時間もかかりますし、インデックス切替の度にメンテナンスをすることもユーザに迷惑をかけるため、サービスへの影響を与えずに、手軽にインデックスを更新することが求められます。 TUNAGでは、 Index Aliases and Zero Downtime | Elasticsearch: The Definitive Guide [2.x] | Elastic を参考にして、Elasticsearch が提供する Index Aliases 機能を用いて、無停止でのインデックスの再構築を行っています。 Index Aliases は、Elasticsearch の インデックスに対し、 エイリアス (別名)をつける機能です。具体的な手順としては下記となります。 現在有効なインデックス(company_01_feed_20180420) への エイリアス として company_01_feed_current を作成しておき、各種コードからは company_01_feed_current を参照するようにしておきます。 新しいインデックスを構築する際は、company_01_feed_20180423 を作成し、データインポート後を行った後、company_01_feed_current の参照先を company_01_feed_20180420 から company_01_feed_20180423 へ切り替えます。アプリケーションから参照している エイリアス 名は同じですが、Elasticsearchの内部的にはAliasが指しているインデックスは異なるという点を活かしています。 切り替え後に、古いインデックス(company_01_feed_20180420) を削除して、メンテナンス完了です。 エイリアス の切り替えは、 Elasticsearch::API::Indices::Actions#update_aliases を利用しており、update_aliases は、Elasticsearch側でアトミックに処理されることが保証されているため、安全に切り替えを行うことができます。 また、TUNAG では、ElasticsearchIndex という ActiveRecord ::Baseを継承したモデルを作成し、インデックスを管理しています。 namespace ' elasticsearch:feed:rebuild ' do desc ' 企業毎にインデックスを新たに作成し、古いインデックスから切り替える ' task feed : :environment do Company .all.each do | company | ElasticsearchIndex .rebuild!(company) end end end class ElasticsearchIndex < ApplicationRecord include Elasticsearch :: Model def self . rebuild! ( company :, model :) next_index = create!( model : model, company : company, rebuild_at : Time .current) next_index.create_index next_index.import if current_exists?(company) current_index = current(company) switch(current_index, next_index) current_index.delete_index else next_index.add_aliases end next_index.active! end def self . switch (current_index, next_index) raise ArgumentError " Not allowed switch index other company index " if current_index.company != next_index.company __elasticsearch__.client.indices.update_aliases body : { actions : [ { remove : { index : current_index.name, alias : alias_name(current_index.company)}}, { add : { index : next_index.name, alias : alias_name(current_index.company)}} ] } end def self . current (company) company.elasticsearch_indices.active.last end def current_exists? (company) current(company).present? &amp;&amp; __elasticsearch__.index_exists?( index : current(company).name) end def create_index self .class.__elasticsearch__.create_index!( force : true , index : name) end def import company.feeds.import( force : true , index : name) end end 終わりに 今回は、Elasticsearchを Rails アプリで実運用したときの事例を紹介しました。elasticsearch-modelで、 ActiveRecord とElasticsearchの連携がスムーズに実現し、elasticsearch- dsl でElasticsearhの機能を Ruby で簡単に操作できてとても開発効率が向上しました。今後は、スコアリング、アナライザの選定、検索結果のハイライトなど未だ使っていない機能の調査をし、サービスの質を向上させてTUNAGをもっと良くしていこうと思います! スタメンでは一緒に働くメンバーも募集しております!是非 Wantedly をご覧ください。