Edited at

既存のRailsアプリの検索にElasticSearchを導入してみる

More than 3 years have passed since last update.

Greenという転職サイトで、ElasticSearchを導入して遊んでみようと思います。

具体的には今の求人が持っているデータをElasticSearchに入れてみて実際に開発環境で動かす事をGoalとします。


全文検索とは?

全文検索(Full text search)とは、コンピュータにおいて、複数の文書(ファイ ル)から特定の文字列を検索すること。「ファイル名検索」や「単一ファイル 内の文字列検索」と異なり、「複数文書にまたがって、文書に含まれる全文を 対象とした検索」という意味で使用される。 - 引用:Wikipedia


インデックスの違い


Bツリーインデックスとは?

images.jpg

- 引用:What Is A B Tree Index

RDBのインデックスについて


転置インデックスとは?

1.gif

- 引用:thinkit

elasticsearchを全文検索サーバとして活用するなら読んでおきたい、6つのブログ記事をピックアップ(追記あり)


見出し語の切り出す方法

上で転置インデックスについて説明しましたが、では索引を作るためにはどういった手段があるでしょうか。

代表的なものだけ簡単に説明します。


形態素解析

これはrubyの人だとmecabを使った事があると思うので、同じみのものですねw

辞書を登録することで、単語を意味のある単位に切ってくれるというものです。

辞書ありきなので、辞書を常にアップデートすることが重要になってきます。

参考)

形態素解析とは?


N-gram

形態素解析は辞書ありきだったのに対して、N-gramは特定の文字数で文字を切っていくタイプです。

下記がイメージ

31.png

参考)

N-gramってなんだ

実際に見るのが早いのでanalyzerを実行できるプラグインを入れておきます。

$ /usr/local/bin/plugin -install polyfractal/elasticsearch-inquisitor

実際に下記のURLにアクセス → analyzersを選択

http://localhost:9200/_plugin/inquisitor/#/analyzersz


全文検索エンジンの代表例

最近だと、Apache Solr、elasticsearch、Amazon cloudsearchあたりですね。

下記の記事に比較が書いてますので参考までに。

全文検索システムの比較 - Elasticsearch vs Solr vs Amazon CloudSearch

SolrとElasticsearchを比べてみよう

wantedly、niconicoなど有名サービスがelasticsearchを導入しているところを見ると、

あまり疑わずelasticsearchを導入すればいいかなーと個人的には思ってます。

cross2015でもこの辺の議論はありました。

参考)

CROSS 2015で話をしてきました #cross2015

Elasticsearchの紹介と特徴 CROSS 2015


インストールから設定まで

まずはelasticsearchをインストールします。

詳細はwantedlyのブログに書いているので、そちらを参考にしてください。

elasticsearch-railsというgemが用意されてます。(document)

まずはGemに追加してbundle install


Gemfile

# for elastic search

gem 'elasticsearch-rails'
gem 'elasticsearch-model'

include Elasticsearch::Model を記入するといくつかのメソッドを用意してくれます。

(search、import等)

_elasticsearch delegate

今回は登録ユーザを検索するので、user modelに読み込ませるmoduleを書いていきます。

require 'active_support/concern'

module JobOffer::Searchable
extend ActiveSupport::Concern

included do
include Elasticsearch::Model

# Customize the index name
index_name "green_application"

# Set up index configuration and mapping
settings index: {
number_of_shards: 1,
number_of_replicas: 0,
analysis: {
filter: {
pos_filter: {
type: 'kuromoji_part_of_speech',
stoptags: ['助詞-格助詞-一般', '助詞-終助詞'],
},
greek_lowercase_filter: {
type: 'lowercase',
language: 'greek',
},
},
tokenizer: {
kuromoji: {
type: 'kuromoji_tokenizer'
},
ngram_tokenizer: {
type: 'nGram',
min_gram: '2',
max_gram: '3',
token_chars: ['letter', 'digit']
}
},
analyzer: {
kuromoji_analyzer: {
type: 'custom',
tokenizer: 'kuromoji_tokenizer',
filter: ['kuromoji_baseform', 'pos_filter', 'greek_lowercase_filter', 'cjk_width'],
},
ngram_analyzer: {
tokenizer: "ngram_tokenizer"
}
}
}
} do
mapping _source: { enabled: true },
_all: { enabled: true, analyzer: "kuromoji_analyzer" } do
indexes :id, type: 'integer', index: 'not_analyzed'
indexes :title, type: 'string', analyzer: 'kuromoji_analyzer'
・・・
end
end

