RAG 架構實作:在 Cloudflare 上打造知識庫問答 | Cloudflare 完整教學

2026/09/07
RAG 架構實作:在 Cloudflare 上打造知識庫問答 | Cloudflare 完整教學

RAG(檢索增強生成,Retrieval-Augmented Generation) 是讓 LLM「有憑有據」回答問題的關鍵架構:在生成回答之前,先從你自己的知識庫檢索出最相關的資料,再交給 LLM 生成答案。這一篇,我們把 Workers AI 的 embedding、Vectorize 的語意搜尋與 LLM 的生成串成一條龍,在 Cloudflare 上做出一個完整、可執行、不會亂編的知識庫問答系統。

前言

上一篇《Vectorize 向量資料庫入門》,我們把「語意搜尋」的第一塊拼圖擺上桌:用 wrangler 建立 index、把 embedding upsert 進 Vectorize、用 query 找出最相似的 topK 個向量。但我們也在文末留了一個伏筆——單純的相似搜尋還不是完整的 AI 應用。它只能「找出相關文件」,不能「回答問題」。使用者想要的不是一堆連結,而是一句直接、準確的答案。

這就是 RAG 要補上的最後一塊拼圖。

RAG(Retrieval-Augmented Generation,檢索增強生成) 是一種 AI 架構模式,它的核心思想只有一句話:在讓 LLM 生成回答之前,先從外部知識庫檢索相關資料,把資料當成「背景」餵給模型,讓它基於真實內容回答,而不是憑記憶瞎猜。 它一次解決了大型語言模型(LLM)的兩個致命問題——知識截止日期(knowledge cutoff)(模型只知道訓練時看過的資料)與幻覺(hallucination)(不知道時就編一個)。

打個比方。沒有 RAG 的 LLM,像一位閉卷考試的學生:題目只要超出他背過的範圍,他要嘛答不出來,要嘛硬掰一個。而 RAG 就像讓這位學生開卷考試——考試前先派一位「圖書館員」(就是我們上一篇的 Vectorize),根據題目從書架上抽出最相關的那幾頁攤在他面前,他再照著這幾頁作答。答案自然又快又準,而且有出處可查

RAG 分成兩個階段,這是理解整篇文章的骨架:

  • 索引階段(Indexing Phase)——把知識庫的文件分塊 → 產 embedding → 存進 Vectorize。這是一次性(或持續更新)的準備工作。
  • 查詢階段(Query Phase)——使用者提問時,把問題向量化 → 檢索 topK → 組 prompt → 餵 LLM 生成。這是每次請求都跑的即時流程。

讀完你會掌握:

  • RAG 的運作原理——為什麼「先檢索再生成」能同時解決幻覺與知識過時,以及索引 / 查詢兩階段各做什麼。
  • 端到端的實作——在 Cloudflare 上把文件分塊、用 Workers AI 產 embedding、存進 Vectorize、檢索 topK、組 prompt、呼叫 LLM 生成,一條龍完整跑通。
  • 關鍵的正確做法——分塊大小怎麼抓、索引與查詢的 embedding 模型為何必須一致、如何用相似度門檻過濾雜訊、prompt 為何一定要放來源。

這一篇是把前面 034 / 035《Workers AI》與 037《Vectorize》的能力組裝成一個真正產品的關鍵一步。開始吧。

核心概念

在動手之前,先把 RAG 的兩階段流程在腦中畫成一張圖。RAG 的全部精髓就是:把「知識」預先變成可檢索的向量(索引階段),再在每次提問時「撈出相關的、餵給 LLM」(查詢階段)。

一、索引階段:把知識變成可搜尋的向量

索引階段是「準備知識庫」的過程,一份文件從原文到可被檢索,要經過四步:

【索引階段 Indexing Phase — 一次性 / 持續更新】

原始文件(R2 / D1 / API)
    │
    ▼ ① 分塊 chunking
