Elasticsearchでカタカナで検索する

現在、Elasticsearchと格闘中です。実験的に扱っているので、まだ本稼働に使ったりとかそういうことでもないのですが、とりあえずRailsで使う際に得られた知見をメモ的に書いておこうと思います。主に自分用です。体系的に書けそうになったらまた書きます。

今回は、カタカナでの検索のメモです。

ちなみに使っているプログラミング言語はRubyで、アプリケーションはRailsで作っていますので、それが前提です。

gemは、elasticsearch-railsとelasticsearch-modelを入れています。

Elasticsearch、日本語くらいでググるとkuromojiを使ったサンプルがよく出てきます。形態素解析でいろんなキーワードに分割されるので、検索の際の速度は上がるのですが、読み仮名(カタカナ)のような情報は区切りが判別しづらいので、tokenizerとしてkuromoji_tokenizerでは対応できません。例えば、「イオンモールオカヤマ」という文字列を解析させると、「イオン」「モールオカヤマ」の2つに分割されてしまい、「オカヤマ」で検索してもヒットしませんでした。

こういう場合は、nGramを使うのが定石のようです。nGramは、キーワードの文字列の長さをほぼ固定して分割していく方式で、色んなパターンにヒットするようになる代わりに、インデックス情報が膨らむようです。しかし、カタカナだけの場合など区切りがわかりにくい場合は、これが適しているとのことです。

では、elasticsearch-modelを使ったサンプルコードを載せてみます。

app/models/shop.rb
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Shop < ActiveRecord::Base
  include Elasticsearch::Model
  index_name [Rails.application.engine_name, Rails.env].join('_')
  settings index: {
               analysis: {
                   tokenizer: {
                       kuromoji_user_dict: {
                           type: :kuromoji_tokenizer
                       },
                       ngram_tokenizer: {
                           type: 'nGram',
                           min_gram: '2',
                           max_gram: '3',
                           token_chars: ['letter', 'digit']
                       }
                   },
                   filter: {
                       # 指定した品詞を除外する
                       pos_filter: {
                           type: :kuromoji_part_of_speech,
                           stoptags: ['助詞-格助詞-一般', '助詞-終助詞']
                       },
                       greek_lowercase_filter: {
                           type: :lowercase,
                           language: :greek,
                       },
                   },
                   analyzer: {
                       kuromoji_analyzer: {
                           type: :custom,
                           tokenizer: :kuromoji_user_dict,
                           filter: [:kuromoji_baseform, :pos_filter, :greek_lowercase_filter, :cjk_width]
                       },
                       ngram_analyzer: {
                           tokenizer: :ngram_tokenizer,
                           filter: [:cjk_width]
                       }
                   }
               }
           } do
    mapping do
      indexes :name,
              type: 'string',
              index: :analyzed,
              analyzer: :kuromoji_analyzer
      indexes :yomigana,
              type: 'string',
              index: :analyzed,
              analyzer: :ngram_analyzer
    end
  end
end

settingsのところで、tokenizerを2つ設定しています。そして、analyzerも2つ設定しています。
これらのanalyzerを、mappingのところでindexを作る際に利用しています。
kuromoji_analyzerは、普通の日本語の文字列に対して、
ngram_analyzerは、ヨミガナに対して使っています。

こうすることで、通常の日本語でも検索できるし、ヨミガナでも検索できるようになりました。


DBのViewを作ったらRailsプログラムが綺麗になった話

最近データベースというかSQLについて勉強しているんですが、奥が深いですね。この前の第9回中国地方DB勉強会のときに聞いたrank関数を使って、ランキング機能をリファクタリングしよう!と思って最近頑張ってます。というのも、複雑なクエリ(遅い)を業種数分(10回くらい)呼んでいたため、Herokuだと結構ギリギリの速度になることもあったので、なんとかしなければ!と思っていたのです。

とりあえず、私の開発環境を載せておきます。

Mac Yosemite
Ruby 2.2
Rails 4.2.1
PostgreSQL 9.3.4

ひとまずrank関数を使うところまで

まず、NewRelicを使ってActiveRecordが出力しているSQLを取得し、それを0xDBEのコンソールに貼り付けて、rank関数を使って業種でパーティションしてランキングを出すところまでしてみました。rank関数は、関数名の通り、ランキングを出力する関数です。詳細については、@soudai1025がアップしている資料を見てもらえばクエリ付きでわかりやすいと思います。

この資料に、以降で説明することもほぼ書かれていますので、通読することをお勧めします。

複雑なクエリを楽にするためにDBのViewを作る

書いたクエリをActiveRecordで表現してみると、かなり複雑で読みにくくなってしまったんですが、@soudai1025から、(DBの)Viewにしてはどうですか?とアドバイスをもらったので、Viewを作ってみました(以降、ViewはDBのViewのこと)。Viewはmigrationで作りましたが、railsでDBのViewを作る機能はないので、普通にSQLを書いてクエリを実行します。

