採用活動でちょっとでもハッタリ効かせようと最近CTOを名乗り始めた@mikedaです。
JOBLISTではElasticsearchを使った全文検索が出来ます。
kuromojiを使った辞書ベースで設定されているのですが、以下のような問題があったため、辞書・同義語を調整して改善してみました。
銀だこ
で検索して築地銀だこ
が出てこない築地銀だこ
で検索して銀だこ
が出てこない地銀
で検索すると築地銀だこ
が出てくるw
今回はMac上のRails + elasticsearch-railsを使った動作検証の手順をメモ代わりに書いておきます。
テスト環境構築
手元で動かさない人は読み飛ばして下さい!
Elasticsearchのインストールと起動
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.6.8.tar.gz tar xvzf elasticsearch-5.6.8.tar.gz mv elasticsearch-5.6.8 elasticsearch elasticsearch/bin/elasticsearch-plugin install analysis-kuromoji elasticsearch/bin/elasticsearch-plugin install analysis-icu elasticsearch/bin/elasticsearch
テスト用のRailsアプリ作成
rails new es_dict cd es_dict # Gemfileに追記 gem 'elasticsearch-rails' gem 'elasticsearch-model' bundle bin/rake db:create bin/rails g model articles content:text bin/rake db:migrate
config/initializers/elasticsearch.rb
Elasticsearch::Model.client = Elasticsearch::Client.new( url: 'http://localhost:9200', trace: Rails.env.development? )
Elasticsearchの設定は別ファイル(config/elasticsearch.yml)にYAMLで書き出しておきます。
index: analysis: filter: katakana_stemmer: type: kuromoji_stemmer minimum_length: 4 kana_converter: type: icu_transform id: Hiragana-Katakana tokenizer: ja_tokenizer: type: kuromoji_tokenizer mode: search analyzer: # 今回使うanalyzer ja: type: custom # kuromojiでトークナイズ(単語分割) tokenizer: ja_tokenizer # 文字単位のフィルター。トークナイズ前に適用される。 char_filter: - icu_normalizer - html_strip - kuromoji_iteration_mark # トークン単位のフィルター。トークナイズ後に実行される。 filter: - katakana_stemmer # コンピューター -> コンピュータ 変換 - kuromoji_part_of_speech # 助詞等を除外 - kana_converter # ひらがな -> カタカナ 変換
app/models/article.rb でモデルとElasticsearchのインデックスを紐づけます。
class Article < ApplicationRecord include Elasticsearch::Model # 設定読み込み settings YAML.load_file(Rails.root.join('config', 'elasticsearch.yml')) # マッピング定義 mapping dynamic: false do indexes :content, type: 'string', analyzer: 'ja' end # インデックス用のjson作成 def as_indexed_json(*) { content: content } end # テストデータ作成用 def self.create_sample_index __elasticsearch__.delete_index! if __elasticsearch__.index_exists? __elasticsearch__.create_index! Article.destroy_all create(content: '銀だこ').__elasticsearch__.index_document create(content: '築地銀だこ').__elasticsearch__.index_document end # 検索テスト用 def self.simple_search(keyword) search( query: { simple_query_string: { query: keyword, fields: [ :content ], default_operator: 'and' } } ).records end end
bin/rails cでデフォルトの動作確認
銀だこ
、 築地銀だこ
がkuromojiでどう分割されるかを確認してみます。
Article.create_sample_index es = Article.__elasticsearch__.client es.indices.analyze(index: 'articles', analyzer: 'ja', text: '銀だこ') # {"tokens"=> # [{"token"=>"銀", ... }, # {"token"=>"ダコ", ... } ]} es.indices.analyze(index: 'articles', analyzer: 'ja', text: '築地銀だこ') # {"tokens"=> # [{"token"=>"築", ... }, # {"token"=>"地銀", ... }, # {"token"=>"ダコ", ... } ]}
銀だこ
は銀
とダコ
に、築地銀だこ
はなんと築
と地銀
とダコ
に分割されていることがわかります。
これで銀だこ
で検索しても築地銀だこ
が出てこない、地銀
検索すると築地銀だこ
が出てくるw、理由がわかりました。
Article.simple_search('銀だこ').map(&:content) # => ["銀だこ"] Article.simple_search('築地銀だこ').map(&:content) # => ["築地銀だこ"] Article.simple_search('築地').map(&:content) # => [] Article.simple_search('地銀').map(&:content) # => ["築地銀だこ"]
ユーザー定義辞書の設定
銀だこを辞書登録してみましょう。
Elasticsearchのインストールディレクトリのconfig/user_dictionary.txtに以下を記載します。
銀だこ,銀だこ,ギンダコ,カスタム名詞
ユーザー定義辞書を使うようにElasticsearchの設定を書き換えます。
tokenizer: ja_tokenizer: type: kuromoji_tokenizer mode: search user_dictionary: user_dictionary.txt # 追記
rails cを再起動して動作確認してきます。
築地銀だこ
が築地
と銀だこ
に分割されるようになりました。
Article.create_sample_index es = Article.__elasticsearch__.client es.indices.analyze(index: 'articles', analyzer: 'ja', text: '銀だこ') # {"tokens"=> # [{"token"=>"銀ダコ", ... } ]} es.indices.analyze(index: 'articles', analyzer: 'ja', text: '築地銀だこ') # {"tokens"=> # [{"token"=>"築地", ... }, # {"token"=>"銀ダコ", ... } ]}
これで銀だこ
で検索して築地銀だこ
が出てくるようになりました。
Article.simple_search('銀だこ').map(&:content) # => ["銀だこ", "築地銀だこ"] Article.simple_search('築地銀だこ').map(&:content) # => ["築地銀だこ"] Article.simple_search('築地').map(&:content) # => ["築地銀だこ"] Article.simple_search('地銀').map(&:content) # => []
ここでサービス的な検討です。
築地
で検索している人に 築地銀だこ
を表示すべきでしょうか?
今回は 築地
で検索している人は地名・駅名の築地を探しているのであって、銀だこ
を探しているわけではない -> 表示すべきではない、と判断しました。
築地銀だこ
を個別に辞書登録します。
銀だこ,銀だこ,ギンダコ,カスタム名詞 築地銀だこ,築地銀だこ,ツキジギンダコ,カスタム名詞
※2,3カラム目を『築地 銀だこ,ツキジ ギンダコ』とスペースを空けて記載した場合は、2つの単語に分割されてインデックスされます。
築地銀だこ
が築地銀だこ
のまま登録されるようになりました。
Article.create_sample_index es = Article.__elasticsearch__.client es.indices.analyze(index: 'articles', analyzer: 'ja', text: '銀だこ') # {"tokens"=> # [{"token"=>"銀ダコ", ... } ]} es.indices.analyze(index: 'articles', analyzer: 'ja', text: '築地銀だこ') # {"tokens"=> # [{"token"=>"築地銀ダコ", ... } ]}
検索してみると、築地
で 築地銀だこ
が出てこないようになりました。
しかし今度は銀だこ
で 築地銀だこ
が出てこなくなりました・・・
Article.simple_search('銀だこ').map(&:content) # => ["銀だこ"] Article.simple_search('築地銀だこ').map(&:content) # => ["築地銀だこ"] Article.simple_search('築地').map(&:content) # => [] Article.simple_search('地銀').map(&:content) # => []
同義語設定
銀だこ
と 築地銀だこ
は同じ単語(同義語)である、という設定をしていきます。
Elasticsearchのインストールディレクトリのconfig/synonyms.txtに以下を記載します。
銀ダコ,築地銀ダコ
Elasticsearchの設定を書き換えます。
index: analysis: filter: # ... synonym: # 追加 type: synonym synonyms_path: synonyms.txt # ... analyzer: # ... filter: - katakana_stemmer - kuromoji_part_of_speech - kana_converter - synonym # 追加
これで銀だこ
と築地銀だこ
で、それぞれ両方の単語が辞書登録されるようになります。
Article.create_sample_index es = Article.__elasticsearch__.client es.indices.analyze(index: 'articles', analyzer: 'ja', text: '銀だこ') # {"tokens"=> # [{"token"=>"銀ダコ", ... }, # {"token"=>"築地銀ダコ", ... } ]} es.indices.analyze(index: 'articles', analyzer: 'ja', text: '築地銀だこ') # {"tokens"=> # [{"token"=>"築地銀ダコ", ... }, # {"token"=>"銀ダコ", ... } ]}
検索すると両方出てくるようになりました!
Article.simple_search('銀だこ').map(&:content) # => ["銀だこ", "築地銀だこ"] Article.simple_search('築地銀だこ').map(&:content) # => ["銀だこ", "築地銀だこ"] Article.simple_search('築地').map(&:content) # => [] Article.simple_search('地銀').map(&:content) # => []
まとめ
Elasticsearchの辞書・同義語を調整して『築地銀だこ』の検索をカスタマイズしてみました。
なんかおかしなところとか、こうしたほうがいいのでは、とかあればぜひアドバイス下さい!
試しにやってみて、辞書・同義語は自分で管理していくとなるとかなり手間がかかるし、何をどう改善していくかのの指標作成も難しそうだなと感じています。
AWSユーザーとしては、Amazon Elasticsearch Serviceがユーザー定義辞書対応してないという問題もあります。
※今は自前運用してますが、出来れば自分でElasticsearch運用したくない!
ただ求人・ファッションなど、ユーザーの検索する単語が限られ、ブランド名など特殊な単語に対応する必要がある場合は、ある程度やってくしかないかなと思っています。
あらゆる言葉を扱う必要があるブログやメディアなどの場合は、デフォルト辞書+ngram併用という選択肢が良さそうかな。
それについては先日公開されたgfxさんの資料がとても参考になります。