def as_indexed_json(options={})
hash = self.as_json(
include: {
job_types: { only: [:job_type_id] },
・・・
}
)
hash['client_name'] = client.name

hash
end
end

module ClassMethods
def create_index!(options={})
client = __elasticsearch__.client
client.indices.delete index: "green_application" rescue nil if options[:force]
client.indices.create index: "green_application",
body: {
settings: settings.to_hash,
mappings: mappings.to_hash
}
end
end
end

analyzerとは?

インデックスを作成したり、クエリを解析するときにデータをどのように処理するかを指定するための機能。分割方法を定義するtokenizerと、分割後の文字列の整形処理を定義するfilterによって構成されます。

mappingの部分で指定しているプロパティの説明は下記になります。

_source

_sourceプロパティのenabledをtrueにすると、データを全てストアしておきます。
今回はelastic searchの方でidを抽出して、RDBを叩くので一旦falseで問題ないです。

また今回はuserデータにいくつかhas_manyのrelationの子データも検索対象としたいので、

def as_indexed_json(options={})メソッドで追加します。

データの型や、その他の設定に関しては本家ドキュメント or elasticsearchの本を見ながら理解してもらえれば。

job_offer modelにて上記のmoduleをincludeします

class JobOffer < ActiveRecord::Base

include Joboffer::Searchable

includeをしたら下記を実行してデータをimportさせます。

# 既にindexを作っている場合は下記のコマンドで削除

JobOffer.__elasticsearch__.client.indices.delete index: JobOffer.index_name rescue nil
JobOffer.create_index! force: true
JobOffer.import

ちなみにこの場合でいくと、子要素データに検索をかける事は上手くいきません。。。

例えば、userというmodelにuser_skillsという子要素を配列で突っ込んで検索をかけると、



{
"query": {
"filtered": {
"query": {
"match": { "user_skills.id": 100 }
},
"filter": {
"range": { "user_skills.year": { "gte": 6 }}
}
}
}
}

以下のようなデータも対象となってしまいます。。。

・・・

user_skills: [
{ id: 100, year: 4 },
{ id: 200, year: 8 }
]

求人検索の方であれば、子要素でそこまで細かいfilterは必要ないので、こちらの方がデータ構造としても扱いやすい & 分かりやすいですね。

しかし、今回は子要素で検索をかける必要があるので(それelasticsearch使うのか!?説は一旦おいておいて)、別の手段で構成を創っていきます。

childデータを、elasticsearch-modelのgemを使って入れるやり方はgithubのexampleを参考に。

僕はなぜか上手く行かず。。。

もし上手くimportでparent/childを実現出来ている人がいれば教えてほしい。。。


kuromojiとは?

rubyの代表格mecabと並びjavaベースの検索システムの形態素解析器 kuromoji

mecab同様辞書が命ですねw

参考)

Java製形態素解析器「Kuromoji」を試してみる

Kuromojiは何で研究にあまり使われないのか?


実際に動かしてみる(Sense)

クエリをelasticsearch上で実行するためのツールとしてsenseというものが用意されています。

下記でインストールして下さい。

$ /usr/local/bin/plugin -install elasticsearch/marvel/latest

参考)

ElasticSearch 101 – a getting started tutorial

elasticsearchのディレクトリ構造とSenseの使い方

基本的には sense上でqueryを実施 → railsのコンソール上で実施 → 組み込みという流れが良さそうです。

スクリーンショット 2015-03-11 11.40.05.png


実際に動かしてみる(rails)

まずは、コンソール上で検索をかけてみる