db/migrate/create_view_foo_ranking
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class CreateViewFooRanking < ActiveRecord::Migration
  def up
    # Viewを作るためのクエリは50行くらいあったのでダミーで…。
    execute <<-SQL
      CREATE OR REPLACE VIEW foo_rankings AS
        SELECT *
        FROM (
          SELECT
            rank()
            OVER(
              PARTITION BY category_id
              ORDER BY count DESC
            ) AS rank,
            count,
            category_id,
            foo.id AS foo_id
            ...
          FROM foos
            INNER JOIN (
              SELECT ...
              FROM (
                SELECT ...
                FROM ...
                  LEFT OUTER JOIN (
                    SELECT ..
                    FROM ..
                  ) AS ..
              ) AS ..
            ) AS ..
        ) AS base
    SQL
  end
 
  def down
    execute <<-SQL
      DROP VIEW foo_rankings
    SQL
  end
end

このマイグレーションを実行後、0xDBEなどのSQLエディタで対象のViewに対してクエリを発行して、思った通りのデータが取れているかを確認します。

View用のModelを作る

View用のモデルは普通に作れるので、定義してみましょう。

app/models/foo_ranking.rb
1
2
3
4
5
6
7
8
class FooRanking < ActiveRecord::Base
  belongs_to :foo
 
  scope :top10, -> { where(arel_table[:rank].lteq(10)) }
  scope :default_order, -> { order(category_id: :asc, rank: :asc) }
 
  default_scope { default_order }
end

データを取得してみる

では、このモデルを使ってみましょう。

トップ10のデータを取得
1
FooRanking.preload(:foo).top10
1つのカテゴリのランキングを取得
1
FooRanking.preload(:foo).where(category_id: 1)
カテゴリごとの10位までを取得してカテゴリ毎に分ける
1
FooRanking.preload(:foo).top10.group_by(&:category_id)

ものすごく複雑だったランキングデータの取得が、1行のコードで取れるようになりました。
あぁ、快…感…。

Viewいいね!でもまだ遅いね!

しかし、Viewは結局のところアクセスされるたびにクエリを発行して擬似的なテーブルを作っているため、複雑なビューだと遅いんですね。
200msecかかってたクエリ10回=2秒
よりは、
500msecかかるView1回
のほうが、かかる時間は短いわけですが、500msecは割と気になる遅さです。Kaminariを使ってページネーションする場合、トータル件数の取得と現ページのデータの取得で2回クエリが発行されて、結果、合計1秒かかったり。

しかし、それをさらに速くする方法があったのです。

MATERIALIZED VIEWにしてさらに高速に!

マテリアライズドビュー(以降、マテビュー)は、Viewの結果をキャッシュしておくViewです。結果を保有しているため、問い合わせが高速です。500msecかかっていたクエリが、2msecになりました!素晴らしい。
作り方は簡単です。CREATE OR REPLACE VIEWにしていたところを、CREATE MATERIALIZED VIEWにするだけです。

1
CREATE MATERIALIZED VIEW foo_rankings AS ...

しかし、マテビューは結果をキャッシュするがゆえの欠点もありますし、そのあたりも書いておきます。

  • PostgreSQL 9.3以降のみで使える
  • 定期的にデータをリフレッシュしなければならない
  • リフレッシュ中はデータがないためロックされる
  • RSpecでテストする場合にデータ投入後にリフレッシュ必須

私の利用シーンでは、ランキングは月に1度の更新でよかったので、マテビューを使うほうがよさそう!と思ってこちらを採用しました。PostgreSQL9.4だと、リフレッシュ中のロックを緩やかにする機能があるらしいので、9.4にできる場合は9.4のほうがよさそうです。

まとめ

ActiveRecordで表現するにはあまりにも複雑な場合はViewを作りましょう。
読みやすいコードにすることが可能です。
要件次第ではマテビューを使うとかなりの高速化になります。


HerokuでPostgresqlのみで全文検索はできない?

Herokuで運営しているサービスの高速化のために全文検索について調査していましたが、どうもHerokuのPostgresqlだけでは完結できそうもない、というところまでわかったので、どんなことを調べたのかメモしときます。

まず、Postgresqlでの日本語の全文検索はできますが、pg_bigm拡張、もしくはpg_trgm拡張が必要のようです。
個別のサーバ環境であれば、それを入れることができますが、Herokuの場合は難しそうです。pg_bigmは入れられません。そして、pg_trgmは有効にできますが、HerokuのPostgresqlの場合だと、検索対象になるデータがアルファベットと数字のみのようで、日本語はダメっぽいです。

参考リンク:
heroku dev center: Extensions, PostGIS, and Full Text Search Dictionaries on Heroku Postgres

