架構藍圖:AI 問答應用(RAG)
把 Workers AI、Vectorize 和 D1 組成一個會讀你自己文件、並且有所本回答問題的應用——這就是 Cloudflare 上最經典的 RAG 架構。
我們要打造什麼?
我們要做一個會根據你自己文件回答問題的問答應用。單純的大型語言模型(LLM)只懂得它訓練時看過的東西,所以無法回答你私有手冊裡的內容——而且還可能很有自信地亂編。RAG(Retrieval-Augmented Generation,檢索增強生成)就是來解決這個問題:先「檢索」出文件中最相關的片段,再讓 LLM 只根據這些片段「生成」答案。
RAG 有兩個階段。離線的「匯入」階段會讀進你的文件,把它們變成向量(一串能代表語意的數字)並存起來。線上的「查詢」階段則接收使用者的問題,找出最相近的片段,再把它們當成上下文(context)餵給 LLM。整體長這樣:
把它想成一場開書考試
閉書考試逼你憑記憶作答——可能記錯。RAG 把它變成開書考試:回答前先翻到最相關的那幾頁(檢索),再根據頁面上真正寫的內容作答(生成)。LLM 就是考生,Vectorize 就是書末的索引。
各元件的職責
五個 Cloudflare 元件分工合作。Worker 是指揮;Workers AI 提供大腦(負責向量化與 LLM);Vectorize 是可搜尋的記憶;D1 保存原始可讀的文字;R2 則存放原始檔案。
Workers —— 指揮
把一切串起來的無伺服器程式碼:對外提供 /ingest 與 /ask,依序呼叫各服務,最後回傳 JSON。
Workers AI —— 大腦
跑兩個模型:一個 embedding 模型把文字變成向量,一個 LLM(Llama)根據上下文寫出最終答案。
Vectorize —— 檢索
向量資料庫。存放每個片段的向量,給它問題的向量,就能在幾毫秒內回傳最相近的前 k 個片段。
D1 —— 原始文字
Vectorize 存的是數字、不是文字。D1(SQL 資料庫)保存每個片段的可讀文字,搜尋後再用 id 撈回來。
R2 —— 原始檔案
物件儲存,放你匯入的原始 PDF、Markdown 或圖片,日後要重新切段或重新向量化時不必再上傳一次。
階段一 —— 匯入(建立索引)
匯入對每份文件只需跑一次(或在它更新時跑)。我們把文件切成小片段(chunk),用 Workers AI 把每段向量化,再把向量存進 Vectorize、把原始文字存進 D1。靠 id 把兩邊綁在一起:同一個 id 同時存在於兩個儲存層。
為什麼要切段?
把整本 50 頁手冊壓成一個向量會讓語意模糊,也無法精準引用某一段。切成約 200 字的片段(並讓相鄰片段稍微重疊,句子才不會被攔腰切斷),就能取得精準、可引用的片段。
階段二 —— 查詢(回答問題)
查詢時,我們用「和匯入時相同」的模型把問題向量化,到 Vectorize 搜尋最接近的前 k 個片段,從 D1 撈回它們的原始文字,再拼成一段 prompt。LLM 讀完這段上下文加上問題後,寫出有依據的答案。
兩邊要用同一個模型
文件和問題必須用「完全相同」的模型、做成「相同維度」的向量。混用不同模型(或不同索引維度)會讓向量落在不同的座標系,「最近」就失去意義,搜尋結果會變垃圾。
一個問題的完整往返
當使用者問一個問題時,Worker 內部到底發生什麼事——Workers AI、Vectorize 和 D1 之間的每一次往返都在這裡:
動手實作
以下是一個完整、可執行的 Worker,包含兩條路由:POST /ingest 用來把文件建索引,POST /ask 用來回答問題。先建立資源、定義資料表、接好繫結(binding),再加上程式碼。
建立索引與資料庫
索引維度(768)必須對應下面的 embedding 模型。bge 系列模型建議用 cosine(餘弦)距離。
# Index sized for the bge-base embedding model (768 dims) wrangler vectorize create rag-index --dimensions=768 --metric=cosine # A D1 database to keep each chunk's original text wrangler d1 create rag-db建立 chunks 資料表
存成 schema.sql,再執行:wrangler d1 execute rag-db --remote --file=./schema.sql
CREATE TABLE IF NOT EXISTS chunks ( id TEXT PRIMARY KEY, doc_id TEXT, text TEXT NOT NULL );接好繫結
繫結讓程式能用 env.AI、env.VECTORIZE、env.DB 來存取 AI、Vectorize 與 D1——原始碼裡不必放金鑰。把 database_id 換成『wrangler d1 create』印出來的那組 id。
{ "name": "rag-worker", "main": "src/index.js", "compatibility_date": "2025-06-01", "ai": { "binding": "AI" }, "vectorize": [ { "binding": "VECTORIZE", "index_name": "rag-index" } ], "d1_databases": [ { "binding": "DB", "database_name": "rag-db", "database_id": "<your-d1-id>" } ] }匯入:切段、向量化、儲存
一次 env.AI.run() 就把所有片段一起向量化。我們用 upsert 把向量寫進 Vectorize(具冪等性,重新匯入同一份文件也安全),並用 INSERT OR REPLACE 以相同 id 把文字寫進 D1。
// POST /ingest { docId, text } async function ingest(env, docId, fullText) { // 1) Split the document into small overlapping chunks const chunks = chunkText(fullText, 200, 40); // 2) Embed every chunk in one Workers AI call const { data } = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: chunks, }); // 3) Save original text in D1, vectors in Vectorize const vectors = []; for (let i = 0; i < chunks.length; i++) { const id = docId + ":" + i; await env.DB.prepare( "INSERT OR REPLACE INTO chunks (id, doc_id, text) VALUES (?, ?, ?)" ).bind(id, docId, chunks[i]).run(); vectors.push({ id, values: data[i], metadata: { docId } }); } await env.VECTORIZE.upsert(vectors); return chunks.length; } // Split text into word chunks with a little overlap for context function chunkText(text, size, overlap) { const words = text.split(/\s+/); const out = []; for (let i = 0; i < words.length; i += size - overlap) { out.push(words.slice(i, i + size).join(" ")); } return out; }提問:向量化、檢索、生成
這是 RAG 的核心。把問題向量化,用 query() 向 Vectorize 取前 5 名,從 D1 撈回這些片段的文字,當成上下文交給 LLM。系統提示(system prompt)強制模型只能根據這段上下文作答。
// POST /ask { question } async function ask(env, question) { // 1) Embed the question with the SAME model used at ingest const { data } = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: [question], }); const queryVector = data[0]; // 2) Retrieve the top-k most similar chunks from Vectorize const { matches } = await env.VECTORIZE.query(queryVector, { topK: 5, returnMetadata: true, }); // 3) Load the original text of those chunks from D1 const ids = matches.map((m) => m.id); const slots = ids.map(() => "?").join(", "); const { results } = await env.DB.prepare( "SELECT text FROM chunks WHERE id IN (" + slots + ")" ).bind(...ids).all(); const context = results.map((r) => r.text).join("\n---\n"); // 4) Generate an answer grounded ONLY in that context const out = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", { messages: [ { role: "system", content: "Answer using ONLY the context below. If it is not there, say you do not know.", }, { role: "user", content: "Context:\n" + context + "\n\nQuestion: " + question, }, ], }); return out.response; }把請求接到路由
default export 把 POST /ingest 與 POST /ask 派發給上面兩個函式,並回傳 JSON。
export default { async fetch(request, env) { const url = new URL(request.url); if (request.method === "POST" && url.pathname === "/ingest") { const { docId, text } = await request.json(); const count = await ingest(env, docId, text); return Response.json({ ingested: count }); } if (request.method === "POST" && url.pathname === "/ask") { const { question } = await request.json(); const answer = await ask(env, question); return Response.json({ answer }); } return new Response("POST JSON to /ingest or /ask", { status: 404 }); }, };部署並試用
部署、匯入一份文件,再針對它提問。答案應該引用你的文件,而不是模型的訓練資料。
wrangler deploy # Index a document curl -X POST https://rag-worker.example.workers.dev/ingest \ -d '{ "docId": "handbook", "text": "Refunds are accepted within 30 days..." }' # Ask a question about it curl -X POST https://rag-worker.example.workers.dev/ask \ -d '{ "question": "How long do I have to get a refund?" }'
重點概念
嵌入(Embedding)
用 AI 模型把文字變成向量的動作。語意相近的內容會得到相近的向量。文件和問題都要用同一個模型、同樣的維度。
向量與相似度
向量就是一串數字(空間中的一個點)。「相似度」是兩個點有多接近——這裡用 cosine(餘弦)距離。點越近,語意越近,檢索就是靠這個。
RAG(檢索增強生成)
先檢索出相關片段,再讓 LLM 根據它們生成。這讓模型擁有最新、私有的知識,也大幅減少亂編答案(幻覺)。
Top-k(前 k 名)
你要取回幾個最接近的片段(這裡 k=5)。太少可能漏掉答案;太多會增加雜訊與成本。先從 3 到 8 之間試起再微調。
上下文視窗(Context window)
LLM 一次最多能讀進的文字量。你檢索到的片段加上問題必須塞得進去——這也是為什麼只取 top-k、而不是全部塞進去的原因。
小提示與陷阱
回傳出處,建立信任
在每個向量的 metadata(中介資料)裡存標題或網址,回答時連同相符片段的出處一起回傳。使用者能查證,你也能藉由看到「實際檢索到什麼」來除錯爛答案。
- 維度必須一致:索引維度、embedding 模型、你 upsert 的向量三者都要對得上(這裡是 768)。
- 問題一定要用「和文件相同」的模型來向量化——不一致會悄悄回傳不相關的結果。
- Vectorize 存的是數字、不是文字。可讀文字放 D1(小片段)或 R2(整份檔案),再用 id 對接。
- 調整片段大小與 top-k:片段越小越精準,top-k 越大越完整但更花錢、也可能撐爆上下文視窗。
- 在 Workers AI 前面加上 AI Gateway,可對 embedding 與 LLM 兩種呼叫做快取、限流與觀測。
- 文件更新時要重新匯入;用固定的 id 做 upsert,舊向量會被取代而不是重複堆積。