MySQLの全文検索で重み付けを操作して特定のカラム優先度を上げる(Rails)

RailsでMySQLの全文検索(FULLTEXT INEDX)を使っていて、検索精度を向上させた工夫の例。

特定のフィールドを優先して検索したいという要件に応えたかった。

やったことは大きく4つ。

  1. 検索対象カラムの分割
  2. 特定カラムへの重み付け
  3. スコア閾値による足切り
  4. スコア降順ソート

前提: ngram parserで日本語対応

MySQLのFULLTEXT INDEXはデフォルトだと単語の区切りにスペースを使う。

英単語ならそれでいいんだけど、日本語のようにスペースで単語が区切られない言語には、ngram parserを使う。

ngram parserはテキストをN文字(デフォルト2文字)の連続する文字列に分割してインデックスを作る。

例としてよく出てくる「東京都」なら「東京」「京都」という2つのトークンになる。

完全な形態素解析ではないけど、日本語の全文検索としては十分実用的。

CREATE FULLTEXT INDEX index_name ON table_name (column_name) WITH PARSER ngram;

Step 1: 検索対象カラムの分割

もともとはこんな感じで、検索用テーブルに body という1つのTEXTカラムを持たせていた。

この記事の例としては、articlesというテーブルがあって、その検索データを格納するsearchesが小テーブルとして存在する、という構成にしている。

どちらも架空のテーブル。

書いていて思ったけど、このサンプルくらいの規模のテーブルなら、直接articlesにfulltext indexでもいいと思う。

実際のものはもっと大規模かつ、更新も横からたくさん飛んでくるので、テーブルを分けて管理している。

# 検索テーブル
create_table :searches do |t|
  t.references :article, null: false
  t.text :body, null: false  # ここに全部詰め込んでいた!
end

# 検索時
where("MATCH(body) AGAINST(? IN NATURAL LANGUAGE MODE)", query)

body の中身はタイトル、カテゴリ、タグなどをスペースで結合した文字列。

検索自体はできるけど、何にマッチしたのか分からないし、フィールドごとの重み付けもできない。

これを個別カラムに分割する。

class AddSearchColumnsToSearches < ActiveRecord::Migration[8.0]
  def up
    add_column :searches, :title, :string, null: false, default: ''
    add_column :searches, :category_names, :string, null: false, default: ''
    add_column :searches, :tag_names, :string, null: false, default: ''

    execute <<-SQL
      CREATE FULLTEXT INDEX index_searches_on_text_columns
      ON searches (title, category_names, tag_names)
      WITH PARSER ngram
    SQL
  end
end

複合カラムのFULLTEXTインデックスを1つ作れば、全カラムを横断した検索ができる。

where("MATCH(title, category_names, tag_names) AGAINST(? IN NATURAL LANGUAGE MODE)", query)

Step 2: 特定カラムに重みを付ける

「タイトルで検索したときはタイトルが一致するものを上位に出したい」みたいな要件が出てくる。

MySQLのFULLTEXT検索には MATCH...AGAINST がスコア(関連度)を返す性質がある。これを利用して、特定カラムのスコアに係数をかけて合算する。

ただし、1つの MATCH 式で使えるカラムは、同じFULLTEXTインデックスに含まれるカラムだけ。

特定カラムだけの MATCH をするには、そのカラム単体のFULLTEXTインデックスが別途必要。

-- 複合インデックス(全カラム横断検索用)
CREATE FULLTEXT INDEX index_searches_on_text_columns
ON search_entries (title, category_names, tag_names)
WITH PARSER ngram;

-- 単体インデックス(重み付け用)
CREATE FULLTEXT INDEX index_searches_on_title
ON search_entries (title)
WITH PARSER ngram;

検索クエリはこうなる。

SELECT *
FROM searches
WHERE MATCH(title, category_names, tag_names)
        AGAINST('面白いタイトル' IN NATURAL LANGUAGE MODE)
    + MATCH(title)
        AGAINST('面白いタイトル' IN NATURAL LANGUAGE MODE) * 2.0
    > 0;

全カラムの基本スコアに加えて、title にマッチした分のスコアを2倍にして加算している。 タイトルにヒットした行は実質3倍のスコアになるので、検索結果の上位に来る。

Railsで書くとこうなる。

class QuerySample < ApplicationRecord
  COMPANY_NAME_WEIGHT = 2.0
  SCORE_THRESHOLD = 3.0

  class << self
    def search_by_fulltext(query)
      score_expr = "MATCH(searches.title, searches.category_names, searches.tag_names) AGAINST(:q IN NATURAL LANGUAGE MODE)" \
                   " + MATCH(searches.title) AGAINST(:q IN NATURAL LANGUAGE MODE) * :w"
      binds = { q: sanitized, w: COMPANY_NAME_WEIGHT }

      joins(:atricle)
        .where("#{score_expr} > :threshold", **binds, threshold: score_threshold)
        .order(Arel.sql(sanitize_sql_array(["#{score_expr} DESC", **binds])))
    end

    # これはお好みに応じて
    def score_threshold
      Rails.env.test? ? 0.0 : SCORE_THRESHOLD
    end
  end
end

Step 3: スコア閾値で足切り

ngram parserは部分一致でマッチするため、関連度の低い結果も返ってくることがある。

MATCH...AGAINST が返すスコアに閾値を設けて、低スコアの結果を除外する。

SCORE_THRESHOLD = 3.0

where("#{score_expr} > :threshold", **binds, threshold: score_threshold)

閾値の適切な値はデータ量やカラム内容によって変わるので、本番データでスコアの分布を見ながら調整が必要。

SELECT *,
  MATCH(title, category_names, tag_names)
    AGAINST('キーワード' IN NATURAL LANGUAGE MODE) AS score
FROM search_entries
WHERE MATCH(title, category_names, tag_names)
  AGAINST('キーワード' IN NATURAL LANGUAGE MODE) > 0
ORDER BY score DESC;

こういうクエリを本番のコンソールで叩いてみて、スコアの分布を確認してから閾値を決めるのが良い。

ただし、テスト環境ではデータが少なくスコアが低くなるので、閾値を0にしないとテストが通らない。

もちろんちゃんとテストデータを作ってもいい。

def self.score_threshold
  Rails.env.test? ? 0.0 : SCORE_THRESHOLD
end

Step 4: スコア降順ソート

MATCH...AGAINST のスコアは ORDER BY にも使える。同じ式を order に渡せばスコアが高い順に並ぶ。

joins(:product)
  .where("#{score_expr} > :threshold", **binds, threshold: score_threshold)
  .order(Arel.sql(sanitize_sql_array(["#{score_expr} DESC", **binds])))

Arel.sql を使ったら負けな気もするけど、こんなところか。

検索データの更新

検索テーブルのデータは、元データの更新時に連動して更新する必要がある。

既存データがある場合はバッチ処理などを作って、更新をしておく。

まとめ

しっかり全文検索をしたいなら、ElasticsearchとかAlgoliaなどの検索エンジンを導入することになるだろう。

それでもMySQLひとつあれば全文検索できるのは導入までの敷居が低いのがよい。

すでにMySQLを使っていれば、負荷に関する調査をする必要あれど気軽に始めることができる。

そして、Elasticsearchなんかは使い始めたら、維持管理が面倒くさい。

以前よりは楽になっていると思うけど、それでもシステム構成はできるだけシンプルな方がいい。

MySQLが入っていればレンタルサーバーでも使えるし、いろんなところで活用できると思う。