Help us understand the problem. What is going on with this article?

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

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

moriyaman
株式会社Atraeで転職サイト"Green" をつくってます。 http://www.green-japan.com/ rubyを使って開発しているので ruby/rails/rubymotion辺りの共有が多いです。
atrae
People Techカンパニーとして、転職サイトGreen, ビジネスマッチングアプリyenta, 組織改善プラットフォームwevoxなどのサービスを運営。全ての社員が誇りを持てる組織と事業の創造にこだわり、関わる人々がファンとして応援したくなるような魅力ある会社であり続けることを目指しています。
https://atrae.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away