Rista Tech Blog

株式会社リスタの技術?ブログ

Elasticsearchの辞書・同義語を調整して『築地銀だこ』の検索をカスタマイズした

Ristaの4人目エンジニアを募集中! iOS Android Rails

採用活動でちょっとでもハッタリ効かせようと最近CTOを名乗り始めた@mikedaです。

JOBLISTではElasticsearchを使った全文検索が出来ます。
kuromojiを使った辞書ベースで設定されているのですが、以下のような問題があったため、辞書・同義語を調整して改善してみました。

  1. 銀だこで検索して築地銀だこが出てこない
  2. 築地銀だこで検索して 銀だこが出てこない
  3. 地銀で検索すると築地銀だこが出てくるw

job-list.net

今回は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さんの資料がとても参考になります。

gfx.hatenablog.com