MENU

MySQLで全文検索その1(Mroonga編)

こんにちは、お久しぶりです。虎の穴ラボのA.M.です。

前回の記事「全文検索エンジンについて調べてみた」から大分間が空いてしまいましたが、今回はとらのあなのサービスで使用頻度の高いMySQLで、実際に全文検索をやってみたいと思います。

MySQLで手軽に全文検索を実現するための手法としては、以下の2つが挙げられます。

  • MySQLのFULLTEXTインデックス:MySQLの標準機能。v5.7から日本語に対応。
  • Mroonga:GroongaベースのMySQLストレージエンジン。

※FULLTEXTインデックスやMroonga、Groongaなどについて詳しくは前回の記事や、第3回ライトニングトークイベント記事をご参照ください。

toranoana-lab.hatenablog.com

toranoana-lab.hatenablog.com

今回は、記事のタイトルにもあるように、Mroongaを使って全文検索をやってみたいと思います。

なお、今回はトークナイザーにN-gram(Bigram)を使用します。 N-gramは検索漏れが発生しにくく、完全一致検索をしたい場合などに向いています。

開発環境

開発環境は以下になります。

  • MacOS Catalina バージョン10.15.3
  • MySQL 5.7.28
  • Mroonga 9.09
  • Ruby 2.6.5
  • Rails 6.0.1
  • Docker version 19.03.8
  • docker-compose version 1.25.4

手動でMroongaをインストールしても良いですが、MySQL+MroongaのDockerイメージが公式で用意されているので、今回はそちらを使用します。

※DockerやRuby、Railsのインストール方法については本記事では解説しません。

※RubyやRailsのバージョンは異なっていても問題ありません。

MySQL+Mroonga環境の用意

まずは以下のような構成でファイルやディレクトリを作成します。

mysql_search_sample
├── db
│   ├── data
│   └── my.cnf
├── docker-compose.yml
└── src

docker-compose.ymlの内容は以下のように記載します。

version: '3'
services:
  mroonga:
    # mroongaのDockerイメージを指定。mysql5728_mroonga909はバージョン指定。
    image: groonga/mroonga:mysql5728_mroonga909
    environment:
      MYSQL_USER: root
      TZ: 'Asia/Tokyo'
    ports:
      # ローカルにMySQLが存在しているのでポート番号を変更しています
      - 3308:3306
    volumes:
      # DBのデータを保持するための設定
      - ./db/data:/var/lib/mysql
      # 自前で作成した設定ファイルを反映する設定
      - ./db/my.cnf:/etc/mysql/my.cnf

MySQLの設定ファイルであるmy.cnfの内容は以下のように記載します。

my.cnf

[mysqld]
innodb_ft_min_token_size=2
mroonga_default_parser=TokenBigram
  • innodb_ft_min_token_size: innoDBの全文検索の最小文字数。日本語は2文字で意味を表すものが多いので2を指定。
    ※2020-03-31 19:00 Groonga公式よりご指摘がありましたので追記
    innodb_ft_min_token_sizeはinnoDBの設定なので、Mroongaを使用する場合は使用されません。そのため、今回は指定しなくても問題ありません。

  • mroonga_default_parser: Mroongaのデフォルトで使用するトークナイザー。デフォルトでBigramなので、今回は指定しなくても問題ないですが、あえて明示的に指定しています。

以下のコマンドで環境を起動します。

$ docker-compose up
Starting mysql_search_sample_mroonga_1 ... done
Attaching to mysql_search_sample_mroonga_1

データを取得して保存するためのアプリを作る

次に、データを取得して保存するためのアプリの実装を行います。

アプリはプロジェクトルートのsrcディレクトリmysql_search_sample/src 配下に作成します。

※RubyやRails、プロジェクト作成の手順は本記事の趣旨とは異なりますので省略します。

※以下、Railsプロジェクト作成済みの状態を前提として解説していきます。

モデルとMigrationファイルの作成

モデルとMigrationファイルを作成します。

bundle exec rails g model mroonga_documents

Migrationファイルの内容は以下のようになります。

  • Mroongaを使用するテーブル
class CreateMroongaDocuments < ActiveRecord::Migration[6.0]
  def change
    create_table :mroonga_documents, options: 'ENGINE=Mroonga' do |t|
      t.text :title
      t.text :body

      t.timestamps
    end
    add_index :mroonga_documents, :body, type: :fulltext
  end
