読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでElasticsearch: 全文検索を実装

検索 elasticsearch

f:id:nipe880324:20151017170217p:plain:w420
RailsでElasticsearchを使ってレストラン検索アプリを作成、店名、住所、カテゴリなどからレストランを全文検索できるようにします。また、フィルタ(filter)も使って検索条件を指定することで、閉店している店舗も含めて検索できるようにします。

今後、Elasticsearchのページネーション・ページあたりの表示件数、ソート、ファセット・post_filter、ハイライト、サジェスト機能などをより実践的な機能を実装していきます。

動作確認

  • Mac OS X 10.11 El Capitan
  • elasticsearch 1.7.2
  • Rails 4.2.3
  • elasticsearch-dsl 0.1.2
  • elasticsearch-model 0.1.8
  • elasticsearch-rails 0.1.8


1. ElasticsearchのMacへのインストール

Homvebrewを使ってMacにElasticsearchインストールします。
Elasticsearchを使うためにはJavaが必要なのでJavaもインストールします。
Mac以外で実行する場合は適宜JavaとElasticsearchをインストールしてください。

Homebrewのインストール

Homebrewが入っていない場合は、MacにHomebrewをインストールするを参照してインストールしてください。


JDK8のインストール

ElasticsearchはJavaを利用しているのでインストールします。

brew install caskroom/cask/brew-cask
brew tap caskroom/versions
brew cask install java
java -version #=> 「java version "1.8.0_60"」のように表示される


Elasticsearchのインストール

brew install elasticsearch
elasticsearch -v #=> 「Version: 1.7.2, ...」と表示されること


Elasticsearch Pluginのインストール

ElasticsearchはPluginをインストールすることで、形態素解析や管理画面など拡張することができます。

which elasticsearch
/usr/local/bin/elasticsearch

# 日本語の形態素解析プラグイン kuromoji のインストール
# https://github.com/elastic/elasticsearch-analysis-kuromoji
/usr/local/bin/plugin install elasticsearch/elasticsearch-analysis-kuromoji/2.7.0

# Elasticsearchの管理プラグイン mervel のインストール
# https://www.elastic.co/guide/en/marvel/current/_installation.html
/usr/local/bin/plugin install elasticsearch/marvel/latest


Elasticsearchの起動

elasticsearchコマンドでElasticsearchを起動させます。

# フォアグラウンドでElasticsearchが起動します
elasticsearch
# バックグラウンドでElasticsearchを起動させる
# elasticsearch -d


Elasticsearchの確認

http://localhost:9200/_plugin/marvel/にアクセスするとMarvelプラグインをいれたので、Elasticsearchの管理画面が見れます。
クラスター、インデックスやキャッシュのヒット、ドキュメント数などが見れます。
f:id:nipe880324:20151017170605p:plain:w420

また、右上の「Dashboard -> Sense」をクリックすると、次のようにクエリを実行できる画面を表示できます。
f:id:nipe880324:20151017170622p:plain:w420



2. Railsプロジェクト作成とテストデータ作成

Railsプロジェクトの作成

rails newコマンドでRailsプロジェクトを作成します。

rails new elasticsearch_test


モデル作成

次のようなシンプルなER図を作成します。

restaurants - (N-1) - categories (カテゴリ)
            - (N-1) - prefs (都道府県)

モデルとマイグレーションファイルを作成します。

rails g model Restaurant name name_kana pref_id:integer zip address category_id:integer closed:boolean
rails g model Pref name
rails g model Category name name_kana

アソシエーションを定義します。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  belongs_to :category
  belongs_to :pref
end

# app/models/category.rb
class Category < ActiveRecord::Base
  has_many :restaurants
end

# app/models/pref.rb
class Pref < ActiveRecord::Base
  has_many :restaurants
end

マイグレーションを実行します。

rake db:migrate


シードデータの作成

検索のテスト用に少量のシードデータを作成しておきます。
後ほど、大量のデータで行いますが、はじめのうちはElasticsearchのクエリ結果が正しいか確認しやすいように5件程度のデータで行います。