一段段語意完整的 chunk
    │
    ▼ ② Workers AI Embedding(@cf/baai/bge-base-en-v1.5)
每個 chunk 的向量(768 維)
    │
    ├──▼ ③ 存入 Vectorize(向量 + metadata,供語意搜尋)
    │
    └──▼ ③ 存入 D1(chunk 原文,供之後撈回全文)
  1. 分塊(chunking)——把長文件切成一段段(chunk)。為什麼要切?因為 embedding 模型有輸入長度上限,而且「一整篇文章」的向量會把所有主題平均成一團模糊的意思,檢索不準。切成小段,每段語意單純,檢索才精確。
  2. 產 embedding——用 Workers AI 的 @cf/baai/bge-base-en-v1.5 把每個 chunk 轉成 768 維向量。
  3. 雙寫入——向量存進 Vectorize(用來做語意搜尋),chunk 的原文存進 D1(之後檢索命中時,用來撈回完整文字餵給 LLM)。這是 RAG 一個常被忽略的重點:Vectorize 存的是「向量 + 精簡 metadata」,真正的全文放在 D1

二、查詢階段:檢索相關內容並生成回答

查詢階段是使用者每次提問時跑的即時流程,共五步:

【查詢階段 Query Phase — 每次請求】

使用者問題
    │
    ▼ ① Workers AI Embedding(同一個模型!)
查詢向量 query vector
    │
    ▼ ② Vectorize.query() 檢索 topK
最相關的 chunk ID + 相似度分數
    │
    ▼ ③ 過濾低分 + 從 D1 撈回原文
相關 chunk 原文
    │
    ▼ ④ 組 prompt(把原文當「背景資訊」)
帶背景的 messages
    │
    ▼ ⑤ Workers AI LLM(@cf/meta/llama-3.1-8b-instruct)
最終回答(有依據、附來源)

這裡有一個貫穿全文的鐵律要先記住:索引階段與查詢階段,必須使用「同一個」embedding 模型。 因為兩邊的向量要落在同一個座標系裡,相似度比較才有意義。索引時用 bge-base-en-v1.5(768 維),查詢時就也得用它,不能換成別的模型。

三、關鍵術語一次記牢

術語是什麼
chunk(分塊)把長文件切成的一小段語意單元,是 embedding 與檢索的最小單位
chunkSize / chunkOverlap分塊大小(字元數)/ 相鄰分塊的重疊量,重疊用來避免語意被切點劈斷
embedding(向量嵌入)由 Workers AI 模型產生、代表一段文字「意思」的浮點數陣列
topKquery 回傳幾個最相似的檢索結果,RAG 常取 3~5
scoreThreshold(相似度門檻)低於此分數的檢索結果視為「不夠相關」而丟棄,避免雜訊污染 prompt
context / prompt augmentation把檢索到的原文塞進 prompt 當「背景資訊」,這正是 RAG 的「A(Augmented)」

實作範例

觀念清楚後,我們把一個完整的 RAG 知識庫問答系統從頭實作出來。專案會用到三個 binding:Workers AI(產 embedding + LLM 生成)、Vectorize(向量檢索)、D1(存 chunk 原文)。所有 TypeScript 型別 AiVectorizeVectorizeVectorD1Database 皆來自 @cloudflare/workers-types

1. 建立資源與 binding

先用 Wrangler 建立三樣東西:Vectorize 索引(768 維、cosine,對應我們用的 embedding 模型)、D1 資料庫,以及 D1 的資料表:

# ① 建立 768 維 cosine 索引(對應 @cf/baai/bge-base-en-v1.5)
npx wrangler vectorize create rag-index --dimensions=768 --metric=cosine

# ② 建立可過濾的 metadata index(要在寫入向量「之前」建好)
npx wrangler vectorize create-metadata-index rag-index --property-name=docId --type=string

