1
/
5

RailsプロジェクトでのElasticsearchとの付き合い方について

(この記事は2018年3月15日に弊社テックブログに掲載した内容となっております。)


こんにちは、AppBrewのエンジニアの吉野です。 前回の記事ではElasticsearchとはどういうものか、ということについて書きました。

Elasticsearchの基本的な知識について | 株式会社AppBrew
(この記事は2018年3月11日に弊社テックブログに掲載した内容となっております。)はじめまして、AppBrewでコスメを中心としたコミュニティである「LIPS」のアプリや サーバーサイドを担当...
https://www.wantedly.com/companies/appbrew/post_articles/872784

この記事では、

①ローカルでのElasticsearchの環境構築

から、

②Railsとどうつなぎ合わせるか

③実運用上での注意点など

について書いていきたいと思います。


動作環境

Rails 4.2.8 Elasticsearch 2.4.5


①ローカルでのElasticsearchの環境設定

開発するにあたり、ローカルで環境がほしいと思います。 ElasticSearchのAnalyzerは標準としていくつかありますが、日本語のTokenizeなどをするならば Analyzer として、kuromoji Analyzer、icu_normalizer あたりを入れていきましょう。 GUIはelasticsearch-headを入れておくことにします。

brew install elasticsearch@2.4
echo 'export PATH="/usr/local/opt/elasticsearch@2.4/libexec/bin:$PATH"' >> ~/.bash_profile
source ~/.bash_profile
plugin install analysis-kuromoji analysis-icu mobz/elasticsearch-head

次に設定を多少いじりましょう

/usr/local/opt/elasticsearch@2.4/libexec/config/elasticsearch.yml

に以下の行を追加しておくと何かと便利です。

script.inline: on
script.indexed: on
script.engine.groovy.inline.search: on
script.search: on
script.groovy.sandbox.enabled: true

起動は

elasticsearch

で可能です。

$ elasticsearch
[2018-02-25 16:53:50,683][INFO ][node ] [Mandroid] version[2.4.6], pid[1939], build[5376dca/2017-07-18T12:17:44Z]
[2018-02-25 16:53:50,683][INFO ][node ] [Mandroid] initializing ...
[2018-02-25 16:53:51,358][INFO ][plugins ] [Mandroid] modules [reindex, lang-expression, lang-groovy], plugins [head, analysis-kuromoji, analysis-icu], sites [head]
[2018-02-25 16:53:51,420][INFO ][env ] [Mandroid] using [1] data paths, mounts [[/ (/dev/disk1)]], net usable_space [40.6gb], net total_space [232.5gb], spins? [unknown], types [hfs]
[2018-02-25 16:53:51,420][INFO ][env ] [Mandroid] heap size [990.7mb], compressed ordinary object pointers [true]
[2018-02-25 16:53:51,420][WARN ][env ] [Mandroid] max file descriptors [10240] for elasticsearch process likely too low, consider increasing to at least [65536]
[2018-02-25 16:53:53,126][INFO ][node ] [Mandroid] initialized
[2018-02-25 16:53:53,126][INFO ][node ] [Mandroid] starting ...
[2018-02-25 16:53:53,213][INFO ][transport ] [Mandroid] publish_address {127.0.0.1:9300}, bound_addresses {[fe80::1]:9300}, {[::1]:9300}, {127.0.0.1:9300}
[2018-02-25 16:53:53,219][INFO ][discovery ] [Mandroid] elasticsearch_yosshi/yI1KRtR_TGCC34vpmzoJEQ
[2018-02-25 16:53:56,268][INFO ][cluster.service ] [Mandroid] new_master {Mandroid}{yI1KRtR_TGCC34vpmzoJEQ}{127.0.0.1}{127.0.0.1:9300}, reason: zen-disco-join(elected_as_master, [0] joins received)
[2018-02-25 16:53:56,281][INFO ][http ] [Mandroid] publish_address {127.0.0.1:9200}, bound_addresses {[fe80::1]:9200}, {[::1]:9200}, {127.0.0.1:9200}
[2018-02-25 16:53:56,281][INFO ][node ] [Mandroid] started
[2018-02-25 16:53:56,363][INFO ][gateway ] [Mandroid] recovered [6] indices into cluster_state
[2018-02-25 16:53:57,592][INFO ][cluster.routing.allocation] [Mandroid] Cluster health status changed from [RED] to [YELLOW] (reason: [shards started [[post_development_3][1]] ...]).

デフォルトはlocalhostの9200番でサーバーが立ちます。 また、

http://localhost:9200/_plugin/head/

へとアクセスすると香ばしいGUIでElasticsearchの中にあるデータを確認できます。

ローカルの設定は以上です。


②Railsとどうつなぎ合わせるか

ここからRails側でElasticsearchを使うための設定について書いていきます。

