sell整合實戰
neurology

架構藍圖:AI 問答應用(RAG)

把 Workers AI、Vectorize 和 D1 組成一個會讀你自己文件、並且有所本回答問題的應用——這就是 Cloudflare 上最經典的 RAG 架構。

768向量維度
top-5每題取片段
5串接服務
insights

我們要打造什麼?

我們要做一個會根據你自己文件回答問題的問答應用。單純的大型語言模型(LLM)只懂得它訓練時看過的東西,所以無法回答你私有手冊裡的內容——而且還可能很有自信地亂編。RAG(Retrieval-Augmented Generation,檢索增強生成)就是來解決這個問題:先「檢索」出文件中最相關的片段,再讓 LLM 只根據這些片段「生成」答案。

RAG 有兩個階段。離線的「匯入」階段會讀進你的文件,把它們變成向量(一串能代表語意的數字)並存起來。線上的「查詢」階段則接收使用者的問題,找出最相近的片段,再把它們當成上下文(context)餵給 LLM。整體長這樣:

schemaCloudflare 上的 RAG —— 全貌

你的文件

匯入:切段 + 向量化

Vectorize + D1(知識儲存層)

使用者問題

檢索:搜尋 Vectorize

生成:Workers AI 大型語言模型

有依據的答案

menu_book

把它想成一場開書考試

閉書考試逼你憑記憶作答——可能記錯。RAG 把它變成開書考試:回答前先翻到最相關的那幾頁(檢索),再根據頁面上真正寫的內容作答(生成)。LLM 就是考生,Vectorize 就是書末的索引。

account_tree

各元件的職責

五個 Cloudflare 元件分工合作。Worker 是指揮;Workers AI 提供大腦(負責向量化與 LLM);Vectorize 是可搜尋的記憶;D1 保存原始可讀的文字;R2 則存放原始檔案。

hub

Workers —— 指揮

把一切串起來的無伺服器程式碼:對外提供 /ingest 與 /ask,依序呼叫各服務,最後回傳 JSON。

neurology

Workers AI —— 大腦

跑兩個模型:一個 embedding 模型把文字變成向量,一個 LLM(Llama)根據上下文寫出最終答案。

scatter_plot

Vectorize —— 檢索

向量資料庫。存放每個片段的向量,給它問題的向量,就能在幾毫秒內回傳最相近的前 k 個片段。

table

D1 —— 原始文字

Vectorize 存的是數字、不是文字。D1(SQL 資料庫)保存每個片段的可讀文字,搜尋後再用 id 撈回來。

folder

R2 —— 原始檔案

物件儲存,放你匯入的原始 PDF、Markdown 或圖片,日後要重新切段或重新向量化時不必再上傳一次。

cloud_upload

階段一 —— 匯入(建立索引)

匯入對每份文件只需跑一次(或在它更新時跑)。我們把文件切成小片段(chunk),用 Workers AI 把每段向量化,再把向量存進 Vectorize、把原始文字存進 D1。靠 id 把兩邊綁在一起:同一個 id 同時存在於兩個儲存層。

schema匯入流程

來源文件 (PDF、MD)

切成小段落 chunk

Workers AI:把每段做成向量

Vectorize:儲存向量

D1:儲存原始文字

可搜尋的知識索引

content_cut

為什麼要切段?

把整本 50 頁手冊壓成一個向量會讓語意模糊,也無法精準引用某一段。切成約 200 字的片段(並讓相鄰片段稍微重疊,句子才不會被攔腰切斷),就能取得精準、可引用的片段。

search

階段二 —— 查詢(回答問題)

查詢時,我們用「和匯入時相同」的模型把問題向量化,到 Vectorize 搜尋最接近的前 k 個片段,從 D1 撈回它們的原始文字,再拼成一段 prompt。LLM 讀完這段上下文加上問題後,寫出有依據的答案。

schema查詢流程

使用者問題

Workers AI:把問題做成向量

Vectorize:搜尋 top-k

最相關的前 k 段內容

組 prompt:context + 問題

Workers AI 大型語言模型:生成

有依據的答案

warning

兩邊要用同一個模型

文件和問題必須用「完全相同」的模型、做成「相同維度」的向量。混用不同模型(或不同索引維度)會讓向量落在不同的座標系,「最近」就失去意義,搜尋結果會變垃圾。

swap_vert

一個問題的完整往返

當使用者問一個問題時,Worker 內部到底發生什麼事——Workers AI、Vectorize 和 D1 之間的每一次往返都在這裡:

