| @技術/プログラミング

MySQL だけでお手軽に全文検索ができるということを知らなかった。 MySQL 5.6 から入っていたようだった。 Tantivy および Tantiny を使ったやり方を以前記事に書いてサイトで実装しているが、 MeCab によるトークナイズでは二文字の熟語がセットになって四文字になっているようなパターンを取り逃すことがあった(「関連記事」は「関連」と「記事」に分割され、「関連」や「記事」というキーワードで検索したときにはヒットするが「関連記事」で検索するとヒットしない)し、記事追加時の検索インデックス更新処理が不要( MySQL にレコードが追加されたときに勝手に更新される)なので試してみることにした。

やり方は以下の記事を参考にした。

最初にデータベースに全文検索用のインデックスを作成した。

ALTER TABLE `entries` ADD FULLTEXT INDEX index_entry_fulltext(title, body) WITH PARSER ngram;

その後、検索部分のコードを書き換えて以下のようにした。

class Entry < ActiveRecord::Base
  scope :search,
        ->(words) {
          return all if words.blank?
          where('MATCH (entries.title, entries.body) AGAINST (? in BOOLEAN MODE)', words)
        }
end

めっちゃ簡単。

このブログは記事数が 1500 記事くらいなのでぶっちゃけ LIKE 検索でも実用的な速度( 100msec 以内)で結果を取得できるが、 FULLTEXT インデックスを使うと 10msec 程度で結果を取得できる。

ただし Tantivy と比べて劣る点もあって以下は注意が必要。

  1. なぜかわからないが Vim で検索すると何もヒットしない。また Rails で検索すると Rails について触れていない記事もヒットする。 ngram によるインデックスというのはこんなものなのかもしれない。検索ワードが日本語のときはいい感じに結果が表示される。
  2. 複数のテーブルにまたがるデータを一個の検索インデックスにまとめることができない。例えば Tantivy のインデックスは記事のタイトル、本文、カテゴリー、タグをインデックス対象としているが、 MySQL の FULLTEXT インデックスだとテーブルごとにしかインデックスを作れないので(当たり前)、複数のテーブルにまたがる検索をするときにはテーブルを JOIN するしかない。 OR マッパーを使っている場合には利用しづらい。

1 の問題に関しては、 MySQL 5.7 からインデックス生成時の PARSER に MeCab などを指定できるようになったのでそうすると回避できるかもしれない。ただし MeCab のインストールや設定を行う必要があるので要注意。

2 の問題に関しては全文検索システムを入れた方が良さげ。 Tantivy であれば非常に簡単に導入できる。

現状、このサイトでは右上の検索窓から検索したときのインクリメンタルサーチとアーカイブページでの絞り込みは Tantivy を、インクリメンタルサーチの結果で必要な情報が得られなかったときの「全文検索する」と 404 Not Found ページの検索は MySQL の全文検索を使うようにしている。

二つの検索

| @ブログ

グローバルナビゲーション(右上の白い領域)内の検索ボタンを押したら Alfred 風のモーダル検索フォームが開いて、そこにキーワードを入力するとインクリメンタルサーチが実行されて逐次検索結果の記事が表示されるようにした。

これまでだと検索すると Archives ページの絞り込み検索に飛ばすだけだったが、 Archives ページに遷移せずに検索できるようになった。また Archives ページだと時系列順でしか検索結果が表示されないが、インクリメンタルサーチではマッチ度順に関連度の高いものを表示するようにしている。ただし表示するのは上位 10 件だけにして、それ以上は Archives ページで時系列順の検索に飛ばしている。

昔ながらのブログの検索 UI には不満がある。ページネーションで何ページも辿って検索結果を見ていくのは大変だし、大抵並び順が時系列順で自分が最も用事がありそうな記事に辿り着くのに時間がかかる。自分のブログの検索はタイトルのみ表示されればよくて本文のプレビューは不要だし(著者だからタイトルを見ただけでどんな記事なのか大体わかる)、何ページもページネーションせずに一覧でガッと検索結果を見たい。それに結果は時系列順ではなく関連度が高い順に並んでいて欲しい。キーワードを一部だけかすってるような最近の記事が最も関連度が高い記事を差し置いて最上位に表示されるのはいまいちだ。