Groongaという全文検索エンジンを使う方法もあるようでした。Rubyで扱えるRroongaを使うと簡単でHerokuでも動くし、Addonを必要としないのが利点のようです。buildpackを使ってGroongaがインストールされたDynoを作り、そこにGroongaのデータも持たせるということのようです。
しかし、Dynoは容量制限があるので、Groongaのデータがおそらく多く持てないということで、万が一不具合が起きても困るので(Dynoが持てる容量を増やすとかできないし)、ちょっと今回はパスしたいです。

参考リンク:
株式会社クリアコード: Heroku用Groongaのビルド方法
SlideShare: HerokuでGroonga

となると、残るはElasticsearchを使う方法になりそう。

誰かこうやったらHerokuのPostgresqlだけでいけるよとか、いい方法をご存知でしたら教えてください…。


ようやくEXPLAINとか使い始めた

先日のDB勉強会でEXPLAINとか使ったことないですし、と言っていた私ですが、運用中のサービスを見るとどう考えても遅いクエリがあったので調査のためにようやくEXPLAINを使いました。しかし読み方がわからない。とりあえずぐぐってみたら色々と興味深い内容があったので、ようやく理解できるようになってきました。

以下のスライドがとても参考になりました。
PostgreSQLクエリ実行の基礎知識 ~Explainを読み解こう~

ふむふむと思って読みすすめると、フルスキャンが走ってしまい、もうどうしようもなく遅いことが分かったので、手を打たないといけない。そこで、インデックスについても勉強しだしたのですが、部分一致検索(ILIKE ‘%パトラッシュ%’)とかだと全くインデックス効かないこととかわかりました。まぁなんか聞いたことあるな、程度には思っていましたが。

関数を作って、関数に対してインデックスを貼るということもできるんですね…。式インデックスというようです。
参考サイト:PostgreSQLで全角半角を区別しない問い合わせ

こういう場合は全文検索を使うのがよさそう、という情報を見たので、今のところは全文検索を行う方向で考えていますが、本番環境がHerokuなので、Herokuで動く全文検索の方法をこれから調べていこうと思います。


第9回中国地方DB勉強会で発表してきた

2015/06/06(土)に、中国地方DB勉強会 IN 米子が開催されました。
中国地方DB勉強会に参加するのは初めてだったのですが、地元の鳥取県での開催ということもあり、発表してみませんか?と声がかかったので、ActiveRecordを使う際の高速化についてのTipsの発表をしました。

当日のまとめについては、本家のサイトに情報がまとまっているので、そこにリンクをしておきます。

第九回 中国地方DB勉強会 in 米子

私の発表スライドは以下になります。
主に、HerokuとActiveRecordとPostGISについてです。

発表の反省点としては、

  • Railsに詳しくない人がいるのにgemの説明を適当にしてしまった
  • 3項目について話をしたので、もっと絞って話をしたほうがよかったのでは?
  • 講師側の理解が浅い項目まで盛り込んでしまった
  • New Relicはいろんな言語に対応してるって言えばよかった

というところ。しかしRubyのActiveRecordについての話になると、Railsやってない人にとっては面白くない時間になるだろうし、難しいところだなぁと思う。

発表のトップバッターだったので、あとはOracleの梶山さんのMySQL5.7の新機能についてや、中国地方DB勉強会主催の曽根さんの発表だったが、どちらもとても為になったので、動画を視聴することをお勧めする。

梶山さんの発表で、私の中で印象に残っているのはこんな感じ。

  • MySQL5.5から5.6〜5.7になってかなり良くなっている
  • MySQL5.6のドキュメントが日本語化された
  • MySQL Enterprise版のデータベースファイアウォール機能すごい(許可されたクエリ以外通さない)
  • MySQL WorkBenchが高機能で無料で使い易いらしい!
  • MySQL WorkBench上のVisual Explain すごいー!!(ボトルネックの可視化)

他にもMySQL Clusterとかの話やレプリケーションの話もあったのだけれど、使ったことがないのであんまりピンと来ていない。ただの経験不足ですね、はい。

曽根さんの発表で印象に残っているのはこんな感じ。

  • SQLでも変数を使うと便利。
  • row_number関数で行番号与えられる。
  • rank関数でランキング番号与えられる。
  • JetBrainsの出してる0xDBE(SQLエディタ)がOS問わず使えて便利っぽい

特にrank関数は、グループ化してランキングが出せるので、私がやっている業務で使えそうだった。ActiveRecordで値を取得して、ランキングはRubyで出力していたので、そこはSQLの世界で完結させることができればパフォーマンスアップも期待できそうだし、DBへのアクセス回数が1回で済むようになりそう(今はカテゴリー数と同じ回数アクセスしてる)。

いつもと違う勉強会に行くのは、刺激が得られる可能性もある反面、わからなさすぎてポカーンとしてしまって結局学びがない場合は時間の無駄になってしまうので、ハイリスク・ハイリターンだと思うが、今回は行ってよかったなと思う。また、地元である鳥取の勉強会に参加できたことはよかった。これを機に、地元で勉強会がもっと開催されるようになると嬉しい。