新卒2年目のエンジニアがキナリノの検索にElasticsearchを導入した話

kinarino_icon

はじめまして。キナリノキナリノモールのサーバーサイドエンジニアをしている加藤です。

キナリノキナリノモールは自分らしい暮らしを楽しむ人へ、「これが好き」、「これが心地よい」と感じてもらえるお買いもの体験と情報を提供しているサービスです。
そんなサービスなので、情報を提供する記事だったりアイテムの検索が重要になります。今回は新卒2年目の時にトレーナー兼キナリノモール側の導入経験者の先輩社員からサポートを受けつつ私がキナリノの記事検索にElasticsearchを導入した話を紹介していきます。
(2023年9月時点で新卒4年目)

導入の背景

もともとキナリノの記事検索の機能はあったのですが、検索対象が記事タイトルのみだったため、タイトルには使われていなく本文だけで使用されている単語で検索するとヒットしない実装でした。また、既存検索では検索ワードでのわかち書きを行なわないため「北欧 雑貨」という2つの単語での検索は行なわれず「北欧雑貨」という1単語での検索が行なわれていました。そのため、キナリノで「北欧雑貨」と検索すると42記事ぐらいしかヒットせず、Googleで「北欧雑貨 キナリノ」と検索したほうが目当てのものが見つかるというあまりイケてない状態でした。
他にも、検索にかかるリソースが多く動作が重くなってしまうためサーバースペックでどうにかしているという状態でもありました。
そのため、検索改善をすることになりすでにキナリノモールでの導入・利用実績のあったElasticsearchを導入することになりました。

Elasticsearchとは

Elasticsearchは、オープンソースの分散型RESTful検索・分析エンジンです。
またApache Luceneをベースにしており、Javaで開発されており全文検索エンジンとしての機能を持っています。

Elasticsearchとは?

※Elasticsearch は Elastic N.V の商標であり、米国およびその他の国で登録されています。

何をしたか?

  1. gemの導入
  2. Elasticsearchへの接続設定および、辞書登録、analyzer設定の実装
  3. モデルごとのsearchkickの設定(mapping等)を実装
  4. 管理画面で従来の方法での検索とElasticsearch検索の切り替え設定の実装
  5. 管理画面に従来の方法での検索とElasticsearch検索の比較ページ作成
  6. キナリノのブラウザ検索ページでElasticsearch検索を使用できるよう実装

Elasticsearchサーバーの構成について 

カカクコムではインフラ周りを専門とした部署が存在し、その部署の方々にElasticsearch環境の構築をお願いしました。
そのためElasticsearchやサーバーの設定について、ここでは割愛させていただきます。
一般的な分散システムに沿った構成となっています。

1. gemの導入

キナリノRuby on Railsで実装されているので、Elasticsearchを使うためのgemを導入しました。
導入したgemは以下の2つになります。この2つはすでにキナリノモールでElasticsearchを使用する際に導入しており実績があったので、そのまま導入しました。

2. Elasticsearchへの接続設定および、辞書登録、analyzer設定の実装

searchkickの設定

searchkickの設定は、config/initializers/searchkick.rbに記述します。
日本語の文章を単語単位にわかち書きするために、kuromojiを使用しています。
辞書とシノニムは別ファイルで管理しており、キナリノに合わせてチューニングしてあります。

ENV['ELASTICSEARCH_URL'] = 'elasticsearchのURL'

searchkick_analyzer = {
  type: 'custom',
  char_filter: ['icu_normalizer', 'kuromoji_iteration_mark'],
  tokenizer: 'kuromoji',
  filter: %w(
    kuromoji_baseform kuromoji_part_of_speech
    ja_stop kuromoji_number kuromoji_stemmer
    synonym_filter
  )
}

searchkick_ngram_analyzer = {
  type: 'custom',
  char_filter: ['icu_normalizer', 'kuromoji_iteration_mark'],
  tokenizer: 'kuromoji',
  filter: %w(
    kuromoji_baseform kuromoji_part_of_speech
    ja_stop kuromoji_number kuromoji_stemmer
    searchkick_ngram
  )
}

user_dictionaries = CSV.read(Rails.root.join('辞書設定ファイルパス')).map { |row| row.join(',') }
synonyms = CSV.read(Rails.root.join('シノニム設定ファイルパス')).map { |row| row.join(',') }

Searchkick.model_options = ({
  language: 'japanese',
  settings: {
    number_of_shards: 3,
    refresh_interval: '60s',
    max_result_window: 1000000,
    analysis: {
      tokenizer: {
        kuromoji: {
          type: 'kuromoji_tokenizer',
          mode: 'normal',
          user_dictionary_rules: user_dictionaries
        }
      },
      filter: {
        synonym_filter: {
          type: 'synonym',
          synonyms: synonyms
        }
      },
      analyzer: {
        searchkick_index: searchkick_ngram_analyzer,
        searchkick_search: searchkick_analyzer
      }
    }
  }
})

