for Startups Tech blog

このブログのデザインを刷新しました。(2023/12/26)

【フォースタ テックブログ】検索におけるユーザーのニーズに合わせてElasticsearchをチューニングした話

こんにちは、主にサーバーサイドを担当している速水です。

社内向けプロダクト「タレントエージェンシー支援システム(SFA/CRM)」では、検索エンジンとしてElasticsearchを採用しており、最近、検索機能の大きな改善を行いました。今回はそれに伴って、Elasticsearchに関するTIPSを紹介していきたいと思います。

タレントエージェンシー支援システム(SFA/CRM)とは

tech.forstartups.com

タレントエージェンシー支援システム(SFA/CRM)は、RailsAPIサーバーとして機能し、Vueでフロントを構成する設計となっています。一部、erbで記述された部分もありますが、直近手を入れている機能については、APIを介してフロント分離をしっかり行っております。この記事では、RailsAPI開発におけるElasticsearchに関する部分を紹介します。

データ型の見直し(Text型→Keyword型)

Elasticsearchで文字列を扱う場合、Text型またはKeyword型、どちらかを使用すると思います。Text型とKeyword型の違いは、データを格納する際に、Analyzerによって単語に分割され、分割された単語ごとにインデックスが構成されるかどうかですが、これまで使用していたText型では、分割によって検索結果が曖昧になりすぎ、困るシーンがありました。例えば、「Rubyエンジニア」を検索した際に、「Ruby」と「エンジニア」に分解されてしまうことで、Ruby以外のエンジニアもヒットしてしまう、といった状況です。

そういった分割をせずに格納、検索するために、データ型をText型からKeyword型に変更し、検索ワードが"完全一致"したらヒットするようにしました。

indexes :all_resume_contents, type: 'text', analyzer: 'kuromoji_analyzer'
indexes :all_resume_contents, type: 'keyword'

検索結果でのハイライトの実現

検索結果でヒットした箇所のハイライトを行うため、options[:set_highlight]を指定することで、highlightが返却されるようにしました。

filters = @search_definition[:query][:bool][:filter]
...
...(filters.pushやset_sort_query)
...
set_highlight if options[:set_highlight]
__elasticsearch__.search(@search_definition)


def set_highlight
  @search_definition[:highlight] = {
    fields: {
      "**": {}
    }
  }
end

Elasticsearchのhighlightは、wildcardを利用すると全文が返ってくるため、ハイライトを中心に前後40文字と...(省略記号)の形式に、APIを生成する際に加工しました。

copy_toによる検索対象フィールドの集約と返却フィールド

Elasticsearchではcopy_toという機能で、検索対象フィールドをまとめることができます。検索対象のフィールドを少なくすることで、検索スピード向上が期待できます。
もともとresumeというインデックスは、Nested datatypeを使っていました。all_resume_contents単体で検索するより、file_idやnameなどをまとめて検索した方が効率が良いため、copy_toでall_resume_contentsを指定します。

ただ、開発を進めていたところ、all_resume_contentsでヒットした際に、そのフィールドが返却されないことに気付きました。調べていくと、copy_toはあくまでindexed documentであり、source documentではないようです。検索にヒットした際のフィールドや値を使用したい場合は、storeオプションをつける必要がありました。

indexes :all_resume_contents, type: 'keyword', store: true
indexes :resume, type: 'nested' do
  indexes :id, type: 'integer'
  indexes :file_id, type: 'keyword'
  indexes :name, type: 'keyword'
  indexes :content, type: 'keyword', copy_to: 'all_resume_contents', index: false

case-insensitiveの対応

大文字と小文字を区別しないことを、ケース・インセンシティブ(case-insensitive)と言います。今回、データ型の見直しを行ったことで、Rubyrubyや、HTMLとhtmlの検索結果が異なる、ということが発生してしまいました。Text型の時はAnalyzerで吸収されていた部分ですが、Keyword型に変更したことで発生しました。Keyword型ではAnalyzerが使えないので、代わりにNormalizerを使用します。

www.elastic.co

Normalizerには、char_filterとfilterを定義することができ、今回は大文字と小文字を区別しないようにするため、filterでlowercasesを指定しました。Normalizerはインデックス、検索時の入力、どちらにも適用されます。

indexes :memo, type: 'keyword', normalizer: 'lowercase_normalizer'

analyzer: {
            kuromoji_analyzer: {
              type: 'custom',
              tokenizer: 'kuromoji_tokenizer',
              filter: %w[kuromoji_baseform pos_filter greek_lowercase_filter cjk_width]
            },
            ngram_analyzer: {
              tokenizer: 'ngram_tokenizer'
            }
          },
          normalizer: {
            lowercase_normalizer: {
              type: 'custom',
              char_filter: [],
              filter: ['lowercase']
            }
          }

いかがでしたでしょうか。シーンはかなり限られるかもしれませんが(笑)、Elasticsearchを使った検索機能の開発をするときの参考になればと思います。

今回の検索機能の改善は、フロント、バックエンドそれぞれ細かくタスクを分解し、チームが一丸となって取り組んだものでした。いざ結合して検索してみると、想定と異なる結果が表示されることもありましたが、上記のような解決法を取りながら、1つ1つ対応して完成した機能は、まさにチームでつくりあげたと言えます。

フォースタートアップスではエンジニアを募集中です。チームで協働するスタイルが向いている方は、是非ご応募ください!