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

  • 225
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

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に放り込んでみる