自前で必要なAPIを叩くようなものをwrapしていっても良いわけですが、 便利なものが世の中にはあるので使っていきましょう。 今回使うGemは以下の4つです。

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は以下のような機能を提供してくれます。

  • elasticsearch
    • ElasticsearchのrubyのAPIClientを提供してくれています。
    • 通信部分で気になることがあればこれを見ましょう
  • elasticsearch-dsl
    • Elasticsearchのmappingの定義、検索のクエリ設計などをrubyっぽく書くことができるようになります。
    • このライブラリ中を見てみると検索クエリの書き方のサンプルが多くのっているのでわからなくなったら見てみましょう。
  • elasticsearch-model
    • 名前の通りincludeすることでそのmodelに対してsearchやimport などElasticsearchに関連したメソッドを生やしてくれるものです
  • elasticsearch-rails
    • development環境でelasticsearch関連のlogをキレイに吐いてくれたり、データ投入のためのrake taskなどを提供してくれます。

必要なgemは入れたので実装に移りましょう。

Searchable moduleを実装

mappingは各modelによって違うとして、いくつか共通化したい処理や設定が存在するかと思います。 そのため、searchable.rbのようなmoduleを作り、共通化していきましょう。

searchable moduleでは2つの役割を担っています

①カスタムしたanalyzerの設定 ②レコードに関するコールバックの設定 ③APIクライアントの初期化

具体的には以下のようなものとなっています。

// 'app/models/concerns/searchable.rb'
require 'elasticsearch/model'

Elasticsearch::Model.client = Elasticsearch::Client.new log: true, scheme: 'https', host: , port: 'ポート番号', user: 'user名', password: 'password'

module Searchable
extend ActiveSupport::Concern

included do
include Elasticsearch::Model

after_commit on: [:create] do
ElasticSearchIndexerJob.perform_later(self.class.to_s, self.id) unless Rails.env.development?
end

after_commit on: [:update] do
ElasticSearchIndexUpdaterJob.perform_later(self.class.to_s, self.id) unless Rails.env.development?
end

after_commit on: [:destroy] do
begin
__elasticsearch__.delete_document unless Rails.env.development?
rescue => ex
end
end

settings analysis: {
analyzer: {
....
}
}
end
end

本来createやupdateはbulkで行うべきなのでしょうが、軽い実装なら上記のようなコールバックを用意しておき、以下のようなjobを用意してあげれば、dbとelasticsearchの同期をとることができます。

class ElasticSearchIndexerJob < ActiveJob::Base
queue_as :default

def perform(klass, id)
sleep(1)
record = klass.constantize.find_by(id: id)
record.__elasticsearch__.index_document if record.present?
end
end

各modelの設定

moduleを作ったため、これをincludeして、各モデルの設定をすれば大丈夫です。 やることは以下4点です。

①indexの定義 ②mappingの定義 ③as_indexed_jsonのオーバーライド ④search queryの設計

まずサンプルコードを貼ると以下のような形となります。

class Post < ActiveRecord::Base
include Searchable

index_name "post" #①

mappings dynamic: 'false' do #②
indexes :content, analyzer: 'kuromoji', type: 'string'
indexes :n_content, analyzer: 'ngram_analyzer', type: 'string'

indexes :like_count, type: 'integer'
indexes :media_status, type: 'integer'
indexes :published_at, type: 'date'

indexes :info, analyzer: 'ngram_analyzer', type: 'string'

indexes :products, type: 'nested' do
indexes :name, analyzer: 'ngram_analyzer', type: 'string'
indexes :id, type: 'integer'
end
...
end

def as_indexed_json(option = {}) #③
info = "hogehoge"
attributes
.symbolize_keys
.slice(:content, :like_count, :published_at)
.merge(n_content: self.content)
.merge(media_status: media_status)
.merge(info: info)
.merge(products: products.map{|p| {id: p.id, name: p.name}})
...
end

has_many :products, through: :post_products