ElasticsearchのAnalyzerについて

Analyzerとは、Elasticsearchで検索を行なう際に、どのような処理を行なって検索を行なうかを定義するものです。
文章そのままでは検索できないので、全角を半角にしたり不要な品詞の除去を行なったりしてくれます。
その処理を行ない検索に利用するのがAnalyzerで、Elasticsearchでは単語単位に分割をして検索に利用します。
Analyzerは、Elasticsearchにはデフォルトでいくつか用意されていますが、自分でAnalyzerを作成することもできます。

キナリノで使用しているAnalyzer

名前 種類 機能
icu_normalizer char_filter 全角 → 半角 といった文字の正規化をしてくれる詳細
kuromoji_iteration_mark char_filter 色々 → 色色 といった踊り文字の正規化
kuromoji_tokenizer tokenizer 辞書に従って文字列を分割してくれる
kuromoji_baseform token_filter 飲み → 飲む といった原型化
kuromoji_part_of_speech token_filter 不要な品詞の除去
ja_stop token_filter  'あれ', 'それ'など不要な文字列の除去
kuromoji_number token_filter 漢数字の数字化 一〇〇〇 → 1000
kuromoji_stemmer token_filter 長音除去 パーカー → パーカ
searchkick_ngram token_filter Searchkickにデフォルトで設定されたngram
synonym_filter token_filter 自分で作成したシノニム設定適用のためのfilter

3. モデルごとのsearchkickの設定(mapping等)を実装

キナリノには記事を管理しているモデルが存在するので、そのモデルに対してindex対象カラムの指定や、associationの関係性や検索時の結果獲得時のソート順指定などsearchkickの設定を行ないました。

# 記事モデル
def search_data
  attributes
end

module ClassMethods
  def es_search(args, **options)
    search_word = args[:search_query].presence || '*'

    # 細かい検索の設定はConditionクラスで行っている
    condition = Condition.new(args)
    searchkick_search(
      search_word,
      page: args[:page] || 1,
      per_page: condition.per_page,
      misspellings: false,
      order: condition.order,
      includes: condition.includes,
      where: condition.where,
      fields: condition.fields,
      operator: :or,
      body_options: { track_total_hits: true },
      **options
    )
  end
end

4. 管理画面で従来の方法での検索とElasticsearch検索の切り替え設定の実装

Elasticsearchのサーバーが障害を起こした際に検索できない状態になってしまうのは困るので、管理画面で従来の方法での検索とElasticsearch検索の切り替え設定を実装しました。
この機能はElasticsearchに登録しているindexのfieldの変更などの一度Elasticsearchを無効化する必要が別の開発ででてきてしまったので、この機能を実装しておくことで対応が楽になりました。

5. 管理画面に従来の方法での検索とElasticsearch検索の比較ページ作成

もともとの検索からElasticsearch検索へ切り替える際に、検索結果が悪化してはいけないので比較確認のため次の画像のような従来の方法での検索とElasticsearch検索の比較ページを作成しました。
このページを利用して、キナリノで使用されているkuromojiに登録されていない単語の辞書登録やシノニムの登録などを行ないました。

導入した結果

一例として「北欧雑貨」という検索ワードでの検索では元々使っていたMySQL検索だと42件しかヒットしていませんでしたが、Elasticsearchを用いた検索だと7425件ヒットするようになりました。
Elasticsearchを用いた検索では従来の検索方法と異なり、タイトルだけではなく本文だったりとか他の項目も検索対象となっているため、ヒット件数が増えたのは当然といえば当然ですが、検索結果が増えてもユーザーが目的の記事を見つけやすくなったと思います。
またor検索のため北欧のみ、もしくは雑貨のみヒットした記事もありますが、両方ヒットした記事が7425件中4000件以上あり両方ヒットした記事が検索上位に来るよう実装したためユーザー本位な検索ができるようになったと思います。

感想

キナリノの検索があまりイケてなかったことについては、新卒入社前の一般キナリノユーザーだったときから感じていたことだったので自分の手で改善できたのはいい経験でした。
また、私自身が新卒2年目での初めての大型実装かつElasticsearch自体未経験でしたが、先輩社員にサポートしてもらいながら実装できたので、自分のスキルアップにもなりました。

カカクコムでは、ともにサービスをつくる仲間を募集しています!

カカクコムのエンジニアリングにご興味のある方は、ぜひこちらをご覧ください!

カカクコム採用サイト