今回作った Alfred 風インクリメンタルサーチではこれらの問題が解消されていて非常に満足。自分にとって自分のブログが世の中の情報の中で一番参照頻度が高いし、そのブログで効率的に情報を取り出せるのは大切なことだと思う。

| @技術/プログラミング

Rust 製の全文検索システム Tantivy を Ruby から使える Tantiny を導入したことを書いた。

結構手軽に使えるのだがやはり日本語のトークナイズ(形態素解析)ができないのでいまいちなところがあった。 Tantivy には lindera-tantivy というものがあって、 Lindera は kuromoji のポートなので、これを使うと日本語や中国語、韓国語の形態素解析ができる。 Tantiny に導入できないか試してみたが、自分の Rust 力では到底無理だった。

ちなみに関連記事の表示でも日本語の形態素解析は行っている。

MeCab に neologd/mecab-ipadic-neologd を組み合わせてナウな日本語に対応させつつ形態素解析している。

この仕組みを作ってトークナイズは Ruby で自前で行い、 Tantiny および Tantivy にはトークナイズ済みの配列を食わせるだけにした( Tantiny はトークナイズ済みのテキストを受け付けることもできる)。トークナイズを自前で行うことで辞書ファイルで拾いきれないような固有名詞もカバーできる。例えば 山と道 なんかは MeCab と mecab-ipadic-neologd にトークナイズさせると に分割されてしまう。自前のトークナイザーで単語として認識させていている。おかげで「山と道」をちゃんと検索できるようになっている

なお、自前のトークナイザーはこんなコードになっている。

class Tokenizer
  attr_reader :text

  class << self
    def run(text)
      self.new(text).tokenize
    end
  end

  def initialize(text)
    @text = text
  end

  def cleansed_text
    @cleansed_ ||= text.
      gsub(/<.+?>/, '').
      gsub(/!?\[(.+)?\].+?\)/, '\1').
      gsub(%r{(?:```|<code>)(.+?)(?:```|</code>)}m, '\1')
  end

  def words_to_ignore
    @words_to_ignore ||= %w[
      これ こと とき よう そう やつ とこ ところ 用 もの はず みたい たち いま 後 確か 中 気 方
      頃 上 先 点 前 一 内 lt gt ここ なか どこ まま わけ ため 的 それ あと
    ]
  end

  def preserved_words
    @preserved_words ||= %w[
      山と道 ハイキング 縦走 散歩 プログラミング はてブ 鐘撞山 散財 はてなブックマーク はてな
    ]
  end

  def nm
    require 'natto'
    @nm ||= Natto::MeCab.new
  end

  def words
    @words ||= []
  end

  def tokenize
    preserved_words.each do |word|
      words << word if cleansed_text.match?(word)
    end

    nm.parse(cleansed_text) do |n|
      next unless n.feature.match?(/名詞/)
      next if n.feature.match?(/(サ変接続|数)/)
      next if n.surface.match?(/\A([a-z][0-9]|\p{hiragana}|\p{katakana})\Z/i)
      next if words_to_ignore.include?(n.surface)
      words << n.surface
    end

    words
  end
end

preserved_words が手製の辞書だ。 はてなはてブ も辞書登録しておかないと MeCab だとバラバラに分割されてしまって検索できなかった。

難点としては記事更新後に自動でインデックスの更新が行われず、 cron によるバッチ処理でインデックス更新を行っている1。なので検索インデックスにデータが反映されるまでにタイムラグがある。 Tantiny でやれれば記事作成・更新時のコールバックとして処理できるのでリアルタイムに変更を検索インデックスに反映させることができるが、個人の日記なのでタイムラグありでも大きな問題にはならない。

本当は Tantiny で lindera-tantivy を使えるようにして Pull Request がカッチョイイのだが、とりあえずは自分は目的が達成できたので満足してしまった。 5 年くらい前から Rust 勉強したいと思っているが、いつまでも経っても Rust を書けるようにはならない。


  1. mecab-ipadic-neologd を VPS 上でインストールできず(めっちゃメモリを使う)、手元の Mac で Docker コンテナ化して Docker Hub 経由でコンテナイメージを Pull して VPS 上で Docker 経由で動かしている(その辺について書いてる記事: ブログのコンテナ化を試みたけどやめた)