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 原文,供之後撈回全文)
- 分塊(chunking)——把長文件切成一段段(chunk)。為什麼要切?因為 embedding 模型有輸入長度上限,而且「一整篇文章」的向量會把所有主題平均成一團模糊的意思,檢索不準。切成小段,每段語意單純,檢索才精確。
- 產 embedding——用 Workers AI 的
@cf/baai/bge-base-en-v1.5把每個 chunk 轉成 768 維向量。 - 雙寫入——向量存進 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 模型產生、代表一段文字「意思」的浮點數陣列 |
| topK | query 回傳幾個最相似的檢索結果,RAG 常取 3~5 |
| scoreThreshold(相似度門檻) | 低於此分數的檢索結果視為「不夠相關」而丟棄,避免雜訊污染 prompt |
| context / prompt augmentation | 把檢索到的原文塞進 prompt 當「背景資訊」,這正是 RAG 的「A(Augmented)」 |
實作範例
觀念清楚後,我們把一個完整的 RAG 知識庫問答系統從頭實作出來。專案會用到三個 binding:Workers AI(產 embedding + LLM 生成)、Vectorize(向量檢索)、D1(存 chunk 原文)。所有 TypeScript 型別 Ai、Vectorize、VectorizeVector、D1Database 皆來自 @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/textsplitters 的 RecursiveCharacterTextSplitter,它會盡量沿著段落、句子等「語意邊界」切;這裡先用一個好懂的手寫版,示範 chunkSize 與 chunkOverlap 的核心邏輯:
// 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、200 起步,並優先沿「語意邊界」(段落、標題)切,而非硬切字數。overlap 一定要有,否則跨越切點的概念會被劈斷。chunkOverlap 150
坑三:沒過濾低相似度結果,雜訊污染答案。
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》見。