Re: 調査依頼: サイト内検索機能の実装方針
調査結果: サイト内検索機能の実装方針
1. クライアントサイド検索ライブラリの比較
5つの候補ライブラリを調査し、以下の比較表にまとめました。
比較表
| 項目 | Fuse.js | Orama | Flexsearch | Pagefind | Lunr |
|---|---|---|---|---|---|
| バンドルサイズ (min+gzip) | 約6-7kB | 約2kB (コア) | 約5-6kB | 別途WASM読み込み(約50kB+) | 約8kB |
| 日本語対応 | 良好(文字レベルのファジーマッチで自然に動作) | 公式対応あり(ただしWASMトークナイザーが大きくブラウザ非推奨) | 一部対応(CJK Charsetあり、カスタムトークナイザー推奨) | 拡張版で対応(Intl.Segmenter使用) | 不十分(カスタムトークナイザー必要) |
| 検索精度 | ファジーマッチが強力、タイプミス耐性あり | 完全一致に強い、ファジーはやや弱い | 全文検索に強い | HTML構造を考慮した検索 | ステミング・TF-IDFベース |
| 設定の柔軟性 | 高い(重み付け、しきい値、キー指定) | 高い(スキーマ定義、ファセット検索可) | 高い(エンコーダー、トークナイザーカスタマイズ) | 低い(HTMLベース前提) | 中程度 |
| メンテナンス | 活発(v7系、GitHubスター17k+) | 非常に活発(v3系、活発な開発) | やや停滞(v0.8系) | 活発(v1系、Rust製) | 停滞(最終リリースが古い) |
| npm週間DL | 約150万 | 約10万 | 約15万 | 約2万 | 約60万 |
| 事前インデックス | 不要 | 必要(30-100ms) | 必要 | ビルド時生成 | 必要 |
| TypeScript | 型定義あり | ネイティブTS | 型定義あり(v0.8) | JS API | 型定義あり |
推奨: Fuse.js
理由:
日本語との相性が最も良い: Fuse.jsは文字レベルのファジーマッチを行うため、日本語テキスト(漢字、ひらがな、カタカナ)に特別な設定なしで対応できます。トークナイザーのカスタマイズや追加のWASMパッケージが不要です。
事前インデックス不要: Oramaは30-100msの事前インデックス構築が必要ですが、Fuse.jsはデータを渡すだけで即座に検索可能です。初期ロードが高速です。
バンドルサイズが小さい: min+gzipで約6-7kBと十分に小さく、追加の依存関係もありません。Oramaはコア2kBと謳っていますが、日本語対応にはWASMトークナイザー(@orama/tokenizers)が必要で、これはブラウザでの使用が公式に非推奨とされています。
重み付き検索: フィールドごとに検索の重み付けが可能(例: titleの一致をdescriptionより優先)。これはサイト横断検索で非常に重要です。
設定が簡潔: Next.js App Routerのクライアントコンポーネントにそのまま組み込めます。既存のSearchBoxコンポーネントのパターンと親和性が高いです。
十分な実績とメンテナンス: GitHubスター17k+、週間DL150万と安定。v7系で活発に開発が続いています。
Pagefindを選ばない理由: 現在のプロジェクトは output: "export" を使用しておらず、サーバーサイドレンダリングモードです。PagefindはビルドされたHTMLファイルをクロールしてインデックスを生成するため、SSRモードのNext.jsとの統合が複雑になります。また、各コンテンツタイプのデータ構造が既にTypeScriptで定義されており、HTMLからの再抽出は非効率です。
Oramaを選ばない理由: 日本語対応にWASMベースのトークナイザーが必要で、公式ドキュメントでブラウザ利用が非推奨とされています。このサイトは完全クライアントサイド検索を想定しているため、この制約は大きなデメリットです。
2. 検索対象コンテンツの棚卸し
ソースコードを精査し、各コンテンツタイプのデータ構造と検索可能フィールドを整理しました。
2-1. ツール (src/tools/)
- データ取得:
allToolMetas(src/tools/registry.ts) - 静的レジストリ、現在31件 - 型定義:
ToolMeta(src/tools/types.ts) - 検索可能フィールド:
name(日本語名) - 重み: 高nameEn(英語名) - 重み: 中description(説明文 120-160字) - 重み: 中shortDescription(短い説明 約50字) - 重み: 中keywords(SEOキーワード配列) - 重み: 高category(text/encoding/developer/security/generator) - フィルター用
2-2. ゲーム (src/app/games/)
- データ取得: ページ内のハードコード配列
GAMES(src/app/games/page.tsx) - 現在4件 - 型定義: インラインオブジェクト(slug, title, description, icon, accentColor, difficulty)
- 検索可能フィールド:
title(日本語タイトル) - 重み: 高description(説明文) - 重み: 中difficulty(難易度) - フィルター用
- 注意: データが少数かつハードコードのため、検索インデックス生成スクリプトで別途定数化するか、そのまま埋め込みが良い
2-3. チートシート (src/cheatsheets/)
- データ取得:
allCheatsheetMetas(src/cheatsheets/registry.ts) - 現在3件 - 型定義:
CheatsheetMeta(src/cheatsheets/types.ts) - 検索可能フィールド:
name(日本語名) - 重み: 高nameEn(英語名) - 重み: 中description(説明文) - 重み: 中shortDescription(短い説明) - 重み: 中keywords(キーワード配列) - 重み: 高category(developer/writing/devops) - フィルター用sections[].title(セクション名) - 重み: 低
2-4. 辞書 - 漢字 (src/lib/dictionary/kanji.ts)
- データ取得:
getAllKanji()- JSONファイル(src/data/kanji-data.json)から読み込み、現在80件 - 型定義:
KanjiEntry(src/lib/dictionary/types.ts) - 検索可能フィールド:
character(漢字1文字) - 重み: 最高onYomi(音読み配列) - 重み: 高kunYomi(訓読み配列) - 重み: 高meanings(意味配列) - 重み: 高examples(用例配列) - 重み: 中radical(部首) - 重み: 中category- フィルター用
2-5. 辞書 - 四字熟語 (src/lib/dictionary/yoji.ts)
- データ取得:
getAllYoji()- JSONファイル(src/data/yoji-data.json)から読み込み、現在101件 - 型定義:
YojiEntry(src/lib/dictionary/types.ts) - 検索可能フィールド:
yoji(四字熟語) - 重み: 最高reading(読み方) - 重み: 高meaning(意味) - 重み: 高category- フィルター用difficulty- フィルター用
2-6. 伝統色 (src/lib/dictionary/colors.ts)
- データ取得:
getAllColors()- JSONファイル(src/data/traditional-colors.json)から読み込み、現在250件 - 型定義:
ColorEntry(src/lib/dictionary/types.ts) - 検索可能フィールド:
name(色名) - 重み: 最高romaji(ローマ字) - 重み: 高hex(カラーコード) - 重み: 中category- フィルター用
2-7. ブログ (src/lib/blog.ts)
- データ取得:
getAllBlogPosts()- Markdownファイル(src/content/blog/*.md)からfrontmatter解析、現在27件 - 型定義:
BlogPostMeta(src/lib/blog.ts) - 検索可能フィールド:
title(タイトル) - 重み: 最高description(説明文) - 重み: 高tags(タグ配列) - 重み: 高category(カテゴリ) - フィルター用
- 注意: contentHtmlは本文全体であり、検索インデックスに含めるとサイズが大きくなる。description + tagsで十分な検索精度が得られる
2-8. クイズ (src/lib/quiz/)
- データ取得:
allQuizMetas(src/lib/quiz/registry.ts) - 現在2件 - 型定義:
QuizMeta(src/lib/quiz/types.ts) - 検索可能フィールド:
title(タイトル) - 重み: 高description(説明文) - 重み: 中shortDescription(短い説明) - 重み: 中keywords(キーワード配列) - 重み: 高type(knowledge/personality) - フィルター用
データ量まとめ
| コンテンツタイプ | 件数 | データソース | 増加ペース |
|---|---|---|---|
| ツール | 31 | 静的レジストリ | 中 |
| ゲーム | 4 | ハードコード | 低 |
| チートシート | 3 | 静的レジストリ | 低 |
| 漢字 | 80 | JSON | 中 |
| 四字熟語 | 101 | JSON | 中 |
| 伝統色 | 250 | JSON | 低 |
| ブログ | 27 | Markdown | 高 |
| クイズ | 2 | 静的レジストリ | 低 |
| 合計 | 約500 |
現在の総コンテンツ数は約500件です。この規模であればFuse.jsで十分に高速な検索が可能です(1000件以下では0.1-1.0msの応答速度)。将来的にコンテンツが数千件に増加しても、Fuse.jsは実用的な速度を維持できます。
3. 検索UIのベストプラクティス
3-1. 推奨UI: ヘッダー検索アイコン + Cmd-Kモーダル
推奨する動線:
- ヘッダーに検索アイコン(虫眼鏡)を設置: 既存のactionsエリア(ThemeToggleの隣)に配置
- Cmd+K / Ctrl+Kキーボードショートカット: パワーユーザー向けにキーボードショートカットで検索モーダルを即座に開く
- モーダルで全画面検索UI: オーバーレイモーダルで検索入力+結果を表示
理由:
- 現在のヘッダーは9つのナビリンクがあり既に混雑しています。検索バーを埋め込むとさらに窮屈になります
- モーダル方式はページ遷移なしで検索でき、現在のページコンテキストを失いません
- Cmd+Kパターンは技術系サイト(Vercel Docs, Next.js Docs, GitHub等)で標準化されており、ターゲットユーザーに馴染みがあります
- モバイルでもモーダルは全画面的に表示でき、既存のMobileNavパターンと統一感を持たせられます
3-2. 検索方式: インクリメンタルサーチ(デバウンス付き)
推奨:
- ユーザーの入力に合わせてリアルタイムで結果を表示(インクリメンタルサーチ)
- 150-200msのデバウンスを入れて過度な検索を防止
- 最低2文字以上の入力で検索を開始(日本語は1文字でも意味があるため1文字から開始してもよい)
理由:
- 既存のSearchBoxコンポーネント(漢字辞典、四字熟語辞典、伝統色ページ)が既にインクリメンタルサーチを採用しており、サイト内の一貫性を保てます
- Fuse.jsはデータ量500件程度で0.1-1.0msの応答なので、デバウンスありのインクリメンタルサーチでも十分に高速です
- 確定検索(Enter押下)方式だと、ユーザーが結果を確認するまでのステップが増え、体験が劣ります
3-3. 検索結果の表示方法
推奨:
- カテゴリ別グループ化: 結果をコンテンツタイプ別(ツール、辞書、ブログ等)にグループ化して表示
- 各グループ内は関連度順: Fuse.jsのスコアで関連度順にソート
- 各グループ最大3-5件表示: 一画面に収まるように制限し、「もっと見る」で展開
- 結果項目にはコンテンツタイプのバッジを表示: どのカテゴリの結果かを視覚的に明確化
結果アイテムの表示要素:
- コンテンツタイプのバッジ(色分け): ツール、ゲーム、辞典、ブログ等
- タイトル(マッチ部分をハイライト)
- 説明文(短縮版)
- パスのURL
3-4. モバイル対応
推奨:
- モバイルではモーダルを画面全体に表示(fullscreen modal)
- 検索入力フィールドに自動フォーカス
- スクロール可能な結果リスト
- ESCキーまたは閉じるボタンでモーダルを閉じる
- 既存のMobileNavのオーバーレイ・ESCキー処理パターンを再利用可能
- 戻るボタン(ブラウザの戻る)でモーダルが閉じるようにhistory APIを活用
4. 実装方針の提案
4-1. 検索インデックスの生成
ビルド時に全コンテンツのメタデータを1つのJSONファイルにまとめる方式を推奨します。
// src/lib/search/buildIndex.ts (ビルドスクリプトまたはサーバーコンポーネントで実行)
type SearchDocument = {
id: string; // 一意識別子 (例: "tool:char-count")
type: ContentType; // "tool" | "game" | "cheatsheet" | "kanji" | "yoji" | "color" | "blog" | "quiz"
title: string;
description: string;
keywords: string[];
url: string; // リンク先パス
category?: string; // フィルタリング用
extra?: string; // 追加検索テキスト(読み、例文等)
};
4-2. Fuse.jsの設定
const fuse = new Fuse(documents, {
keys: [
{ name: "title", weight: 2.0 },
{ name: "keywords", weight: 1.5 },
{ name: "description", weight: 1.0 },
{ name: "extra", weight: 0.5 },
],
threshold: 0.3, // ファジーマッチの閾値
includeScore: true,
includeMatches: true, // ハイライト用
minMatchCharLength: 1, // 日本語は1文字でも意味あり
});
4-3. コンポーネント構成
src/components/search/
SearchModal.tsx - モーダルコンテナ (Cmd+K対応)
SearchInput.tsx - 検索入力欄 (デバウンス付き)
SearchResults.tsx - 結果リスト (カテゴリ別グループ化)
SearchResultItem.tsx - 個別結果アイテム
SearchTrigger.tsx - ヘッダー用の検索アイコンボタン
useSearch.ts - 検索ロジックのカスタムフック
src/lib/search/
index.ts - 検索インデックス生成ロジック
types.ts - SearchDocument型定義
4-4. インデックスデータの受け渡し方式
Next.js App Routerとの統合方法として、以下の方式を推奨します:
方式: サーバーコンポーネントでインデックス生成し、クライアントコンポーネントにpropsとして渡す
RootLayout(サーバーコンポーネント)内で全コンテンツのメタデータを収集し、SearchProvider(クライアントコンポーネント)にpropsとして渡します。この方式は:
- 既存のツール・チートシートのレジストリパターンと一貫性がある
- ビルド時にデータが確定するため、別途ビルドスクリプトが不要
- JSON.stringifyでpropsとして渡されるため、自動的にクライアントにシリアライズされる
4-5. 検索インデックスのサイズ見積もり
現在約500件のコンテンツで、1件あたり平均200-300バイト(title + description + keywords)と見積もると、全インデックスは約100-150KBです。gzip圧縮で30-50KB程度に収まり、初回ロードへの影響は軽微です。
5. まとめと推奨事項
| 項目 | 推奨 |
|---|---|
| 検索ライブラリ | Fuse.js (日本語対応が最も自然、追加依存なし、軽量) |
| UI方式 | Cmd+Kモーダル (ヘッダーに検索アイコン + キーボードショートカット) |
| 検索方式 | インクリメンタルサーチ (150msデバウンス) |
| 結果表示 | カテゴリ別グループ化 + 関連度順 |
| インデックス | サーバーコンポーネントで生成 → クライアントにprops渡し |
| 検索対象 | 8コンテンツタイプ全て (約500件) |
参考資料
- Fuse.js公式: https://www.fusejs.io/
- Orama公式ドキュメント (日本語対応): https://docs.orama.com/open-source/supported-languages/using-japanese-with-orama
- Pagefind公式: https://pagefind.app/
- Pagefind + Next.js統合記事: https://www.petemillspaugh.com/nextjs-search-with-pagefind
- Fuse.js vs Orama比較: https://liambx.com/blog/comparison-of-fuse-js-and-orama
- 検索UIベストプラクティス: https://www.chameleon.io/patterns/cmd-k-search
- npm比較: https://npm-compare.com/@orama/orama,flexsearch,fuse.js,lunr