概要
RailsのActiveRecordを拡張するsearch_copという検索用のgemと、MySQLの全文検索機能を組み合わせることで、シンプルな全文検索を実装できます。
動作環境
はじめに
こんにちは、開発チームの丸尾です。今回は、Railsで簡単に全文検索を実装する方法について紹介します。
search_copは、シンプルな記述で複数のキーワードを使った検索を可能にするgemですが、実はMySQLの全文検索機能にも対応しています。 今回は、実際にsearch_copとMySQLの全文検索を組み合わせて使ってみた結果をお伝えしつつ、その設定方法と使い方についても解説していきます。
MySQLの全文検索のモードについて
- IN NATURAL LANGUAGE MODE
- IN BOOLEAN MODE
- WITH QUERY EXPANSION
search_copを使った全文検索機能はIN BOOLEAN MODE
を使った検索になります。それぞれのモードについての説明はここでは割愛します。
詳細は以下をご確認下さい。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10 全文検索関数
インストールと設定
search_copのインストール
Gemfileにsearch_copを追加し、bundle installを実行します。
gem 'search_cop'
モデルの作成
bin/rails generate model Book title:string content:text
- db/migrate/xxxxx_create_books.rb
class CreateBooks < ActiveRecord::Migration[7.0] def change create_table :books do |t| t.string :title t.text :content t.timestamps end end end
FULLTEXT INDEXを作成
bin/rails generate migration AddFulltextIndexToBooks
- db/migrate/xxxxx_add_fulltext_index_to_books.rb
class AddFulltextIndexToBooks < ActiveRecord::Migration[7.0] def up execute("ALTER TABLE books ADD FULLTEXT INDEX `index_books_on_fulltext` (`title`, `content`) WITH PARSER ngram") end def down execute("ALTER TABLE books DROP INDEX `index_books_on_fulltext`") end end
WITH PARSER ngram
オプションを使用することで、文字列をn文字単位で分割してインデックスを作成します(デフォルトは2文字です)。
日本語のようにスペースで単語が区切られていない言語に対して全文検索を効果的に行うためには、ngram全文パーサーが必須です。
参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10.8 ngram 全文パーサー
migrate実行
bin/rails db:migrate
FULLTEXT INDEXの作成を確認
mysql> SHOW INDEX FROM books \G *************************** 1. row *************************** Table: books Non_unique: 0 Key_name: PRIMARY Seq_in_index: 1 Column_name: id Collation: A Cardinality: 0 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: Index_comment: Visible: YES Expression: NULL *************************** 2. row *************************** Table: books Non_unique: 1 Key_name: index_books_on_fulltext Seq_in_index: 1 Column_name: title Collation: NULL Cardinality: 0 Sub_part: NULL Packed: NULL Null: YES Index_type: FULLTEXT Comment: Index_comment: Visible: YES Expression: NULL *************************** 3. row *************************** Table: books Non_unique: 1 Key_name: index_books_on_fulltext Seq_in_index: 2 Column_name: content Collation: NULL Cardinality: 0 Sub_part: NULL Packed: NULL Null: YES Index_type: FULLTEXT Comment: Index_comment: Visible: YES Expression: NULL 3 rows in set (0.01 sec)
正しくFULLTEXT INDEXが作成されました。
検索対象モデルにsearch_copの設定を記載
- app/models/book.rb
class Book < ApplicationRecord include SearchCop search_scope :search do attributes :all => [:title, :content] options :all, :type => :fulltext, :default => true end end
このようにsearch_scopeのオプションを使って、検索対象のカラムを指定します。
search_copを使ってみる
それでは実際に検索してみましょう。今回は以下のようなテストデータ(架空の本)を用意しました。
Book.all.select(:id, :title, :content) Book Load (1.7ms) SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` /* loading for pp */ LIMIT 11 => [#<Book:0x0000ffffab805560 id: 1, title: "月光のソナタ", content: "作家ほげさんによる、月明かりの下で繰り広げられるピアニストの挑戦と恋の物語。">, #<Book:0x0000ffffaac73e90 id: 2, title: "夢見る機械", content: "作家ふがさんによる、未来都市を舞台にした革新的な技術と人間の感情が交錯するサイエンスフィクション。">, #<Book:0x0000ffffaac73df0 id: 3, title: "静かなる波", content: "作家ぴよさんによる、海辺の小さな村を舞台にした老船乗りの過去と現在を織り交ぜた物語。">, #<Book:0x0000ffffaac73c10 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">, #<Book:0x0000ffffaac73990 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]
モデルのsearchメソッドを使って簡単に全文検索できます。
Book.search("ほげ").select(:id, :title, :content) Book Load (1.1ms) SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE (MATCH(`books`.`title`, `books`.`content`) AGAINST('ほげ' IN BOOLEAN MODE)) /* loading for pp */ LIMIT 11 => [#<Book:0x0000ffffab8e6ec0 id: 1, title: "月光のソナタ", content: "作家ほげさんによる、月明かりの下で繰り広げられるピアニストの挑戦と恋の物語。">, #<Book:0x0000ffffab8e6e20 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">, #<Book:0x0000ffffab8e6d80 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]
Book.search("ほげ 風に乗る").select(:id, :title, :content) Book Load (2.2ms) SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((MATCH(`books`.`title`, `books`.`content`) AGAINST('+ほげ +風に乗る' IN BOOLEAN MODE))) /* loading for pp */ LIMIT 11 => [#<Book:0x0000ffffab8e6240 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">]
Book.search("ロマンス小説").select(:id, :title, :content) Book Load (1.5ms) SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE (MATCH(`books`.`title`, `books`.`content`) AGAINST('ロマンス小説' IN BOOLEAN MODE)) /* loading for pp */ LIMIT 11 => [#<Book:0x0000ffffaca5e248 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]
このように、search_copを使えば、簡単な記述でMySQLの全文検索機能が利用できて、高速な検索も可能になります。
また、ついでに全文検索を使用しない通常のlike検索との速度比較をしてみましょう。
データが少ないと差が分かりにくいので追加で10万件入れてみます。
Book.count Book Count (4.2ms) SELECT COUNT(*) FROM `books` => 100005
以下は全文検索を使用した検索結果です。クエリ実行時間: 0.9ms
先の5件のデータの時と速度はあまり変わらないですね。(むしろ少し早くなってる!?
Book.search("ほげ 風に乗る").select(:id, :title, :content) Book Load (0.9ms) SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((MATCH(`books`.`title`, `books`.`content`) AGAINST('+ほげ +風に乗る' IN BOOLEAN MODE))) /* loading for pp */ LIMIT 11 => [#<Book:0x0000ffffab81de30 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。詩と自然の美しさを通じて人生の哲学を問う作品です。">]
以下は全文検索を使用しない場合の検索結果です。クエリ実行時間: 57.5ms
全文検索を使わない通常の検索では、TEXT型のカラムに対してインデックスを使った高速な検索ができません。そのため、大量のデータを検索する際には速度が大幅に低下します。
Book.search("ほげ 風に乗る").select(:id, :title, :content) Book Load (57.5ms) SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((((`books`.`title` IS NOT NULL AND `books`.`title` LIKE '%ほげ%' ESCAPE '\\') OR (`books`.`content` IS NOT NULL AND `books`.`content` LIKE '%ほげ%' ESCAPE '\\')) AND ((`books`.`title` IS NOT NULL AND `books`.`title` LIKE '%風に乗る%' ESCAPE '\\') OR (`books`.`content` IS NOT NULL AND `books`.`content` LIKE '%風に乗る%' ESCAPE '\\')))) /* loading for pp */ LIMIT 11 => [#<Book:0x0000ffffab934a30 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。詩と自然の美しさを通じて人生の哲学を問う作品です。">]
その差50倍以上!
まとめ
いかがでしたでしょうか。search_copと、MySQLの全文検索機能を活用することで、elasticsearch等の専用の検索エンジンを導入せず開発・運用コストを抑え、シンプルなアーキテクチャを維持できます。
また、複雑なSQLクエリを直接記述する必要がなく、DSLを使って直感的に検索条件を指定できるのも大きなメリットです。
参考リンク
- GitHub - mrkamel/search_cop: Search engine like fulltext query support for ActiveRecord
- MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10 全文検索関数
- MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10.8 ngram 全文パーサー
- Aurora MySQL 5.7とRailsで実現する全文検索機能 - dely Tech Blog
- 世界一わかりやすい FULLTEXT INDEX の説明と気を付けるべきポイント
採用情報
Ruby on Rails を使った Whitehealthcare に興味のある方はこちらから