Less is Best

rubyが好き。技術の話とスタートアップに興味があります。

DockerでElasticSearchサーバーを立てる その2

前回に引き続きやっていきます。ElasticSearchを実際に使ってサンプルアプリを作ってみることにしました。

作ったサンプルアプリはこちら

https://github.com/yss44/searchkick_test

Pluginの確認を行なう。

そういえば、Pluginがしっかり起動しているか確認していなかったので確認。

$ curl -XGET localhost:9200/_nodes/plugin?pretty
{
  "ok" : true,
  "cluster_name" : "elasticsearch",
  "nodes" : {
    "e54cayVVTAS4JdxIXy5DpA" : {
      "name" : "Sludge",
      "transport_address" : "inet[/10.0.2.15:9300]",
      "hostname" : "vagrant-ubuntu-saucy-64",
      "version" : "0.90.7",
      "http_address" : "inet[/10.0.2.15:9200]",
      "plugins" : [ {
        "name" : "analysis-kuromoji",
        "description" : "Kuromoji analysis support",
        "jvm" : true,
        "site" : false
      }, {
        "name" : "HQ",
        "description" : "No description found for HQ.",
        "url" : "/_plugin/HQ/",
        "jvm" : false,
        "site" : true
      } ]
    }
  }
}

しっかり入っているようです。安心しました。

Searchkickのアプリ作成

*baseアプリの作成

 rails g scaffold article title:string content:text

 rails g scaffold comment article:references text:text

 vim models/article.rb

article.rb

+ has_many :comments
    rake db:migrate

を行なってシンプルなcrudアプリを生成。これでテストを行っていきます。

  • searchkickの設定 Defaultでelasticsearchのurlはlocalhost:9200を使う。今回はDocker上で動いているので、環境変数でDocker上のelasticsearchのurlを指定する必要がある。

今回はアプリの起動時に環境変数読み込む形にしておく。

config/settings.yml

development:
  ELASTICSEARCH_URL: http://192.168.33.10:9200

test:
  ELASTICSEARCH_URL: http://192.168.33.10:9200

production:
  ELASTICSEARCH_URL:http://192.168.33.10:9200

config/application.rb

# you've limited to :test, :development, or :production.
Bundler.require(:default, Rails.env)


ENV.update YAML.load_file('config/settings.yml')[Rails.env] rescue {}

module SearchkickTest
  class Application < Rails::Application
    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.

これで準備OK,searchkickを導入して行く

article.rb

class Article < ActiveRecord::Base
  has_many :comments
  searchkick
end

あとは、適当にテキストを集めて来てデータベースに入れておく(seeds.rb)

で、searchkickのインストール時に入るrakeでreindex掛けてみる

rake searchkick:reindex CLASS=Article

アプリケーション起動時に環境変数指定しているので、rakeの時に環境変数が反映されずエラーがでました。開発環境汚したくないので次のような形で無理矢理指定して行きます。

rake searchkick:reindex CLASS=Article ELASTICSEARCH_URL=http://192.168.33.10:9200

で無事にインデックス完了。ちゃんとインデックスされてるか確認する。

http://192.168.33.10:9200/_plugin/HQ/

statusがyellowになってる。。。

RED - Damnit. Some or all of (primary) shards are not ready.

YELLOW - Elasticsearch has allocated all of the primary shards, but some/all of the replicas have not been allocated.

GREEN - Great. Your cluster is fully operational. Elasticsearch is able to allocate all shards and replicas to machines within the cluster.

レプリケーションされていないかららしい。気にしないことにしておく。

Elasticsearch "Yellow" cluster status explained

コントローラーに検索用のアクション作成

articles_controller.rb

  def search
    @articles = Article.search params[:q]
    return render json:@articles
  end

routes.rb

  get "search" => "articles#search"

articles#indexのviewに検索ボックスを追加

<div>
  <%= form_tag('/search',method: :get) do %>
    <input type="text" name="q">
    <button tye="submit">Search</button>
  <% end %>
</div>

とかしてあげると、しっかり検索して、jsonで返ってくることを確認。

Indexされるデータを指定する

models/articles.rb

class Article < ActiveRecord::Base
  has_many :comments
  searchkick

  def search_data
    {
      title:title,
      content:content,
      score:score,
      comments:comments.map(&:text)
    }
  end
end

そして、reindexする。

rake searchkick:reindex CLASS=Article ELASTICSEARCH_URL=http://192.168.33.10:9200

すると、コメントの中身からも検索をかけられるようになっています。

Eager load associations