# db/seeds.rb
ActiveRecord::Base.transaction do
  # すべてのレコードを削除する
  [Category, Pref, Restaurant].each(&:delete_all)

  # カテゴリの作成(3件)
  teisyoku = Category.create!(name: '定食',      name_kana: 'ていしょく')
  italian = Category.create!(name: 'イタリアン', name_kana: 'いたりあん')
  izakaya = Category.create!(name: '居酒屋',    name_kana: 'いざかや')

  # 都道府県の作成(2件)
  tokyo = Pref.create!(name: '東京都')
  kanagawa = Pref.create!(name: '神奈川県')

  # レストラン作成(各カテゴリ, 都道府県の掛け算で6件)
  Restaurant.create!([
    {
      name: '松屋', name_kana: 'まつや', zip: '240-0113', address: '三浦郡葉山町堀内24-3',
      pref: tokyo, category: teisyoku, closed: false
    },
    {
      name: 'ラ・マーレ・ド・茶屋', name_kana: 'らまーれどちゃや', zip: '142-0111', address: '港区六本木1-1-1',
      pref: kanagawa, category: teisyoku, closed: false
    },
    {
      name: 'レストラン シェ・リュイ', name_kana: 'しぇりゅい', zip: '150-0033', address: '渋谷区猿楽町11-11',
      pref: tokyo, category: italian, closed: false
    },
    {
      name: 'スパゲティ ハシヤ', name_kana: 'はしや', zip: '162-0023', address: '三浦1-11',
      pref: kanagawa, category: italian, closed: true
    },
    {
      name: '牛角', name_kana: 'ぎゅうかく', zip: '130-0033', address: '池袋3-33',
      pref: tokyo, category: izakaya, closed: false
    },
    {
      name: '沖縄そば やんばる', name_kana: 'おきなわそばやんばる', zip: '231-0011', address: '西区横浜1-11',
      pref: kanagawa, category: izakaya, closed: true
    }
  ])
end

シードデータを投入します。

rake db:seed

検索画面を作成

シンプルな検索画面を作成します。

rails g controller top index

パスを修正します。

# config/routes.rb
Rails.application.routes.draw do
  root 'top#index'
end

レストランをすべて表示するようにします。

# app/controllers/top_controller.rb
class TopController < ApplicationController
  def index
    @restaurants = Restaurant.all.includes(:pref, :category)
  end
end

レイアウトファイルにBootstrapを読み込むようにしておきます。

<!DOCTYPE html>
<html>
<head>
  <title>ElasticsearchTest</title>
  <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track' => true %>
  <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
  <%= csrf_meta_tags %>
  <!-- headにcssとscriptを追加 -->
  <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
  <script src="//netdna.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
</head>
<body>

<!-- headにcssとscriptを追加 -->
<div class="container">
  <%= yield %>
</div>

</body>
</html>

シンプルにすべてのレストランを表示するようにします。

<!-- app/views/top/index.html.erb -->
<h1>レストラン検索</h1>