end

Mroongaを使用するためにオプションにENGINE=Mroongaを指定しています。

また、全文検索を行うカラムには全文検索インデックスとしてtype: :fulltextを指定しています。

続いて、DBを作成し、Migrationを実行してテーブルを作成します。

$ bundle exec rails db:create
$ bundle exec rails db:migrate

テストデータ取得処理の実装

続いて、テスト用データを取得して登録するためのRakeタスクを作成します。 今回はQiitaAPIでQiitaの記事を、フォローが多い順の上位10件のタグごとに1000件(100件×10ページ)ずつ取得してDBに登録します。

import_data.rake

require "open-uri"
require "json"

namespace :import_data do
  desc "Import data from Qiita"
  task :qiita => :environment do
    # タグをフォローが多い順から10個取得
    tags_url = "https://qiita.com/api/v2/tags?page=1&per_page=10&sort=count"
    tags_array = []
    open(tags_url) do |tags_json|
      tags = JSON.parse(tags_json.read)
      tags.each do |tag|
        tags_array.push(tag["id"])
      end
    end
    puts "タグ一覧"
    puts tags_array.join(',')
    sleep 60
    tags_array.each do |tag|
      puts "tag: #{tag} のデータを取得開始"
      (1..10).each do |page_num|
        puts "#{page_num}ページ目取得"
        url = "https://qiita.com/api/v2/items?page=#{page_num}&per_page=100&query=tag:#{tag}"
        puts url
        open(url) do |entries_json|
          entries = JSON.parse(entries_json.read)
          entries.each do |entry|
            begin
              MroongaDocument.create(title: entry["title"], body: entry["body"])
            rescue => e
              # たまに保存に失敗する記事があるのでエラー時はスキップ
              puts "保存に失敗したのでスキップ"
              puts entry["title"]
            end
          end
        end
        # QiitaAPI(v2)は未認証の場合は1時間に60回なので、1分間隔で実行
        sleep 60
      end
      puts "タグ: #{tag}のデータ取得完了"
    end
  end
end

ここまで出来たら、Rakeタスクを実行してデータを取得します。

$ bundle exec rails import_data:qiita
タグ一覧
Python,JavaScript,Ruby,Rails,PHP,AWS,iOS,Java,Android,Swift
tag: Python のデータを取得開始
1ページ目取得
https://qiita.com/api/v2/items?page=1&per_page=100&query=tag:Python
2ページ目取得
https://qiita.com/api/v2/items?page=2&per_page=100&query=tag:Python
...

概算で100分くらいかかるので、しばらく放置しておきましょう。

※QiitaAPIの認証を行えば1時間あたりのリクエスト上限が1000回になるので、早く済ませたい場合は公式ドキュメントを参照して設定してください。

検索ロジックの実装

モデルに検索ロジックを実装します。

Mroongaを使用するテーブルには全文検索を行う処理を実装します。

mroonga_document.rb

class MroongaDocument < ApplicationRecord
  scope :full_text_search, -> (query) {
    where("MATCH(body) AGAINST(? IN BOOLEAN MODE)", "*D+ #{query}")
  }
end

実際に実行されるクエリは以下のようになります。

SELECT `mroonga_documents`.* FROM `mroonga_documents` WHERE MATCH(body) AGAINST('*D+ #{query}' IN BOOLEAN MODE);

ここでこのクエリについて解説します。

MATCH() ... AGAINST 構文

MySQLで全文検索を行うための関数です。WHERE句に指定する条件式として以下のように使用します。

MATCH (カラム1, カラム2, ...) AGAINST ({検索キーワード} [{検索モード}])

検索モード

検索モードは以下の3つです。

  • 自然言語全文検索: 形態素解析を使用して検索キーワードを意味のある単語として解釈して検索するモード
  • ブール全文検索: 特別なクエリー言語のルールで検索文字列を解釈するモード。演算子としてANDを表す+や、NOTを表す-などが使用可能。
  • クエリー拡張を使用した全文検索: 自然言語全文検索を改善したもの

※詳しくはMySQLのリファレンスをご参照ください。

今回はブール全文検索を使用するめに、IN BOOLEAN MODE を指定しています。

