RailsでMySQL InnoDBの全文検索機能とMeCabによる形態素解析を使って出来るだけ検索結果をヒットさせる

前のエントリーの通り、妻と自分用にレシピ検索アプリを作りました。
ソースコードこちら

出来るだけ検索結果で、食材をいい感じでヒットさせるために色々やったので、記録しておきます。
あと、検索結果を出来るだけヒットさせることに、全文検索機能は実はあんまり関係ないので、タイトルは釣りです。
いい感じでヒットするというのは、例えば白菜、はくさい、ハクサイでそれぞれ検索しても白菜、はくさい、ハクサイで登録されたデータが全てヒットするという状態です。これにプラスして、レシピタイトルのあいまい検索が可能になる、というのがゴールです。
要点としては、表記にゆらぎがあっても検索で出来るだけヒットさせるために、データ登録時にMeCab形態素解析して読みがなも登録する、検索時もMeCab使う、collate(照合順序)にutf8_unicode_ciを使用する、です。



妻の要望、実際の使い方は以下の様な感じ。
食材メインでの検索。献立的アプローチ。


何日間かの献立を作成するため、今ある食材を使うレシピを探す。

レシピを作るのに足りない使用食材と、料理本の掲載ページ、レシピ名を買い物メモ兼献立メモに書き足す。

メモを持って足りない食材を買いに行く


一方、自分の要望、使い方
レシピ名で検索するのがメイン。索引代わり


今ある食材を確認して、その食材を使うレシピをいくつか思い出す。

あのレシピどの料理本にのってたかな?レシピ名で曖昧検索、料理本の掲載ページを確認

料理本を見て、食材がたりなかったら適当にあるもので作る、アレンジする


妻と私では、検索ではあるのですが、求めているものが違います。
妻の場合は、食材で検索し、レシピで使う食材が出来るだけヒットして欲しい。
それに対して、私は料理名が曖昧検索出来るといい。


使い勝手的に、検索フォームはシンプルに一つだけのほうが混乱がなくわかりやすいと思ったので、フォームは一つだけにして内部的に妻の使い方でも、自分の使い方でも、ヒットするように実装しました。

レシピのデータ構造は、検索時に食材名を厳密にヒットさせるなら食材をマスタにして正規化する、タグ等にして補完で入力をカバーするなどのアプローチも考えられます。
しかし、それでは入力、マスター管理が面倒になってしまいますし、例えば豚肉、豚バラ薄切り、豚コマ切れ肉、など等、表記の揺れがどうしてもでてきてしまいます。
今回は、時間のあるときにお互いに少しずつレシピを手入力していくことにしたので、妻が出来るだけ簡単に入力できるように、タイトル、レシピ本の情報をstringで、その他食材等の情報をtextと全て自由記述で保存し、検索で頑張ることにしました。

以下の様なデータで、実際にそれぞれの要望をどう解決していったのか見ていきます。


料理名:豚肉と白菜のうま煮
料理本・掲載ページ:料理本A 20ページ
食材:

 白菜
 豚肉
 人参
 しいたけ

料理名:なんとか鍋
料理本・掲載ページ:料理本A 42ページ
食材:

 はくさい
 ・・・・

料理名:味噌汁
料理本・掲載ページ:料理本B 30ページ
食材:

 ハクサイ
 ・・・・


このデータに対して、妻の使い方ですと、白菜、はくさい、ハクサイ等の検索ワードでも、表記にゆらぎがある食材に検索でいい感じにヒットすればいいわけです。自分の場合はいわゆるLike検索ができればOKです。


全てヒットするために一つずつ解決していきました。

はくさい、ハクサイの検索でひらがな、カタカナ両方にヒットするように

これは、collate(照合順序)をutf8_unicode_ciにすればヒットするようになります。データベース全体がutf8_unicode_ciになるのは嫌だったので、検索用にutf8_unicode_ciの別テーブルを作成、レシピ登録時にレコードを生成するようにしました。
https://github.com/katahirado/kkfoodstuff/blob/master/config/database.yml.sample
https://github.com/katahirado/kkfoodstuff/blob/master/db/migrate/20140318065327_create_search_contents.rb
ちなみにテストのDBは、デフォルトだと、schema.rbから生成されて、migrationのoptions: 'COLLATE=utf8_unicode_ci'が無視されて、ひらがなでカタカナにヒットさせるといった検索のテストが失敗してしまうので、SQLダンプファイルをrake db:structure:dumpで作成の上、config/application.rb で、db/schema.rb ではなく db/structure.sql を吐くよう指定しました。

config.active_record.schema_format = :sql

漢字で登録された食材の白菜に、ひらがなやカタカナで検索してもヒットさせるように

形態素解析を利用して、読みがなも登録してしまうという方法を取りました。
mecabmecab-ipadicをインストール、RubyMeCabを繋ぐgemはnattoを利用しました。
豚肉と白菜のうま煮の食材部分"白菜\n豚肉\n人参\nしいたけ"を処理して下記のようになるようにしました。

Analyze.parse(recipe.content)
=> "白菜 ハクサイ 豚肉 ブタニク 人参 ニンジン しいたけ シイタケ"

