Claude Codeで検索機能を実装する実践ガイド|Postgres・Meilisearch・Algolia比較
Claude Codeで検索機能を設計・実装する手順を、DB選定、索引、同期、UI、テストまで実例で解説。
検索機能は「入力欄」ではなく発見体験である
検索機能とは、ユーザーの入力に合わせて候補を探し、絞り込み・並び替え・ハイライトまで返す体験です。単にLIKE '%keyword%'で一覧を返すだけなら半日で作れますが、PVを伸ばし、回遊を増やし、問い合わせや購入につなげる検索はそこでは止まりません。読者が「Claude Code Algolia」「API 認証」「Postgres 速度」のように曖昧な語で探しても、意図に近い記事を上位に出し、カテゴリで絞れ、該当箇所が見える必要があります。
Masaが過去に小さな記事サイトへ検索を入れたとき、最初の失敗はUIだけを先に作ったことでした。見た目は完成しているのに、索引に入れるフィールド、公開状態、ロケール、古い記事の重み付け、クリック後の改善サイクルが未定義で、結果として「検索はあるが使われない」状態になりました。Claude Codeを使うと実装速度は上がりますが、要件が薄いまま依頼すると、薄い検索が高速に生成されるだけです。
この記事ではClaude Codeに投げる要件プロンプトから、PostgreSQL全文検索、Meilisearch、Algoliaの選定、インデックススキーマ、同期ジョブ、フィルタ・ファセット設計、UIのデバウンス、テスト、ロールアウトチェックまでを一つの流れでまとめます。Algolia単体の詳細はClaude CodeでAlgolia検索を実装するガイド、API設計の基本はClaude CodeでAPI開発を進める実践手順、速度面の補強はClaude Codeでパフォーマンス最適化を進める方法も合わせて確認してください。
まずユースケースを3つに分ける
検索技術を選ぶ前に、誰が何を探すかを分けます。ここを飛ばすと、Algoliaが必要な場面でPostgresだけを使ったり、逆にPostgresで十分な社内管理画面へ外部検索SaaSを入れて運用を重くしたりします。
| ユースケース | 代表例 | 重視すること | 向いている構成 |
|---|---|---|---|
| 記事・ドキュメント検索 | ブログ、FAQ、社内ナレッジ | タイトル重み、ハイライト、タグ絞り込み | Postgres全文検索またはMeilisearch |
| EC・教材カタログ検索 | 商品、講座、テンプレート | ファセット、並び替え、同義語、クリック改善 | MeilisearchまたはAlgolia |
| 管理画面・監査検索 | 顧客、請求、ログ | 権限、完全一致、監査性、漏洩防止 | Postgres中心 |
| 多言語コンテンツ検索 | 日本語、英語、中国語などの記事 | ロケール分離、翻訳タイトル、言語別SEO語 | MeilisearchまたはAlgolia |
PV成長を狙う記事サイトでは、検索結果ページ自体も回遊導線です。0件検索が多い語は新記事の候補になり、クリックされない上位記事はタイトルやdescriptionの改善対象になります。検索は実装タスクではなく、コンテンツ運用の計測点として扱うのが現実的です。
Claude Codeへ渡す要件プロンプト
Claude Codeには「検索ボックスを作って」ではなく、データ、権限、性能、改善方法をまとめて渡します。harness(エージェントの足場)として、以下のようなプロンプトを最初に使うと実装の抜けが減ります。
あなたは既存Next.jsアプリの検索機能を実装する担当です。
目的:
- 公開済み記事を検索し、PV回遊と問い合わせ導線を増やす
- クエリ、カテゴリ、タグ、ロケールで絞り込める
- タイトル、要約、本文を対象にし、タイトルを最も強く評価する
- 検索語に一致した箇所をハイライトする
制約:
- 下書き、非公開、権限付き記事は返さない
- 管理キーや書き込みキーをブラウザへ出さない
- 300msのデバウンスとAbortControllerで過剰リクエストを避ける
- 0件検索、遅い検索、クリック率をログに残す
成果物:
- 技術選定メモ(Postgres全文検索、Meilisearch、Algoliaの比較)
- インデックススキーマ
- 同期ジョブ
- /api/search の実装
- React検索UI
- VitestまたはPlaywrightのテスト
- ロールアウトチェックリスト
この段階でClaude Codeに既存のDBスキーマ、記事frontmatter、公開URL、認証方式を読ませます。特に検索は「表示してよい情報」だけを索引に入れる設計が重要です。email、内部メモ、下書き本文、顧客IDのようなデータは、UIに出さなくても検索API経由で漏れる可能性があります。
Postgres、Meilisearch、Algoliaの選び方
2026年6月時点で、小から中規模の検索実装は次の判断で十分です。PostgreSQLの全文検索は公式ドキュメントのFull Text Searchにある通り、tsvector、tsquery、ランキング、ハイライトまでDB内で扱えます。MeilisearchはFirst ProjectとFiltering, sorting, and facetingが導入しやすく、タイプミス許容やファセットが必要な記事検索に向きます。AlgoliaはInstantSearch.jsとReact InstantSearchのUI部品が強く、改善運用や商用検索に向きます。
| 選択肢 | 選ぶべき条件 | 注意点 |
|---|---|---|
| Postgres全文検索 | DBがPostgres、公開記事が数万件以下、権限条件が複雑 | 日本語形態素解析は別途検討。ランキング改善は自前 |
| Meilisearch | 導入を軽くしたい、タイポ許容とファセットが必要 | 同期ジョブと検索サーバー運用が必要 |
| Algolia | EC、教材、SaaSのように検索が売上へ直結する | コスト、APIキー設計、イベント計測を最初に決める |
最初から完璧な検索基盤を狙う必要はありません。記事数が少なく、権限が単純ならPostgresで始め、検索語ログが増えてからMeilisearchやAlgoliaへ切り替える設計で十分です。ただし、URL、検索APIのレスポンス形、idの命名は移行しやすくしておきます。
Postgres全文検索のインデックススキーマ
Postgresで始める場合、本文を毎回to_tsvectorへ変換するのではなく、生成列とGINインデックスを使います。以下はそのままpsqlで試せる最小構成です。
CREATE TABLE IF NOT EXISTS articles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
locale text NOT NULL,
status text NOT NULL CHECK (status IN ('draft', 'published', 'private')),
title text NOT NULL,
summary text NOT NULL,
body text NOT NULL,
category text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
popularity integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(array_to_string(tags, ' '), '')), 'B') ||
setweight(to_tsvector('simple', coalesce(body, '')), 'C')
) STORED
);
CREATE INDEX IF NOT EXISTS articles_search_vector_idx
ON articles USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS articles_locale_status_idx
ON articles (locale, status, updated_at DESC);
simple設定は英数字やタグ中心の記事では扱いやすい一方、日本語の分かち書きには弱いです。日本語検索の精度を本気で上げるなら、MeilisearchやAlgoliaに寄せるか、Postgres側で拡張や前処理を検討します。ここで大事なのは、タイトル、要約、タグ、本文に重みを付けることです。本文に一度だけ出る語より、タイトルに出る語を上位にすべきだからです。
Next.js APIで検索結果を返す
次はAPIです。pgを使う例にしておくと、PrismaやDrizzleを使っていない環境でも試せます。websearch_to_tsqueryはユーザーが普段入力する検索語に近い構文を扱えるため、検索ボックス向きです。
// app/api/search/route.ts
import { Pool } from "pg";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
type SearchHit = {
id: string;
slug: string;
title: string;
summary: string;
category: string;
updatedAt: string;
rank: number;
};
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim().slice(0, 80) ?? "";
const locale = request.nextUrl.searchParams.get("locale") ?? "ja";
const category = request.nextUrl.searchParams.get("category");
const limit = Math.min(Number(request.nextUrl.searchParams.get("limit") ?? 10), 20);
if (q.length < 2) {
return NextResponse.json({ hits: [], total: 0 });
}
const sql = `
WITH input AS (
SELECT websearch_to_tsquery('simple', $1) AS tsq
)
SELECT
id,
slug,
title,
summary,
category,
updated_at AS "updatedAt",
ts_rank_cd(search_vector, input.tsq) AS rank
FROM articles, input
WHERE status = 'published'
AND locale = $2
AND search_vector @@ input.tsq
AND ($3::text IS NULL OR category = $3)
ORDER BY rank DESC, popularity DESC, updated_at DESC
LIMIT $4;
`;
const { rows } = await pool.query<SearchHit>(sql, [q, locale, category, limit]);
return NextResponse.json({
hits: rows.map((row) => ({
...row,
url: locale === "ja" ? `/blog/${row.slug}` : `/${locale}/blog/${row.slug}`
})),
total: rows.length
});
}
このAPIは公開記事だけを返します。カテゴリはSQLパラメータにしているため、文字列連結によるSQLインジェクションを避けられます。ハイライトをDBでHTML生成する方法もありますが、本文にHTMLが混ざるサイトではエスケープ漏れが怖いので、まずはクライアント側で安全に分割表示するほうが扱いやすいです。
Meilisearchへ切り替えるときの同期ジョブ
記事数が増え、タイプミス許容、ファセット、並び替えが欲しくなったらMeilisearchが候補になります。DBを正とし、公開してよいフィールドだけを検索索引へ同期します。
// scripts/sync-meilisearch.ts
import "dotenv/config";
import { MeiliSearch } from "meilisearch";
type ArticleRecord = {
id: string;
title: string;
summary: string;
body: string;
locale: string;
status: "published";
category: string;
tags: string[];
url: string;
popularity: number;
updatedAtTimestamp: number;
};
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST ?? "http://127.0.0.1:7700",
apiKey: process.env.MEILISEARCH_ADMIN_KEY
});
const index = client.index<ArticleRecord>("articles");
await index.updateSettings({
searchableAttributes: ["title", "summary", "body", "tags"],
filterableAttributes: ["locale", "status", "category", "tags"],
sortableAttributes: ["updatedAtTimestamp", "popularity"],
displayedAttributes: ["id", "title", "summary", "locale", "category", "tags", "url"]
});
const records: ArticleRecord[] = [
{
id: "ja_claude-code-search-functionality",
title: "Claude Codeで検索機能を実装する実践ガイド",
summary: "検索基盤の選定からUI、テスト、運用までを扱う記事です。",
body: "公開本文から抽出した検索対象テキストを入れます。",
locale: "ja",
status: "published",
category: "use-cases",
tags: ["Claude Code", "検索機能", "全文検索"],
url: "/blog/claude-code-search-functionality",
popularity: 18,
updatedAtTimestamp: 1780272000
}
];
const task = await index.addDocuments(records, { primaryKey: "id" });
console.log(`Queued Meilisearch task ${task.taskUid}`);
同期ジョブは「全件入れ替え」から始めても構いませんが、公開直後に検索へ出る必要があるサイトでは差分同期にします。CMSの公開イベント、GitHub Actions、cronのいずれかで動かし、完了タスクIDをログに残します。ここでも下書きや権限付き本文を絶対に索引へ入れないことが最優先です。
フィルタ・ファセット設計は増やしすぎない
ファセットは便利ですが、多すぎるとユーザーを迷わせます。記事サイトなら最初はcategory、tags、localeで十分です。ECならbrand、price_range、in_stock、ratingが候補になります。管理画面ならstatusやowner_idは検索結果を守るためのフィルタであり、ユーザーに見せるファセットとは分けます。
失敗しやすいのは、DBの列をそのまま全部ファセットにする設計です。値の種類が多すぎるslugやupdated_atをファセットにしても使いません。逆にtagsを入れ忘れると、記事検索の回遊性が落ちます。Claude Codeには「表示用ファセット」と「権限用フィルタ」を分けて実装するよう明示してください。
デバウンス付きReact UI
UIは300ms程度のデバウンスとAbortControllerを入れます。これで一文字ごとの無駄なレスポンス競合を減らせます。
// components/ArticleSearchBox.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
type SearchHit = {
id: string;
title: string;
summary: string;
url: string;
category: string;
};
function useDebounce<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function Highlight({ text, query }: { text: string; query: string }) {
const keyword = query.trim();
if (!keyword) {
return <>{text}</>;
}
const splitter = new RegExp(`(${escapeRegExp(keyword)})`, "ig");
const exact = new RegExp(`^${escapeRegExp(keyword)}$`, "i");
return (
<>
{text.split(splitter).map((part, index) =>
exact.test(part) ? <mark key={`${part}-${index}`}>{part}</mark> : <span key={`${part}-${index}`}>{part}</span>
)}
</>
);
}
export function ArticleSearchBox({ locale = "ja" }: { locale?: string }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [hits, setHits] = useState<SearchHit[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const params = useMemo(() => {
const next = new URLSearchParams({ q: debouncedQuery, locale });
if (category) next.set("category", category);
return next;
}, [category, debouncedQuery, locale]);
useEffect(() => {
if (debouncedQuery.trim().length < 2) {
setHits([]);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error("Search request failed");
return response.json();
})
.then((data: { hits: SearchHit[] }) => setHits(data.hits))
.catch((error) => {
if (error.name !== "AbortError") console.error(error);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [debouncedQuery, params]);
return (
<section aria-label="記事検索">
<div className="flex gap-2">
<input
aria-label="検索キーワード"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Claude Codeの記事を検索"
/>
<select aria-label="カテゴリ" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="">すべて</option>
<option value="use-cases">ユースケース</option>
<option value="advanced">応用</option>
</select>
</div>
{loading && <p>検索中...</p>}
<ul>
{hits.map((hit) => (
<li key={hit.id}>
<a href={hit.url}>
<Highlight text={hit.title} query={debouncedQuery} />
</a>
<p>
<Highlight text={hit.summary} query={debouncedQuery} />
</p>
</li>
))}
</ul>
</section>
);
}
Algoliaを使う場合は、React側で公式のInstantSearch、SearchBox、Hits、RefinementList、Highlightを組み合わせると短く実装できます。ただし、管理キーはサーバーだけで使い、ブラウザには検索専用キーまたは制限付きキーだけを渡します。
テストとロールアウトチェック
検索は壊れてもサイト全体が落ちないため、バグが放置されがちです。少なくとも「公開記事だけ返る」「短すぎるクエリでは検索しない」「カテゴリで絞れる」「検索語がハイライトされる」をテストします。
// tests/search-query.test.ts
import { describe, expect, it } from "vitest";
function shouldSearch(query: string) {
return query.trim().length >= 2 && query.length <= 80;
}
function buildSearchUrl(query: string, locale: string, category?: string) {
const params = new URLSearchParams({ q: query.trim(), locale });
if (category) params.set("category", category);
return `/api/search?${params.toString()}`;
}
describe("search request helpers", () => {
it("rejects empty and one-character queries", () => {
expect(shouldSearch("")).toBe(false);
expect(shouldSearch("a")).toBe(false);
expect(shouldSearch("api")).toBe(true);
});
it("builds a category-filtered URL", () => {
expect(buildSearchUrl("Claude Code", "ja", "use-cases")).toBe(
"/api/search?q=Claude+Code&locale=ja&category=use-cases"
);
});
});
ロールアウト前には、1. 索引対象から下書きを除外したか、2. 0件時の表示があるか、3. 検索APIのp95が許容範囲か、4. スパム的な長文クエリを切っているか、5. ログに個人情報を残していないか、6. サイトマップや内部リンクから検索導線へ自然につながるか、を確認します。検索ページが遅い場合は、DBインデックス、APIキャッシュ、結果件数、画像の遅延読み込みを順に見ます。
具体的な落とし穴
一つ目は、検索対象に非公開データを混ぜることです。外部検索SaaSでは一度送ったデータが管理画面やログに残るため、「UIで隠す」では不十分です。送る前に削ります。
二つ目は、同義語を最初から増やしすぎることです。「AI」「Claude」「ChatGPT」「自動化」を全部つなぐと、検索意図がぼやけます。0件検索やクリック率の悪い語から追加します。
三つ目は、ファセットの値を正規化しないことです。API、api、Apiが別タグになると絞り込みが壊れます。同期前に小文字化や表示名変換を行います。
四つ目は、検索結果のクリックを見ないことです。検索語、順位、クリック先、0件を週次で見れば、記事タイトル、内部リンク、講座ページへの導線を改善できます。これはAdSense向けにも重要で、単なる情報まとめよりも、読者が次に読むべき記事へ自然に進める構造のほうが滞在時間を伸ばしやすいからです。
ClaudeCodeLabでは、Claude Codeを使った検索設計、API実装、記事回遊改善を研修・相談のテーマとして扱っています。自社サイトや社内ナレッジに検索を入れるなら、最初に「何を検索対象にしないか」と「検索ログをどう改善へ回すか」を一緒に決めると、後戻りが少なくなります。興味があれば研修・相談ページから確認してください。
まとめ
Claude Codeで検索機能を作るときは、UIからではなく要件、索引、同期、フィルタ、テスト、改善ログの順で考えます。Postgres全文検索は小さく始めるのに向き、Meilisearchは記事・ドキュメント検索を軽く強化し、Algoliaは検索が売上やCVに直結する場面で力を発揮します。
この記事で紹介した内容を実際に試した結果、最初にインデックススキーマとattributesToRetrieve相当の返却項目を絞った構成ほど、UI実装後の修正が少なくなりました。特に0件検索のログをClaude Codeへ渡して改善案を出させる流れは、同義語追加、記事タイトル修正、内部リンク改善を同じ週次作業にまとめられるため、PV成長と記事品質改善の両方に効きます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。