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

Rails Webook

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

RailsでElasticsearch: サジェスト (Suggest) 機能でオートコンプリート

elasticsearch 検索

f:id:nipe880324:20151027234933p:plain:w420

今回は、「Elasticsearchのサジェスト(Suggest)機能でオートコンプリートを実装」します。

RailsでElasticsearchを使って検索機能を実装してきました。


サジェスト機能では、サジェスター(Suggester)を利用し、入力したテキストから似たような単語を返す機能です。
Elasticsearch 1.7 では、サジェスト機能の一部はまだ「開発中」のようです。

参考までに、ソースコードはこちらです。elasticsearch_test - GitHub


動作確認

  • 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. データを投入

そろそろデータが寂しくなってきたので、livedoorグルメの研究用データセットを使い、それっぽいレストラン情報を表示するようにします。

データベースをpostgresqlに変更

現在、sqlite3を使っているので、postgresqlに変更します。

sqlite3コメントアウトし、pgを追加します。

# Gemfile

#gem 'sqlite3'
# Use postgresql
gem 'pg'

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

bundle install


database.ymlを修正します。

default: &default
  adapter: postgresql
  username: postgres
  password: postgres
  encoding: unicode
  pool: 5

development:
  <<: *default
  database: elasticsearch_test_development

test:
  <<: *default
  database: elasticsearch_test_test

production:
  <<: *default
  database: elasticsearch_test_production


データを投入する

データベースをpostgresユーザーをオーナーとして作成します。

createdb -U postgres elasticsearch_test_development

dump.sqlからダンプファイルをダウンロードし、下記コマンドでデータを投入します。

psql -U postgres elasticsearch_test_development < db/dump.sql

※ dump.sqlは、livedoorグルメの研究用データセットを少し加工したダンプデータです

マイグレーションファイルを使わずに、データベースをマイグレートしたので、今後マイグレーションが使えなくなります。
そのため、マイグレーションファイルを削除します。