# ③ 建立 D1 資料庫
npx wrangler d1 create rag-knowledge-base

D1 資料表用來存每個 chunk 的原文,查詢時靠 chunk ID 撈回:

npx wrangler d1 execute rag-knowledge-base --remote --command "
CREATE TABLE IF NOT EXISTS chunks (
  id TEXT PRIMARY KEY,       -- chunk ID,與 Vectorize 的向量 id 一致
  doc_id TEXT NOT NULL,      -- 所屬文件 ID
  content TEXT NOT NULL,     -- chunk 原文(餵給 LLM 的背景資訊)
  title TEXT NOT NULL,       -- 文件標題(用於標示來源)
  url TEXT
);
CREATE INDEX IF NOT EXISTS idx_doc_id ON chunks (doc_id);
"

wrangler.jsonc 裡把三個 binding 都綁上:

// wrangler.jsonc
{
  "name": "rag-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "ai": { "binding": "AI" },
  "vectorize": [
    { "binding": "VECTORIZE", "index_name": "rag-index" }
  ],
  "d1_databases": [
    { "binding": "DB", "database_name": "rag-knowledge-base", "database_id": "你的-d1-database-id" }
  ]
}

對應的型別定義:

// src/types.ts
export interface Env {
  AI: Ai;
  VECTORIZE: Vectorize;
  DB: D1Database;
}

export interface Document {
  id: string;
  title: string;
  content: string;
  url?: string;
}

2. 索引階段(一):文件分塊 chunking

第一步是把長文件切成一段段 chunk。生產環境建議用 @langchain/textsplittersRecursiveCharacterTextSplitter,它會盡量沿著段落、句子等「語意邊界」切;這裡先用一個好懂的手寫版,示範 chunkSizechunkOverlap 的核心邏輯:

// src/chunking.ts
// chunkSize:每段最大字元數;overlap:相鄰段落重疊字元數(避免語意被切點劈斷)
export function splitText(text: string, chunkSize = 800, overlap = 150): string[] {
  const chunks: string[] = [];
  let start = 0;

  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push(text.slice(start, end).trim());
    if (end === text.length) break;
    start = end - overlap; // ← 回退 overlap 個字元,讓下一段與這段頭尾重疊
  }

  return chunks.filter((c) => c.length > 0);
}

chunkOverlap 是關鍵:如果一個完整的概念剛好落在切點上,重疊區能確保它在前後兩個 chunk 裡都完整出現,不會因為被硬切成兩半而在檢索時漏掉。一般文字文件用 800 / 150 起步即可。

3. 索引階段(二):產 embedding + 雙寫入 Vectorize 與 D1

把每個 chunk 用 Workers AI 轉成向量,原文寫進 D1、向量寫進 Vectorize。這裡示範批次產 embedding(一次傳入多段文字,效率遠優於逐一請求):

// src/indexing.ts
import type { Env, Document } from "./types";
import { splitText } from "./chunking";

export async function indexDocument(env: Env, doc: Document): Promise<{ chunks: number }> {
  // ① 分塊
  const chunkTexts = splitText(doc.content);

  // ② 批次產 embedding:一次把所有 chunk 傳給 Workers AI
  //    注意:index 與 query 必須用「同一個」模型 —— 這裡是 @cf/baai/bge-base-en-v1.5
  const embResult = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
    text: chunkTexts,
  });
  // embResult.data 是二維陣列,data[i] 對應 chunkTexts[i] 的 768 維向量

  const vectors: VectorizeVector[] = [];

  for (const [i, chunkText] of chunkTexts.entries()) {
    const chunkId = `${doc.id}-chunk-${i}`;

    // ③a 原文寫入 D1(之後檢索命中時用它撈回全文)
    await env.DB.prepare(
      "INSERT OR REPLACE INTO chunks (id, doc_id, content, title, url) VALUES (?, ?, ?, ?, ?)"
    )
      .bind(chunkId, doc.id, chunkText, doc.title, doc.url ?? null)
      .run();

    // ③b 準備向量:values 必須是 embResult.data[i](不是 embResult、也不是 embResult.data)
    vectors.push({
      id: chunkId,
      values: embResult.data[i],
      metadata: { docId: doc.id, title: doc.title },
    });
  }

  // ④ 批次 upsert 進 Vectorize(每批最多 1,000 個)
  const BATCH = 500;
  for (let i = 0; i < vectors.length; i += BATCH) {
    await env.VECTORIZE.upsert(vectors.slice(i, i + BATCH));
  }

  return { chunks: chunkTexts.length };
}

有兩個細節新手最常踩雷,務必留意:

  • embResult.data[i] 才是向量。 env.AI.run() 回傳的 data 是二維陣列,你要傳給 values 的是 data[i],不是整個 data,更不是 embResult
  • 寫入是最終一致性的。 upsert 立即回傳,但向量通常要等 5~10 秒才可被 query 查到——別在索引完的同一個請求裡馬上查詢就期待看到新資料。

4. 查詢階段:檢索 topK → 組 prompt → LLM 生成

這是 RAG 的心臟。使用者的問題進來後,我們走完「向量化 → 檢索 → 過濾 → 撈原文 → 組 prompt → 生成」六步:

// src/rag.ts
import type { Env } from "./types";

export interface RAGResult {
  answer: string;
  sources: Array<{ title: string; url: string | null; score: number }>;
}

export async function ragQuery(
  env: Env,
  question: string,
  options: { topK?: number; scoreThreshold?: number } = {}
): Promise<RAGResult> {
  const { topK = 5, scoreThreshold = 0.5 } = options;

  // ① 問題向量化 —— 必須用與索引時「相同」的 embedding 模型!
  const emb = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: [question] });
  const queryVector = emb.data[0];

  // ② 在 Vectorize 檢索最相似的 topK 個 chunk
  const matches = await env.VECTORIZE.query(queryVector, {
    topK,
    returnMetadata: "indexed",
  });

  // ③ 過濾低相似度結果:query 一定會回 topK 個,但不夠相關的要丟掉
  const relevant = matches.matches.filter((m) => m.score >= scoreThreshold);

  if (relevant.length === 0) {
    // 寧可誠實說「找不到」,也別硬塞不相關內容讓 LLM 幻覺
    return { answer: "抱歉,知識庫中找不到與您問題相關的資訊。", sources: [] };
  }

  // ④ 用 chunk ID 從 D1 撈回原文(Vectorize 只存向量,全文在 D1)
  const ids = relevant.map((m) => m.id);
  const placeholders = ids.map(() => "?").join(", ");
  const { results: chunks } = await env.DB.prepare(
    `SELECT id, content, title, url FROM chunks WHERE id IN (${placeholders})`
  )
    .bind(...ids)
    .all<{ id: string; content: string; title: string; url: string | null }>();

  // ⑤ 組 prompt:把檢索到的原文當成「背景資訊」—— 這就是 RAG 的「增強(Augmented)」
  const context = chunks
    .map((c, i) => `[來源 ${i + 1}:${c.title}]\n${c.content}`)
    .join("\n\n---\n\n");

  // ⑥ 呼叫 LLM 生成回答,明確要求「只根據背景資訊回答」
  const llm = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
    messages: [
      {
        role: "system",
        content: `你是一個嚴謹的知識助理。請「只根據」以下背景資訊,用繁體中文回答使用者的問題。
若背景資訊不足以回答,請如實說明「資料中沒有相關資訊」,絕對不要自行編造。

背景資訊:
${context}`,
      },
      { role: "user", content: question },
    ],
    max_tokens: 1024,
    temperature: 0.3, // 低溫度讓回答更貼近事實、少發散
  });

  // 整理來源(讓使用者能查證答案出處)
  const chunkMap = new Map(chunks.map((c) => [c.id, c]));
  const sources = relevant
    .map((m) => {
      const c = chunkMap.get(m.id);
      return c ? { title: c.title, url: c.url, score: m.score } : null;
    })
    .filter((s): s is NonNullable<typeof s> => s !== null);

  return { answer: llm.response ?? "無法生成回答", sources };
}

留意第 ⑤ 步:把檢索到的原文塞進 system prompt 當背景,這一步就是 RAG 名字裡的「A(Augmented,增強)」——我們用外部知識「增強」了 LLM 的 prompt。同時第 ⑥ 步的 system prompt 明確指示模型「只根據背景資訊回答、不足就誠實說沒有」,這是壓制幻覺的最後一道防線。

5. 串成 Worker 入口

最後用 Hono 把「索引」與「問答」兩個端點接起來,整個 RAG 系統就完整了:

// src/index.ts
import { Hono } from "hono";
import type { Env } from "./types";
import { indexDocument } from "./indexing";
import { ragQuery } from "./rag";

const app = new Hono<{ Bindings: Env }>();

// 索引階段:POST 一份文件,系統自動分塊、產 embedding、雙寫入
app.post("/index", async (c) => {
  const doc = await c.req.json<{ id: string; title: string; content: string; url?: string }>();
  if (!doc.id || !doc.title || !doc.content) {
    return c.json({ error: "缺少必要欄位:id, title, content" }, 400);
  }
  const result = await indexDocument(c.env, doc);
  return c.json({ success: true, message: `文件 ${doc.id} 已索引`, ...result });
});

// 查詢階段:GET /ask?q=... 回傳 RAG 生成的答案 + 來源
app.get("/ask", async (c) => {
  const question = c.req.query("q");
  if (!question) return c.json({ error: "請提供問題參數 ?q=..." }, 400);

  const result = await ragQuery(c.env, question);
  return c.json(result);
});

export default app;

部署後,先 POST /index 灌入文件(等 5~10 秒讓向量可見),再 GET /ask?q=你的問題,就能拿到一個根據你自己知識庫、有來源可查的回答。這就是一套最小但完整的生產級 RAG 系統。

常見錯誤與最佳實踐

RAG 的坑幾乎都集中在「模型一致性」「分塊」「過濾」與「prompt」這四件事上。以下是最高頻的幾個。

坑一:索引與查詢用了不同的 embedding 模型。

這是 RAG 最致命也最隱蔽的錯誤。索引時用 @cf/baai/bge-base-en-v1.5(768 維),查詢時卻換成 bge-small(384 維)或其他模型——兩邊向量落在不同座標系,相似度分數會全部失真,檢索結果看起來「有回東西但完全不相關」。鐵律:索引與查詢必須用同一個 embedding 模型,連 Vectorize 索引的維度也要跟著模型走。 如果你哪天要換更好的 embedding 模型,必須把整個知識庫重新索引一遍,不能只換查詢端。

坑二:chunk 切太大或太小。

切太大(例如整篇文章一個 chunk),embedding 變成一團「平均意思」,語意模糊、檢索不準,還浪費 context;切太小(例如一句話一個 chunk),語意被切碎,檢索到的片段缺乏上下文,LLM 拿到殘缺資訊反而答不好。建議 chunkSize 8001000、chunkOverlap 150200 起步,並優先沿「語意邊界」(段落、標題)切,而非硬切字數。overlap 一定要有,否則跨越切點的概念會被劈斷。

坑三:沒過濾低相似度結果,雜訊污染答案。

query 一定會回傳 topK 個結果——就算最相似的那個其實也很不相關,它照樣回給你。若你不加過濾,直接把這些不相關的 chunk 塞進 prompt,等於餵給 LLM 一堆雜訊,它反而更容易亂答。務必設一個 scoreThreshold(cosine 建議 0.4~0.5),把低於門檻的結果丟掉;若全部被丟掉,就誠實回「找不到」。 寧缺勿濫,是 RAG 品質的分水嶺。

// ❌ 不過濾:把 topK 全部塞進 prompt,不相關的變雜訊
const relevant = matches.matches;

// ✅ 過濾:只留分數夠高的,寧可回「找不到」也別硬塞
const relevant = matches.matches.filter((m) => m.score >= 0.5);
if (relevant.length === 0) return { answer: "資料中沒有相關資訊。", sources: [] };

坑四:prompt 沒放來源,或沒約束模型「只根據背景回答」。

RAG 的「A」就是把檢索到的原文放進 prompt。如果你組 prompt 時忘了把 D1 撈回的原文塞進背景資訊,LLM 等於沒拿到任何依據,只能靠記憶瞎猜,幻覺照舊。更進一步,system prompt 一定要明確指示「只根據背景資訊回答、資料不足就誠實說沒有」,並搭配較低的 temperature(如 0.3),把模型自由發揮的空間壓到最小。放來源、給約束、降溫度,是壓制幻覺的三件套。

坑五:寫入後立刻查詢,查不到剛索引的資料。

Vectorize 的寫入是最終一致性,upsert 後需 5~10 秒向量才可見。別在索引完的同一個請求裡馬上 query。若流程需要「索引 → 立刻可查」,把兩階段拆開,或用 Cloudflare Workflows 處理非同步索引,避開一致性延遲。

最佳實踐小結: 索引與查詢用同一個 embedding 模型;chunk 用 800~1000 / 150~200 起步、沿語意邊界切;批次產 embedding、批次 upsert;檢索務必用 scoreThreshold 過濾雜訊,寧缺勿濫;prompt 一定放來源、明確約束「只根據背景回答」、降低 temperature;Vectorize 只存向量與精簡 metadata,全文放 D1;接受寫入的最終一致性延遲。

小結

上一篇《Vectorize 向量資料庫入門》,我們把 embedding 存進向量資料庫、用語意找出最相關的內容。這一篇,我們把它接上 Workers AI 與 LLM,做出了完整的 RAG(檢索增強生成) 知識庫問答系統:

  • RAG 兩階段——索引階段(分塊 → 產 embedding → 雙寫入 Vectorize + D1)是一次性準備;查詢階段(向量化 → 檢索 topK → 過濾 → 撈原文 → 組 prompt → LLM 生成)是每次請求的即時流程。
  • 端到端實作——用 Workers AI 的 @cf/baai/bge-base-en-v1.5 產 embedding、Vectorize 做語意檢索、D1 存 chunk 原文、@cf/meta/llama-3.1-8b-instruct 生成回答,全程不離開 Cloudflare 網路。
  • 關鍵正確做法——索引與查詢同一個 embedding 模型、chunk 大小適中且有 overlap、用 scoreThreshold 過濾低相似度、prompt 放來源 + 約束模型 + 降溫度壓制幻覺。

到這裡,你已經能親手打造一個生產級的 RAG 問答系統了。但你也會發現,這條 pipeline 有不少「重複的苦工」:管理 chunking 策略、維護索引、處理更新與一致性……如果你只是想快速做一個知識庫問答,有沒有更省事的方法?

有——Cloudflare AutoRAG(現稱 AI Search) 就是把上面這整條 pipeline 全託管起來的服務:你只要把文件丟進 R2,Cloudflare 自動幫你解析、分塊、產 embedding、建索引、查詢、生成。下一篇《AutoRAG 自動化 RAG》,我們就來看看這個「零維護」的 RAG 方案怎麼用,以及它與自建 RAG 各自適合什麼場景。

想深入 RAG 的完整實作與 API 細節,可以參考 Cloudflare 官方的 RAG 建構教學。把檢索與生成串成一條龍之後,我們下一篇《AutoRAG 自動化 RAG》見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →