🐈

RailsアプリケーションにElasticsearchを追加する

2023/04/14に公開

はじめに

普段は個人投資家として投資を行いながら
複数のスタートアップで開発の支援をしたり、リードエンジニアをしています

https://twitter.com/i/events/1439793574602625028?s=20

ElasticSearchとは

全文検索エンジン
全文検索やフィルタリング、ソート、類似度検索、地理空間検索などが可能

Docker周り

ほとんどのプロダクトはDockerで構築されているのでDocker周りの話から

Dockerファイルの作成

FROM docker.elastic.co/elasticsearch/elasticsearch:7.15.1
RUN elasticsearch-plugin install analysis-kuromoji

docker-composeファイルの作成

version: '3.9'
services:
  elasticsearch:
    build: .
    environment:
      - "discovery.type=single-node"
    ports:
      - 9200:9200

volumesなどの細かな設定は割愛

Rails周り

gemのインストール

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

Model周り

Elasticsearchの処理の共通化

elasticsearch-modelで使う処理などを共通化するためconcernsをつくってみる

module ElasticsearchOperator
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    include Elasticsearch::Model::Callbacks # インデックスの自動生成をしない場合は書かない

    settings index: default_settings
    mappings dynamic: false
  end

  class_methods do
    def index_name
      "#{Rails.env}-#{self.name.underscore.pluralize}"
    end

    def default_settings
      {
        analysis: {
          analyzer: {
            kuromoji_analyzer: {
              type: 'custom',
              tokenizer: 'kuromoji_tokenizer',
              filter: [
                'kuromoji_baseform', 'pos_filter',
                'cjk_width'
              ]
            }
          },
          filter: {
            ja_stop: {
              type: 'stop',
              stopwords: '_japanese_'
            },
            kuromoji_number: {
              type: 'kuromoji_number'
            },
            kuromoji_stemmer: {
              type: 'kuromoji_stemmer'
            },
            pos_filter: {
              type: 'kuromoji_part_of_speech',
              stoptags: ['助詞-格助詞-一般', '助詞-格助詞-引用', '助詞-格助詞-連語']
            }
          },
          tokenizer: {
            kuromoji_tokenizer: {
              type: 'kuromoji_tokenizer'
            }
          }
        }
      }
    end

    def default_mappings
      {
        dynamic: false
      }
    end
    
    def create_index!
      self.__elasticsearch__.delete_index! if self.__elasticsearch__.index_exists?
      self.__elasticsearch__.create_index! index: index_name
      self.__elasticsearch__.refresh_index!
    end
  end
end

default_settingsメソッド

Elasticsearchのインデックス設定に関するデフォルト値を定義

analysis

テキスト解析に関する設定

analyzer

テキストのトークン化やフィルタリングに使用されるアナライザーを定義
kuromoji_analyzerのようなカスタムアナライザーを定義

tokenizer

テキストをトークンに分割する方法を定義するための設定
kuromoji_tokenizerのように、形態素解析を行うトークナイザーを定義

filter

アナライザーで使用されるフィルターを定義

・ ja_stop

日本語のストップワードを除去するためのフィルター
typeをstopに設定し、stopwordsオプションで日本語のストップワードを指定

・ kuromoji_number

数字を正規化するためのフィルター
typeをkuromoji_numberに設定することで、数字を正規化する

・ kuromoji_stemmer

単語の語幹を抽出するためのフィルタ-
typeをkuromoji_stemmerに設定することで、語幹を抽出する

・ pos_filter

形態素解析において特定の品詞を除去するためのフィルター
typeをkuromoji_part_of_speechに設定し、stoptagsオプションで除去したい品詞を指定
この例では、「助詞-格助詞-一般」、「助詞-格助詞-引用」、「助詞-格助詞-連語」を除去

検索対象のモデル

例としてArticleというモデルの場合

class Article < ApplicationRecord
  include ElasticsearchOperator

  mappings do
    indexes :title, type: 'text', analyzer: 'kuromoji_analyzer'
    indexes :body, type: 'text', analyzer: 'kuromoji_analyzer'
  end
end

ここに個々のマッピング設定を記載
index設定も変えたい場合は、ElasticsearchOperator.default_settingsに追加すれば可能

インデックスの作成 / 削除

ElasticsearchOperatorにインデックスを作成・削除するメソッドを定義

    def create_index!
      self.__elasticsearch__.delete_index! if self.__elasticsearch__.index_exists?
      self.__elasticsearch__.create_index! index: index_name
      self.__elasticsearch__.refresh_index!
    end

頻繁にリフレッシュを行うとパフォーマンスに影響が出るため、必要なタイミングでのみ使用する

呼び出し

Article.create_index!

もちろんバッチ処理をつくって検索対象のモデルのインデックスを定期更新するが、割愛

検索


  def self.search(query)
    search_definition = {
      size: 10000, # Elasticsearchのデフォルトのサイズが10件なので調整
      query: {
        multi_match: {
          query: query,
          fields: [
            'title^2', # titleを2倍に
            'body',
                        .......
          ],
          type: 'most_fields',
          fuzziness: 'AUTO'
        }
      }
    }
    response = __elasticsearch__.search(search_definition)

    article_ids = response.results.map { |result| result._id.to_i }
    Article.where(id: product_ids)
  end

Article.search('検索ワード')

応用

別テーブルの別カラムの結果をもとに検索したい

例えばArticleの検索機能をつくっているときに、
Publisherという別テーブルのカラムの値をもとにArticleを検索したい時など

仮想のカラム名をつけてindexさせることで可能

def index_with_publisher_data(publisher)
  data = as_json.merge(publisher_name: publisher&.name, publisher_name_kana: publisher&.name_kana)
  data.delete('id')
  __elasticsearch__.index_document({ body: data, id: self.id })
end

Dockerで起動している別リポジトリと共通でElasticsearchを使いたい

プロダクトによってはAPI専用のRailsアプリケーションのリポジトリと
社内運用のための管理画面やバッチ処理などのRailsアプリケーションのリポジトリが存在している場合がある

例えば検索の処理はAPIのリポジトリで行い、
インデックス作成のジョブなどは別のリポジトリで行いたいなどのケース

ネットワークの作成

コンテナ間で通信するためのネットワークを作成する
共通のネットワークがすでにある場合は不要

$ docker network create common_network

ただし、Elasticsearchのコンテナが起動しているリポジトリのほうに
ネットワークの設定がされていなければ追記する

version: '3.9'

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.14.1
    environment:
      - discovery.type=single-node
    ports:
      - 9200:9200
    networks:
      - common_network

networks:
  common_network:
    external: true

Rails側の修正

両方のリポジトリに追加

config/initializers/elasticsearch.rb

require 'elasticsearch/model'
require 'elasticsearch/transport'

config = {
  host: ENV['ELASTICSEARCH_HOST'] || 'your_host_or_ip:9200',
  user: ENV['ELASTICSEARCH_USER'],
  password: ENV['ELASTICSEARCH_PASSWORD'],
  transport_options: {
    request: { timeout: 5 }
  }
}.compact


Elasticsearch::Model.client = Elasticsearch::Client.new(config)

  def self.search(query)
    search_definition = {
      query: {
        multi_match: {
          query: query,
          fields: ['title', 'vendor_name', 'vendor_name_kana'],
          type: 'most_fields',
          fuzziness: 'AUTO'
        }
      }
    }
    __elasticsearch__.search(search_definition)
  end

IPアドレスの調べ方

$ docker network inspect common_network

externalネットワーク内でコンテナのIPアドレスを固定する方法もあるが割愛

Discussion