*D+ について

Dはデフォルトの演算子を指定するためのプラグマ(クエリーの実行方法を指定するためのメタデータ)です。

プラグマは*から始まるというルールがあるので、*Dと指定します。

今回は*D+と指定しているので、デフォルトでAND検索を行うという意味になります。

例を挙げると、以下の条件式は「Ruby」と「Docker」の両方を含むものと解釈できます。

MATCH(body) AGAINST('*D+ Ruby Docker' IN BOOLEAN MODE)

複数キーワードを半角スペースで区切っているだけですが、*D+の指定によりAND検索として実行されます。

OR検索をする場合は以下のように演算子ORをキーワード間に入れます。

MATCH(body) AGAINST('*D+ Ruby OR Docker' IN BOOLEAN MODE)

また、NOT検索をする場合は以下のように演算子-を除外したいキーワードの前に指定します。

MATCH(body) AGAINST('*D+ Ruby -Docker' IN BOOLEAN MODE)

他にも使用可能な演算子やプラグマがあるので、もっと詳しく知りたい方はMroongaの公式リファレンスをご参照ください。

実際に検索してみる

データ取得が完了したら、Railsコンソールで検索処理を実行してみます。

$ bundle exec rails c
Loading development environment (Rails 6.0.1)
  • 「Ruby」を含む記事を検索
irb(main):001:0> MroongaDocument.full_text_search("Ruby").count
   (151.7ms)  SELECT COUNT(*) FROM `mroonga_documents` WHERE (MATCH(body) AGAINST('*D+ Ruby' IN BOOLEAN MODE))
=> 368
  • 「Ruby」と「Docker」の両方を含む記事を検索
    条件が厳しくなっているので件数は減ります。
irb(main):002:0> MroongaDocument.full_text_search("Ruby Docker").count
   (79.9ms)  SELECT COUNT(*) FROM `mroonga_documents` WHERE (MATCH(body) AGAINST('*D+ Ruby Docker' IN BOOLEAN MODE))
=> 24
  • 「Ruby」または「Docker」の両方を含む記事を検索
    条件が緩くなるので、件数が一番多くなります。
irb(main):003:0> MroongaDocument.full_text_search("Ruby OR Docker").count
   (134.6ms)  SELECT COUNT(*) FROM `mroonga_documents` WHERE (MATCH(body) AGAINST('*D+ Ruby OR Docker' IN BOOLEAN MODE))
=> 687
  • 「Ruby」を含み、「Docker」を含まない記事を検索
    「Ruby」のみの検索結果368件から数が減っていることが確認できます。
    ちょうど「Ruby」と「Docker」の両方を含む場合の検索結果24件分が除外されています。
irb(main):004:0> MroongaDocument.full_text_search("Ruby -Docker").count
   (120.5ms)  SELECT COUNT(*) FROM `mroonga_documents` WHERE (MATCH(body) AGAINST('*D+ Ruby -Docker' IN BOOLEAN MODE))
=> 344

以上の検索結果件数から、意図した条件で検索できていると言えるでしょう。

まとめ

Dockerを利用することで、全文検索の実行環境はわりと簡単に構築することができますので、ご興味ある方はぜひお手元で試してみてください。
(環境構築よりもテストデータの用意の方が実は時間がかかってたりします。。)

今回はN-gramを使用してブール全文検索を行いましたが、次回の記事では、自然言語処理(形態素解析)を使用した自然言語全文検索についてご紹介したいと思います。

P.S

4/22(水) オンラインにて虎の穴ラボによるLT会を開催します!! ぜひご参加ください!!

yumenosora.connpass.com

また、残念ながら、COVID-19の感染被害の防止の為中止となった技術書典ですが、虎の穴ラボでは用意していた同人誌を技術書典 応援祭にて0円にて頒布しております。 ぜひ御覧ください。 techbookfest.org

加えて今回は、有償版の同人誌も作成しております。Go、Kotlin、Rustに関連する内容をまとめた、内容の濃い薄い本になります。 こちらも、ぜひ入手してお楽しみください。 techbookfest.org

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。 虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今季何見ました?」といったオタクトークであったり、何でもお応えします。

WantedlyLAPLASでの採用も行っております)

yumenosora.co.jp

news.toranoana.jp