全端實戰:用 Cloudflare 全家桶打造 AI 知識庫 | Cloudflare 完整教學
這是全系列的壓軸實戰——我們要把前面 48 篇學到的所有零件,親手組裝成一個可上線的成品:一個「AI 筆記知識庫」。前端用 Workers Static Assets、API 用 Worker、結構化資料用 D1、附件用 R2、embedding 用 Workers AI、語意搜尋用 Vectorize,再用 Secrets 與驗證守好門。逐段 build、逐段講解,讓你看懂整套 Cloudflare 全端方案怎麼串起來。
前言
走到這裡,你已經認識了 Cloudflare 平台的每一個零件:Workers 怎麼跑、KV/D1/R2 怎麼存、Workers AI 怎麼推論、Vectorize 怎麼做語意搜尋、Queues 怎麼削峰、Service Bindings 怎麼串微服務,還有效能怎麼調、安全怎麼守。但「認識每個零件」和「組出一個成品」是兩回事——這一篇,我們就把它們組裝起來。
我們要做的成品是一個 AI 筆記知識庫(AI Knowledge Base):使用者可以新增筆記、上傳附件,而最關鍵的功能是——用「自然語言語意搜尋」找筆記。你搜「怎麼降低邊緣延遲」,就算某篇筆記通篇沒出現「延遲」兩個字、只寫了「Smart Placement 讓 Worker 靠近後端」,一樣能被找出來。這正是傳統 LIKE '%關鍵字%' 做不到、而 embedding + 向量搜尋才能做到的事。
先看這個成品會用到前面哪些服務(以及對應的系列文章):
- Workers Static Assets——把前端 HTML/JS 直接交給 Worker 服務,不需另外部署靜態網站。
- Worker(API 路由)——一個
fetchhandler 依路徑分派 CRUD 與搜尋。 - D1——存筆記的結構化中繼資料(標題、內文、附件 key、時間戳)。
- R2——存使用者上傳的附件(PDF、圖片等大檔)。
- Workers AI——把筆記內文轉成 embedding 向量,也可用來做摘要。
- Vectorize——存 embedding、做語意相似度搜尋。
- Secrets/驗證——用 API key 守住寫入端點,呼應上一篇《安全最佳實踐》。
讀完這篇你會掌握:
- 多 binding 的 wrangler.jsonc——一個檔案同時綁定 assets、d1、r2、ai、vectorize。
- 端到端資料流——一筆筆記如何同時寫進 D1、R2、Vectorize,又如何被語意搜尋撈回。
- 服務間的分工——D1 存事實、R2 存檔案、Vectorize 存語意,三者如何用 id 互相指向。
- 整合的坑與最佳實踐——binding 沒設、資料不一致、成本、驗證等實戰陷阱。
架構拆解
動手前,先把整個系統的資料流畫清楚。這個 AI 知識庫有兩條主要路徑:寫入路徑(新增筆記)與搜尋路徑(語意搜尋)。
寫入一筆筆記時,發生這些事:
- 前端(Static Assets 服務的頁面)發
POST /api/notes,帶標題、內文、可選的附件。 - Worker 收到請求,先驗證 API key(Secrets),再驗證輸入。
- 若有附件,把檔案位元組寫進 R2,拿到一個 R2 key。
- 把標題、內文、R2 key、時間戳寫進 D1,拿到筆記
id。 - 用 Workers AI 把內文轉成 embedding 向量。
- 把「向量 + note id」寫進 Vectorize。
語意搜尋時,發生這些事:
- 前端發
GET /api/search?q=...。 - Worker 用 Workers AI 把查詢字串也轉成 embedding 向量。
- 拿這個向量去 Vectorize 查 top-k 最相似的向量,得到一串 note id + 相似度分數。
- 用這些 id 回 D1 撈出完整筆記內容。
- 回傳給前端。
把它畫成一張分工圖,核心觀念就是「每個服務只做它最擅長的一件事」:
| 服務 | 職責 | 存什麼 |
|---|---|---|
| Workers Static Assets | 服務前端靜態檔 | HTML / CSS / JS |
| Worker | API 路由、編排各服務、驗證 | (無狀態,只跑邏輯) |
| D1 | 唯一真相來源、SQL 查詢 | 筆記中繼資料(id、標題、內文、r2_key) |
| R2 | 物件儲存、附件 | PDF / 圖片等大檔位元組 |
| Workers AI | 推論、把文字轉成向量 | (無狀態,只做運算) |
| Vectorize | 語意搜尋索引 | embedding 向量 + note id |
這裡有個貫穿全系列的最佳實踐再次出現:用原生 binding,而不是 REST API。我們不會在程式裡去打 D1 的 HTTP API、不會用 access key 連 R2、不會拿 API token 呼叫 Workers AI——這些全部透過 env.DB、env.BUCKET、env.AI、env.VECTORIZE 這種 binding 直接呼叫,延遲最低、也不用管金鑰。
關於 D1、R2、Workers AI、Vectorize 各自的深入用法,前面都有專篇——這一篇的重點是把它們編排在一起,所以每個服務我們只講「整合時的關鍵點」,細節可回頭參考各自那一篇。
逐段實作
我們一段一段來:先設定多 binding 的 wrangler.jsonc,再建立 D1 schema 與 Vectorize 索引,然後寫 Worker 的 API 路由、逐一實作寫入與搜尋,最後補上前端。
1. 多 binding 的 wrangler.jsonc
一個全端應用的所有服務綁定,都集中在這一個檔案。這是整篇的地基——assets、d1_databases、r2_buckets、ai、vectorize 一次到位:
// wrangler.jsonc — 全端 AI 知識庫的所有 binding
{
"name": "ai-knowledge-base",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
// 前端:Workers Static Assets(./public 目錄裡的 HTML/JS)
"assets": {
"directory": "./public",
"binding": "ASSETS", // 讓 Worker 能 fallback 呼叫靜態資源
"not_found_handling": "single-page-application"
},
// D1:結構化中繼資料
"d1_databases": [
{
"binding": "DB",
"database_name": "notes-db",
"database_id": "<你的 d1 database id>"
}
],
// R2:附件物件儲存
"r2_buckets": [
{ "binding": "BUCKET", "bucket_name": "notes-attachments" }
],
// Workers AI:embedding 與推論
"ai": { "binding": "AI" },
// Vectorize:語意搜尋索引
"vectorize": [
{ "binding": "VECTORIZE", "index_name": "notes-index" }
],
"observability": { "enabled": true, "head_sampling_rate": 1 }
}
搭配的建立指令(每個資源都要先在 Cloudflare 帳號裡建好,才能綁定):
# D1 資料庫
npx wrangler d1 create notes-db
# R2 儲存桶
npx wrangler r2 bucket create notes-attachments
# Vectorize 索引:768 維、用 cosine 相似度
# (維度要和你選的 embedding 模型輸出一致)
npx wrangler vectorize create notes-index \
--dimensions=768 --metric=cosine
# 生產環境的 API key(呼應上一篇的 Secrets 管理)
npx wrangler secret put API_KEY
⚠️ 最容易踩的坑:
vectorize create的--dimensions必須和你的 embedding 模型輸出維度完全一致。我們等下用的@cf/baai/bge-base-en-v1.5輸出 768 維,所以索引也開 768;若你換成別的模型,維度不對會直接寫入失敗。
2. D1 schema
D1 存的是「唯一真相來源」。建一張 notes 表,r2_key 欄位指向附件在 R2 的位置(可為空):
-- schema.sql
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY, -- 用 crypto.randomUUID() 產生
title TEXT NOT NULL,
content TEXT NOT NULL,
r2_key TEXT, -- 附件在 R2 的 key,無附件則為 NULL
created_at INTEGER NOT NULL -- Unix 毫秒時間戳
);
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);
套用 schema 到 D1:
npx wrangler d1 execute notes-db --file=./schema.sql --remote
3. Worker API 路由骨架
Worker 的 fetch handler 是整個 API 的入口。我們用一個簡單的路由器依 method + path 分派,並示範 Static Assets 的 fallback——API 路徑走我們的邏輯,其餘一律交給前端靜態資源:
// src/index.ts
export interface Env {
ASSETS: Fetcher; // Static Assets binding
DB: D1Database; // D1
BUCKET: R2Bucket; // R2
AI: Ai; // Workers AI
VECTORIZE: VectorizeIndex; // Vectorize
API_KEY: string; // 由 wrangler secret put 設定
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const { pathname } = url;
try {
// ── API 路由 ──
if (pathname === '/api/notes' && request.method === 'POST') {
return await createNote(request, env, ctx);
}
if (pathname === '/api/notes' && request.method === 'GET') {
return await listNotes(env);
}
if (pathname === '/api/search' && request.method === 'GET') {
return await searchNotes(url.searchParams.get('q') ?? '', env);
}
// ── 其餘交給前端靜態資源(SPA) ──
return env.ASSETS.fetch(request);
} catch (err) {
// 明確錯誤處理:記結構化 log、回帶 requestId 的錯誤
const requestId = crypto.randomUUID();
console.error(JSON.stringify({ requestId, error: String(err) }));
return Response.json({ error: 'Internal Error', requestId }, { status: 500 });
}
},
} satisfies ExportedHandler<Env>;
4. 建立筆記:同時寫 R2、D1、Vectorize
這是最能體現「編排」的一段。一個 createNote 要協調四個服務:驗證 → (可選)寫 R2 → 寫 D1 → 用 AI 產 embedding → 寫 Vectorize。逐步看:
// src/notes.ts
async function requireApiKey(request: Request, env: Env): Promise<boolean> {
const provided = request.headers.get('X-API-Key') ?? '';
const a = new TextEncoder().encode(provided);
const b = new TextEncoder().encode(env.API_KEY);
if (a.byteLength !== b.byteLength) return false;
// 定時安全比較,防 timing 側信道(呼應上一篇《安全最佳實踐》)
return crypto.subtle.timingSafeEqual(a, b);
}
export async function createNote(
request: Request, env: Env, ctx: ExecutionContext,
): Promise<Response> {
// 4-1. 驗證:寫入端點必須帶 API key
if (!(await requireApiKey(request, env))) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// 4-2. 解析並驗證輸入(multipart:title、content、可選 file)
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const content = String(form.get('content') ?? '').trim();
if (!title || !content) {
return Response.json({ error: 'title 與 content 必填' }, { status: 422 });
}
const id = crypto.randomUUID();
// 4-3. 若有附件,先寫進 R2,拿到 key
let r2Key: string | null = null;
const file = form.get('file');
if (file instanceof File && file.size > 0) {
r2Key = `attachments/${id}/${file.name}`;
await env.BUCKET.put(r2Key, file.stream(), {
httpMetadata: { contentType: file.type },
});
}
// 4-4. 寫進 D1(唯一真相來源),用參數化查詢防注入
await env.DB
.prepare('INSERT INTO notes (id, title, content, r2_key, created_at) VALUES (?, ?, ?, ?, ?)')
.bind(id, title, content, r2Key, Date.now())
.run();
// 4-5. 用 Workers AI 把內文轉成 embedding 向量
const { data } = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
text: [`${title}\n\n${content}`],
});
const vector = data[0]; // 768 維陣列
// 4-6. 寫進 Vectorize:只存向量 + note id(metadata 不塞全文!)
await env.VECTORIZE.upsert([
{ id, values: vector, metadata: { title } },
]);
return Response.json({ id, title, r2_key: r2Key }, { status: 201 });
}
有幾個關鍵決策值得停下來看:
- R2 先寫、D1 後寫:先把附件放好拿到 key,再把 key 寫進 D1,避免 D1 裡出現指向不存在檔案的 key。
- Vectorize 只存向量 + id:
metadata只放少量欄位(這裡放title方便除錯),絕不把整篇內文塞進去——內文的唯一真相在 D1,Vectorize 只是索引。 - embedding 的輸入:我們把
title + content一起餵給模型,讓標題也參與語意,搜尋更精準。
5. 語意搜尋:查詢也 embedding,再回 D1 撈
搜尋路徑是寫入的鏡像:把查詢字串一樣轉成向量,去 Vectorize 找最近的幾筆,拿到 id 後回 D1 撈完整內容:
// src/notes.ts(續)
export async function searchNotes(query: string, env: Env): Promise<Response> {
if (!query.trim()) {
return Response.json({ error: 'q 參數必填' }, { status: 400 });
}
// 5-1. 查詢字串轉成同一個模型的 embedding
const { data } = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
text: [query],
});
const queryVector = data[0];
// 5-2. 去 Vectorize 找 top-5 最相似的向量
const matches = await env.VECTORIZE.query(queryVector, { topK: 5 });
const ids = matches.matches.map((m) => m.id);
if (ids.length === 0) {
return Response.json({ results: [] });
}
// 5-3. 用 id 回 D1 撈完整、最新的內容(參數化 IN 查詢)
const placeholders = ids.map(() => '?').join(', ');
const { results } = await env.DB
.prepare(`SELECT id, title, content, r2_key FROM notes WHERE id IN (${placeholders})`)
.bind(...ids)
.all();
// 5-4. 依 Vectorize 的相似度分數排序(D1 的 IN 不保證順序)
const scoreById = new Map(matches.matches.map((m) => [m.id, m.score]));
const ranked = (results ?? []).sort(
(a, b) => (scoreById.get(b.id as string) ?? 0) - (scoreById.get(a.id as string) ?? 0),
);
return Response.json({ results: ranked });
}
這一段藏著一個常被忽略的細節:D1 的 WHERE id IN (...) 不保證回傳順序,而相似度排序在 Vectorize 那邊。所以撈回來後,要用 Vectorize 給的 score 自己重排一次,否則最相關的筆記可能排在後面。
順帶補上 listNotes(單純的 D1 查詢,列出最新筆記):
// src/notes.ts(續)
export async function listNotes(env: Env): Promise<Response> {
const { results } = await env.DB
.prepare('SELECT id, title, created_at FROM notes ORDER BY created_at DESC LIMIT 50')
.all();
return Response.json({ notes: results ?? [] });
}
6. 前端:Static Assets 呼叫 API
前端放在 ./public/index.html,由 Static Assets 服務。它就是一個純靜態頁面,用 fetch 呼叫我們的 API——注意寫入時帶上 X-API-Key:
<!-- public/index.html(節錄核心 JS) -->
<script>
// 語意搜尋
async function search(q) {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const { results } = await res.json();
render(results); // 把結果畫到畫面上
}
// 新增筆記(帶 API key)
async function createNote(title, content, file) {
const form = new FormData();
form.append('title', title);
form.append('content', content);
if (file) form.append('file', file);
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'X-API-Key': window.API_KEY }, // 實務上由登入流程取得,勿寫死
body: form,
});
return res.json();
}
</script>
到這裡,一個完整的全端應用就成形了。本機開發時,一個指令就能把所有 binding 一起跑起來:
npx wrangler dev # 本機模擬 assets + D1 + R2 + AI + Vectorize
# 一切正常後,一個指令上線:
npx wrangler deploy
常見錯誤與最佳實踐
把多個服務編排在一起,坑會比單一服務多。以下五個是整合全端應用時最容易踩的:
陷阱一:binding 名稱對不上,env.XXX is undefined。 程式裡寫 env.DB,但 wrangler.jsonc 的 binding 卻叫 DATABASE,執行時就是一個 undefined。正確做法: 讓 wrangler.jsonc 裡每個 binding 名稱和 Env 介面、程式呼叫三者完全一致,並跑 wrangler types 產生型別,讓 TypeScript 幫你在編譯期就抓出來。
陷阱二:Vectorize 維度和 embedding 模型不符。 索引開 1536 維卻用輸出 768 維的模型,upsert 直接失敗。正確做法: 建索引時的 --dimensions 必須等於模型輸出維度(bge-base-en-v1.5 是 768),換模型就要重建索引。
陷阱三:把整篇內文塞進 Vectorize 的 metadata,導致資料不一致。 metadata 有大小限制,且筆記更新後兩邊會不同步。正確做法: Vectorize 只存「向量 + id + 極少量 metadata」,完整內容永遠回 D1 撈——D1 是唯一真相來源,Vectorize 只是索引。
陷阱四:多服務寫入的一致性沒顧好。 R2、D1、Vectorize 是三個獨立系統,沒有跨服務交易。若 D1 寫成功、Vectorize 寫失敗,就會有「查得到筆記卻搜不到」的鬼影資料。正確做法: 依相依順序寫(R2 → D1 → Vectorize),並在關鍵步驟失敗時明確回錯;若要更嚴謹,可把「產 embedding + 寫 Vectorize」丟到 Queues 非同步重試(見 Queues 專篇),讓寫入 D1 先成功、索引最終一致。
陷阱五:寫入端點忘了驗證,或把 API key 寫死在前端。 沒驗證的 POST /api/notes 等於開放任何人塞資料;把真正的金鑰硬編進 index.html 則等於公開。正確做法: 寫入端點一律用 timingSafeEqual 驗 API key(呼應上一篇《安全最佳實踐》),前端的金鑰要走登入流程動態取得、wrangler secret put 管生產密鑰,絕不寫死。
最佳實踐小結:
- 名稱三處一致——
wrangler.jsonc的 binding、Env介面、程式呼叫,並用wrangler types把關。 - 各司其職——D1 存事實(唯一真相)、R2 存檔案、Vectorize 存語意,用 id 互相指向。
- 維度對齊、metadata 精簡——Vectorize 維度跟緊模型;只存向量 + id,不塞全文。
- 考慮最終一致——跨服務寫入沒有交易,關鍵索引可交給 Queues 重試。
- 守好寫入端——
timingSafeEqual驗 key、wrangler secret put管密鑰、前端不寫死金鑰。
小結
上一篇《安全最佳實踐》,我們把單一 Worker 的防線一層層疊起來;這一篇《全端實戰》,我們把整套 Cloudflare 全家桶——前端、API、資料、附件、AI、語意搜尋、驗證——編排成一個真正能上線的 AI 知識庫:
- 架構分工——Workers Static Assets 服務前端、Worker 當 API、D1 存事實、R2 存檔案、Workers AI 產 embedding、Vectorize 做語意搜尋,各司其職、用 id 互相指向。
- 端到端資料流——寫入時 R2 → D1 → Vectorize 依序協調;搜尋時查詢也 embedding,去 Vectorize 找 top-k,再回 D1 撈完整內容並依分數重排。
- 一個 wrangler.jsonc 綁定全部——
assets、d1_databases、r2_buckets、ai、vectorize集中管理,wrangler deploy一鍵上線。 - 整合的坑——binding 名稱一致、Vectorize 維度對齊、metadata 別塞全文、跨服務最終一致、寫入端點驗證。
一句話收束整個系列:Cloudflare 的威力,不在任何單一服務有多強,而在它們能用 binding 無縫組裝成一個完整的邊緣全端應用——不用租主機、不用管連線字串、不用自架 GPU,一個獨立開發者就能把「AI + 資料 + 搜尋 + 前端」全部跑在同一張全球網路上,wrangler deploy 一鍵上線。
你已經走完了從零到全端的完整旅程。最後一步,我們把視野拉高:下一篇《Cloudflare vs GCP vs Kubernetes》,會把 Cloudflare 這套邊緣全家桶,和傳統雲(GCP)與自管容器編排(Kubernetes)放在一起比較——什麼場景該用誰、各自的成本與心智負擔在哪、又該如何混搭。做完了成品,再回頭看選型,你會更有判斷力。我們下一篇見。
想查閱 Vectorize、Workers AI 與 Static Assets 的官方最新指南,可以參考 Cloudflare Vectorize 官方文件(各項功能名稱、模型維度與支援矩陣以當下官方文件為準)。