JobOffer.__elasticsearch__.search(

query: {
term: { "title" : "ruby" }
},
filter: {
term: {
status_id: 1 # open求人
}
},
sort: [
{ point: "desc"}
],
size: 100,
from: 200
).records.to_a

ちなみに.to_aをすると、elasticsearch-railsがdocumentのidを引っこ抜いて、rdb側にidでwhereを実施→配列にして返してくれる!

elasticsearch-rails使って運用する際はsourceはfalseで問題ないですね。

その辺の実装は下記にリンク貼っておきます。

参考)

ids

records

デフォルトのサイズが10件になっているので、sizeを指定することで.to_aで返ってくる要素数を変更できます。fromはoffsetと同じようなイメージです。


query と filterの違い

ほとんど同じ事ができるので最初は戸惑うqueryとfilter

実質はかなり違うので簡単な説明を下記に書いておきます。


  • query

全文検索を実施したい際に用いる(キーワード検索等)

relevance scoreに影響有り


  • filter

status_idや完全一致で検索をかけられるものに関しては用いる(ステータス検索等)

キャッシュが効くのでパフォーマンス的にはかなり良い

relevance scoreに影響は出ない


どうクエリを生成していくべきか?

大きく2つ考えられる


  • 入力情報から動的にクエリを生成

  • query-stringを作って一発解決!

実際にやろうと思うと動的のクエリ生成は結構難しい。

検索項目やソートが多くなればなるほど、多様なクエリを作れるようにしないといけない。

一方でquery-stringはある種sqlインジェクション的な危険をはらんでいるw

ググるとQiitaの人が同じような事を書いていた。

Qiitaがquery-stringっぽい構文を自前実装した理由

Qiitaは前者で実装しているらしい。

rubyクライアント向けのOSSも作ってて素敵w

Greenでも本格的に導入するなら、クエリ周りのOSSは作る必要あるな。

一応試しに開発環境にいれるためにクエリ生成を書いてみた。

  def build_elasticsearch_query

hash = {}
if keyword
hash[:query] = {
simple_query_string: {
fields: ["abstract", "title", "access"],
query: "#{keyword}",
default_operator: "and"
}
}
end
hash[:filter] = {}
hash[:filter][:and] = []
hash[:filter][:and] << { term: { status_id: 1 } } unless find_closed
hash[:filter][:and] << { range: { salary: { gte: salary_bottom.value } } }
hash
end

簡単に作っただけなので、問題はないが、本格的なクエリになればなるほど条件分岐も増えて大変。。。

さすがに汎用性のある状態で使いたいので、上手くOSSにしたいところ。


elasticsearch関連で使えそうな便利ライブラリ

searchkickは使えてないけど、かなり便利そう!

速攻で検索機能を実装できるsearchkickを調べた(Ruby)


総論


  • RDB=遅い→全文検索だ!! 的な雰囲気を出している人がいるが、当たり前ながら得意不得意があるので、全てのアプリケーションで有効というわけではないw

  • rails/rubyとのgemも出ているので、取っ付き易い

  • ハイライトや予測変換など、使ってないけどキーワード検索をさせる上で面白い機能がいっぱいあった


  • ElasticSearch Serverの本が分かりやすいので、とりあえず実運用させる際は買うことをおすすめしますw


参考



「niconicoの検索を支えるElasticsearch」と題して、第7回Elasticsearch勉強会で発表しました


Qiitaがquery-stringっぽい構文を自前実装した理由

Rails ConsoleでActiveRecordをElasticsearchに放り込んでみる

Elasticsearch at CrowdWorks

elasticsearch-rails を使っているときの custom analyzer 設定の書き方

Ruby: Rails から Elastic Search で全文検索を実行

active_record_associations_parent_child.rb

Getting Started with Elasticsearch on Rails

How To Setup Elasticsearch In Your Rails Application In Development

fields not in mapping are included in the search results returned by ElasticSearch

elasticsearch/elasticsearch-rails

elasticsearch-railsのupdateコールバック処理がモヤモヤ

Rails ConsoleでActiveRecordをElasticsearchに放り込んでみる