ホワイトヘルスケア - テックブログ

ホワイトヘルスケアは、日本のヘルスケア領域における社会課題に正面から向き合い、現実世界を生きる人々の不安や痛みといった見過ごされるべきでない問題に対して本質的な解決に取り組むことで、持続的な医療システム・社会の実現を目指します。

Railsでお手軽全文検索 - search_copとMySQLの全文検索機能を活用

概要

RailsActiveRecordを拡張するsearch_copという検索用のgemと、MySQL全文検索機能を組み合わせることで、シンプルな全文検索を実装できます。

動作環境

はじめに

こんにちは、開発チームの丸尾です。今回は、Railsで簡単に全文検索を実装する方法について紹介します。

search_copは、シンプルな記述で複数のキーワードを使った検索を可能にするgemですが、実はMySQL全文検索機能にも対応しています。 今回は、実際にsearch_copとMySQL全文検索を組み合わせて使ってみた結果をお伝えしつつ、その設定方法と使い方についても解説していきます。

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: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]

sqlを見ると全文検索が正しく動いてそうですね。

このように、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を使って直感的に検索条件を指定できるのも大きなメリットです。

参考リンク

採用情報

Ruby on Rails を使った Whitehealthcare に興味のある方はこちらから

whitehealthcare.co.jp