これをレシピ名も同様の処理を施して、料理登録時に、検索用のテーブルに格納しました。
これでひらがな、カタカナで検索しても漢字で登録された食材にヒットするようになりました。
ついでにFULLTEXT INDEXも貼ってます。
https://github.com/katahirado/kkfoodstuff/blob/master/db/migrate/20140320120819_add_fulltext_index_to_search_contents.rb


参考

白菜と漢字で検索しても食材がはくさい、ハクサイとなっているレシピにヒットさせる

上記が出来たので簡単です。検索ワードをMeCabでparseして、読みがなと元の検索ワードをORで検索すればヒットします。
ちなみに検索ワードを形態素解析でばらしたりせず、読みがなを付与するだけにしています。
これは、例えば'はくさい'というキーワードが入力された場合に形態素解析してしまうと、"は くさい"などとなり、'は'が助詞と解釈されるといった、意図に反するような場合が出るためです。
投げられるSQLはこんな感じです。

SELECT `search_contents`.* FROM `search_contents`  WHERE match(search_contents.title,search_contents.content) against('白菜 ハクサイ' in boolean mode)

白菜のうま煮など、レシピ名のあいまい検索でヒットさせる

データ件数的には2000件弱程度なので、like検索でも問題ないのですが、NgramとBoolean Full-Text Searchesを利用しました。
これもレシピ登録時に、検索用のテーブルにngram(bi-gram)変換したデータを登録しています。
検索ワードもngram(bi-gram)変換しています。
Ngramを使った部分だけのSQLではこんな感じです。

SELECT `search_contents`.* FROM `search_contents`  WHERE match(search_contents.title_ngram,search_contents.content_ngram) against ('+(+白菜 +菜の +のう +うま +ま煮)' in boolean mode)

わかち書きでの全文検索も検討したのですが、検索ワードの例で出したように例えば"はくさいの〜"というレシピ名を形態素解析すると、"は くさい の 〜"などと、はが助詞と解釈されたりして、うぐぐとなったので、手っ取り早くNgramにしました。


参考

検索ワードがスペースで区切られていた場合はAND検索

"白菜 豚肉"と食材が入力される場合、"豚肉と うま煮"などのようにレシピ名の絞り込みとして入力される場合があります。
Ngramに対しては、要素ごとに条件を生成してそれをANDでつないで検索します。
食材の場合は検索ワード、読みのORのペアごとに条件を生成し、それをANDでつないで検索します。
投げられるSQLはそれぞれこうなります。

SELECT `search_contents`.* FROM `search_contents`  WHERE ((match(search_contents.title,search_contents.content) against('白菜 ハクサイ' in boolean mode) AND match(search_contents.title,search_contents.content) against('豚肉 ブタニク' in boolean mode)) OR match(search_contents.title_ngram,search_contents.content_ngram) against ('+白菜' in boolean mode) AND match(search_contents.title_ngram,search_contents.content_ngram) against ('+豚肉' in boolean mode))

SELECT `search_contents`.* FROM `search_contents`  WHERE ((match(search_contents.title,search_contents.content) against('豚肉と ブタニクト' in boolean mode) AND match(search_contents.title,search_contents.content) against('うま煮 ウマニ' in boolean mode)) OR match(search_contents.title_ngram,search_contents.content_ngram) against ('+豚肉 +肉と' in boolean mode) AND match(search_contents.title_ngram,search_contents.content_ngram) against ('+うま +ま煮' in boolean mode))

検索結果は読みがな順

レシピ登録時に料理名の読みがなを検索用テーブルに登録するようにして、検索結果の並び順に使用しました。

検索ワードが一文字の場合に対応する

だいぶいい感じにヒットしてくれるようになりましたが、まだ問題が残っていました。
ngramをbi-gramで処理しているので、一文字の検索ワードが来ると、空でSQLに投げてしまい、エラーになってしまいます。
色々方法はあろうかと思いますが、そもそも全文検索を使う意味があるほどのデータ量でもないので、検索ワードに1文字の単語が来た場合のみ、Ngramではなく、LIKE検索で逃げることにしました。

SELECT `search_contents`.* FROM `search_contents`  WHERE (match(search_contents.title,search_contents.content) against('+(豚 ブタ) +(煮 ニ)' in boolean mode) OR (origin_title LIKE '%豚%'  AND origin_title LIKE '%煮%' ) OR (origin_content LIKE '%豚%'  AND origin_content LIKE '%煮%' ))  ORDER BY `search_contents`.`title_yomi` ASC

その他

collate(照合順序)にutf8_unicode_ciを使うと、前述のとおり、ゆらぎを吸収して検索が出来るわけですが、それには相応のデメリットもあります。
例えばウドを検索しようとすると、ゴボ'ウと'ササミのサラダとか、サヤエンド'ウと'豚バラの〜とか、ザワークラ'ウト'などがヒットしたりします。
ただ、これは逆にいうと、例えば粉山椒にサンショウがヒットするということでもあるので、やむを得ない部分かなと思います。
今回色々富豪なアプローチをしていますが、データ件数が2000件弱と少ないですし、一度登録後はめったにデータ更新もされないと思うのでよしとしています。

実際の処理はソースコードを読めばだいたい分かるかと思います。

以上。