LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

Rails × Amazon Elasticsearch Serviceで全文検索

qiita.com

この記事はLCL Advent Calendar 2020 - 16日目の記事です。

バックエンドエンジニアの横塚です。

最近はほぼフルリモートということもあり、お仕事デスク周りのアップデートを計画中です。

久しぶりのブログ執筆になりますが、本記事ではLCLのバスツアー検索サービスにて運用しているRails × Elasticsearchについてお話ししたいと思います。

経緯

LCLのバスツアー検索サービスでは、ツアーをキーワードで検索する機能があります。
f:id:ytkr0813:20201215133450p:plain

キーワード検索機能を追加する際に、RDBによる検索も考えたのですが、今後の拡張性やデータ増加によるパフォーマンスなどを加味し全文検索エンジンを導入することにしました。
全文検索エンジンといえばElasticsearch以外にもSolrなどが有名ですが、近年世の中的にも利用が増えていて、新鮮な情報も豊富なElasticsearchを選択しました。
LCLではインフラの大半をAWSで構成していること、またメンテナンスコストを最小限に抑えるためにフルマネージドなAmazon Elasticsearch Serviceを利用することにしました。

導入方法

AmazonElasticsearchServiceの準備

ドメインの設定

  • ドメイン名の指定
    エンドポイントが決まります。
    https://search-[自分で指定したドメイン名]-[ランダムな文字列].ap-northeast-1.es.amazonaws.com
  • データノードの設定
    アプリケーションの規模と動作環境に応じてインスタンスタイプとノード数を決めます。
    本番環境などでは、複数のアベイラビリティゾーンにまたがった構成にして耐障害性を高めることが推奨されています。

アクセスとセキュリティの設定

  • ネットワーク構成
    セキュリティ要件に応じて、VPC内に構築するかパブリックアクセスに構築するか決めます。
  • アクセスポリシー
    要件に合わせて細かいポリシーの設定が可能です。

Rails側の準備

Gemfile

elasticsearchの公式Gemが用意されているので利用します。

gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'
gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'

config

  • config/initializers/elasticsearch.rb
config = { host: ENV['ELASTICSEARCH_HOST'] || "es:9200/", port: '443' }
Elasticsearch::Model.client = Elasticsearch::Client.new(config)

環境変数のELASTICSEARCH_HOSTにはAmazonElasticsearchServiceで作成したエンドポイントを読み込ませます。
portを指定しないと通信できないので要注意です。

検索対象モデルの設定

Tourモデルを例に、設定していきます。マッピングなどの細かい設定は省略しています。 kuromojiというオープンソースの形態素解析エンジンを使っています。

  • app/models/concerns/tour_searchable.rb
    インデックス名や、マッピング情報など全文検索に関する設定はこちらに記述する
module TourSearchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    # インデックス名
    index_name "tour_index_#{Rails.env}"

    settings index: {
        analysis: {
            tokenizer: {
                kuromoji_tokenizer: {
                    type: 'kuromoji_tokenizer',
                    mode: 'search'
     .... # 省略
       }
     }

    mappings dynamic: 'false' do
      indexes :client_code, type: 'keyword'
      indexes :title, type: 'text', analyzer: 'kuromoji_analyzer'
      indexes :description,  type: 'text', analyzer: 'kuromoji_analyzer'
    end
  
    def as_indexed_json(*)
      attributes
          .symbolize_keys
          .slice(:client_code, :title, :description)
    end
  end

  class_methods do
    def search(keyword)
      __elasticsearch__.search({
            size: 10000,
            query: {
            bool: {
                must: [
                    { multi_match: {
                        fields: %w(client_code title description),
                        type: 'cross_fields',
                        query: keyword,
                        operator: 'and'
                    }}
                ]
            }
        }
        })
    end
  end
end
  • app/models/tour.rb
    上述のmoduleをincludeしておきます。
include TourSearchable

インデックスの作成

Tour.__elasticsearch__.create_index! force: true

レコードのインポート

実際に検索するデータElasticsearchへインポートします

# デフォルトで1000件ずつインポートされる
Tour.__elasticsearch__.import

# batch_sizeオプションで件数の指定ができる
Tour.__elasticsearch__.import(batch_size: 100)

バスツアーの実際の運用では、クライアントから提供されるツアーデータをupdate-insertで定期取り込みする際にインポートしているので、where句による絞り込みをした上でimportします。

Tour.__elasticsearch__.import(query: -> { where(client_code: some_code) }, batch_size: 100)

検索する

result = Tour.search("鴨川シーワールド")
result.results.total
=> 4

まとめ

細かい部分はだいぶ省略してしまいましたが、elasticseach-railsとAmazonElasticsearchServiceを使って簡単に全文検索を実装することができました。
シノニム(類義語)の登録や、インデックスエイリアスを利用した無停止でのインデックス再構築などの話も機会があれば記事にしたいと思います。