検索時に、has_manyやbelongs_toで繋がっているモデルも一度にネストした状態で取得できます。ネストした子モデルを取得するために何度もDBに取得しに行かないようにします。

controllers/articles_controller.rb

  def search
    @articles = Article.search params[:q],include:[:comments]
    return render json:@articles
  end

autocomplete

検索キーワードの自動補完を行なう設定をしてみます。どうやらtypeahead.jsかjquery UIを使用して実装するのが良いみたいです。今回はtypeahead.jsを使ってやってみます。

まずはモデルの変更。タイトルを自動補完するように設定します。

article.rb

 class Article < ActiveRecord::Base
   has_many :comments
-  searchkick
+  searchkick autocomplete:["title"]

次にコントローラーでautocompleteを受けるアクションを作成

articles_controller.rb

+  def autocomplete
+    render json: Article.search(params[:term], autocomplete: true, limit: 10).map(&:title)
+  end
+

ルーティングを通します。

routes.rb

   get "search" => "articles#search"
+  get "autocomplete" => "articles#autocomplete"
 

vendor/assets以下にtypeahead.jsを落として来て配置。application.jsで読み込みます。とりあえずautocompleteの処理を書くために、custom.js.coffeを追加し、application.jsでこちらも読み込んでおきます。

application.js

//= require typeahead
//= requrie custom

続いてViewの設定をします。 まずは、Viewのサーチボックスにidを振っておきます。

index.html.erb

 <div>
   <%= form_tag('/search',method: :get) do %>
-    <input type="text" name="q">
+    <input type="text" name="q" id="query">
     <button tye="submit">Search</button>
   <% end %>
 </div>

最後に、typeahead.jsを使って、autocompleteするように記述。

custom.js

+$(->
+    $("#query").typeahead(
+        remote: "/autocomplete?term=%QUERY"
+    )
+
+)

これで準備は整いました。あとは

    rake searchkick:reindex CLASS=Article ELASTICSEARCH_URL=http://192.168.33.10:9200

を行なって検索ボックスに入力を始めると、自動補完されていろいろと出て来たかと思います。CSSが乗っていないのが原因でひどくズレていますが、今回は動作を確認することが目的なので省略します。

条件指定して検索

ここからは、解説のみになります。 SQLライクに検索出来るみたいですね。

Product.search "2% Milk", where: {in_stock: true}, limit: 10, offset: 50

特定のフィールドだけを検索する

fields: [:name, :brand]

whereの条件指定方法

where: {
  expires_at: {gt: Time.now}, # lt, gte, lte also available
  orders_count: 1..10,        # equivalent to {gte: 1, lte: 10}
  aisle_id: [25, 30],         # in
  store_id: {not: 2},         # not
  aisle_id: {not: [25, 30]},  # not in
  or: [
    [{in_stock: true}, {backordered: true}]
  ]
}

limit,offset

limit: 20, offset: 40

boost

boost: "orders_count" # give popular documents a little boost

Personalized Results

特定のユーザーに最適化された結果を返すことも出来るようです。parsonalize:でなにをkeyとして結果を絞るかを指定。user_idsに製品を買った人のIDを指定する。検索時にユーザーIDを指定して検索掛けると、user_idが含まれているProductをboostして表示する。というような形でしょうか。

class Product < ActiveRecord::Base
  searchkick personalize: "user_ids"

  def search_data
    {
      name: name,
      user_ids: orders.pluck(:user_id) # boost this product for these users
      # [4, 8, 15, 16, 23, 42]
    }
  end
end

Keep Getting Better

検索された結果を元に、検索エンジンを学習させて行く機能をつける方法もあるようです。公式のドキュメントだけだとよくわからんって感じですが、ここみたら解決しました。検索された際に、コントローラーでメトリクス収集すること、それと毎日cronなんかでreindexし直すことが必要があるみたいですね。

class Search < ActiveRecord::Base
  belongs_to :product
  # fields: id, query, searched_at, converted_at, product_id
end
class Product < ActiveRecord::Base
  has_many :searches

  searchkick conversions: "conversions" # name of field

  def search_data
    {
      name: name,
      conversions: searches.group("query").count
      # {"ice cream" => 234, "chocolate" => 67, "cream" => 2}
    }
  end
end

RailsApache Solrを使って全文検索機能を実装しようとしていましたが、ElasticSearchの方がシンプルで分かりやすいような気がします。Apache Solrを使って開発していた際に、本番環境への移行で非常に苦労しました。。。Sunspot_solrからの切り替えやらなんやら。今度は、ElasticSearchを利用して実装してみようかなと思います。