rm -f ./db/migrate/*.rb


Elasticsearchにインデクシング

データを投入したついでに、いくつかカラムも追加したので、それらのデータをElasticsearchにインデクシングします。

まずは、マッピング(DBでいうスキーマ)を修正します。
詳細は、コメントで記載しています。

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

  ...

  # インデックス(index)とマッピング(mappings)の設定

  # katakana_analyzerというカタカナとしてアナライズするアナライザーを定義
  # kuromoji_tokenizerでトークン化し、katakana_readingformでフィルターをする
  # katakana_readingformはカタカナかローマ字に変換するトークンフィルター
  settings index: {
    analysis: {
      analyzer: {
        katakana_analyzer: {
          tokenizer: 'kuromoji_tokenizer',
          filter: ['katakana_readingform']
        }
      },
      filter: {
        katakana_readingform: {
          type: 'kuromoji_readingform',
          use_romaji: false
        }
      }
    }
  } 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 :alphabet

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

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

      indexes :access_count, type: 'integer'

      # 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'
      indexes :updated_at, type: 'date', format: 'date_time'

      # ロケーション情報も定義
      indexes :location, type: 'geo_point'

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

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

      # サジェストのために、レストラン名を、そのまま(raw)、ひらがな(hira)、
      # かたかな(kana)、ローマ字(romaji)で作成
      # type: 'completion'にすることで前方一致でサジェスト検索できる
      indexes :suggest do
        indexes :name_raw, type: 'completion'
        indexes :name_hira, type: 'completion'
        indexes :name_kana, type: 'completion', index_analyzer: 'katakana_analyzer'
        indexes :name_romaji, type: 'completion'
      end
    end
  end
  ...


次にインデクシング時に呼ばれるas_indexed_jsonメソッドを修正します。

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

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

  # ロケーションが存在しない場合もあるため、
  # マッピングのgeo_point型の場合、"lat,lon"の形式で返す必要がある
  # @see https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-geo-point-type.html
  def location
    (lat && lon) ? "#{lat},#{lon}" : nil
  end
end


そして、インデックスを再作成して、データをインデクシングします。
※ レストラン数が20万件ほどあるので、数十分ほどかかります。

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

最後に、少しデータ項目も増えたので検索結果画面の表示内容を修正します。

<!-- 検索結果の表示 -->
<div class="col-xs-9">
  <!-- 検索結果の数をカラム区切りで表示できるように number_with_delimiter を呼ぶようにする -->
  <div>検索結果: <%= number_with_delimiter current_document %> / 約<%= number_with_delimiter @restaurants.results.total %></div>
  <div>表示件数: <%= per_page_links %></div>
  <div>ソート順: <%= sort_links %></div>

  <div id="results">
    <% @restaurants.each do |r| %>
      <hr />
      <div class="result">
        <h4><%= highlight_or_text(r, :name) %><%= highlight_or_text(r, :name_kana) %></h4>
        <!-- description を表示するように修正 -->
        <p><%= r.description %></p>
        <p class="text-muted">
          <!-- アクセス数 を表示するように修正 -->
          <small><b>アクセス数</b> (<%= r.access_count %>)</small>
          <small><b>都道府県:</b> <%= highlight_or_text(r, :'pref.name') %></small>
          <small><b>カテゴリ:</b> <%= highlight_or_text(r, :'category.name') %></small>
          <small><b>出店日:</b> <%= r.created_at %></small>
        </p>
      </div>
    <% end %>
  </div>

  ...
</div>


では、検索をしてみると、Elasticsearchにうまくインデクシングされていれば、検索結果が「約20万件」と表示されれると思います。
f:id:nipe880324:20151027234849p:plain:w420



2. Elasticsearchのサジェストクエリ

Elasticsearchの公式ドキュメントの「suggester」を参考にすると、サジェストを行うには、_search_suggestの2つのエンドポイントがあることがわかります。

今回は、オートコンプリートのためのサジェストであり、検索結果やアグリゲーションなど必要ないため、_suggestのほうを利用します。

Marvel Senseを開き、サジェストのクエリを実行します。

GET restaurant_development/restaurant/_search
{
  "size": 0,
  "suggest": {
    "my_suggest": {
      "text" : "レストラン",
      "completion" : {
        "field" : "suggest.name_raw"
      }
    }
  }
}


// 結果
{
   "took": 41,
   "timed_out": false,
   "_shards": {
      "total": 5,
      "successful": 5,
      "failed": 0
   },
   "hits": {
      "total": 214227,
      "max_score": 0,
      "hits": []
   },
   "suggest": {
      "my_suggest": [
         {
            "text": "レストラン",
            "offset": 0,
            "length": 5,
            "options": [
               {
                  "text": "レストラン鎌倉山",
                  "score": 3
               },
               {
                  "text": "レストラン オリーブ",
                  "score": 2
               },
               {
                  "text": "レストラン オーパス",
                  "score": 2
               },
               {
                  "text": "レストラン スコット",
                  "score": 2
               },
               {
                  "text": "レストラン ポルト",
                  "score": 2
               }
            ]
         }
      ]
   }
}

※Marvel Senseから_suggestをうまく使えなかったので_searchで実行しています。

このように、suggest.<サジェスト名>.options内に配列で返ってきます。




3. オートコンプリートでサジェスト機能を利用

jQuery UI と Elasticsearchのサジェスト機能でオートコンプリートを実装します。

jQuery UIでオートコンプリート

まずは、jQuery UIでオートコンプリートを作成します。

Gemfileにjquery-ui-railsを追加します。

# Gemfile
gem 'jquery-ui-rails'

インストールします。

bundle install

application.jsにオートコンプリートモジュールのみ追加します。

// app/assets/javascripts/applciation.js

//= require jquery
//= require jquery-ui/autocomplete
//= require jquery_ujs
//= require turbolinks
//= require_tree .

application.cssにもオートコンプリートのスタイリングファイルを追加します。

/* app/assets/stylesheets/applicaiton.css */

 *= require jquery-ui/autocomplete
 *= require jquery-ui-custom
 *= require_tree .
 *= require_self
 */