schema請求時序
D1Vectorize"Workers AI"Worker使用者D1Vectorize"Workers AI"Worker使用者POST /ask(問題)把問題做成向量問題向量query(向量, topK=5)前 5 筆相符(id、分數)依 id 取回原文各片段文字chat(context + 問題)答案JSON(答案)
construction

動手實作

以下是一個完整、可執行的 Worker,包含兩條路由:POST /ingest 用來把文件建索引,POST /ask 用來回答問題。先建立資源、定義資料表、接好繫結(binding),再加上程式碼。

  1. 建立索引與資料庫

    索引維度(768)必須對應下面的 embedding 模型。bge 系列模型建議用 cosine(餘弦)距離。

    bash
    # 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
  2. 建立 chunks 資料表

    存成 schema.sql,再執行:wrangler d1 execute rag-db --remote --file=./schema.sql

    sql
    CREATE TABLE IF NOT EXISTS chunks (
      id      TEXT PRIMARY KEY,
      doc_id  TEXT,
      text    TEXT NOT NULL
    );
  3. 接好繫結

    繫結讓程式能用 env.AI、env.VECTORIZE、env.DB 來存取 AI、Vectorize 與 D1——原始碼裡不必放金鑰。把 database_id 換成『wrangler d1 create』印出來的那組 id。

    jsonc
    {
      "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>" }
      ]
    }
  4. 匯入:切段、向量化、儲存

    一次 env.AI.run() 就把所有片段一起向量化。我們用 upsert 把向量寫進 Vectorize(具冪等性,重新匯入同一份文件也安全),並用 INSERT OR REPLACE 以相同 id 把文字寫進 D1。

    js
    // 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;
    }
  5. 提問:向量化、檢索、生成

    這是 RAG 的核心。把問題向量化,用 query() 向 Vectorize 取前 5 名,從 D1 撈回這些片段的文字,當成上下文交給 LLM。系統提示(system prompt)強制模型只能根據這段上下文作答。

    js
    // 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;
    }
  6. 把請求接到路由

    default export 把 POST /ingest 與 POST /ask 派發給上面兩個函式,並回傳 JSON。

    js
    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 });
      },
    };
  7. 部署並試用

    部署、匯入一份文件,再針對它提問。答案應該引用你的文件,而不是模型的訓練資料。

    bash
    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?" }'
school

重點概念

scatter_plot

嵌入(Embedding)

用 AI 模型把文字變成向量的動作。語意相近的內容會得到相近的向量。文件和問題都要用同一個模型、同樣的維度。

social_distance

向量與相似度

向量就是一串數字(空間中的一個點)。「相似度」是兩個點有多接近——這裡用 cosine(餘弦)距離。點越近,語意越近,檢索就是靠這個。

auto_stories

RAG(檢索增強生成)

先檢索出相關片段,再讓 LLM 根據它們生成。這讓模型擁有最新、私有的知識,也大幅減少亂編答案(幻覺)。

filter_list

Top-k(前 k 名)

你要取回幾個最接近的片段(這裡 k=5)。太少可能漏掉答案;太多會增加雜訊與成本。先從 3 到 8 之間試起再微調。

crop

上下文視窗(Context window)

LLM 一次最多能讀進的文字量。你檢索到的片段加上問題必須塞得進去——這也是為什麼只取 top-k、而不是全部塞進去的原因。

tips_and_updates

小提示與陷阱

format_quote

回傳出處,建立信任

在每個向量的 metadata(中介資料)裡存標題或網址,回答時連同相符片段的出處一起回傳。使用者能查證,你也能藉由看到「實際檢索到什麼」來除錯爛答案。

  • 維度必須一致:索引維度、embedding 模型、你 upsert 的向量三者都要對得上(這裡是 768)。
  • 問題一定要用「和文件相同」的模型來向量化——不一致會悄悄回傳不相關的結果。
  • Vectorize 存的是數字、不是文字。可讀文字放 D1(小片段)或 R2(整份檔案),再用 id 對接。
  • 調整片段大小與 top-k:片段越小越精準,top-k 越大越完整但更花錢、也可能撐爆上下文視窗。
  • 在 Workers AI 前面加上 AI Gateway,可對 embedding 與 LLM 兩種呼叫做快取、限流與觀測。
  • 文件更新時要重新匯入;用固定的 id 做 upsert,舊向量會被取代而不是重複堆積。