zudo-paper

Algolia から MiniSearch に検索機能を移行した

Author: Takazudo | 作成: 2026/03/06

概要

Takazudo Modularのサイト検索をAlgoliaからMiniSearchに移行した。外部サービスへの依存をなくして、ビルド時にインデックスをJSONとして生成し、Netlify Functionで検索するというセルフホスト構成に変えた。そのまとめ。

Takazudo Modularの検索画面

背景

Takazudo Modularでは記事の全文検索にAlgoliaを使っていた。Algoliaは高機能なのだが、以下の点が気になっていた。

  • APIキーの管理が必要(ALGOLIA_ADMIN_KEY, NEXT_PUBLIC_ALGOLIA_APP_ID, NEXT_PUBLIC_ALGOLIA_SEARCH_KEY, SKIP_ALGOLIA_INDEXINGなど環境変数が多い)
  • ビルド時にAlgoliaへインデックスを送信する処理がCIを複雑にしていた
  • 無料枠の制限を意識する必要がある
  • サイト規模的に、ここまでの検索エンジンは不要

サイトの記事数や更新頻度を考えると、セルフホストの全文検索で十分という判断になった。

MiniSearch の選定

軽量な全文検索ライブラリとしてMiniSearchを採用した。npmパッケージminisearchをインストールするだけで使える。ブラウザでもNode.jsでも動作し、JSONデータからインデックスを構築できる。

ビルド時のインデックス生成

Next.jsのビルドプロセスでsearch-data.jsonを生成する。全記事のtitle、description、tags、excerptなどを含むJSONファイルで、これが検索のデータソースになる。

サーバーサイド検索: 本番環境

本番環境では、Netlify Functionnetlify/functions/search.ts)として検索APIを実装した。search-data.jsonをesbuildでバンドル時にimportして、MiniSearchインスタンスをモジュールレベルで初期化する。Warm invocationで再利用される。

// netlify/functions/search.ts
import searchData from "./search-data.json";
 
const miniSearch = new MiniSearch<SearchDocument>({
  fields: config.fields,
  storeFields: config.storeFields,
  searchOptions: config.searchOptions,
});
miniSearch.addAll(documents);

APIはGET /.netlify/functions/search?q=<query>&locale=ja&excerpt_length=200で呼べる。

クライアントサイド検索: 開発環境

開発時はNetlify Functionが動かないので、use-search.tsフックがクライアントサイドで直接MiniSearchを使う。/search-index.jsonをfetchしてMiniSearchインスタンスを構築する。本番では/api/searchエンドポイント(Netlify Function)を呼ぶ。

async function performSearch(query: string, locale: "ja" | "en") {
  if (isDev) {
    const ms = await loadDevIndex(); // クライアントサイド MiniSearch
    const results = ms.search(query).map(/* ... */);
    return applyLocaleBoost(results, locale);
  }
  // 本番: Netlify Function を呼ぶ
  const res = await fetch(
    `/api/search?q=${encodeURIComponent(query)}&locale=${locale}`,
  );
  return (await res.json()).results;
}

開発環境と本番環境で検索の実行方法が異なるが、呼び出し側からは同じインターフェースで使える。

ロケール対応のスコアブースト

サイトには日本語ページと英語ページが混在している。検索時にロケールに応じたスコアブーストを実装して、日本語検索ページでは日本語記事のスコアを3倍に、英語検索ページでは英語記事のスコアを3倍にする。

// src/utils/locale-boost.ts
const LOCALE_BOOST_FACTOR = 3;
 
export function applyLocaleBoost<T extends { id: string; score: number }>(
  results: T[],
  locale: string,
): T[] {
  return results
    .map((r) => {
      const isEnArticle = r.id.endsWith(".en");
      const isMatchingLocale = locale === "en" ? isEnArticle : !isEnArticle;
      const adjustedScore = isMatchingLocale
        ? r.score * LOCALE_BOOST_FACTOR
        : r.score;
      return { ...r, score: adjustedScore };
    })
    .sort((a, b) => b.score - a.score);
}

英語記事はIDの末尾が.enで判別できるという規約を利用している。シンプルだが実用上は十分。

esbuild の制約と対処

Netlify Functionsはesbuildでバンドルされるのだが、../../src/utils/locale-boostのようなパスからのimportが解決できない問題に遭遇した。Netlify Functionのバンドルプロセスがプロジェクトルートのsrc/ディレクトリを参照できない。

対処として、Netlify Function内にapplyLocaleBoost関数をインライン化した。共有ユーティリティはsrc/utils/locale-boost.tsに置きつつ、Netlify Function側は独立したコピーを持つ形。DRY原則には反するが、esbuildの制約上やむを得ない。

検索結果のハイライトとexcerpt

検索キーワードにマッチする部分を<mark>タグでハイライト表示する。excerptは検索キーワードの周辺を中心に200文字程度を切り出す。HTMLエスケープも適切に処理している。

ローディングUI

検索中のローディング表示として、サイトで使っているハムスターアニメーション(CSSアニメーション)を小さいサイズ(200px)で表示するようにした。元々はページ遷移時のオーバーレイで使っていたものを、コンポーネントとして切り出して再利用した。

キーワードログ

検索キーワードをNetlify Blobskeyword-logsストア)に非同期で記録するようにした。ゼロ結果の検索も記録して、改善のヒントにする。

Algolia の完全除去

MiniSearch移行完了後、Algolia関連のコード・設定・ドキュメントをすべて削除した。

  • src/utils/algolia-queries.mjs(検索インデックス設定)を削除
  • AlgoliaHit型定義を削除
  • CIワークフローからAlgolia環境変数を除去(main-deploy.ymlpreview-next-deploy.yml
  • .env.exampleからAlgolia設定を除去
  • ドキュメント類をMiniSearchに更新(CLAUDE.md、workflows README、4つのドキュメントファイル)

環境変数が4つ減って、CIの設定がシンプルになった。

アーキテクチャまとめ

全体のデータフローはこうなっている。

ビルド → JSON → サーバーレス関数という直線的なデータフローで、全体の見通しが良い。

パフォーマンス: インデックスJSONはユーザーにダウンロードされない

この構成で重要なのは、本番環境では検索インデックスのJSON(search-data.json)がユーザーのブラウザにダウンロードされないという点。検索インデックスはNetlify Functionにesbuildでバンドルされてサーバーサイドに留まり、ユーザーには検索結果のレスポンス(数KB程度)だけが返る。

現時点でインデックスJSONのサイズは約388KB、286ドキュメント。gzip圧縮しても100〜150KB程度になる。仮にこれをクライアントサイドに全件ダウンロードする方式にしていたら、検索を使わないユーザーにも毎回このコストがかかることになる。記事が増えて600件になればJSONは800KB超になりうるので、クライアントサイド方式ではスケールしない。

サーバーサイドAPI方式なら、インデックスはNetlify Functionのメモリ上に保持され、warm invocationで再利用される。ユーザーが増えてもインデックスの転送コストはゼロ。MiniSearchのインメモリ検索は高速なので、レスポンスも速い。

将来的にドキュメント数が数千件規模になった場合、コールドスタート時のインデックス構築時間が気になる可能性はある。その場合はMiniSearchのシリアライズ済みインデックスを事前構築する方法がある。ただし現在の規模では全く問題ない。

余談

検索という機能は外部サービスに頼りがちだが、サイト規模によってはMiniSearch + サーバーレス関数で十分実用的な構成が作れる。Algoliaのような高機能な検索は大規模サイトには必要だろうが、個人や小規模サイトならセルフホストで事足りる。環境変数の管理コストが減るだけでも地味にメリットがある。