Laravel + MySQL5.7 で日本語全文検索をする方法とちょっとした注意点

皆さんこんにちは

ちょっとしたアプリケーションを作っていると、キーワードやフリーワードを利用した検索が必要になったりします。
でも、そのときにLIKE検索なんてやっていると、全データを走査する可能性があり、あまり気分の良いものではありません。
そこで、適当な全文検索エンジンを使ってインデックスを張っておきたいなと思うわけですが...MySQLの場合、5.6まではデフォルトで(InnoDBに)日本語全文検索エンジンを入れていなかったりと、少々ハードルが高かったのです。

今回はMySQL5.7でデフォルトでNgramが搭載されているようなので、Laravelで全文検索を実装してみましょう。
例によって今回もLaradockを使用して即席環境を構築しています。

# MySQLに全文検索が来た！
大昔のtritton, ちょい昔のgroongaのように、外部のプラグインを導入することなく、裸のMySQLで全文検索エンジンが使えるようになったようです。
いい時代になりました。
http://www.thecompletelistoffeatures.com/

# Laravelで使う
## テーブル定義
Laravelで使う場合はインデックスを追加してあげれば良いです。
例えば、あるmigrationファイルで、以下のように定義追加をしてあげましょう。

```php
Schema::create('boards', function (Blueprint $table) {
            $table->increments('id');
            $table->text('content');
            $table->timestamps();
        });
DB::statement('ALTER TABLE boards ADD FULLTEXT index content (`content`) with parser ngram');
```
mecabでもできるっぽいですが、辞書ファイルどうしたらいいのかわからんので、今回はngram使ってます。
## 実使用
おそらくMySQLに依存した書き方になるので、基本的には生クエリを投げる必要があります。

```php
Board::whereRaw("match(`content`) against (? IN NATURAL LANGUAGE MODE)", [$freeword])->get();
```
個人的には例にあるBoardクラスのスコープに設定してしまうと、使い回しがしやすいと思います。
生クエリを書くのは(少なくとも私にとっては)ストレスが大きいのです。

```Board.php
<?php

use Illuminate\Database\Eloquent\Model;

class Board extends Model
{
    public function scopeFreeword($query, $freeword)
    {
        $query->whereRaw("match(`content`) against (? IN BOOLEAN MODE)", [$freeword]);
    }
}
```
こうしておけば、

```php
$data = Board::freeword('やったぜ！')->get();
```
みたいな感じで、簡単にデータが取り出せます。
## 確認する
インデックスが張られているかどうかを確認するためには以下のコマンドを、mysqlクライアントで打ち込んでやりましょう。

```
mysql> SET GLOBAL innodb_ft_aux_table = 'homestead/boards';
mysql> SET character_set_results=utf8;
mysql> SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_CACHE ORDER BY doc_id, position;
+--------+--------------+-------------+-----------+--------+----------+
| WORD   | FIRST_DOC_ID | LAST_DOC_ID | DOC_COUNT | DOC_ID | POSITION |
+--------+--------------+-------------+-----------+--------+----------+
| あい |            3 |           3 |         1 |      3 |        0 |
| いう |            3 |           3 |         1 |      3 |        3 |
| うえ |            3 |           3 |         1 |      3 |        6 |
| えお |            3 |           3 |         1 |      3 |        9 |
| おか |            3 |           3 |         1 |      3 |       12 |
| かき |            3 |           4 |         2 |      3 |       15 |
| きく |            3 |           4 |         2 |      3 |       18 |
| くけ |            3 |           4 |         2 |      3 |       21 |
| けこ |            3 |           4 |         2 |      3 |       24 |
| こさ |            3 |           4 |         2 |      3 |       27 |
| さし |            3 |           5 |         3 |      3 |       30 |
| しす |            3 |           5 |         3 |      3 |       33 |
| すせ |            3 |           5 |         3 |      3 |       36 |
...
```
2-gramでのインデックスが作成されているようです

# テストするときに注意
当然、全文検索ができているかどうかのテストをするのですが。。。
ちょっとだけ注意しなければならない点があります。
以下のテストを見てください

```BoardTest.php

use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;

use App\Board;

class BoardTest extends TestCase
{

    use DatabaseTransactions;

    public function testFulltext()
    {
        $content = [
            'あいうえおかきくけこさしすせそ',
            'かきくけこさしすせそたちつてと',
            'さしすせそたちつてとなにぬねの',
            'たちつてとなにぬねのはひふへほ'
        ];
        $boards = [];
        foreach ($content as $val) {
            $board = new Board();
            $board->content = $val;
            $board->save();
            $boards[] = $board;
        }

        $data = Board::freeword('あいうえお')->get();
        $this->assertEquals(1, $data->count());
        $data = Board::freeword('さしすせそ')->get();
        $this->assertEquals(3, $data->count());
    }
}
```
一見すると、通りそうなものですが。。。

```
# phpunit --group=fulltext
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

F

Time: 4.38 seconds, Memory: 10.00MB

There was 1 failure:

1) BoardFullTextTest::testFulltext
Failed asserting that 0 matches expected 1.
```
だめでした。
実は、以下の部分を書き換えると、成功します。

```php
- use DatabaseTransactions;
+ use DatabaseMigrations;
```

```
# phpunit --group=fulltext
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

.

Time: 5.13 seconds, Memory: 12.00MB
```
全文検索のテストは直接インデックスに働きかけるものになるのですが、MySQLではインデックスが作られるのはデータがコミットされた瞬間のようです。
そのため、テスト時のデータリセットをトランザクションモードにしていると、当然コミットされないため、テストが失敗しているんじゃないかなぁって思います。

というわけで、テスト時にはテストデータはちゃんとコミットしましょうってことでした。

# おまけ - ブーリアンモード
今回はブーリアンモードにしているので、こんな感じで検索できます。

```php
        $data = Board::freeword('+さしす +たちつ')->get();
        $this->assertEquals(2, $data->count());
        $data = Board::freeword('+さしす -たちつ')->get();
        $this->assertEquals(1, $data->count());
        $data = Board::freeword('さしす たちつ')->get();
        $this->assertEquals(4, $data->count());
```
検索ワードを半角スペースで区切った上で、

- `+`をつけることで、その文言が必要になります( AND )
- `-`をつけることで、その文言が含まれない物を探します( NOT )
- 何もつけていない場合はあってもなくてもいいです( OR )

検索の幅を広げるのに利用してみましょう。

# まとめ
マストではなかったのですが、できれば全文検索使ったほうがいいなぁって思ったので、調べてみたのですが、まぁ、簡単ですね。
MySQLが5.7に上がったことで、マイナーではありますが、こんなに便利になったなんて知りませんでした。

今回はこんなところです。

# 参考
https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html
[[Laravel] FULLTEXTインデックスを使った全文検索](http://www.84kure.com/blog/2016/04/14/laravel-fulltext%E3%82%A4%E3%83%B3%E3%83%87%E3%83%83%E3%82%AF%E3%82%B9%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E5%85%A8%E6%96%87%E6%A4%9C%E7%B4%A2/)