東京から見ると 200ms で返ってくる RAG が、ニューヨークやベルリンから触ると 1 秒近くかかる。最初にこの差を計測したとき、原因がアプリのコードではなくインフラの位置にあると気付くまで、私は数日を消費しました。Cloud Run(東京リージョン)+ マネージド Vector DB という構成では、海外ユーザーが世界の裏側にあるリージョンへ往復する以上、どうしても物理的な遅延が乗ってしまいます。
ここではその課題に対する一つの答えとして、Gemini Embedding と Cloudflare Vectorize を組み合わせた「完全エッジ RAG」の本番構成を共有します。Workers のサブリクエスト制限や JSON ボディ上限のような、ドキュメントには書かれていないけれど実装するとぶつかる落とし穴を中心に、コピペで動くコードと運用設計までまとめました。検証は本記事執筆時点(2026 年 5 月)の API バージョンで行っています。
なぜエッジ RAG なのか — 「速度」だけが目的ではありません
エッジ RAG というと、まず頭に浮かぶのは「グローバル低レイテンシ」だと思います。これは確かに大きな利点ですが、私が本番で運用してみて感じている価値は、それ以外にも 3 つあります。
1 つ目は コスト構造 です。Cloudflare Vectorize はインデックス料金とクエリ料金が極めて安く(後述しますが、500 万ベクトルでも月額 $1 弱)、無料枠も寛大です。マネージド Vector DB の固定インスタンス料金(月額 $70 から)と比べると、個人開発の規模では一桁から二桁の差になります。
2 つ目は 冷起動問題からの解放 です。Workers は基本的に冷起動を意識しなくて良い設計で、世界中のエッジで動的にスケールします。Lambda や Cloud Run のように初回呼び出しで数秒待たされる、という現象が起きません。RAG のような対話的な用途では、これが体感品質を大きく左右します。
3 つ目は デプロイの単純さ です。Vector DB・Embedding・LLM の API 呼び出し・フロントエンドが全て一つの Workers コードベースに入るため、CI/CD や監視の対象が劇的に減ります。個人開発で運用工数を最小化したいときに、この単純さは何より価値があります。
逆にトレードオフとしては、Workers の CPU 制限(1 リクエストあたり最大 50ms〜30 秒、プランによる) や サブリクエスト数の上限(無料 50 / 有料 1000) があります。RAG では Embedding API + Vectorize クエリ + Gemini 生成で最低 3 サブリクエスト消費するため、後段でリランキングや複数クエリ展開を行う場合は注意が必要です。
全体アーキテクチャ — 4 つの構成要素
今回構築する構成は、以下の 4 つだけで完結します。Cloud Run も VM も不要です。
- Cloudflare Workers(Hono フレームワーク):API エンドポイントとオーケストレーション
- Cloudflare Vectorize:エッジ分散型のベクトルストア
- Gemini Embedding API(
text-embedding-004、768 次元):クエリと文書の埋め込み生成
- Gemini 2.5 Flash:取得文書を踏まえた回答生成
データの流れは「クエリ受信 → Embedding 生成 → Vectorize 検索 → 取得文書をプロンプトに差し込んで Gemini で生成 → 回答返却」という標準的な RAG ですが、すべてが Workers ランタイム内で完結する点が肝です。なお関連する基礎は Gemini API を Cloudflare Workers で動かすエッジ AI 入門 と Hono × Cloudflare Workers でエッジ AI を構築するガイド でも触れていますので、Workers 自体に不慣れであれば先にそちらを参照してください。
Step 1: Vectorize インデックスを作成する
まずは Vectorize のインデックスを作ります。重要なのは 次元数(768)と類似度メトリック(cosine)を Gemini Embedding に合わせる ことです。後から変更できないため、ここでミスると作り直しになります。
# wrangler.toml と同じディレクトリで実行
npx wrangler vectorize create gemini-rag-index \
--dimensions=768 \
--metric=cosine
# メタデータインデックスも作っておくとフィルタ検索に便利
npx wrangler vectorize create-metadata-index gemini-rag-index \
--property-name=tenant_id \
--type=string
wrangler.toml には以下のバインディングを追加します。これにより Workers コードから env.VECTORIZE でアクセスできるようになります。
# wrangler.toml
name = "gemini-edge-rag"
main = "src/index.ts"
compatibility_date = "2026-04-01"
[[vectorize]]
binding = "VECTORIZE"
index_name = "gemini-rag-index"
[vars]
GEMINI_MODEL = "gemini-2.5-flash"
EMBEDDING_MODEL = "text-embedding-004"
GEMINI_API_KEY は wrangler secret put GEMINI_API_KEY でシークレット化します。vars に直接書くとリポジトリに漏れる事故が起きやすいので、API キーは絶対に secret put 経由で管理してください。
Step 2: ドキュメントを Embedding して投入する
次に、検索対象となる文書を Vectorize に流し込みます。私はこの工程を Cloudflare の Cron Triggers で日次バッチとして動かしていますが、最初は手元のスクリプトから一回投入するだけで十分です。
// scripts/ingest.ts — Workers から呼び出す投入用エンドポイント
import { Hono } from "hono";
type Env = {
VECTORIZE: VectorizeIndex;
GEMINI_API_KEY: string;
EMBEDDING_MODEL: string;
};
const app = new Hono<{ Bindings: Env }>();
app.post("/ingest", async (c) => {
// 認証は別途実装(管理者トークン推奨)
const docs = await c.req.json<Array<{ id: string; text: string; tenant_id: string }>>();
// Gemini Embedding API は 1 リクエストあたり最大 100 件のバッチ投入が可能
// ただし Workers のレスポンスボディは最大 100MB なので、慎重にチャンク分割する
const CHUNK = 100;
for (let i = 0; i < docs.length; i += CHUNK) {
const slice = docs.slice(i, i + CHUNK);
const embedRes = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${c.env.EMBEDDING_MODEL}:batchEmbedContents`,
{
method: "POST",
headers: {
"x-goog-api-key": c.env.GEMINI_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({
requests: slice.map((d) => ({
model: `models/${c.env.EMBEDDING_MODEL}`,
content: { parts: [{ text: d.text }] },
taskType: "RETRIEVAL_DOCUMENT",
})),
}),
},
);
if (!embedRes.ok) {
// 429(レート制限)と 5xx は呼び出し側で必ずリトライさせる
const detail = await embedRes.text();
throw new Error(`embedding_failed: ${embedRes.status} ${detail.slice(0, 200)}`);
}
const embedData = await embedRes.json<{
embeddings: Array<{ values: number[] }>;
}>();
// Vectorize に投入。values の次元数は 768 で固定
await c.env.VECTORIZE.upsert(
slice.map((d, idx) => ({
id: d.id,
values: embedData.embeddings[idx].values,
metadata: {
tenant_id: d.tenant_id,
// 本文は Vectorize のメタデータに最大 10KB まで格納できる
// 長文の場合は KV や R2 に保存してキーだけメタデータに入れる方が安全
text: d.text.slice(0, 9500),
},
})),
);
}
return c.json({ ok: true, count: docs.length });
});
export default app;
ここで強調したいのは taskType: "RETRIEVAL_DOCUMENT" の指定です。Gemini Embedding はクエリと文書で異なる埋め込み空間に最適化する仕組みを持っており、文書側は RETRIEVAL_DOCUMENT、クエリ側は RETRIEVAL_QUERY を指定するだけで検索精度が体感で 5〜10% 向上します。これを忘れると「なんとなく検索結果がイマイチ」という状態に陥るので、必ず付けてください。
Step 3: クエリ → 検索 → 生成のエッジ RAG パイプライン
投入が終わったら、検索と生成のパイプラインを実装します。Hono のルーティングはシンプルですが、内部の処理順序とエラー伝播の設計が品質を分ける場所です。
// src/index.ts — 検索 + 生成エンドポイント
import { Hono } from "hono";
import { cors } from "hono/cors";
type Env = {
VECTORIZE: VectorizeIndex;
GEMINI_API_KEY: string;
EMBEDDING_MODEL: string;
GEMINI_MODEL: string;
};
const app = new Hono<{ Bindings: Env }>();
app.use("/api/*", cors({ origin: ["https://your-frontend.example"] }));
async function embed(text: string, env: Env, taskType = "RETRIEVAL_QUERY") {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${env.EMBEDDING_MODEL}:embedContent`,
{
method: "POST",
headers: {
"x-goog-api-key": env.GEMINI_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({
content: { parts: [{ text }] },
taskType,
}),
},
);
if (!res.ok) throw new Error(`embed_failed:${res.status}`);
const data = await res.json<{ embedding: { values: number[] } }>();
return data.embedding.values;
}
async function generateAnswer(
query: string,
contexts: string[],
env: Env,
): Promise<string> {
const prompt = `あなたは正確で誠実なアシスタントです。
以下の参考情報のみに基づいて日本語で回答してください。
参考情報に答えがない場合は「資料からは判断できません」と答えてください。
出典番号は [#1] [#2] のように本文中に付与してください。
# 参考情報
${contexts.map((c, i) => `[#${i + 1}] ${c}`).join("\n\n")}
# 質問
${query}`;
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${env.GEMINI_MODEL}:generateContent`,
{
method: "POST",
headers: {
"x-goog-api-key": env.GEMINI_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 1024,
// Workers の CPU 制限を超えないよう Flash + 控えめなトークン上限に
},
}),
},
);
if (!res.ok) throw new Error(`generate_failed:${res.status}`);
const data = await res.json<{
candidates: Array<{ content: { parts: Array<{ text: string }> } }>;
}>();
return data.candidates[0]?.content.parts[0]?.text ?? "";
}
app.post("/api/ask", async (c) => {
const { query, tenant_id } = await c.req.json<{
query: string;
tenant_id?: string;
}>();
// 1) クエリを埋め込む
const queryVector = await embed(query, c.env, "RETRIEVAL_QUERY");
// 2) Vectorize で類似検索(テナント絞り込み付き)
const filter = tenant_id ? { tenant_id: { $eq: tenant_id } } : undefined;
const result = await c.env.VECTORIZE.query(queryVector, {
topK: 5,
returnMetadata: "all",
filter,
});
const contexts = result.matches
.filter((m) => m.score >= 0.65) // 類似度の足切り。0.6 以下はノイズが増える
.map((m) => String(m.metadata?.text ?? ""))
.filter((t) => t.length > 0);
if (contexts.length === 0) {
return c.json({
answer: "関連する資料が見つかりませんでした。質問を別の言い回しで試してください。",
sources: [],
});
}
// 3) Gemini で回答生成
const answer = await generateAnswer(query, contexts, c.env);
return c.json({
answer,
sources: result.matches.map((m) => ({ id: m.id, score: m.score })),
});
});
export default app;
このコードのポイントは 3 つあります。
第一に、スコア足切り(>= 0.65) を入れている点です。Vectorize は近似最近傍探索なので、関連性の薄い文書も常に上位に混ざります。Gemini に文脈として渡す前に切り捨てておかないと、ハルシネーションの温床になります。
第二に、メタデータフィルタでテナント分離 をしている点です。SaaS でマルチテナント運用するときに、これを忘れると他テナントのデータが混入します。Vectorize のメタデータインデックスを Step 1 で作っておくと、フィルタ付きクエリのレイテンシが大きく改善します。
第三に、ヒット 0 件のときの挙動を明示している 点です。これを書かないと、Gemini が無理やり答えを作ってしまい、ユーザーが嘘の情報を信じてしまいます。意図的に「資料からは判断できません」と返すようにする設計が、誠実さの第一歩です。
Step 4: プロダクション品質を確保する 3 つの防御層
検索と生成が動いただけでは本番運用には足りません。Workers ランタイム特有の制約と、外部 API の不確実性に対処するため、最低でも 3 つの防御層を入れています。
4.1 タイムアウト + リトライ
Gemini API は稀に 503 を返すことがあります。指数バックオフ付きでリトライしますが、Workers の総 CPU 時間が無料 10ms / 有料 30 秒という制限があるため、無限リトライはできません。
async function fetchWithRetry(
url: string,
init: RequestInit,
opts: { timeoutMs: number; maxRetries: number },
): Promise<Response> {
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs);
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
clearTimeout(timer);
// 429 / 5xx のみリトライ。4xx の他のエラーは即座に上に投げる
if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
if (attempt < opts.maxRetries - 1) {
// 200ms, 400ms, 800ms の指数バックオフ
await new Promise((r) => setTimeout(r, 200 * Math.pow(2, attempt)));
continue;
}
}
return res;
} catch (err) {
clearTimeout(timer);
lastErr = err;
if (attempt === opts.maxRetries - 1) throw err;
await new Promise((r) => setTimeout(r, 200 * Math.pow(2, attempt)));
}
}
throw lastErr;
}
私はリトライ回数を 3 回・タイムアウトを 5 秒に設定しています。これより緩くすると Workers の総 CPU 時間を使い切ってリクエスト全体が落ちる事故が起きました。詳しいリトライ戦略は Gemini API のレート制限とクォータ管理本番ガイド も合わせて参照してください。
4.2 サーキットブレーカー(Durable Objects)
連続して Embedding API が落ちている状況で、Workers が呼び続けるのは無駄です。Durable Objects に状態を持たせて、一定時間は呼び出しをスキップするサーキットブレーカーを薄く実装しています。詳細は Gemini API のリジリエンス設計(サーキットブレーカー+バルクヘッド) で書きましたので、ここでは「入れている」とだけ触れておきます。
4.3 コストガード
ユーザーが極端に長いクエリを送ってくると、Embedding と生成のコストが想定外に膨らみます。簡単ですが、リクエストの段階で文字数とトークン上限を切るのが最も効果的です。
const MAX_QUERY_CHARS = 1000;
if (query.length > MAX_QUERY_CHARS) {
return c.json(
{ error: "query_too_long", limit: MAX_QUERY_CHARS },
400,
);
}
加えて、Gemini Flash の maxOutputTokens を 1024 に絞ることで、月末の請求書を見て青ざめるリスクを大幅に減らせます。
よくある落とし穴と回避策
実装中に何度もぶつかった、ドキュメントには明記されていない落とし穴を共有します。
- 次元数を間違えると無言で失敗します:Vectorize は次元数が一致しない投入を拒否しますが、エラーメッセージが分かりづらく「なぜか入らない」状態になります。Gemini Embedding の
text-embedding-004 は 768 次元固定 なので、インデックス作成時に必ず合わせること。Cohere や OpenAI の Embedding と取り違えないよう、embedding.values.length を投入前に assert するのが安全です。
taskType を指定し忘れると検索精度が落ちます:これは前述しましたが、本当に「気付かないけど 5〜10% 損している」状態になるので、コードレビューでは必ず確認してください。
- Workers のサブリクエスト上限に引っかかります:1 リクエストで Embedding × 1 + Vectorize × 1 + Gemini × 1 = 3 サブリクエストが基本です。リランキングや multi-query 展開を入れると 5〜10 になり、無料プランの 50 はすぐ消費します。Cron で大量投入する場合は有料プラン必須と思ってください。
- Vectorize のメタデータは 10KB 上限です:本文を全部メタデータに突っ込むと、長文ドキュメントで真っ先に爆発します。10KB を超える可能性がある場合は、KV か R2 に本文を保存し、メタデータには ID だけ持たせる設計に切り替えてください。
returnMetadata: true ではメタデータインデックス分しか返ってきません:全メタデータが欲しいときは returnMetadata: "all" を明示的に指定する必要があります。これは後方互換のための仕様で、最初は引っかかります。
- 冷えキャッシュでは初回の Embedding が遅いことがあります:Gemini Embedding API 側の挙動で、同じテキストの再埋め込みは内部キャッシュで高速化されますが、初回は 200〜400ms かかります。p95 レイテンシのモニタリングを入れて、定期的にウォームアップクエリを走らせる運用が安定します。
月次コスト試算 — 個人開発スケールの目安
具体的な数字があると検討しやすいので、月間 30,000 クエリ(1 日約 1,000 クエリ)の小規模サービスを想定して試算します。
- Cloudflare Workers:100,000 リクエスト/日まで無料。30,000/月は完全に無料枠内。
- Cloudflare Vectorize:50,000 ベクトル × 30,000 クエリで月額約 $0.4(初年度は無料枠あり)。
- Gemini Embedding(
text-embedding-004):30,000 クエリ × 平均 200 トークン ≈ 600 万トークン。text-embedding-004 は無料枠が極めて寛大で、個人開発スケールでは実質無料に収まることが多いです。
- Gemini 2.5 Flash 生成:30,000 リクエスト × 平均(入力 4K + 出力 0.5K)= 入力 1.2 億トークン + 出力 1500 万トークン。Flash の入力 $0.10 / 100万トークン換算で約 $12 + 出力換算で約 $6 = 月額約 $18。
つまり総額で 月額 $20 弱 に収まります。これと同等の規模を Cloud Run + Pinecone で組むと、固定インスタンス料金だけで月額 $70 を超えるので、エッジ RAG 構成は個人開発スケールでは明確に有利です。コスト最適化のさらに踏み込んだ手法は Gemini API のコスト最適化完全ガイド と Gemini API コンテキストキャッシュでコストを下げる実装パターン でも整理しているので、合わせて参照してください。
なお Cloudflare Workers と Vectorize の料金は変動するため、本番投入前に必ず Cloudflare の公式料金ページ と Google AI の Gemini API 料金ページ を確認することをおすすめします。
設計判断のトレードオフを言語化する力をつけるという意味で、エッジ RAG のような「正解が一つではない」領域で迷ったときの背骨になってくれる一冊です。
全体を振り返って — 今日試すなら、まず Vectorize インデックスを 1 つ作ってください
エッジ RAG は、グローバル展開を考えていない個人サービスにとっても、コスト・運用工数・レイテンシのバランスが極めて良い選択肢です。今日この記事を読んで何か一つだけ試すなら、wrangler vectorize create でインデックスを 1 つ作って、手元の Markdown ファイルを 10 件投入し、wrangler tail を眺めながらクエリを投げてみてください。私はこの「初回投入から最初のクエリが返ってくるまで」の体験で、エッジ RAG が自分の運用スタイルに合うかどうかが一気に肌感覚で分かりました。
そこまでで合いそうだと感じたら、本記事の防御層(タイムアウト・サーキットブレーカー・コストガード)を順に足していけば、本番運用に必要な土台はかなり手に入ります。私もまだ運用しながら微調整を続けている領域なので、もしより良いパターンを見つけたら、ぜひ教えていただけたら嬉しいです。