Meilisearch全文検索エンジン入門:高速・タイポ耐性・簡単セットアップ
Meilisearchは、ElasticsearchやAlgoliaのようなパワフルな検索機能を、驚くほど簡単にセットアップできるオープンソース全文検索エンジンです。この記事では、Meilisearchの基本から実践的な活用法まで詳しく解説します。
Meilisearchとは
Meilisearchは、Rustで書かれた高速・軽量・使いやすい全文検索エンジンです。
主な特徴
- 超高速 - ミリ秒単位でのレスポンス
- タイポ耐性 - スペルミスを自動修正
- シンプルAPI - RESTful APIで簡単に統合
- フィルタリング - ファセット検索、範囲検索
- ハイライト - 検索結果のマッチ箇所を強調
- 多言語対応 - 日本語を含む多言語サポート
- セルフホスト可能 - 完全にオープンソース
インストール
Docker(推奨)
# Dockerで起動
docker run -d \
--name meilisearch \
-p 7700:7700 \
-e MEILI_MASTER_KEY=YOUR_MASTER_KEY_HERE \
-v $(pwd)/meili_data:/meili_data \
getmeili/meilisearch:latest
# 起動確認
curl http://localhost:7700/health
バイナリで直接インストール
# macOS(Homebrew)
brew install meilisearch
# Linux
curl -L https://install.meilisearch.com | sh
# 起動
meilisearch --master-key=YOUR_MASTER_KEY_HERE
Node.jsクライアントのインストール
npm install meilisearch
基本的な使い方
インデックスの作成とドキュメント追加
// meilisearch-client.ts
import { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: 'http://localhost:7700',
apiKey: 'YOUR_MASTER_KEY_HERE',
});
// インデックスを作成
const index = await client.createIndex('movies', {
primaryKey: 'id',
});
// ドキュメントを追加
const movies = [
{
id: 1,
title: 'The Shawshank Redemption',
genre: 'Drama',
year: 1994,
rating: 9.3,
director: 'Frank Darabont',
},
{
id: 2,
title: 'The Godfather',
genre: 'Crime',
year: 1972,
rating: 9.2,
director: 'Francis Ford Coppola',
},
{
id: 3,
title: 'The Dark Knight',
genre: 'Action',
year: 2008,
rating: 9.0,
director: 'Christopher Nolan',
},
];
// ドキュメントを一括追加
const task = await client.index('movies').addDocuments(movies);
console.log('Task UID:', task.taskUid);
// タスクの完了を待つ
await client.waitForTask(task.taskUid);
基本的な検索
// movies.ts
import { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: 'http://localhost:7700',
apiKey: 'YOUR_MASTER_KEY_HERE',
});
// シンプルな検索
const results = await client.index('movies').search('godfather');
console.log(results.hits);
// [{ id: 2, title: 'The Godfather', ... }]
// タイポ耐性(自動修正)
const typoResults = await client.index('movies').search('godfater');
console.log(typoResults.hits);
// [{ id: 2, title: 'The Godfather', ... }] ← スペルミスでも見つかる
Next.jsとの統合
サーバー側での検索API
// app/api/search/route.ts
import { MeiliSearch } from 'meilisearch';
import { NextRequest, NextResponse } from 'next/server';
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST || 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_API_KEY,
});
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const query = searchParams.get('q') || '';
const filter = searchParams.get('filter');
try {
const results = await client.index('movies').search(query, {
limit: 20,
filter: filter || undefined,
attributesToHighlight: ['title'],
});
return NextResponse.json(results);
} catch (error) {
return NextResponse.json(
{ error: 'Search failed' },
{ status: 500 }
);
}
}
クライアント側の検索コンポーネント
// components/SearchBox.tsx
'use client';
import { useState, useEffect } from 'react';
import { debounce } from 'lodash';
interface SearchResult {
id: number;
title: string;
genre: string;
year: number;
rating: number;
_formatted?: {
title: string;
};
}
export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
// デバウンス付き検索
const search = debounce(async (q: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const data = await response.json();
setResults(data.hits);
} catch (error) {
console.error('Search error:', error);
} finally {
setLoading(false);
}
}, 300);
useEffect(() => {
search(query);
}, [query]);
return (
<div className="search-container">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search movies..."
className="search-input"
/>
{loading && <div className="loading">Searching...</div>}
<ul className="results">
{results.map((result) => (
<li key={result.id} className="result-item">
{/* ハイライト表示 */}
<h3
dangerouslySetInnerHTML={{
__html: result._formatted?.title || result.title,
}}
/>
<p>
{result.genre} • {result.year} • ★{result.rating}
</p>
</li>
))}
</ul>
</div>
);
}
フィルタリングとファセット検索
設定可能な属性を定義
// インデックス設定
await client.index('movies').updateSettings({
filterableAttributes: ['genre', 'year', 'rating'],
sortableAttributes: ['year', 'rating'],
searchableAttributes: ['title', 'director'],
});
フィルタ検索
// ジャンルでフィルタ
const results = await client.index('movies').search('', {
filter: 'genre = Drama',
});
// 複数条件(AND)
const results2 = await client.index('movies').search('', {
filter: ['genre = Drama', 'year > 1990'],
});
// 複数条件(OR)
const results3 = await client.index('movies').search('', {
filter: 'genre = Drama OR genre = Action',
});
// 範囲検索
const results4 = await client.index('movies').search('', {
filter: 'rating >= 9.0 AND year >= 2000',
});
ファセットカウント
// ファセットを取得
const results = await client.index('movies').search('action', {
facets: ['genre', 'year'],
});
console.log(results.facetDistribution);
// {
// genre: { Action: 15, Drama: 8, Comedy: 3 },
// year: { 2020: 5, 2021: 8, 2022: 12 }
// }
ファセット付き検索UI
// components/FacetedSearch.tsx
'use client';
import { useState, useEffect } from 'react';
interface FacetDistribution {
[key: string]: { [value: string]: number };
}
export default function FacetedSearch() {
const [query, setQuery] = useState('');
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [results, setResults] = useState([]);
const [facets, setFacets] = useState<FacetDistribution>({});
const search = async () => {
const params = new URLSearchParams({ q: query });
if (selectedGenre) {
params.append('filter', `genre = ${selectedGenre}`);
}
const response = await fetch(`/api/search?${params}`);
const data = await response.json();
setResults(data.hits);
setFacets(data.facetDistribution || {});
};
useEffect(() => {
search();
}, [query, selectedGenre]);
return (
<div className="flex gap-4">
{/* サイドバー: ファセット */}
<aside className="w-64">
<h3>Genre</h3>
<ul>
<li>
<button onClick={() => setSelectedGenre(null)}>
All ({Object.values(facets.genre || {}).reduce((a, b) => a + b, 0)})
</button>
</li>
{Object.entries(facets.genre || {}).map(([genre, count]) => (
<li key={genre}>
<button
onClick={() => setSelectedGenre(genre)}
className={selectedGenre === genre ? 'font-bold' : ''}
>
{genre} ({count})
</button>
</li>
))}
</ul>
</aside>
{/* メイン: 検索結果 */}
<main className="flex-1">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.map((result: any) => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</main>
</div>
);
}
ソート
// 評価でソート(降順)
const results = await client.index('movies').search('', {
sort: ['rating:desc'],
});
// 複数ソート条件
const results2 = await client.index('movies').search('', {
sort: ['year:desc', 'rating:desc'],
});
// 検索結果の関連性とソート条件を組み合わせ
const results3 = await client.index('movies').search('action', {
sort: ['rating:desc'],
});
ページネーション
// 基本的なページネーション
const results = await client.index('movies').search('action', {
limit: 20,
offset: 0,
});
console.log(results.hits); // 検索結果
console.log(results.estimatedTotalHits); // 総ヒット数
// ページ2
const page2 = await client.index('movies').search('action', {
limit: 20,
offset: 20,
});
日本語検索
Meilisearchは日本語検索にも対応しています。
// 日本語ドキュメントを追加
const japaneseMovies = [
{
id: 1,
title: '千と千尋の神隠し',
director: '宮崎駿',
year: 2001,
genre: 'アニメ',
},
{
id: 2,
title: '君の名は。',
director: '新海誠',
year: 2016,
genre: 'アニメ',
},
];
await client.index('japanese-movies').addDocuments(japaneseMovies);
// 日本語検索
const results = await client.index('japanese-movies').search('千尋');
// [{ id: 1, title: '千と千尋の神隠し', ... }]
// 部分一致
const results2 = await client.index('japanese-movies').search('新海');
// [{ id: 2, title: '君の名は。', director: '新海誠', ... }]
インデックスの更新と削除
// ドキュメントを更新(部分更新)
await client.index('movies').updateDocuments([
{ id: 1, rating: 9.5 }, // ratingだけ更新
]);
// ドキュメントを完全に置き換え
await client.index('movies').addDocuments(
[{ id: 1, title: 'Updated Title', genre: 'Drama', year: 1994, rating: 9.5 }],
{ primaryKey: 'id' }
);
// ドキュメントを削除
await client.index('movies').deleteDocument(1);
// 複数削除
await client.index('movies').deleteDocuments([1, 2, 3]);
// 条件に一致するドキュメントを削除
await client.index('movies').deleteDocuments({
filter: 'year < 1990',
});
// インデックス全体を削除
await client.deleteIndex('movies');
マルチテナント対応
// テナントごとのインデックス
const createTenantIndex = async (tenantId: string) => {
const indexName = `tenant_${tenantId}_products`;
await client.createIndex(indexName, { primaryKey: 'id' });
return client.index(indexName);
};
// 検索時にテナントIDを使う
const searchTenant = async (tenantId: string, query: string) => {
const indexName = `tenant_${tenantId}_products`;
return await client.index(indexName).search(query);
};
パフォーマンス最適化
インデックス設定の最適化
await client.index('movies').updateSettings({
// 検索対象の属性を制限
searchableAttributes: ['title', 'director'],
// 表示する属性を制限
displayedAttributes: ['id', 'title', 'year', 'rating'],
// ランキングルールをカスタマイズ
rankingRules: [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
'rating:desc', // カスタムルール
],
// タイポ耐性の設定
typoTolerance: {
enabled: true,
minWordSizeForTypos: {
oneTypo: 5,
twoTypos: 9,
},
},
});
バッチ処理
// 大量のドキュメントを追加する場合はバッチで
const batchSize = 1000;
const allMovies = [...]; // 10000件のデータ
for (let i = 0; i < allMovies.length; i += batchSize) {
const batch = allMovies.slice(i, i + batchSize);
const task = await client.index('movies').addDocuments(batch);
await client.waitForTask(task.taskUid);
console.log(`Indexed ${i + batch.length} / ${allMovies.length}`);
}
セキュリティ
APIキーの管理
// マスターキーで新しいAPIキーを作成
const searchKey = await client.createKey({
description: 'Search-only key for frontend',
actions: ['search'],
indexes: ['movies'],
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30日後
});
console.log('Search Key:', searchKey.key);
フロントエンドでは検索専用キーを使用します。
// フロントエンドで使用
const client = new MeiliSearch({
host: 'https://your-meilisearch.com',
apiKey: 'SEARCH_ONLY_KEY', // 検索専用キー
});
まとめ
Meilisearchの主な利点をまとめます。
- 簡単セットアップ - Docker一発で起動
- 超高速検索 - ミリ秒単位のレスポンス
- タイポ耐性 - スペルミスを自動修正
- ファセット検索 - フィルタとカウント
- 多言語対応 - 日本語も完全サポート
- RESTful API - あらゆる言語から利用可能
ElasticsearchやAlgoliaに比べて、Meilisearchは驚くほど簡単にセットアップでき、それでいてパワフルです。ECサイト、ブログ、ドキュメント検索など、あらゆる検索機能に最適です。
今すぐMeilisearchを試して、ユーザー体験を劇的に向上させましょう。