Laravel + Meilisearchで12万件の中古バイクデータを爆速検索できるようにした話
はじめに
バイク中古・新車一括検索プラットフォーム「MotoHub」を個人開発しています。
中古バイクの在庫データが12万件を超え、MySQLのLIKE検索ではユーザーがキーワードを入力するたびに数秒待たされる状態でした。さらに「都道府県」「メーカー」「排気量」「価格帯」といった条件でフィルタリングしつつ全文検索したい。
MySQLだけでは限界。全文検索エンジンを導入しよう。 そこでMeilisearchを選びました。
なぜMeilisearchを選んだか
検索エンジンの候補は3つありました。
| 検索エンジン | 料金 | メモリ | 日本語 | 導入コスト |
|---|---|---|---|---|
| Algolia | 有料(従量課金) | クラウド | ◎ | 低 |
| Elasticsearch | 無料 | 重い(4GB〜) | ◎ | 高 |
| Meilisearch | 無料 | 軽い(1GB〜) | ○ | 低 |
さくらVPS 4GBで運用しているので、Elasticsearchはメモリ的に厳しい。Algoliaは12万件の従量課金が怖い。Meilisearchは軽量・無料・Laravel Scoutとの相性が良い。
技術スタック
- Laravel 12 / PHP 8.3
- Meilisearch(Dockerコンテナ)
- Laravel Scout(Meilisearchドライバ)
- さくらVPS 4GB / Docker
1. 環境構築
docker-compose.yml にMeilisearchを追加
# docker-compose.yml(該当部分のみ)
meilisearch:
image: getmeili/meilisearch:latest
ports:
- "7700:7700"
volumes:
- meilisearch-data:/meili_data
environment:
- MEILI_MASTER_KEY=${MEILISEARCH_KEY}
- MEILI_ENV=production
.env の設定
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=your-master-key-here
Laravel Scoutのインストール
composer require laravel/scout
composer require meilisearch/meilisearch-php
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
2. Listing(中古バイク在庫)モデルにSearchableを追加
MotoHubでMeilisearchに入れているのは Listing(中古バイクの在庫データ) です。車種マスター(BikeModel)ではなく、実際の中古バイク1台1台のデータです。
// app/Models/Listing.php
use Laravel\Scout\Searchable;
class Listing extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'manufacturer_id' => $this->manufacturer_id,
'bike_model_id' => $this->bike_model_id,
'category_id' => $this->category_id,
'prefecture' => $this->prefecture,
'total_price' => $this->total_price,
'mileage' => $this->mileage,
'model_year' => $this->model_year,
'is_new' => $this->is_new,
'is_sold_out' => $this->is_sold_out,
'tag_slugs' => $this->tag_slugs,
'bargain_score' => $this->bargain_score,
];
}
public function searchableAs(): string
{
return 'listings';
}
}
3. Meilisearchのインデックス設定
ここが重要です。Meilisearchに「どのカラムでフィルタリングできるか」「どのカラムでソートできるか」を教える必要があります。
// config/scout.php(該当部分)
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'listings' => [
'filterableAttributes' => [
'prefecture', // 都道府県で絞り込み
'manufacturer_id', // メーカーで絞り込み
'bike_model_id', // 車種で絞り込み
'category_id', // カテゴリで絞り込み
'is_new', // 新車/中古
'is_sold_out', // 売り切れ除外
'tag_slugs', // タグ検索
'total_price', // 価格帯フィルタ
'mileage', // 走行距離フィルタ
'model_year', // 年式フィルタ
],
'sortableAttributes' => [
'total_price', // 価格順
'mileage', // 走行距離順
'model_year', // 年式順
'bargain_score', // お得度順
'created_at', // 新着順
],
],
],
],
これにより「東京都のホンダ、50万円以下、走行距離少ない順」のような複合条件の検索が爆速で動きます。
インデックス設定の同期コマンド
php artisan meilisearch:sync
4. 12万件のデータをインポート
php artisan scout:import "App\Models\Listing"
実行結果:
Imported [App\Models\Listing] models up to ID: 83019
Imported [App\Models\Listing] models up to ID: 83519
Imported [App\Models\Listing] models up to ID: 84019
...
Imported [App\Models\Listing] models up to ID: 427158
All [App\Models\Listing] records have been imported.
500件ずつチャンクで処理され、約1分30秒で完了しました。
インポートの工夫
12万件を一気にインポートするとメモリ不足になるので、chunkサイズを調整しています。
// config/scout.php
'chunk' => [
'searchable' => 500, // デフォルト500、メモリに応じて調整
],
5. 検索の実装
リポジトリパターンで検索
MotoHubではリポジトリパターンで検索処理をまとめています。
// ListingRepository.php(概要)
public function search(array $filters)
{
$query = Listing::search($filters['keyword'] ?? '');
// フィルタリング
if (isset($filters['prefecture'])) {
$query->where('prefecture', $filters['prefecture']);
}
if (isset($filters['manufacturer_id'])) {
$query->where('manufacturer_id', $filters['manufacturer_id']);
}
if (isset($filters['price_max'])) {
$query->where('total_price', '<=', $filters['price_max']);
}
// ソート
$query->orderBy($filters['sort'] ?? 'created_at', 'desc');
return $query->paginate(30);
}
Listing::search() がLaravel Scout経由でMeilisearchに問い合わせます。MySQLと同じような書き方でフィルタリング・ソートが使えるのがScoutの便利なところです。
フロントからの検索例
GET /bikes?keyword=CBR&prefecture=東京都&price_max=500000&sort=total_price
これだけで「東京都のCBR、50万円以下、安い順」が瞬時に返ります。
6. パフォーマンス比較
実際に計測した結果がこちらです。
| 検索方法 | 「CBR250RR 東京」 | 「ニンジャ 大阪 50万以下」 |
|---|---|---|
| MySQL(WHERE + LIKE) | 約800ms | 約1,200ms |
| Meilisearch | 約15ms | 約20ms |
約50倍の高速化を実現しました。
特にフィルタリング条件が増えるほど差が開きます。MySQLは条件が増えるたびに遅くなりますが、Meilisearchは事前にインデックスを作っているので条件が増えてもほとんど速度が変わりません。
メモリ使用量
# Meilisearchコンテナのメモリ使用量
docker stats meilisearch --no-stream
# CONTAINER CPU % MEM USAGE / LIMIT MEM %
# meilisearch 0.1% 280MiB / 4GiB 6.8%
12万件インデックスしてもメモリ280MB程度。さくらVPS 4GBで余裕で動きます。
7. 車種サジェストはMySQL LIKEのまま
ここは正直に書きます。車種のサジェスト(オートコンプリート)にはMeilisearchを使っていません。
車種マスター(BikeModel)は約4,000件程度なので、MySQL LIKEで十分高速です。
// 車種サジェストAPI
BikeModel::with('manufacturer')
->where('name', 'like', "%{$q}%")
->orWhere('model_code', 'like', "%{$q}%")
->orWhereHas('manufacturer', fn ($mq) =>
$mq->where('name', 'like', "%{$q}%"))
->limit(10)
->get();
12万件の在庫検索 → Meilisearch、4,000件の車種サジェスト → MySQL。 適材適所で使い分けています。何でもMeilisearchに入れればいいわけではなく、データ量と要件に合わせて選ぶのが大事です。
8. 本番運用で気をつけていること
データ更新時の自動インデックス
Scoutを使っているので、Listingのcreate/update/deleteが自動でMeilisearchに反映されます。
// 新しい中古バイクが入荷したとき
$listing = Listing::create([...]);
// → Meilisearchに自動反映
// 売れたとき
$listing->update(['is_sold_out' => true]);
// → Meilisearchのインデックスも自動更新
手動で再インポートする必要はありません。
バックアップ
Meilisearchのデータは meilisearch-data ボリュームに保存されています。最悪消えても scout:import で約1分30秒で再構築できるので、DBのバックアップさえあれば問題ありません。
VPS 4GBでの運用実績
2ヶ月運用して、メモリ不足やクラッシュは一度も発生していません。Meilisearch + MySQL + Laravel + Nginx + Redis が全部同じVPSで動いています。
まとめ
| 項目 | 結果 |
|---|---|
| 検索速度 | 800ms → 15ms(50倍高速化) |
| インポート時間 | 12万件で約1分30秒 |
| メモリ使用量 | 約280MB |
| 導入コスト | 無料 |
| 運用コスト | ほぼゼロ(Docker内で完結) |
Meilisearchは個人開発の検索機能に最適です。Elasticsearchほど重くなく、Algoliaほど高くない。Laravel Scoutとの組み合わせで導入も簡単でした。
使い分けのポイントとしては:
- 大量データ(万件〜)の全文検索 + フィルタリング → Meilisearch
- 少量データ(数千件)のシンプルな検索 → MySQL LIKEで十分
12万件でもVPS 4GBで余裕で動くので、「検索が遅い」と悩んでいる個人開発者にはおすすめです。
前回の記事:個人開発のMotoHubにGSC分析→SEO改善を実施した話
🏍 MotoHub: https://motohub.jp
X: https://x.com/motohub_jp
GitHub: https://github.com/ausssxi/MotoHub



