TUNAGの全文検索を支える Elasticsearch × Rails

こんにちは、スタメンの松谷です。

弊社は「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 は、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
  ​
  included do
    include Elasticsearch::Model  ​
    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
  ​
  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? && __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をご覧ください。