def self.search(params) #④
search_definition = Elasticsearch::DSL::Search.search {
from (page.to_i - 1) * 20
size 20
query {
function_score {
query {
filtered {
query {
multi_match {
query text
operator 'and'
fields %w{ content n_content info }
}
}
}
...
}
__elasticsearch__.search(search_definition)
end
end

それぞれについて見ていきましょう

indexの定義

これはわざわざ説明するまでのこともなくindexの名前を設定しています。 バージョニングなどを考えたindex名については後述しますが、基本的にはわかりやすい名前をつけておけば大丈夫です。

mappingの定義

どのfiledがどういう型か、どういったanalyzerを使用するかということを定義しています。 RDBでいうところの1対多の関係を表すときは

type: 'nested'

をつけておくと配列をindexingすることができます。

as_indexed_jsonのオーバーライド

mappingに則り、indexingするデータをhashの形へと変換してあげます。 ここで不足があると mappingは定義したのにデータが投入されない ということが起こってしまうので気をつけましょう

search queryの設計

最後にmappingをもとに検索クエリを組み立てて行きます。 本当に多くのクエリを用いて検索ができるため、詳細は省きますが、elasticsearch-dslのgithubを見てみると多くのサンプルを見ることができます。

これでsearchクエリさえ組み立てることができれば大丈夫というところまできました。 mappingの定義が大丈夫となったら

(Model名).__elasticsearch__.import

をしてデータを投入してみましょう。

これでいつでも検索できるようになりました!


③実運用上の問題点と解決法

最後に運用上でやっていることや問題となったことについてまとめたいと思います。

いまいちどの辺がクエリにヒットしてレコードが引っ張られているのかわからない問題

我々がやったことといえば、mappingを決め、要件をJSONに落としこんだだけですが、 実際与えらえれたクエリに対してどの辺がヒットしているのか? ということを確認したいニーズは多少あるかと思います。 そんなときは highlight という機能を使うと幸せになれるかと思います。

上記の

self.search(params)

のクエリ定義の中に

def self.search(params) 
search_definition = Elasticsearch::DSL::Search.search {
from (page.to_i - 1) * 20
size 20
highlight {
fileds: [:n_content, :content] # ここを追加
}
query {
function_score {
query {
...

highlightというブロックを追加します。 そして検索して結果をほっていくと

pry(main)> Post.search({text: "キャンメイク"}).response.hits.hits.map(&:highlight)
=> {"n_content"=>
["<em>キャンメイク春のコスメ</em>♡\n" + "可愛<em>い</em>過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色<em>ん</em>なカラーが出ますね。\n" + "すご<em>く</em>楽しみです!\n" + "セザ<em>ンヌの新色も楽しみで欲しいのい</em>っぱ<em>い</em>w"],
"content"=>["<em>キャンメイク</em>春のコスメ♡\n" + "可愛い過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色んなカラーが出ますね。\n" + "すごく楽しみです!\n" + "セザンヌの新色も楽しみで欲しいのいっぱいw"]

となり、ひっかかった部分が強調タグで囲まれていることがわかりますね。 "キャンメイク"と打ったのに”ク”だけに反応していたりして、なかなか難しそうですね。

引っ張ってきたレコード、スコア順じゃない問題

elasticsearch-modelの恩恵は偉大で

(Model名).search(params).records.to_a

とするとSQLを発行し、レコードを取得できるわけですが、クエリを見てみると

Post Load (0.5ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`id` IN (301306, 300359, 297179, 304908, 302542, 299076, 312292, 303429, 308305, 304312, 311940, 297988, 301020, 307750, 305654, 298394, 311388, 308077, 297068, 296636)

のようにwhereで引いてきているため、elasticsearchでのスコア順が保証してくれません。

そのため自前でソートするのがよいかと思います。

(例)

Post.search(params).map(&:id).tap { |ids| Post.where(id: ids).order("field(posts.id, #{ids.join(',')})") }

で順番が保証されます。

activerecord-importとelasticsearch-modelのimportがconflictする問題

(※導入しようとした当時はconflictしてたのですが、現在ではactiverecord-importのimportメソッドはbulk_importにrenameしたようです。 )

一応困ってる人がいたりするかもしれないので軽く残しておくと elasticsearch-modelが各モデルに対して生やしてくれるメソッドとactiverecord-importが ActiveRecord::Associations::CollectionAssociation に生やしてくれるimportメソッドが被ってしまうという問題があり、メソッドの探索順序的に elasticsearch-modelが生やしてくれるimportが常に使われてしまうという問題がありました。

これは

config/application.rb

require 'activerecord-import/base'

class ActiveRecord::Base
class << self
alias :bulk_insert :import
remove_method :import
end
end

としてaliasを貼ってあげることで 解決していました

fieldを追加したりmappingを変えたくなったんだけどサービスはとめたくない問題

ベストプラクティスかどうかはわかりませんが、 弊社ではindex_nameにsuffixをつけることでblue-greenの切り替えをを行っています。

具体的には

config/initializers

に以下のような定数を記述したファイルをつくり、

  CURRENT_ES_POST_INDEX = 2
CURRENT_ES_PRODUCT_INDEX = 1
CURRENT_ES_USER_INDEX = 2
CURRENT_ES_ARTICLE_INDEX = 0

各modelのindex_nameを

class Post < ActiveRecord::Base
include Searchable
index_name "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX}"
...
end

のようにしておくと

①mappingを定義しなおしたものをデプロイ ②rake taskなどでCURRENT_HOGEHOGE_INDEX をインクリメントしたインデックスをつくりデータを投入

Post.__elasticsearch__.index_name = "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX + 1}"
Post.__elasticsearch__.import

③作り終えたら実際に

config/initializers

の下にある定数をインクリメントしデプロイ

というフローでblue-greenを実現することができます。

検索候補とか出したい

検索システムを作ってると候補を出したいってなることがあるかと思います。

書き始めると長くなるので、とても参考になるURLを貼っておきます。

https://medium.com/hello-elasticsearch/elasticsearch-%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E3%82%B5%E3%82%B8%E3%82%A7%E3%82%B9%E3%83%88%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E8%A8%AD%E8%A8%88-352a230030dd

基本的には,弊社では検索のログや、ブランド名、商品名、ユーザー名などを、(とても参考になるURLで言われているような)Analyzerを用いてtokenizeするだけでそこそこのものができたりします。


株式会社AppBrewでは一緒に働く仲間を募集しています
1 いいね!
1 いいね!
今週のランキング
株式会社AppBrewからお誘い
この話題に共感したら、メンバーと話してみませんか?