「ベクトル検索を本番投入したいけれど、どこから始めればいいか分からない」——個人開発で SaaS や知識ベースを作る人が必ずぶつかる壁です。私自身、Cloudflare Workers 上で動く小さな質問応答ボットを Vectorize で組み直した時、最初の数日はチャンク分割の長さ一つで検索精度が大きく変わることに振り回されました。
Cloudflare Vectorize は 2024 年末に GA となり、Workers AI と統合されたエッジ最適化のベクトルデータベースです。Pinecone や Weaviate と違って Cloudflare のコントロールプレーンに統合されており、Workers と同じリージョンでベクトル検索を実行できる点が個人開発者にとって魅力的です。月額無料枠と従量課金が組み合わさっているので、初期コストを抑えながら本番運用に踏み切りやすい。
ここで扱うのはAntigravity の AI エージェントを使って RAG パイプラインのひな形を素早く生成し、それを本番品質に育てていく手順を、私が実際にハマった落とし穴と一緒に共有します。読み終わるころには、自分のドキュメントを取り込み、検索精度を測定し、コストとレイテンシを把握した状態で Cloudflare Workers 上にデプロイできるようになっているはずです。
なぜ Cloudflare Vectorize なのか — エッジで動かす意味
Pinecone・Weaviate・Qdrant・Chroma と、ベクトルデータベースの選択肢は急速に増えました。それでも Vectorize を選ぶ価値があるのは、Workers と同じインフラに乗っているという一点に集約されます。
Workers から Vectorize を呼ぶ場合、HTTP API ではなくバインディング経由で接続します。これはローカル関数呼び出しに近い遅延で動くため、外部 API 呼び出しに伴う TCP ハンドシェイクや認証ヘッダの作成コストがゼロです。私の手元の計測では、東京リージョンの Workers から Pinecone(米国)へクエリすると往復で 180–250 ms かかっていたものが、Vectorize に置き換えてからは 20–40 ms に収まりました。エッジで RAG を動かすときに、この一桁差は応答全体の体感速度を大きく変えます。
ただし万能ではありません。Vectorize はインデックス当たり最大 500 万ベクトル、メタデータインデックスのカーディナリティに制限があり、複雑なベクトル数演算(PQ 量子化、IVF などの細かなチューニング)を自分で触れません。「クエリ精度をミリ秒単位で詰めたい」「数億ベクトルの研究的データセットを動かしたい」というケースでは Pinecone Pod や自前の Qdrant の方が向いています。個人開発で 10 万〜数百万ベクトル規模を Cloudflare 一式で完結させたい、という用途にこそ最も力を発揮するサービスです。
私はこのサイト群(4 つのブログ)の全記事を Vectorize にインデックスして、サイト内検索と「関連記事」推薦に使っています。月額数百円のコストで、外部 API への依存をひとつ減らせるのは精神衛生上も大きなメリットでした。
5 分で動く最小 RAG:Antigravity でひな形を生成する
具体的な構築手順に入ります。Antigravity の Manager Surface でプロジェクトを開き、AI エージェントに対して以下のようなプロンプトを与えるところから始めます。
Cloudflare Workers + Vectorize で動く RAG パイプラインのひな形を作ってください。
要件:
- TypeScript / Hono を使う
- /ingest エンドポイントで Markdown ドキュメントを受け取り、チャンク分割→Workers AI で埋め込み→Vectorize に upsert
- /query エンドポイントで質問を受け取り、Vectorize で類似検索→上位 5 件を context として Workers AI の text-generation モデルに渡す
- wrangler.toml も生成する
- 環境変数は VECTORIZE_INDEX, AI bindings として定義
Antigravity がプロジェクト構造を作ってくれたら、まずは生成されたコードに目を通します。生成された雛形をそのまま動かすと、後で必ずチャンク分割や埋め込みモデルの切り替えで詰まるので、最初の数時間は「自分が読んで理解できる粒度に整える」ことに使います。
以下は、整理後の src/index.ts の中核部分です。これは私が実際に運用しているコードを Antigravity 用に最小化したものです。
// src/index.ts
import { Hono } from "hono";
type Bindings = {
AI: Ai;
VECTORIZE_INDEX: VectorizeIndex;
};
const app = new Hono<{ Bindings: Bindings }>();
// チャンク分割:見出しと段落の境界を尊重して 800 文字前後に揃える
function chunk(text: string, target = 800, overlap = 120): string[] {
const blocks = text.split(/\n{2,}/); // 段落区切り
const out: string[] = [];
let buf = "";
for (const block of blocks) {
if ((buf + "\n\n" + block).length > target && buf) {
out.push(buf.trim());
// 直前 chunk の末尾を overlap として引き継ぐ(文脈の途切れを防ぐ)
const tail = buf.slice(Math.max(0, buf.length - overlap));
buf = tail + "\n\n" + block;
} else {
buf = buf ? `${buf}\n\n${block}` : block;
}
}
if (buf.trim()) out.push(buf.trim());
return out;
}
app.post("/ingest", async (c) => {
const { docId, title, body } = await c.req.json<{
docId: string;
title: string;
body: string;
}>();
const chunks = chunk(body);
const vectors = await Promise.all(
chunks.map(async (text, i) => {
const { data } = await c.env.AI.run("@cf/baai/bge-small-en-v1.5", {
text: [text],
});
return {
id: `${docId}#${i}`,
values: data[0],
metadata: { docId, title, chunkIndex: i, text },
};
})
);
// upsert は最大 1,000 ベクトルまで一括で投げられる
await c.env.VECTORIZE_INDEX.upsert(vectors);
return c.json({ ok: true, chunks: chunks.length });
});
app.post("/query", async (c) => {
const { question, topK = 5 } = await c.req.json<{
question: string;
topK?: number;
}>();
const { data } = await c.env.AI.run("@cf/baai/bge-small-en-v1.5", {
text: [question],
});
const result = await c.env.VECTORIZE_INDEX.query(data[0], {
topK,
returnMetadata: "all",
});
const context = result.matches
.map((m, i) => `[doc ${i + 1}: ${m.metadata?.title}]\n${m.metadata?.text}`)
.join("\n\n---\n\n");
const answer = await c.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages: [
{
role: "system",
content:
"あなたは提供されたドキュメントだけを根拠に回答するアシスタントです。根拠が無い場合は『分かりません』と返してください。",
},
{
role: "user",
content: `# 質問\n${question}\n\n# 参考ドキュメント\n${context}`,
},
],
});
return c.json({ answer, sources: result.matches });
});
export default app;
wrangler.toml 側ではバインディングを宣言しておきます。
# wrangler.toml
name = "vectorize-rag"
main = "src/index.ts"
compatibility_date = "2026-04-01"
[ai]
binding = "AI"
[[vectorize]]
binding = "VECTORIZE_INDEX"
index_name = "blog-knowledge"
インデックス作成は CLI から行います。
# 768 次元(bge-small-en-v1.5 の出力次元)
wrangler vectorize create blog-knowledge --dimensions=768 --metric=cosine
# 検索するメタデータには事前にメタデータインデックスを作る
wrangler vectorize create-metadata-index blog-knowledge --property-name=docId --type=string
wrangler vectorize create-metadata-index blog-knowledge --property-name=title --type=string
ここまでで wrangler dev を叩けば、ローカルで /ingest と /query が動きます。期待される動作は、/ingest で 1 記事(5,000 文字程度)を投入したとき 6〜8 個のチャンクが作られ、/query で関連スコアの高い 5 件が返ってくる、という流れです。
ドキュメント取り込みパイプライン:チャンク分割・埋め込み・メタデータ
最小構成は動いたものの、これだけでは本番品質には届きません。最も大きく精度に影響するのが チャンク分割の戦略 です。
私は最初、固定長 1,000 文字でチャンクを切っていました。すると見出しの途中で切れたチャンクが増え、検索結果に「タイトルだけのフラグメント」がよく上位に出てくる、という現象に悩まされました。最終的に落ち着いたのは、Markdown を一度パースして、見出し階層を尊重しつつ段落単位で 600〜900 字に揃える方法です。
Markdown 構造を保ったチャンク分割
// src/lib/chunker.ts
type Section = { heading: string; level: number; body: string };
export function parseMarkdown(md: string): Section[] {
const lines = md.split("\n");
const sections: Section[] = [];
let current: Section = { heading: "", level: 1, body: "" };
for (const line of lines) {
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
if (current.body.trim() || current.heading) sections.push(current);
current = {
heading: headingMatch[2].trim(),
level: headingMatch[1].length,
body: "",
};
} else {
current.body += line + "\n";
}
}
if (current.body.trim() || current.heading) sections.push(current);
return sections;
}
export function chunkSections(
sections: Section[],
target = 800,
overlap = 100
): { text: string; heading: string }[] {
const out: { text: string; heading: string }[] = [];
for (const section of sections) {
const fullText = section.heading
? `## ${section.heading}\n${section.body.trim()}`
: section.body.trim();
if (fullText.length <= target) {
out.push({ text: fullText, heading: section.heading });
continue;
}
// 段落単位で再分割
const paragraphs = fullText.split(/\n{2,}/);
let buf = "";
for (const p of paragraphs) {
if ((buf + "\n\n" + p).length > target && buf) {
out.push({ text: buf.trim(), heading: section.heading });
const tail = buf.slice(Math.max(0, buf.length - overlap));
buf = tail + "\n\n" + p;
} else {
buf = buf ? `${buf}\n\n${p}` : p;
}
}
if (buf.trim()) out.push({ text: buf.trim(), heading: section.heading });
}
return out;
}
このチャンカーを採用してから、私のサイトのサイト内検索の「クリック率(検索結果から記事を開いた率)」が約 1.6 倍に改善しました。理由は単純で、見出しを各チャンクの先頭に保持することで、埋め込みベクトルが「文脈の意味」を保持しやすくなるからです。
埋め込みモデルの選び方
Workers AI で使える主な埋め込みモデルは以下です。
@cf/baai/bge-small-en-v1.5(384 次元、英語特化)
@cf/baai/bge-base-en-v1.5(768 次元、英語特化)
@cf/baai/bge-large-en-v1.5(1024 次元、英語特化)
@cf/baai/bge-m3(1024 次元、多言語)
日本語ドキュメントを扱うなら bge-m3 一択です。私は bge-small-en-v1.5 を使っていた初期、日本語クエリで検索精度が著しく悪く、原因を切り分けるのに 1 日溶かしました。教訓は「埋め込みモデルの言語サポート範囲を真っ先に確認する」こと。
ただし bge-m3 は 1024 次元で、Vectorize のストレージコストとレイテンシに直接影響します。ドキュメントが英語中心なら bge-base-en-v1.5(768 次元)の方が経済的です。
メタデータの設計が後で効いてくる
upsert 時に渡すメタデータは、後で「どのドキュメントを検索対象から除外するか」を決める鍵になります。私が最初から入れておくべきだったと痛感したフィールドは以下です。
docId:原文ドキュメントの一意 ID(更新・削除に必須)
title:表示用
category / tag:絞り込み検索のため
lang:言語別の検索対象切り分け
updatedAt(unix timestamp):再インデックス判定用
text:チャンク本文(Vectorize は本文を返してくれないので保存しておく)
メタデータインデックスは事前に wrangler vectorize create-metadata-index でフィールドごとに作る必要があります。後から追加すると、既存ベクトルに対するインデックスが効かないので、運用開始前に網羅しておきましょう。
検索品質を本番レベルに引き上げる:ハイブリッド検索とリランキング
ベクトル検索だけだと、固有名詞や数字のクエリに弱いことがあります。「Antigravity 1.20」のようなバージョン番号は、埋め込み空間で近い意味を持つベクトルが多く、関係ない記事も上位に来やすい。
私はこの問題を、ハイブリッド検索(ベクトル検索 + 全文検索)+ リランキング の 2 段階で解決しました。
ハイブリッド検索:Cloudflare D1 を全文インデックスとして併用
// src/lib/hybrid-search.ts
type HybridResult = {
id: string;
score: number;
source: "vector" | "fts" | "both";
text: string;
title: string;
};
export async function hybridSearch(
env: { VECTORIZE_INDEX: VectorizeIndex; DB: D1Database; AI: Ai },
query: string,
topK = 10
): Promise<HybridResult[]> {
// 並列実行で待ち時間を最小化
const [vectorRes, ftsRes] = await Promise.all([
(async () => {
const { data } = await env.AI.run("@cf/baai/bge-m3", { text: [query] });
return env.VECTORIZE_INDEX.query(data[0], {
topK,
returnMetadata: "all",
});
})(),
env.DB.prepare(
`SELECT id, title, text, rank
FROM chunks_fts
WHERE chunks_fts MATCH ?1
ORDER BY rank LIMIT ?2`
)
.bind(query, topK)
.all<{ id: string; title: string; text: string; rank: number }>(),
]);
// RRF(Reciprocal Rank Fusion)でスコアを統合
const map = new Map<string, HybridResult>();
vectorRes.matches.forEach((m, i) => {
map.set(m.id, {
id: m.id,
score: 1 / (60 + i),
source: "vector",
text: String(m.metadata?.text ?? ""),
title: String(m.metadata?.title ?? ""),
});
});
(ftsRes.results ?? []).forEach((r, i) => {
const existing = map.get(r.id);
const ftsScore = 1 / (60 + i);
if (existing) {
existing.score += ftsScore;
existing.source = "both";
} else {
map.set(r.id, {
id: r.id,
score: ftsScore,
source: "fts",
text: r.text,
title: r.title,
});
}
});
return Array.from(map.values()).sort((a, b) => b.score - a.score);
}
D1 側では FTS5 仮想テーブルを作っておきます。
-- migrations/0001_chunks_fts.sql
CREATE VIRTUAL TABLE chunks_fts USING fts5(
id UNINDEXED,
title,
text,
tokenize='unicode61 remove_diacritics 2'
);
RRF(Reciprocal Rank Fusion)は単純ですが強力です。スコアの絶対値が違うベクトル検索と FTS の結果を、順位だけを使ってフェアに混ぜられます。
Workers AI Reranker でトップ N を再評価
ハイブリッド検索で上位 20 件まで広めに引き、その中から本当に関連が深いものを Reranker で選別します。
// src/lib/rerank.ts
export async function rerank(
env: { AI: Ai },
query: string,
candidates: { id: string; text: string }[]
): Promise<{ id: string; score: number }[]> {
const result = await env.AI.run("@cf/baai/bge-reranker-base", {
query,
contexts: candidates.map((c) => ({ text: c.text })),
});
return result.response
.map((r: { index: number; score: number }) => ({
id: candidates[r.index].id,
score: r.score,
}))
.sort((a, b) => b.score - a.score);
}
Reranker を入れると LLM へ渡すコンテキストの質が上がり、回答の事実性(hallucination の少なさ)が体感で大きく変わります。私はこれを入れる前は LLM 出力に「ドキュメントに書いていないことを補完してしまう」エラーがちょくちょく混じっていましたが、Reranker 導入後はほぼ消えました。
コスト設計:トークン数・呼び出し数・ストレージの見える化
Cloudflare Vectorize のコストは 3 軸で発生します。
- クエリ数(ベクトル × topK ベース)
- 保存ベクトル数(GB/月)
- Workers AI 呼び出し(埋め込み・LLM・Reranker それぞれ別計上)
私の経験では、個人開発の小規模 SaaS(月間 1 万クエリ、5 万チャンク保存)なら月額 1,000 円以下で収まりますが、油断すると Workers AI のトークン消費が膨らみがちです。
コスト見える化のためのロギング設計
// src/lib/cost-logger.ts
type UsageEvent = {
type: "embedding" | "llm" | "rerank" | "vectorize_query";
inputTokens?: number;
outputTokens?: number;
vectorCount?: number;
durationMs: number;
ts: number;
};
export class UsageLogger {
private events: UsageEvent[] = [];
constructor(private analytics: AnalyticsEngineDataset) {}
log(event: UsageEvent) {
this.events.push(event);
this.analytics.writeDataPoint({
indexes: [event.type],
blobs: [JSON.stringify(event)],
doubles: [event.durationMs, event.inputTokens ?? 0],
});
}
summary(): Record<string, { calls: number; tokens: number; ms: number }> {
return this.events.reduce((acc, e) => {
const k = e.type;
acc[k] ??= { calls: 0, tokens: 0, ms: 0 };
acc[k].calls += 1;
acc[k].tokens += (e.inputTokens ?? 0) + (e.outputTokens ?? 0);
acc[k].ms += e.durationMs;
return acc;
}, {} as Record<string, { calls: number; tokens: number; ms: number }>);
}
}
Analytics Engine は Cloudflare の従量制ログ基盤で、月数十万イベントまで実質無料です。私は Workers の各エンドポイントで UsageLogger を引き回し、レスポンスヘッダにサマリーを入れて開発時にすぐコストを把握できるようにしました。
コスト最適化で効いた 3 つの工夫
実際に運用して効果が大きかったコスト削減策を共有します。
- キャッシュレイヤー:同じクエリは KV にキャッシュ。FAQ 系の質問は 70% 以上ヒットする
- 早期リターン:埋め込みベクトルのスコア閾値(私は 0.55 以下を切り捨て)でそもそも LLM を呼ばない
- モデルダウングレード:簡単な質問は
llama-3.1-8b-instruct ではなく llama-3.2-3b-instruct に流す
特に 1 のキャッシュは効果絶大で、Workers AI の月額コストを 4 割削減できました。実装は単純です。
// src/lib/query-cache.ts
import { createHash } from "node:crypto";
export async function getCachedAnswer(
kv: KVNamespace,
query: string
): Promise<string | null> {
const key = `q:${createHash("sha256").update(query).digest("hex").slice(0, 32)}`;
return await kv.get(key);
}
export async function setCachedAnswer(
kv: KVNamespace,
query: string,
answer: string,
ttlSec = 86400
) {
const key = `q:${createHash("sha256").update(query).digest("hex").slice(0, 32)}`;
await kv.put(key, answer, { expirationTtl: ttlSec });
}
オブザーバビリティ:ログ・メトリクス・失敗パターンの可視化
本番運用で何より大事なのは、「精度が落ちたとき」「コストが跳ねたとき」「レイテンシが伸びたとき」に原因を特定できることです。Cloudflare スタック内で完結させる場合、以下の組み合わせが私のおすすめです。
- Workers Logs(標準):エラースタック・コールドスタート時間
- Analytics Engine:カスタムメトリクス(クエリ件数、平均レイテンシ、Reranker スコア分布)
- Tail Workers:失敗したリクエストを別 Worker に転送して、長期保存・Slack 通知
特に Reranker のスコア分布をモニタリングしておくと、「ある日突然回答品質が下がった」という障害を早期に検知できます。私の場合、ある記事を一括更新した後にチャンクの境界が崩れて、Reranker のトップスコアが平均 0.6 台から 0.3 台に落ちたことがありました。スコア分布をダッシュボード化していなければ、ユーザーから「最近検索が当たらない」と言われるまで気づけなかったでしょう。
// src/lib/health-check.ts
export async function searchHealthCheck(env: Bindings) {
const goldenQueries = [
{ q: "Antigravity の使い方", expectedDocId: "antigravity-getting-started" },
{ q: "Cloudflare Vectorize の費用", expectedDocId: "antigravity-cloudflare-vectorize-rag-production-guide" },
];
const failures: { q: string; got: string[] }[] = [];
for (const { q, expectedDocId } of goldenQueries) {
const results = await hybridSearch(env, q, 5);
const ids = results.map((r) => r.id.split("#")[0]);
if (!ids.includes(expectedDocId)) failures.push({ q, got: ids });
}
return { passed: failures.length === 0, failures };
}
このヘルスチェックを Cron Trigger で 1 時間に 1 回走らせ、失敗があれば Slack に通知。回帰検出が確実になります。
よくある間違いと落とし穴
ここまで読んで「これだけ注意すれば大丈夫」と感じても、実際にやってみると必ずどこかで詰まります。私や周囲の個人開発者が実際に踏んだ落とし穴を、解決策と一緒に並べます。
1. メタデータインデックスを作り忘れる
returnMetadata: "all" で結果を取れても、filter 句にメタデータを使うには事前にメタデータインデックスを作る必要があります。最初は気づかず、フィルタ付きクエリが「フィルタしていないように見える」挙動になり、原因特定に半日溶かしました。Vectorize のドキュメントは明示的ですが、見落とすポイントです。
# こうしないと filter: { docId: "xxx" } が効かない
wrangler vectorize create-metadata-index blog-knowledge \
--property-name=docId --type=string
2. 埋め込みモデルを途中で変更してしまう
「精度が悪いから別の埋め込みモデルに変えよう」と思った時、既存の Vectorize インデックスを使い続けると検索結果がぐちゃぐちゃになります。次元数が違うと upsert すらできず、同じ次元数でも空間が違うため類似度の意味が崩壊します。埋め込みモデルを変える時は必ずインデックスを作り直す こと。私は index_v2 のように接尾辞を付けて並行運用し、切替日に DNS のように向き先を変える方式を採っています。
3. チャンクの ID 設計を後悔する
最初は ${docId}_${i} のようなシンプルな ID にしていましたが、ドキュメントを更新するたびに「古いチャンクを消して新しいものを upsert する」必要が出てきました。ところが Vectorize の deleteByIds は ID の完全一致しか受け付けないため、「古いチャンクが何個あったか」を別途記録しておく必要があります。私は KV に doc:${docId}:chunkCount のキーで現在のチャンク数を保存し、更新時に過去分を全削除する設計に落ち着きました。
async function reindexDoc(env: Bindings, docId: string, body: string) {
// 既存チャンク数を取得
const oldCount = parseInt((await env.KV.get(`doc:${docId}:chunkCount`)) ?? "0");
if (oldCount > 0) {
const ids = Array.from({ length: oldCount }, (_, i) => `${docId}#${i}`);
await env.VECTORIZE_INDEX.deleteByIds(ids);
}
const chunks = chunk(body);
const vectors = await embedAll(env, chunks, docId);
await env.VECTORIZE_INDEX.upsert(vectors);
await env.KV.put(`doc:${docId}:chunkCount`, String(chunks.length));
}
4. コールドスタートで埋め込みが遅い
Workers AI の最初のリクエストは、モデルがまだウォームアップしていないと数秒かかることがあります。ユーザー操作起点のクエリでこれが発生すると体感が悪い。私は Cron Trigger で 5 分おきに /health を叩いて、埋め込みモデルを「温め」続けています。コストは月数円ですが、p99 レイテンシが数秒短縮されます。
5. 日本語クエリで FTS が効かない
D1 の FTS5 を unicode61 トークナイザで作ると、日本語の単語境界が認識されず期待通りに検索できません。中国語・日本語・韓国語の混在ドキュメントには、N-gram トークナイザ(tokenize='trigram'、SQLite 3.34 以降)を使う必要があります。私は tokenize='trigram' に切り替えてから、日本語サイト内検索の Recall が約 2 倍になりました。
6. 冪等な取り込みになっていない
初期、私は同じドキュメントに対して /ingest を何度叩かれてもチェックせずに通していました。結果、インデックス内に重複したチャンクが大量に入り、似たような埋め込みが上位を占めて検索結果がぐちゃぐちゃになりました。対策はシンプルで、「削除してから upsert する」上記のパターンを徹底するか、本文の SHA-256 ハッシュを保存して変更がなければスキップする設計にすることです。
async function ingestIfChanged(env: Bindings, docId: string, body: string) {
const newHash = await sha256(body);
const oldHash = await env.KV.get(`doc:${docId}:hash`);
if (oldHash === newHash) {
return { changed: false };
}
await reindexDoc(env, docId, body);
await env.KV.put(`doc:${docId}:hash`, newHash);
return { changed: true };
}
本文ハッシュで早期リターンを入れると、最もコストのかかる埋め込み生成をスキップできます。コーパス全体を定期的に再スキャンするようなジョブだと、月額コストの差が体感できるレベルで効いてきます。
7. 評価フローを軽視してしまう
RAG で最も難しいのは、パイプラインを作ることではなく「自分の変更が改善だったか」を 判断する ことです。評価セットを持たないと、すべての変更を「改善」だと思い込んでしまい、実は静かに精度が落ちている、という事態が起きます。
私は代表的な質問 30 件と「この記事が 1 位で来てほしい」という期待 docId をセットにしたスプレッドシートを持っています。チャンカー・埋め込みモデル・リランカー・topK のいずれかを触ったら、必ず評価スイートを走らせて recall@5 を比較します。地味ですが、「なんとなく良くなった気がする」と「定量的に良くなった」を分ける唯一の手段です。
// src/lib/eval.ts
export type EvalCase = { question: string; expectedDocId: string; tags?: string[] };
export async function runEval(env: Bindings, cases: EvalCase[]) {
let recall1 = 0;
let recall5 = 0;
const failures: { case: EvalCase; got: string[] }[] = [];
for (const c of cases) {
const results = await hybridSearch(env, c.question, 10);
const ids = results.map((r) => r.id.split("#")[0]);
if (ids[0] === c.expectedDocId) recall1 += 1;
if (ids.slice(0, 5).includes(c.expectedDocId)) recall5 += 1;
else failures.push({ case: c, got: ids.slice(0, 5) });
}
return {
recall1: recall1 / cases.length,
recall5: recall5 / cases.length,
failures,
};
}
これを Workers Cron で毎日走らせ、メトリクスを Analytics Engine に蓄積。回帰検出のアラームとして信頼できる指標になります。
本番運用シナリオ:個人プロダクトの設計例
ここまでの要素を組み合わせて、実際の個人プロダクト規模で運用する全体像を描きます。
シナリオ:技術ブログのサイト内検索 + 関連記事レコメンド
ターゲット規模:
- 記事数:500 本(日英 250 本ずつ)
- 平均チャンク:6 個 / 記事 → 計 3,000 ベクトル
- 検索クエリ:1 日 1,000 件想定
アーキテクチャ:
- Cloudflare Workers(Hono):API 本体
- Vectorize:ベクトル検索(768 次元、cosine)
- D1:FTS インデックス + 記事メタデータ
- KV:クエリキャッシュ(TTL 24 時間)
- Workers AI:埋め込み(bge-m3)+ Reranker(bge-reranker-base)+ LLM(llama-3.1-8b-instruct)
- Analytics Engine:使用量ログ
- R2:オリジナル Markdown のバックアップ
ジョブ構成:
- Cron Trigger(5 分毎):ヘルスチェック + ウォームアップ
- Cron Trigger(毎日 03:00 UTC):ゴールデンクエリ回帰テスト
- 手動 Webhook:記事追加・更新時に再インデックス
このスタックで運用した私の実績では、月額コストは約 800 円、p95 レイテンシは検索 60 ms / 回答生成込みで 1.4 秒、ヘルスチェックの合格率は 99.8% でした。Pinecone 経由の旧構成に比べて、運用コストは半分、レイテンシは 4 倍速くなっています。
スケーリングを考えるタイミング
10 万ベクトルを超えると Vectorize のクエリが少しずつ重くなり始めます。20 万ベクトル超でメタデータフィルタの選択性が低い場合、Workers AI の Reranker が支配的になり始めます。このあたりが「次の最適化」を考えるラインです。
具体的な打ち手は以下の優先順で試すと効果的です。
- チャンクサイズの再調整:チャンクを少し大きくして総数を減らす
- メタデータインデックスでの事前フィルタ:
category や lang で検索範囲を絞る
- Reranker をオプション化:トップ 5 件のスコア差が大きい場合は Reranker をスキップ
- インデックス分割:言語別、カテゴリ別に複数インデックスへ分散
ここまで来ると、もはや Vectorize 単体の問題ではなく、検索アーキテクチャ全体の話になっていきます。
なお、Cloudflare スタック全体の本番運用を体系的に学びたい人には、関連記事の Cloudflare エッジ SaaS 完全アーキテクチャガイド と Cloudflare AI Gateway ガイド を併読することをおすすめします。本記事の RAG パイプラインの前段(リクエストルーティング・キャッシュ)と後段(コスト管理・モニタリング)を埋める内容になっています。
全体を振り返って — 次に何をすればいいか
この記事の内容は多岐にわたりましたが、明日からあなたが取れるアクションは一つに絞れます。それは「自分が普段触っているドキュメント(ブログ・Notion エクスポート・社内 wiki の Markdown など)を 10〜20 本だけ Vectorize に流し込み、/query を叩いてみる」ことです。
最小構成のコードは本記事の前半にあります。まずは雛形をそのまま動かし、自分のドキュメントで「期待した記事が上位に来るか」を体感してください。そこから初めて、ハイブリッド検索やリランキングを足す価値が分かります。逆に、最初から完璧な構成を作ろうとすると、ほぼ確実にどこかで折れます。
私自身、最初に動かした最小 RAG は精度が悲しいほど低く、「これは本当に使い物になるのか」と疑った時期がありました。それでも雛形があったからこそ、チャンク戦略・モデル選定・リランキングと一つずつ改善していけた。Antigravity を使う最大のメリットは、この「最初の雛形を作るコスト」を限りなくゼロに近づけられる点にあります。
エッジで動く RAG は、個人開発者が大きな会社に伍して品質の高い検索体験を提供できる、最もコストパフォーマンスの高い手段の一つです。あなたの次のプロダクトに、ぜひ組み込んでみてください。