LaravelでMySQL全文検索を実装する

MySQLのN-gram全文検索インデックスを使って、Laravelアプリケーションに高速な全文検索機能を実装する方法を解説します。

#laravel #mysql #fulltext-search

LIKE検索の課題

LIKE '%keyword%' を使った部分一致検索は、データ量が増えるとパフォーマンスが低下します。

-- インデックスが使えない
SELECT * FROM shops WHERE name LIKE '%太郎%'

MySQLの**全文検索インデックス(Fulltext Index)**を使うことで、この問題を解決できます。

N-gramパーサーとは

MySQLの全文検索では、N-gramパーサーを使用することで日本語にも対応できます。

N-gramは文字列をN文字ずつに分割してインデックスを作成する方法です:

"サンプル太郎" → ["サン", "ンプ", "プル", "ル太", "太郎"](2-gram/Bigram)

これにより、部分一致検索でもインデックスを活用できます。

実装手順

1. Modelとmigrationを生成

php artisan make:model Shop --migration

2. Modelを定義

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Shop extends Model
{
    use HasFactory;

    protected $table = 'shops';

    protected $fillable = ['name', 'age', 'gender_id'];
}

3. 全文検索用のmigrationを作成

仮想カラムと全文検索インデックスを定義します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

class CreateShopsTable extends Migration
{
    public function up()
    {
        Schema::create('shops', function (Blueprint $table) {
            $table->bigIncrements('id')->comment('店舗ID');
            $table->string('name', 255)->comment('店舗名');
            $table->unsignedInteger('age')->comment('設立年数');
            $table->smallInteger('gender_id')->comment('対象性別');
            $table->timestamps();
        });

        // 検索用の仮想カラムを追加
        DB::statement("
            ALTER TABLE shops
            ADD free_word TEXT AS (
                CONCAT(
                    IFNULL(age, ''), ' ',
                    IFNULL(name, ''), ' ',
                    CASE gender_id
                        WHEN 1 THEN '男性'
                        WHEN 2 THEN '女性'
                        ELSE ''
                    END
                )
            ) STORED
        ");

        // N-gram全文検索インデックスを作成
        DB::statement("
            ALTER TABLE shops
            ADD FULLTEXT INDEX ftx_free_word (free_word)
            WITH PARSER ngram
        ");
    }

    public function down()
    {
        Schema::dropIfExists('shops');
    }
}

migrationの解説

仮想カラム(Generated Column)

ALTER TABLE shops ADD free_word TEXT AS (...) STORED
  • 仮想カラム: 他のカラムから自動生成されるカラム
  • STORED: 物理的に保存される(検索インデックスを作成可能)
  • 複数のカラムを結合して検索対象を作成

CONCAT関数

CONCAT(IFNULL(age, ''), ' ', IFNULL(name, ''), ' ', ...)
  • 複数の文字列を連結
  • IFNULL: NULL値を空文字列に変換

CASE式

CASE gender_id
    WHEN 1 THEN '男性'
    WHEN 2 THEN '女性'
    ELSE ''
END
  • IDを日本語の名称に変換
  • 日本語でも検索可能にする

4. Seederでテストデータを作成

<?php

namespace Database\Seeders;

use App\Models\Shop;
use Illuminate\Database\Seeder;

class DummyShopsSeeder extends Seeder
{
    public function run()
    {
        $data = [
            ['name' => 'サンプル太郎', 'age' => 25, 'gender_id' => 1],
            ['name' => 'サンプル花子', 'age' => 30, 'gender_id' => 2],
            ['name' => 'サンプル二郎', 'age' => 20, 'gender_id' => 1],
        ];

        Shop::query()->insert($data);
    }
}
php artisan migrate
php artisan db:seed --class=DummyShopsSeeder

5. Controllerで全文検索を実装

<?php

namespace App\Http\Controllers;

use App\Models\Shop;
use Illuminate\Http\Request;

class ShopController extends Controller
{
    public function index(Request $request)
    {
        $query = Shop::query();
        $freeWord = $request->input('free_word');

        if ($freeWord) {
            // 全文検索を実行
            $query->whereRaw(
                "MATCH(free_word) AGAINST (? IN BOOLEAN MODE)",
                [$freeWord]
            );
        }

        $shops = $query
            ->select(['id', 'name', 'age', 'gender_id'])
            ->paginate(20);

        return view('index', [
            'shops' => $shops,
            'parameters' => $request->all(),
        ]);
    }
}

全文検索の解説

MATCH ... AGAINST構文

MATCH(free_word) AGAINST ('検索キーワード' IN BOOLEAN MODE)
  • MATCH: 全文検索インデックスを使用
  • AGAINST: 検索キーワードを指定
  • BOOLEAN MODE: 演算子を使った詳細な検索が可能

BOOLEANモードの演算子

// AND検索
$query->whereRaw("MATCH(free_word) AGAINST ('+太郎 +男性' IN BOOLEAN MODE)");

// OR検索
$query->whereRaw("MATCH(free_word) AGAINST ('太郎 花子' IN BOOLEAN MODE)");

// NOT検索
$query->whereRaw("MATCH(free_word) AGAINST ('+サンプル -花子' IN BOOLEAN MODE)");

6. Viewの実装

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>全文検索デモ</title>
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
<div class="container">
    <div class="card">
        <div class="card-header">全文検索</div>
        <div class="card-body">
            <form action="/admin" method="GET">
                <div class="mb-2">
                    <label for="free_word" class="form-label">キーワード</label>
                    <input type="text"
                           class="form-control"
                           name="free_word"
                           id="free_word"
                           value="{{ $parameters['free_word'] ?? '' }}">
                </div>
                <button type="submit" class="btn btn-primary">検索</button>
                <a href="/admin" class="btn btn-secondary">クリア</a>
            </form>
        </div>
    </div>

    <div class="mt-3">
        <p>検索結果: {{ $shops->total() }}件</p>
        <table class="table">
            <thead>
                <tr>
                    <th>ID</th>
                    <th>名前</th>
                    <th>年齢</th>
                    <th>性別</th>
                </tr>
            </thead>
            <tbody>
                @foreach($shops as $shop)
                    <tr>
                        <td>{{ $shop->id }}</td>
                        <td>{{ $shop->name }}</td>
                        <td>{{ $shop->age }}</td>
                        <td>{{ $shop->gender_id === 1 ? '男性' : '女性' }}</td>
                    </tr>
                @endforeach
            </tbody>
        </table>
        {{ $shops->links() }}
    </div>
</div>
</body>
</html>

セキュリティ上の注意点

プリペアドステートメントの使用

whereRawを使う場合、必ずプレースホルダー(?)を使用してSQLインジェクション対策を行います。

// ❌ 危険: SQLインジェクションのリスク
$query->whereRaw("MATCH(free_word) AGAINST ('$freeWord' IN BOOLEAN MODE)");

// ✅ 安全: プリペアドステートメント
$query->whereRaw("MATCH(free_word) AGAINST (? IN BOOLEAN MODE)", [$freeWord]);

入力値のバリデーション

$validated = $request->validate([
    'free_word' => 'nullable|string|max:255',
]);

LIKE検索との比較

LIKE検索

// インデックスが使えない
$query->where('name', 'LIKE', "%{$keyword}%");
  • メリット: シンプルな実装
  • デメリット: データ量が増えると遅い、インデックスが使えない

全文検索

// 全文検索インデックスを使用
$query->whereRaw("MATCH(free_word) AGAINST (? IN BOOLEAN MODE)", [$keyword]);
  • メリット: 高速、大量データでも性能が安定
  • デメリット: セットアップが必要、ストレージ使用量が増える

まとめ

MySQLの全文検索を使うことで:

  • 高速な検索: インデックスを活用した効率的な検索
  • 日本語対応: N-gramパーサーで日本語の部分一致検索が可能
  • 柔軟な検索: BOOLEANモードでAND/OR/NOT検索に対応

大量のテキストデータを検索する必要がある場合、全文検索の導入を検討する価値があります。

参考リンク