アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

全件検索から全文検索へ (Rails で MySQL の全文検索)

こんにちは、tahara です。 ようやくいこーよを like '%foo%' の(SQL 力づく)全件検索から、MySQL全文検索に変更しました。

最初は mroonga を使おうかと思ったのですが、結局は MeCab を使って MySQL全文検索をそのまま使うことにしました。

MySQL全文検索MyISAM じゃないと動かないので ENGINE = MyISAM で検索用のテーブルを作成します。

class CreateFacilityIndices < ActiveRecord::Migration
  def up
    execute <<SQL
create table facility_indices (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `facility_id` int(11) NOT NULL,
  `content` text NOT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  FULLTEXT INDEX (content)
) ENGINE = MyISAM DEFAULT CHARSET utf8;
SQL
    Facility.find_each {|f|f.update_facility_index()}
  end

  def down
    execute "drop table facility_indices"
  end
end

検索したいテーブル(モデル)の after_save で上記のテーブ(facilities_indices)の登録、更新を行うようにします。 また、全文検索用のスコープも作成します。 order by で floor しているのは、同じようなスコアの場合は他の条件でソートしたいめです。

class Facility < ActiveRecord::Base
  after_save :update_facility_index
  has_one(:facility_index, :dependent => :destroy)

  def update_facility_index
    index = self.facility_index || self.build_facility_index
    index.update_from_facility
  end

  scope(:scoped_by_word,
        lambda { |text|
          joins(:facility_index).
          where("MATCH (facility_indices.content) AGAINST (?)", FacilityIndex.normalize(text)).
          order(sanitize_sql(["floor(MATCH (facility_indices.content) AGAINST (?)) desc", FacilityIndex.normalize(text)]))
        })
end

FacilityIndex では次のように、全文検索のインデックスを作ります。

  1. 検索対象の検索対象フィールドを一つも文字列にコンカチします。
  2. 地域と都道府県は全文検索結果スコアへの影響を大きくするために、単純に数回繰り返します。
  3. nkf で文字種の正規化を行います。
  4. MeCab で分解します。
  5. 助詞、助動詞は除きます。
  6. 原形があれば、原形を使うようにします。
  7. 「ある」などは検索上無意味なのでストップワードしとて除外します。
  8. MeCab 前にやっちゃうと、うまくいかないカタカナひらがな変換を行います。
  9. 以上のように正規化したものを FULLTEXT INDEX の付いたカラムに登録します。

検索時にはユーザの入力した検索ワードを同様に FacilityIndex::normalize で正規化して MATCH (...) AGAINST で検索します。 上記の Facility::scoped_by_word です。

# -*- coding: utf-8 -*-
require 'nkf'
require 'MeCab'

# 一文字からも検索できるように
# sudo vi /etc/mysql/my.cfg
# [mysqld]
# ft_min_word_len = 1
class FacilityIndex < ActiveRecord::Base
  belongs_to :facility

  # Facility.find_each {|f|f.update_facility_index()}
  def update_from_facility
    facility = self.facility
    # 都府県は除く
    prefecture_name = facility.prefecture_name.try(:gsub, /(都|府|県)$/, '')
    # region と prefecture は加重する。
    self.content = FacilityIndex.normalize("#{facility.name} #{facility.kana} #{facility.pr} #{facility.description} #{([facility.prefecture.region.name]*7).join(' ')} #{([prefecture_name]*5).join(' ')}#{facility.address} #{facility.search_keyword} #{facility.features.map(&:name).join(' ')} #{facility.tag_list.join(' ')}")
    self.save!
    self
  end

  class << self

    def normalize(text)
      # UTF8
      # 半角カタカナ => 全角カタカナ
      # MIMEはデコードしない
      # 全角アルファベット、全角スペースを半角に
      #
      # ひらがな、カタカナは変換すると MeCab トークナイザが
      # 正しく動かないのでそのままにする。
      text = NKF.nkf('-WwXm0Z1', text).gsub(/[\r、。・()「」【】!?]/, '')
      mecab = MeCab::Tagger.new
      node = mecab.parseToNode(text)
      s = ''
      while node
        features = node.feature.force_encoding('UTF-8').split(/,/)
        unless %w[助詞 助動詞].include?(features[0])
          # 原形を使う。
          word = features[6]
          # 原形がなければ、表層形を使う。
          word = node.surface.force_encoding('UTF-8') if word == '*'
          unless stop_word?(word)
            s += word + ' '
          end
        end
        node = node.next
      end
      # Mecab の後にカタカナをひらがなに変換する
      NKF.nkf('-Wwh1', s)
    end

    def stop_word?(word)
      # 下の select_stop_word でひっかかったワード
      return true if ["", "ある", "市", "OK", "いる", "施設", "する", "-", "お", "れる", "できる"].include?(word)
      false
    end

    # 全件の 50% にでてくるものは MySQL全文検索でひっかからないので、
    # そのようなワードをストップワードにする。
    def select_stop_word()
      hash = Hash.new(0)
      FacilityIndex.find_each do |x|
        x.content.split(/ /).uniq.each do |word|
          hash[word] += 1
        end
      end
      half = FacilityIndex.count / 2
      stop_words = hash.select do |k, v|
        v > half
      end
      puts stop_words
      stop_words.map {|k, v| k}
    end
  end
end

これで、全文検索ができました。 ある程度あいまいな検索ができるようになった上に、検索も速くなりました。

最後に、弊社ではシステムエンジニア、プログラマ、インフラエンジニアなどを募集しています。 おきがるにお問い合わせください。