こんにちは、主にサーバーサイドを担当している速水です。
社内向けプロダクト「タレントエージェンシー支援システム(SFA/CRM)」では、検索エンジンとしてElasticsearchを採用しており、最近、検索機能の大きな改善を行いました。今回はそれに伴って、Elasticsearchに関するTIPSを紹介していきたいと思います。
タレントエージェンシー支援システム(SFA/CRM)は、RailsがAPIサーバーとして機能し、Vueでフロントを構成する設計となっています。一部、erbで記述された部分もありますが、直近手を入れている機能については、APIを介してフロント分離をしっかり行っております。この記事では、RailsのAPI開発における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)と言います。今回、データ型の見直しを行ったことで、Rubyとrubyや、HTMLとhtmlの検索結果が異なる、ということが発生してしまいました。Text型の時はAnalyzerで吸収されていた部分ですが、Keyword型に変更したことで発生しました。Keyword型ではAnalyzerが使えないので、代わりにNormalizerを使用します。
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つ対応して完成した機能は、まさにチームでつくりあげたと言えます。
フォースタートアップスではエンジニアを募集中です。チームで協働するスタイルが向いている方は、是非ご応募ください!