全端實戰:用 Cloudflare 全家桶打造 AI 知識庫 | Cloudflare 完整教學

2026/09/18
全端實戰:用 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 路由)——一個 fetch handler 依路徑分派 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 知識庫有兩條主要路徑:寫入路徑(新增筆記)與搜尋路徑(語意搜尋)。

寫入一筆筆記時,發生這些事:

  1. 前端(Static Assets 服務的頁面)發 POST /api/notes,帶標題、內文、可選的附件。
  2. Worker 收到請求,先驗證 API key(Secrets),再驗證輸入。
  3. 若有附件,把檔案位元組寫進 R2,拿到一個 R2 key。
  4. 把標題、內文、R2 key、時間戳寫進 D1,拿到筆記 id
  5. Workers AI 把內文轉成 embedding 向量。
  6. 把「向量 + note id」寫進 Vectorize

語意搜尋時,發生這些事:

  1. 前端發 GET /api/search?q=...
  2. Worker 用 Workers AI 把查詢字串也轉成 embedding 向量。
  3. 拿這個向量去 Vectorize 查 top-k 最相似的向量,得到一串 note id + 相似度分數。
  4. 用這些 id 回 D1 撈出完整筆記內容。
  5. 回傳給前端。

把它畫成一張分工圖,核心觀念就是「每個服務只做它最擅長的一件事」:

服務職責存什麼
Workers Static Assets服務前端靜態檔HTML / CSS / JS
WorkerAPI 路由、編排各服務、驗證(無狀態,只跑邏輯)
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.DBenv.BUCKETenv.AIenv.VECTORIZE 這種 binding 直接呼叫,延遲最低、也不用管金鑰。

關於 D1、R2、Workers AI、Vectorize 各自的深入用法,前面都有專篇——這一篇的重點是把它們編排在一起,所以每個服務我們只講「整合時的關鍵點」,細節可回頭參考各自那一篇。

逐段實作

我們一段一段來:先設定多 binding 的 wrangler.jsonc,再建立 D1 schema 與 Vectorize 索引,然後寫 Worker 的 API 路由、逐一實作寫入與搜尋,最後補上前端。

1. 多 binding 的 wrangler.jsonc

一個全端應用的所有服務綁定,都集中在這一個檔案。這是整篇的地基——assetsd1_databasesr2_bucketsaivectorize 一次到位:

// 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.jsoncbinding 卻叫 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 綁定全部——assetsd1_databasesr2_bucketsaivectorize 集中管理,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 官方文件(各項功能名稱、模型維度與支援矩陣以當下官方文件為準)。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →