PHP+MySQLで全文検索

以前はSennaというかTritonnでやってたんですけど、プロジェクトがディスコンになったので、代替方法を探してました。
Tritonn後継のgroongaエンジンを使ったmroongaってのがあるんだけど、Windowsに対応してないので見送りました。
そこで、オーソドックスなNGRAMを用いた方法で実装しました。


NGRAMとは、例えば「あいうえお」を「あい いう うえ えお」と分割する分かち書きです。
これを検索用カラムに入れてFULLTEXTインデックスを貼り、「あいう」を検索したければ

SELECT * FROM table WHERE MATCH(search) AGAINST('"あい いう"' IN BOOLEAN MODE);

とかクエリを投げればヒットするというもの。
ポイントは、FULLTEXTインデックスを使うためにストレージエンジンにMyISAMが必要になる事と、BOOLEAN MODEで検索をするという事。
ちなみに、今回は2文字で区切りましたが、「あいう いうえ うえお」と3文字以上で区切ってもNGRAMと呼びます。2文字で区切るのは特にBIGRAMと呼びます。


今回はPHPから使うために、検索文字列をBIGRAM形式で分割する関数を用意する必要があります。
私はこんな感じのを用意しました。

function strNgram($string, $n = 1, $prefix = '') {
	$spaces = array(
		' ',
		' ',
	);
	$string = str_replace($spaces, '', $string);
	$length = mb_strlen($string);
	$ngrams = array();
	for ($i = 0; $i < $length - ($n - 1); $i++) {
		$ngram = mb_substr($string, $i, $n);
		$ngrams[] = $prefix . $ngram;
	}
	return join(' ', $ngrams);
}

使い方としては、

$text = 'あいうえお';
$text_ngram = strNgram($text, 2);
$db->query("INSERT INTO table SET text='{$text}',search='{$text_ngram}'");

とか呼び出せば、検索用カラムに「あい いう うえ えお」を格納します。
第2パラメータに何文字で区切るかを指定していて、今回はBIGRAMなので2を指定しています。


実際に「あいう」で検索したい時には、

$search = 'あいう';
$search_ngram = strNgram($search, 2);
$res = $db->query("SELECT * FROM table WHERE MATCH(search) AGAINST('"'.$search_ngram.'"' IN BOOLEAN MODE)"); 

のようにすれば良いです。
もし、「あいう」かつ「うえお」を含むものを検索したかったら、BOOLEAN MODEなので、+を使って、

$search_a = 'あいう';
$search_a_ngram = strNgram($search, 2);
$search_b = 'うえお';
$search_b_ngram = strNgram($search, 2);
$res = $db->query("SELECT * FROM table WHERE MATCH(search) AGAINST(sprintf('+"%s" +"%s"', $search_a_ngram, $search_b_ngram) IN BOOLEAN MODE)"); 

となりますね。
もちろん、上記のコードたちはサンプルであって、実践的なものではありません。


AGAINST内のBIGRAM文字列を"でくくってるのがポイントです。
例えば、「あいう」を検索するのに、

SELECT * FROM table WHERE MATCH(search) AGAINST('+あい +いう' IN BOOLEAN MODE)

のように検索しましょうと解説しているサイトもありますが(かつては私もこれで良いと思ってた)、この記述はあくまで「あい」と「いう」を含むレコードを探せという指定ですので、例えば、検索用カラムに「あい いか かい いう」と入っている場合もヒットしてしまいます。


勘の良い人はここで、じゃあBIGRAMで格納してる検索用カラムを「あ」とか1文字で検索したい時はどうするの? と思ったかもしれません。
結論から述べると、

SELECT * FROM table WHERE MATCH(search) AGAINST('"あ"' IN BOOLEAN MODE)

で検索しても、何もヒットしません。
当然「あ」一文字の単語が検索用カラム内に存在しないからです。
これを回避する手段としては、1文字で区切ったNGRAMも入れるしかありません。この場合はUNIGRAMと呼びます。
具体的には、「あい いう うえ えお あ い う え お」と、BIGRAMとUNIGRAM両方を格納しておきます。
DBのインデックス容量が増大しますしそのぶん検索速度も遅くなりますので、場合によっては潔く、1文字だけでは検索できないという仕様のサービスにする等の割り切りが必要かも知れませんね。


さて、ロジック的には上記の説明は正しく動くように思えますが、実際はそうではありません。
まず、MYSQLの仕様として、FULLTEXTのインデックス化が働く単語の長さはデフォルトで4になっているそうです。
ですので、このままだと、「あい」などの2文字の単語はインデックス化されないのでヒットしません。
また、日本語なら問題ないのですが、英語で例えば「shine」をNGRAM化して「sh hi in ne」を検索用カラムに格納したとします。
これを「+hi +in」で検索してもなんとひっかからないのです。
実はMySQLにはストップワードというものが設定されており、inとかtheとかそういう単語はインデックスの対象外なのです。
これらに対策するためには、以下の内容をmy.cnf(Windowsだとmy.ini)の[mysqld]セクションに追加する必要があります。

character-set-server = utf8
skip-character-set-client-handshake
ft_min_word_len = 1 
ft_stopword_file = ''

上の2行は今回と関係ありませんが、少なくともPHPからMySQLを呼び出す時はこれを指定しておかないと文字が化ける事うけあいです。
ここで、俺は安いレンタルサーバーだからroot権限なんてねーよって人もおられるかもしれません。
はっきり言って全文検索はインデックスを駆使してもだいぶ重たい処理ですので、root権限がもらえないようなレベルのレンタルサーバーサービスでこういう検索機能を提供しようとするのはもともと無理な相談だと思います。
今は月額500円くらいでもVPSサーバーが借りれる時代なので、せめてそういうのを検討するのをおすすめします。


前半は他のサイトでも見かける内容ですが、後半のMySQLの設定は見かけないので、自分で解決した記念にカキコ。