2021, Apr 19

【Rails】ActionTextの内容をElasticSearchで全文検索

ActionTextに入っている中身を全文検索したいときに試したことをまとめます。

タイトルままですが、Rails の ActionText で作ったコンテンツの中身を全文検索するために試したことを記載します。

一応動きますが、これが良い方法かどうかはわかりませんという前提で書いてみます。

事前準備

公式はこちら

Gemを追加

elasticsearchを使用するためのGemを追加します。

gem "elasticsearch-model"
gem "elasticsearch-rails"

ActionTextを使う

model として Message を作っている前提で行きたいと思います。 MessageモデルはActionText要素としてhas_rich_text :contentを含めています。

# == Schema Information
#
# Table name: messages
#
#  id         :bigint           not null, primary key
#  title      :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
# Indexes
#
#  index_messages_on_title  (title)
#
class Message < ApplicationRecord
  has_rich_text :content
end

ActionText で使える関数

ActionTextでは、データベース上にHTML形式でデータを保有しています。

全文検索するときは、HTMLタグは必要ないのでタグを除いたデータが欲しいです。そんなときは用意されているto_plain_textを使用できます。

# https://edgeapi.rubyonrails.org/classes/ActionText/RichText.html#method-i-to_plain_text

message = Message.first

message.content.to_plain_text

こんな感じで取得できます。全文検索にはこのto_plain_textで取得したデータをElasticSearchにインデックス登録しました。

ActiveSupportConcernを作る

Concernを使ってElasticSearchを使用できるようにします。

# app\models\concerns\message_searchable.rb
module MessageSearchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    include Elasticsearch::Model::Callbacks
    # インデックスするフィールドの一覧
    INDEX_FIELDS = %w(title updated_at created_at content).freeze
    # インデックス名
    index_name "message_#{Rails.env}"
    # マッピング情報
    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
      mappings dynamic: 'false' do
        # 今回は title と content に対して全文検索してみる
        indexes :title, analyzer: 'kuromoji', type: 'text'
        indexes :content, type: :object do
          indexes :to_plain_text, analyzer: 'kuromoji', type: 'text'
        end
      end
    end
    # インデックスするデータを生成
    # @return [Hash]
    # def as_indexed_json(option = {})
    #   self.as_json.select { |k, _| INDEX_FIELDS.include?(k) }
    # end

    # contentのbodyの部分にAction Textの中身が入っているので"to_plain_text"で取り出す。
    def as_indexed_json(options={})
      as_json(
        include: { 
          content: {methods: [:to_plain_text], only: [:to_plain_text] }
        }).select { |k, _| INDEX_FIELDS.include?(k) }
    end

    # Queryのカスタマイズ
    def self.query(keyword)
      __elasticsearch__.search({
        size: 10000,
        query: {
          multi_match: {
            fields: INDEX_FIELDS,
            type: 'cross_fields',
            query: keyword,
            operator: 'and'
          }
        }
      })
    end
  end

  module ClassMethods
    # indexの作成メソッド
    def create_index!
      client = __elasticsearch__.client
      client.indices.delete index: self.index_name rescue nil
      client.indices.create(index: self.index_name,
                            body: {
                                settings: self.settings.to_hash,
                                mappings: self.mappings.to_hash
                            })
    end
  end
end

そしてこれをMessageモデルに繋げます。

class Message < ApplicationRecord
  include FinanceSearchable #追加

  has_rich_text :content
end

これでMessageモデルへの変更都度、インデックスが登録されるようになりました。to_plain_textで取得したActionTextの中身も登録されるはずです。

Controllerから検索する

search_param[:search]のような感じで、GETのリクエストパラメータを受け取ったら、それをsearchメソッドに渡すだけ!

@messages = Message.search(search_param[:search]).records.order(updated_at: "DESC")

すばらしく簡単ですね。contentの中身もちゃんと検索されました。

今回はこれで以上です。