<ul>
  <% @restaurants.each do |r| %>
    <li><%= r.name %> (都道府県: <%= r.pref.name %>、カテゴリ: <%= r.category.name %></li>
  <% end %>
</ul>


動作確認

rails sでサーバを起動し、http://localhost:3000/にアクセスすると次のように表示されると思います。
f:id:nipe880324:20151017170800p:plain:w420



3. RailsとElasticsearchで全文検索を実装

ElasticsearchのセットアップとRailsで検索できるようにしたのでElasticsearchの基礎をまじえながらRilsで使う方法を説明します。


ElsticSearchの起動

フロントエンドで起動しておきます。

elasticsearch

また、http://localhost:9200/_plugin/marvel/にアクセスし、Marvel(管理画面)も開いておきます。


RailsでElasticsearchを使った検索の流れ

RailsでElasticsearchを使ったときの検索の流れは次のようになります。

  1. [前準備] Elasticsearchにインデックス(RDSでいうとデータベース)を作成
  2. [前準備] 作成したインデックスにドキュメントタイプ(RDSでいうとテーブル)を作成
  3. [前準備] DBからデータを取得し、ドキュメント(RDSでいうレコード)を作成
  4. 検索画面を作成する(画面から検索ボタンを押すと、Railsが検索パラメータを受け取る)
  5. 検索パラメータからElasticsearchのクエリを作成し、検索する
  6. Elasticsearchのレスポンスを画面に表示する
  7. 定期的にドキュメントを追加/更新/削除する

次からはこの流れにしたがって、RailsとElasticsearchを使って「フリーキーワード検索」を実装していきます。

Elasticsearchのgemをインストール

まずは、RailsからElasticsearchを便利に使えるgemをインストールします。

# Gemfile

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

gemをインストールします。

bundle

それぞれのgemが提供する機能は次のとおりです。


[前準備] Elasticsearchにインデックス(RDSでいうとデータベース)を作成

インデックスとはRDSでいうデータベースのようなものです。
インデックスを作るには、ActiveRecordElasticsearch::Modelをインクルードし、Elasticsearchクライアントのメソッドを呼び出すことでインデックスを作成できます。

RestaurantにElasticsearch::Modelをインクルードします。
そして、index_nameメソッドにインデックス名を指定します。デフォルトはモデル名になりますが、環境ごとにインデックスを分けたほうが都合が良いので、Rails.envを追加します。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  belongs_to :category
  belongs_to :pref

  include Elasticsearch::Model

  index_name "restaurant_#{Rails.env}" # インデックス名を指定(RDBでいうデータベース)
end

そして、下記コマンドを実行することで、インデックスの作成ができます。
既にインデックスがある場合は削除もできます。

# rails console

# インデックスの作成。index:にはインデックス名、settingsにはインデックスの設定、mappingsにはマッピングの設定を記載
Restaurant.__elasticsearch__.client.indices.create \
  index: Restaurant.index_name,
  body: { settings: Restaurant.settings.to_hash, mappings: Restaurant.mappings.to_hash }

# インデックスの削除
Restaurant.__elasticsearch__.client.indices.delete index: Restaurant.index_name rescue nil

これらのコマンドは結構長いので、下記コマンドで強制的インデックスの削除/作成ができます。

# rails console
Restaurant.__elasticsearch__.create_index! force: true
Restaurant.__elasticsearch__.refresh_index!

これで、restaurant_developmentというインデックスを作成できました。


[前準備] 作成したインデックスにドキュメントタイプ(RDSでいうとテーブル)を作成

ドキュメントタイプはRDSでいうテーブルのようなもので、マッピング(RDSでいうDDL)というもので作成します。

では、マッピングを定義し作成します。
Elasticsearch:Modelのsettingsmappingsを定義することでマッピングを定義します。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  ...

  index_name "restaurant_#{Rails.env}" # インデックス名を指定(RDBでいうデータベース)
  # document_type # ドキュメントタイプを指定(RDBでいうテーブル)。デフォルトでクラス名

  # インデックス設定とマッピング(RDBでいうスキーマ)を設定
  settings do
    mappings dynamic: 'false' do # デフォルトでマッピングが自動作成されるがそれを無効にする
      # マッピングの公式ドキュメント
      # https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-core-types.html

      # indexesメソッドでインデックスする値を定義します。
      # analyzer: インデクシング時、検索時に使用するアナライザーを指定します。指定しない場合、グローバルで設定されているアナライザーが利用されます。
      # kuromojiは日本語のアナライザーです。
      indexes :name,      analyzer: 'kuromoji'
      indexes :name_kana, analyzer: 'kuromoji'

      indexes :zip
      indexes :address, analyzer: 'kuromoji'

      # type: booleanでclosedはboolean型として定義します
      indexes :closed, type: 'boolean'

      # date型として定義
      # formatは日付のフォーマットを指定(2015-10-16T19:26:03.679Z)
      # 詳細: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html
      indexes :created_at, type: 'date', format: 'date_time'

      # 階層化してインデクシングできます。pref.nameとして検索できます。
      indexes :pref do
        indexes :name, analyzer: 'keyword', index: 'not_analyzed'
      end

      indexes :category do
        indexes :name, analyzer: 'keyword', index: 'not_analyzed'
      end
    end
  end
end

インデックスを作成し直します。

# rails console
Restaurant.__elasticsearch__.create_index! force: true
Restaurant.__elasticsearch__.refresh_index!

curlコマンドで定義したマッピングを確認できます。

curl -XGET 'localhost:9200/restaurant_development/_mapping/restaurant?pretty=true'
{
  "restaurant_development" : {
    "mappings" : {
      "restaurant" : {
        "dynamic" : "false",
        "properties" : {
          "address" : {
            "type" : "string",
            "analyzer" : "kuromoji"
          },
          "category" : {
            "properties" : {
              "name" : {
                "type" : "string",
                "index" : "not_analyzed",
                "analyzer" : "keyword"
              }
            }
          },
          "closed" : {
            "type" : "boolean"
          },
          "created_at" : {
            "type" : "date",
            "format" : "date_time"
          },
          "name" : {
            "type" : "string",
            "analyzer" : "kuromoji"
          },
          "name_kana" : {
            "type" : "string",
            "analyzer" : "kuromoji"
          },
          "pref" : {
            "properties" : {
              "name" : {
                "type" : "string",
                "index" : "not_analyzed",
                "analyzer" : "keyword"
              }
            }
          },
          "zip" : {
            "type" : "string"
          }
        }
      }
    }
  }
}


[前準備] DBからデータを取得し、ドキュメントタイプにドキュメント(RDSでいうレコード)を作成

ドキュメントをインポートするときに呼ばれるメソッドas_indexed_jsonを定義します。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  ...

  # インデクシング時に呼び出されるメソッド
  # マッピングのデータを返すようにする
  def as_indexed_json(options = {})
    attributes
      .symbolize_keys
      .slice(:name, :name_kana, :zip, :address, :closed, :created_at)
      .merge(pref: { name: pref.name })
      .merge(category: { name: category.name })
  end
end

# 次のような出力になります
Restaurant.first.as_indexed_json
# => {:name=>"松屋", :name_kana=>"まつや", :zip=>"240-0113", :address=>"三浦郡葉山町堀内24-3", :closed=>false, :created_at=>Fri, 16 Oct 2015 19:26:03 UTC +00:00, :pref=>{:name=>"東京都"}, :category=>{:name=>"定食"}}

そして、importメソッドでインデクシングを行います。

Restaurant.import
#=> 0

これで、インデクシングされたので、MarvelのINDICESのrestaurant_developmentのDocuments数が「6」になっています。
f:id:nipe880324:20151017170915p:plain:w420

Marvel Senseからクエリを実行すると、次のように結果が返ってきます。
f:id:nipe880324:20151017170931p:plain:w420


検索フォームを作成する

フリーキーワードを入力できる検索フォームを作成します。
検索/検索結果表示画面(index.html.erb)にフォームを追加します。
検索結果の表示も変えています。

<!-- app/views/top/index.html.erb -->
<h1>レストラン検索</h1>

<!-- 検索フォーム -->
<%= form_tag root_path, method: :get, enforce_utf8: false do %>
  <div class="form-group">
    <%= search_field_tag :q, params[:q], class: 'form-control', placeholder: '店名、場所、カテゴリ' %>
  </div>
  <button type="submit" class="btn btn-default">検索</button>
<% end %>

<br>

<!-- 検索結果の表示 -->
<div class="col-xs-9">
  <div id="results">
    <% @restaurants.each do |r| %>
      <hr />
      <div class="result">
        <h4><%= r.name %><%= r.name_kana %></h4>
        <p class="text-muted">
          <small>都道府県: <%= r.pref.name %></small>
          <small>カテゴリ: <%= r.category.name %></small>
        </p>
      </div>
    <% end %>
  </div>
</div>

画面を表示すると次のようになります。
f:id:nipe880324:20151017170957p:plain:w420


検索パラメータからElasticsearchのクエリを作成し、検索する

まずは、Elasticsearchのクエリを作成し、RailsからElasticsearchにどのようなクエリを投げればよいか確認します。
Marvel Senseを開き、クエリを試します。
複数のフィールドからクエリ文字列にマッチするドキュメントを取得するにはmulti_matchを使用します。
参考: Multi Matchクエリ

Rubyから次のクエリを作成するようにします。
queryにはユーザが入力した検索キーワード、fieldsには検索を行うフィールド名を指定します。
下記の例では、namename_kanaaddresspref.namecategory.nameに「牛角」という文字列が入っているドキュメントを取得します。
pref.namecategory.nameは"keyword"アナライザーを使用しているので全文マッチしないとヒットしません。例:「東京」ではヒットせず、「東京都」でヒットする。ここらへんの検索のチューニングは専門的なので省きます。

GET restaurant_development/_search
{
  "query": {
    "multi_match": {
      "query":    "牛角",
      "fields": ["name", "name_kana", "address", "pref.name", "category.name"]
    }
  }
}

// 結果
{
   "took": 3,
   "timed_out": false,
   "_shards": {
      "total": 5,
      "successful": 5,
      "failed": 0
   },
   "hits": {
      "total": 1, // ヒットしたドキュメント数
      "max_score": 0.08322528, // 最大の関連度
      "hits": [
         {
            "_index": "restaurant_development", // インデックス
            "_type": "restaurant",  // ドキュメントタイプ
            "_id": "5",
            "_score": 0.08322528, // 関連度
            "_source": {  // データ
               "name": "牛角",
               "name_kana": "ぎゅうかく",
               "zip": "130-0033",
               "address": "池袋3-33",
               "closed": false,
               "created_at": "2015-10-16T19:26:03.683Z",
               "pref": {
                  "name": "東京都"
               },
               "category": {
                  "name": "居酒屋"
               }
            }
         }
      ]
   }
}

上記のクエリを作成するには、ユーザの検索キーワードが必要になります。
そのため、コントローラーで検索フォームのqパラメータをsearchメソッドに渡すようにします。

# app/controller/top_controller.rb
class TopController < ApplicationController
  def index
    @restaurants = Restaurant.search(params)
  end
end

そして、Restaurantモデルにsearchメソッドを定義します。
searchメソッドは、Elasticsearchのクエリを作成し、検索を実施、Elasticsearchからのレスポンスを返します。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  ...

  # Elasticsearchのクエリを作成し、検索を実施する
  # Elasticsearchからのレスポンスを返す
  def self.search(params = {})
    # 検索パラメータを取得
    keyword = params[:q]

    # 検索クエリを作成(Elasticsearch::DSLを利用)
    # 参考: https://github.com/elastic/elasticsearch-ruby/tree/master/elasticsearch-dsl
    #
    # 検索キーワードが入力されたときは、下記クエリを作成
    # "query": {
    #   "multi_match": {
    #     "query":    "牛角", // 検索キーワード
    #     "fields": ["name", "name_kana", "address", "pref.name", "category.name"]
    #   }
    # }
    #
    # 検索キーワードが入力されてない時は、下記クエリを作成(すべてのドキュメントを取得)
    # "query": {
    #   "match_all": {}
    # }
    search_definition = Elasticsearch::DSL::Search.search {
      query {
        if keyword.present?
          multi_match {
            query keyword
            fields %w{ name name_kana address pref.name category.name }
          }
        else
          match_all
        end
      }
    }

    # 検索クエリをなげて結果を表示
    # __elasticsearch__にElasticsearchを操作するたくさんのメソッドが定義されている
    __elasticsearch__.search(search_definition)
  end

  ...
end

最後にapplication.rbにrequire 'elasticsearch/rails/instrumentation'を追加します。
こうすることで、RailsログにElasticsearchの実行時間やは発行したクエリが表示されるようになります。

# config/application.rb

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
require 'elasticsearch/rails/instrumentation'


これで画面から「牛角」と検索すると牛角が表示されます。「東京都」と検索すると東京都の店が表示されます。
f:id:nipe880324:20151017171045p:plain:w420


このときRailsログで注目してもらいたいのが、SQLの実行がないこと(AcriveRecord: 0.0ms)です。代わりに、Elasticsearchへの実行時間が記載されています。さらに、デバッグ用でElasticsearchに送られたクエリも表示されています。

Started GET "/?q=%E6%9D%B1%E4%BA%AC%E9%83%BD" for ::1 at 2015-10-17 16:05:19 +0900
Processing by TopController#index as HTML
  Parameters: {"q"=>"東京都"}
  Restaurant Search (9.1ms) {index: "restaurant_development", type: "restaurant", body: {query: {multi_match: {query: "東京都", fields: ["name", "name_kana", "address", "pref.name", "category.name"]}}}}
  Rendered top/index.html.erb within layouts/application (10.2ms)
Completed 200 OK in 44ms (Views: 34.0ms | ActiveRecord: 0.0ms | Elasticsearch: 9.1ms)


Elasticsearchのレスポンスを画面に表示する

既に画面に表示されてレストランの表示がされてしまっていますが、__elasticsearch__.searchメソッドの帰り値について説明することでうまく表示されている理由を説明します。

ElasticsearchはヒットするドキュメントをJSON形式で返します。Elasticsearch::Modelによりそれをインスタン化して扱いやすいものにしています。

response = __elasticsearch__.search(search_definition)

# レスポンスのクラス名はElasticsearch::Model::Response::Response
response.class #=> Elasticsearch::Model::Response::Response

# ヒットしたドキュメント数を取得
response.results.total #=> 3

# ヒットしたドキュメントの最初のドキュメントを取得
response.results.first
# => #<Elasticsearch::Model::Response::Result:0x007fc0c6dc28a8
#  @result=
#   {"_index"=>"restaurant_development",
#    "_type"=>"restaurant",
#    "_id"=>"1",
#    "_score"=>0.41762865,
#    "_source"=>
#     {"name"=>"松屋",
#      "name_kana"=>"まつや",
#      "zip"=>"240-0113",
#      "address"=>"三浦郡葉山町堀内24-3",
#      "closed"=>false,
#      "created_at"=>"2015-10-16T19:26:03.679Z",
#      "pref"=>{"name"=>"東京都"},
#      "category"=>{"name"=>"定食"}}}>

# response.first でも同じ結果が返ってきます
# これは、responseオブジェクトがEnumerableモジュールのメソッドをresultsにデリゲートしているためです。
result = response.first

# 関連度を取得
result._score #=> 0.41762865

# ドキュメント(_source)の内容を取得
result._source.name #=> "松屋"
# _sourceを省略できる
result.name #=> "松屋"

# 入れ子になっている値は.(ドット)でアクセスできる
result.pref #=> {"name"=>"東京都"}
result.pref.name #=> "東京都"

# データベースからヒットしたドキュメントのActiveRecordのインスタンスの配列を取得
response.records.to_a
#=> [#<Restaurant:xxxx>, #<Restaurant:yyyy>, ...]

このようにレスポンスがActiveRecordと同じように扱えるため、eachpref.nameなどと記載したままでも問題なく表示されていたということです。
せっかくなので、ヒットしたドキュメント数を表示するように追加しています。

# app/views/top/index.html.erb
...

<!-- 検索結果の表示 -->
<div class="col-xs-9">
  <!-- 検索結果の数を表示する -->
  <div>検索結果: 約<%= @restaurants.results.total %></div>

  <div id="results">
    ...
  </div>
</div>


ドキュメントも追加/更新/削除する

これで、RailsとElasticsearchを使って、検索キーワードで検索をできるようにできました。
最後に、ドキュメントの取得元のDBのレコードの追加/更新/削除があった場合、ドキュメントも追加/更新/削除するようにします。
そうしないと、レコードが更新されたのに、検索結果の内容が正しくないものになってしまいます。

DBを更新しても、Elasticsearchのドキュメントを更新しないと以下のようになってしまいます。

# DBの変更前にクエリを実行
response = Restaurant.search(q: "牛角")
response.first.address #=> "池袋3-33"

# DBを更新
record = Restaurant.find_by(name: "牛角")
record.update(address: "池袋9-99") #=> true

# DBの更新後にクエリ実行。住所が変わっていない
response = Restaurant.search(q: "牛角")
response.first.address #=> "池袋3-33"

一番シンプルな方法は、Elasticsearch::Model::Callbacksをインクルードします。
これをインクルードすることで、after_commit後にドキュメントを作成/更新/削除する処理が走るようになります。(ソースコード: コールバックの実装

class Restaurant < ActiveRecord::Base
  ...

  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks

  ...

DBを更新するとドキュメントも更新されるようになります。

# DBの変更前にクエリを実行
response = Restaurant.search(q: "牛角")
response.first.address #=> "池袋3-33"

# DBを更新。コールバックでドキュメントも更新される
record = Restaurant.find_by(name: "牛角")
record.update(address: "池袋5-55") #=> true

# DBの更新後にクエリ実行。住所が変わっている
response = Restaurant.search(q: "牛角")
response.first.address #=> "池袋5-55"

これはシンプルなのですが、大量にレコード追加/更新/削除があると、Elasticsearchのドキュメントの追加/更新/削除が溜まっていき、溜まっている場合、それがボトルネックとなり、パフォーマンスが悪くなってしまいます。
そのため、一般的には、非同期で行うのが普通です。非同期で行うので検索結果の内容がDBの内容と少しの時間ずれが発生します。
非同期でドキュメントを更新する方法は次のとおりです。
elasticsearch-model - 非同期コールバックを参照ください。




4. 検索条件を指定する

Restaurantモデルのclosedは、レストランが閉店したかどうかをboolean値で保持しています。
次のようなチェックボックスを表示し、デフォルトでは、閉店したレストランを除外して検索できるようにします。
f:id:nipe880324:20151018035057p:plain:w320

チェックボックスをクリックすることで、閉店したレストランも含めて検索できるようにします。
f:id:nipe880324:20151018035115p:plain:w320


検索条件を指定するfilterクエリ

Marvel Senseを開きます。

下記のように、filteredを指定し、filter内でフィルタを指定します。
termは、termフィルタで、「"closed"フィールドがfalseのものを取得する」という検索条件になります。

GET restaurant_development/_search
{
  "query": {
    "filtered": {
      "query": {
        "match_all": {}
      },
      "filter": {
        "term": { "closed": false }
      }
    }
  }
}

フィルターにはさまざまなものがあり、query-dsl-filterを参照ください。
よく使うのは、複数のフィルタをANDやORで指定できる「boolフィルタ」や日付や数値の範囲で検索できる「rangeフィルタ」、termsフィルタやtermフィルタです。


検索条件を実装

まず、画面にチェックボックスを追加します。

<!-- app/views/top/index.html.erb -->
<h1>レストラン検索</h1>

<!-- 検索フォーム -->
<%= form_tag root_path, method: :get, enforce_utf8: false do %>
  ...
  
  <!-- チェックボックスのフィールドを追加 -->
  <div class="checkbox">
    <label>
      <%= check_box_tag :closed, 't', params[:closed].present? %> 閉店しているレストランも検索結果に含める
    </label>
  </div>
  <button type="submit" class="btn btn-default">検索</button>
<% end %>
...


次に、searchメソッドclosedチェックボックスが押されているかどうかを取得します。
「閉店しているレストランも検索結果に含める」チェックボックスが押されている場合のみ、params[:closed]に値が設定されるので、closed変数はtrueになります。
チェックボックスが押されていない場合は、blankになるので、closed変数はfalseになります。

# app/models/restaurant.rb

def self.search(params = {})
  # 検索パラメータを取得
  keyword = params[:q]
  closed  = params[:closed].present?
  ...

次にフィルタを利用したDSLを作成します。
Elasticsearch::DSLでのtermフィルタの使い方は、elasticsearch-ruby/elasticsearch-dsl/lib/elasticsearch/dsl/search/filters/term.rbに記載されているので参考にしてクエリを記載します。

# app/models/restaurant.rb

search_definition = Elasticsearch::DSL::Search.search {
  query {
    filtered {
      query {
        if keyword.present?
          multi_match {
            query keyword
            fields %w{ name name_kana address pref.name category.name }
          }
        else
          match_all
        end
      }

      # 開店しているレストランのみ表示する条件(closed: false)
      # closed=trueの場合は、この検索条件を実施しない
      filter {
        term closed: 'false'
      } unless closed
    }
  }
}


フィルタの動作確認

http://localhost:3000/でアクセスすると、検索結果が「開店しているレストラン4件」のみ表示されます。
f:id:nipe880324:20151018035057p:plain:w320

「閉店しているレストランも検索結果に含める」チェックボックスを選択し、検索ボタンを押すと、すべてのレストラン(6件)表示されます。
f:id:nipe880324:20151018035115p:plain:w320

まとめ

これで、シンプルですが、RailsとElasticsearchを使って、検索キーワードで検索をできるようにできました。
これに、Elasticsearchの機能の「ページネーション・ページ当たりの表示数」、「ソート」、「ファセット・post_filter」、「ハイライト」、「サジェスト」などの機能を付け加えていけばより実践的な検索機能が実装できるようになります。

次は「ページネーションとページあたりの表示変更できる」を実装します。

以上です。