Bootstrapに合うようにjQuery UIのオートコンプリートのスタイリングを修正します。

/* app/assets/stylesheets/jquery-ui-custom.css */
.ui-autocomplete {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 1000;
  float: left;
  display: none;
  min-width: 160px;
  _width: 160px;
  padding: 4px 0;
  margin: 2px 0 0 0;
  list-style: none;
  background-color: #ffffff;
  border-color: #ccc;
  border-color: rgba(0, 0, 0, 0.2);
  border-style: solid;
  border-width: 1px;
  -webkit-border-radius: 5px;
  -moz-border-radius: 5px;
  border-radius: 5px;
  -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
  -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
  -webkit-background-clip: padding-box;
  -moz-background-clip: padding;
  background-clip: padding-box;
  *border-right-width: 2px;
  *border-bottom-width: 2px;

  .ui-menu-item > a.ui-corner-all {
    display: block;
    padding: 3px 15px;
    clear: both;
    font-weight: normal;
    line-height: 18px;
    color: #555555;
    white-space: nowrap;

    &.ui-state-hover, &.ui-state-active {
      color: #ffffff;
      text-decoration: none;
      background-color: #0088cc;
      border-radius: 0px;
      -webkit-border-radius: 0px;
      -moz-border-radius: 0px;
      background-image: none;
    }
  }
}

最後にオートコンプリートを呼び出すようにします。

# app/assets/javascripts/top.coffee
$ ->
  $('#q').autocomplete
    source: "/top/suggest.json"


サーバーサイドのサジェスト機能を実装

クライアント側の処理を追加したので、サーバー側でサジェスト機能の実装をします。

top/suggestのルートを追加します。

# config/routes.rb
Rails.application.routes.draw do
  root 'top#index'
  get  'top/suggest', to: 'top#suggest', defaults: { format: 'json' }
end


コントローラーにsuggestアクションを追加します。

# app/controllers/top_controller.rb
class TopController < ApplicationController
  ...

  def suggest
    # jQuery UI Autocompleteから"term"キーで入力フィールドの値が送られてくる
    # namesはレストラン名の配列
    names = Restaurant.suggest(params[:term])
    render json: names
  end
end


そして、Restaurantモデルにsuggetsメソッドを追加します。
elasticsearch-dsl gemではまだ_suggestエンドポイントにアクセスするDSLは定義されていなかったので、elasticserch-persistence gemを利用します。

Gemfileに追加し、

# Gemfile
gem 'elasticsearch-persistence', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'

インストールします。

bundle install
class Restaurant < ActiveRecord::Base

  ...

  # サジェストのキー
  SUGGEST_KEYS = %w( name_raw name_kana name_romaji name_hira )

  ...

  # サジェスト結果を配列で返す
  # @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html
  def self.suggest(keyword)

    # 次のようなサジェストのボディ(Hash)を作成
    #
    # name_raw: {
    #   text: <keyword>,
    #   completion: { field: "suggest.name_raw", size: 10 }
    # },
    # name_kana: {
    #   ...
    # }
    #
    suggest_definition = SUGGEST_KEYS.inject({}) do |result, key|
      result.merge(
        key => {
          text: keyword,
          completion: { field: "suggest.#{key}", size: 10 }
        }
      )
    end

    # elasticsearch-persistence を利用し、Elasticsearchにサジェストクエリを送る
    response = Elasticsearch::Persistence.client.suggest({
      index: Restaurant.index_name,
      body: suggest_definition
    })

    # Elasticsearchからの結果を配列に変換する
    SUGGEST_KEYS.map do |key|
      response[key][0]['options'].map{|opt| opt.fetch('text', nil)}
    end.flatten.uniq
  end

  ...

動作確認

サジェスト機能を使ってオートコンプリートを実装しましたので、画面から確認します。
検索バーに適当にキーワードを入れると、前方一致で検索した店名が検索されます。
ひらがな、カタカナ、ローマ字などでもサジェスト検索するようにしていますので、それらのキーワードでもサジェストされます。
f:id:nipe880324:20151027234933p:plain:w420