サイト内検索の作り方|全文検索・あいまい検索・UIを最短で実装
サイト内検索を自前DBか外部SaaSか迷う人へ。全文検索・あいまい検索・検索UIの選び方と、コピペで動くコードをMasaの失敗談つきで解説。
自分のブログに検索ボックスを付けた翌週、ログを見て頭を抱えました。「claude code 料金」で検索した人に、料金の記事が一件も出ていなかったんです。
原因は単純で、僕は LIKE '%claude code 料金%' という一行で検索を済ませていました。スペースを含む文字列とまるごと一致する記事なんて、当然ありません。検索ボックスは置いた。でも「探せる」状態にはなっていなかった。
サイト内検索って、入力欄を一個置けば終わり、ではないんですよね。今日はそのあたりを、自分が踏んだ地雷ごと共有します。
この記事の要点
- サイト内検索は「方式選び」が9割。
LIKE・全文検索・あいまい検索・外部SaaSの4択を、規模と目的で決める - 数万件以下・権限が単純なら、まずDBの全文検索で十分。表記ゆれや日本語精度が欲しくなったら外部に寄せる
- 検索UIは「デバウンス」「途中キャンセル」「ハイライト」「0件表示」の4点を押さえれば体験が一気に良くなる
- 一番怖いのは速度より情報漏れ。下書き・個人情報は索引に入れる前に削る
- 検索ログ(0件・クリック率)は記事ネタとリンク改善の宝の山。実装より運用で差がつく
検索ボックスの裏で起きていること
まず、僕が最初に勘違いしていた話から。
検索は「入力された文字と一致する行を返す」だけの処理に見えます。でも実際にユーザーがやるのは、Claude Code 料金のように複数の単語を空けて打つ、認証 APIのように順番を気にしない、postgreみたいに途中まで打ってタイプミスする——こういう「ふわっとした入力」です。
LIKE '%...%'は、この入力を文字列のかたまりとして扱ってしまう。だから単語の分割も、語順の入れ替えも、タイプミスの許容も、何ひとつできません。検索らしく振る舞わせるには、入力を「単語」に分けて、それぞれが本文のどこかに出るかを見る仕組みが要ります。これが全文検索の出発点です。
もう一つ大事なのが「重み付け」。料金という語がタイトルに出る記事と、本文の終わりに一回だけ出る記事を、同じ扱いにしてはいけません。前者を上に出す。この当たり前を、LIKEは一切やってくれないんです。
方式は4つ。規模と目的で選ぶ
検索の作り方は、ざっくり4段階あります。いきなり一番上を目指さなくていい。今の自分のサイトがどこにいるかで選びます。
| 方式 | 何ができる | 向いている規模・場面 | 弱点 |
|---|---|---|---|
LIKE検索 | 部分一致だけ | 数百件・社内ツールの簡易フィルタ | 語順・複数語・ランキング不可 |
| DBの全文検索 | 単語分割・重み付け・ランキング | 数万件まで・権限が複雑な記事/管理画面 | 日本語の精度、表記ゆれは自前 |
| あいまい検索エンジン | タイプミス許容・ファセット・並び替え | 数万〜数十万件・回遊を伸ばしたい | 検索サーバーと同期処理の運用 |
| 検索SaaS | UI部品・分析・改善運用まで一式 | 検索が売上に直結するECや教材 | コスト・キー設計・計測の初期設計 |
「あいまい検索エンジン」はMeilisearchやTypesenseのような自前ホストできるもの、「検索SaaS」はAlgoliaが代表です。Algoliaに絞った具体的な実装はClaude CodeでAlgolia検索を実装するガイドに分けてあるので、SaaS前提ならそちらが早いです。この記事は「そもそもどれを選ぶか」と「自前で全文検索+UIをどう組むか」に軸を置きます。
僕の経験則はシンプルで、記事数が数万件以下で、出していい記事と隠す記事の判定がDBの条件で書けるうちは、DBの全文検索で十分です。検索SaaSは便利だけど、月額と「何を外に送るか」の判断がついて回る。最初からそこに行く必要はありません。
自前 vs 外部、判断のものさし
「自前で書くか、お金を払って外に任せるか」で迷ったら、僕は次の順で考えます。
- 検索が売上に直結するか。ECや有料教材の検索で、ヒット精度がそのまま購入率に響くなら、SaaSのランキング改善や分析に払う価値があります。読みもの中心のブログなら、まだ早い。
- 表記ゆれ・タイプミスを許容したいか。
postgreでPostgreSQLを当てたい、api認証でAPI 認証を出したい——この「ゆるさ」が欲しいなら、あいまい検索エンジンか SaaS。DBの全文検索は素のままだと、ここが弱いです。 - 運用に手を割けるか。外部エンジンやSaaSは、DBと検索索引を「同期」し続ける仕事が増えます。公開した記事が検索に出るまでのズレを誰が見るのか。ここを決められないなら、まずDB内で完結させたほうが事故りません。
逆に、管理画面や監査ログの検索みたいに「権限が命・外に出したら事故」な領域は、迷わずDB中心です。検索SaaSは一度送ったデータが向こうのログや管理画面に残るので、「画面で隠す」では守れません。
DBの全文検索:索引の作り方
ここからは手を動かします。PostgreSQLを例にしますが、考え方はMySQLでも同じです。本文を検索のたびに変換するのではなく、tsvectorという検索用の列をあらかじめ作って、そこにGINインデックスを張ります。
ポイントは、タイトル・要約・タグ・本文に重みを付けること。setweightの'A'が一番強く、'D'が一番弱い。タイトルの一致を最優先にしたいので、タイトルを'A'に置きます。
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(),
-- 検索用の列。タイトルを最も強く(A)、本文を弱く(C)評価する
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
);
-- 全文検索を速くするGINインデックス
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設定は日本語の分かち書きには弱いです。英数字やタグ中心の記事サイトなら扱いやすいけれど、日本語本文の精度を本気で上げたいなら、PostgreSQL側で形態素解析の拡張を入れるか、あいまい検索エンジンに寄せる判断になります。「DBで始めて、足りなくなったら移す」を前提に、最初からURLとレスポンスの形だけ揃えておくと、後で楽です。
検索APIを書く:公開記事だけ返す
次にAPIです。フレームワークはNext.jsを例にしますが、肝は二つだけ。websearch_to_tsqueryで人間の入力をそのまま解釈することと、status = 'published'で下書きを絶対に外に出さないことです。
// 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) {
// 検索語は長さを制限する(長文クエリでDBを叩かせない)
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);
// 1文字検索は無視する(ノイズと負荷の元)
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,
});
}
カテゴリはSQLパラメータ($3)で渡しているので、文字列連結によるインジェクションを避けられます。ハイライトをDB側でHTML生成する手もありますが、本文にHTMLが混ざるサイトだとエスケープ漏れが怖い。僕は表示側(次のUI)で安全に分割するほうを選んでいます。API設計まわりの基本はClaude CodeでAPI開発を進める実践手順もあわせてどうぞ。
検索UIで効く4つの工夫
検索体験の良し悪しは、ほぼフロント側で決まります。僕がいつも入れるのは次の4つです。
- デバウンス:一文字打つたびにAPIを叩くと、無駄なリクエストが10連発で飛びます。300msほど待ってから投げる。
- 途中キャンセル:前の検索が返る前に次を打ったら、古いほうを
AbortControllerで止める。これがないと、遅れて返ってきた古い結果が新しい結果を上書きする「ちらつき」が起きます。 - ハイライト:一致した語を
<mark>で囲う。どこが当たったか一目で分かると、クリック率が変わります。 - 0件表示:「見つかりませんでした」を必ず出す。無言で空っぽは、壊れているのと見分けがつきません。
下のコンポーネントに、この4つを全部入れてあります。
// components/ArticleSearchBox.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
type SearchHit = {
id: string;
title: string;
summary: string;
url: string;
category: string;
};
// 入力が止まってから delayMs 後に値を確定させる
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, "\\$&");
}
// 一致部分を <mark> で囲う
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 [searched, setSearched] = 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([]);
setSearched(false);
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);
setSearched(true);
})
.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="記事を検索(例:認証 API)"
/>
<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>}
{searched && hits.length === 0 && !loading && (
<p>「{debouncedQuery}」に一致する記事は見つかりませんでした。</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に移すときも、このUIの考え方はそのまま使えます。SaaSの場合は公式のUI部品(AlgoliaならInstantSearch)を使うと短く書けますが、その際も管理キーはサーバーだけ、ブラウザに渡すのは検索専用キーを徹底してください。
あいまい検索へ移すなら同期がすべて
DBの全文検索で物足りなくなったら——タイプミスを許したい、ファセットで絞らせたい、並び替えを増やしたい——あいまい検索エンジンやSaaSの出番です。ここで一番大事なのは検索の速さではなく、「DBを正、検索索引はコピー」と決めて、同期を回し続けることです。
同期で僕が必ず守っているルールはこれだけ。
- 索引に入れるのは公開記事だけ。
statusで必ず絞る - 索引に送るフィールドを「検索と表示に要る分」に削る。本文の生データや内部メモは送らない
- 記事を公開・更新・削除したら索引も追従する(公開イベント、cron、CIのどれかで)
- ファセットの値は送る前に正規化する。
API・api・Apiが別物になると絞り込みが壊れる
最初は「全件入れ替え」で十分です。公開した瞬間に検索へ出したいサイトだけ、差分同期に踏み込めばいい。とにかく下書きと個人情報を索引に入れない——これだけは方式が何であろうと最優先です。
検索ログは記事ネタの金脈
最後に、実装より大事かもしれない話を。
検索は壊れてもサイトが落ちないので、放置されがちです。でも検索ログには、読者が「あると思って探したのに無かったもの」が全部記録されています。僕が週次で見ているのはこの3つ。
- 0件だった検索語:それ、書くべき記事の候補です。僕の「料金」事件も、0件ログを見て気づきました
- クリックされない上位記事:タイトルかdescriptionが検索意図とズレているサイン
- よく検索される語:内部リンクや特集として目立たせると、回遊が伸びます
このログをそのままClaude Codeに渡して「0件の語に対する記事案と、既存記事への内部リンク案を出して」と頼むと、ネタ出しからリンク改善まで一気に進みます。検索は作って終わりじゃなくて、回しながらサイトを育てる道具なんですよね。読み込みが重いと感じたら、Claude Codeでパフォーマンス最適化を進める方法でDBインデックスやキャッシュを見直すのも効きます。なお、PostgreSQLの全文検索の細かい挙動は公式のFull Text Searchが一番正確です。
よくある質問
Q. 小さなブログでも全文検索エンジンは必要ですか? いりません。記事が数百〜数千件で権限が単純なら、DBの全文検索で十分です。表記ゆれやタイプミス許容が欲しくなってから、あいまい検索エンジンやSaaSを検討すれば間に合います。
Q. LIKE検索のままではダメですか?
社内ツールの簡易フィルタなら可です。ただし複数単語・語順違い・ランキングが要る一般公開の検索では、LIKEだと「探せない」体験になります。僕の冒頭の失敗がまさにそれでした。
Q. 日本語の検索精度が低いのですが?
PostgreSQLのsimple設定は日本語の分かち書きに弱いです。形態素解析の拡張を入れるか、日本語に強いあいまい検索エンジン(MeilisearchやTypesense)へ寄せると改善します。
Q. 検索SaaSに何を送ってはいけませんか? 下書き本文、メールアドレス、住所、決済ID、内部メモ、顧客IDなど非公開データです。送ると向こうのログや管理画面に残るので、「画面で隠す」では守れません。送る前に削るのが鉄則です。
Q. 検索UIで最低限やるべきことは? デバウンス、途中キャンセル(AbortController)、ハイライト、0件表示の4つです。これだけで「ちゃんと動く検索」に見えます。
実際に試した結果
「料金」事件のあと、僕がまずやったのは検索エンジンの乗り換え——ではなく、LIKEをwebsearch_to_tsqueryに変えて、タイトルに重みを付けただけでした。それで複数語も語順違いも当たるようになり、料金の記事もちゃんと一位に出た。手間は半日です。
そのうえで0件ログを毎週Claude Codeに渡すようにしたら、「探されているのに無い記事」が見えるようになって、ネタ切れの悩みが減りました。検索は高機能なものを最初から入れるより、今の規模に合った方式を選んで、ログを回すほうが効く。遠回りに見えて、これが一番早かったというのが正直な実感です。
検索設計や記事回遊の改善を一緒に詰めたいときは、研修・相談ページから声をかけてください。まずは「何を検索対象にしないか」を決めるところから始めると、後戻りがぐっと減ります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。