sellIntegration
neurology

Blueprint: an AI Q&A app (RAG)

Combine Workers AI, Vectorize and D1 into an app that reads your own documents and answers questions with sources — the canonical RAG architecture on Cloudflare.

768Embedding dimensions
top-5Chunks per question
5Services wired
insights

What are we building?

We are building a Q&A app that answers questions from your own documents. A plain large language model (LLM) only knows what it saw during training, so it cannot answer about your private handbook — and it may confidently make things up. RAG (Retrieval-Augmented Generation) fixes this: first retrieve the most relevant pieces of your documents, then let the LLM generate an answer using only those pieces.

RAG has two phases. An offline ingest phase reads your documents, turns them into vectors (lists of numbers that capture meaning) and stores them. An online query phase takes a user question, finds the closest stored chunks, and feeds them to the LLM as context. Here is the whole picture:

schemaRAG on Cloudflare — the big picture

Your documents

Ingest: chunk + embed

Vectorize + D1 (knowledge store)

User question

Retrieve: search Vectorize

Generate: Workers AI LLM

Grounded answer

menu_book

Think of it like an open-book exam

A closed-book exam forces you to answer from memory — and you might misremember. RAG turns it into an open-book exam: before answering, you flip to the most relevant pages (retrieval) and write your answer based on what they actually say (generation). The LLM is the student; Vectorize is the index at the back of the book.

account_tree

Who does what?

Five Cloudflare pieces work together. The Worker is the conductor; Workers AI provides the brains (both embeddings and the LLM); Vectorize is the searchable memory; D1 keeps the original readable text; R2 holds the raw source files.

hub

Workers — orchestrator

The serverless code that ties everything together: it exposes /ingest and /ask, calls each service in order, and returns JSON.

neurology

Workers AI — brains

Runs two models: an embedding model that turns text into vectors, and an LLM (Llama) that writes the final answer from the context.

scatter_plot

Vectorize — retrieval

The vector database. Stores every chunk's vector and, given the question's vector, returns the top-k most similar chunks in milliseconds.

table

D1 — original text

Vectorize stores numbers, not words. D1 (a SQL database) keeps each chunk's readable text, looked up by id after the search.

folder

R2 — raw files

Object storage for the source PDFs, Markdown or images you ingest, so you can re-chunk or re-embed later without re-uploading.

cloud_upload

Phase 1 — Ingest (build the index)

Ingest runs once per document (or whenever it changes). We split the document into small chunks, embed each chunk with Workers AI, then store the vectors in Vectorize and the original text in D1. The id links the two: the same id lives in both stores.

schemaIngest pipeline

Source docs (PDF, MD)

Split into chunks

Workers AI: embed each chunk

Vectorize: store vectors

D1: store original text

Searchable knowledge index

content_cut

Why chunk at all?

Embedding a whole 50-page manual into one vector blurs its meaning, and you could never quote a precise passage. Splitting into ~200-word chunks (with a little overlap so sentences are not cut mid-thought) gives sharp, quotable pieces to retrieve.

search

Phase 2 — Query (answer a question)

At query time we embed the question with the SAME model used at ingest, search Vectorize for the top-k closest chunks, pull their original text from D1, and stitch them into a prompt. The LLM reads that context plus the question and writes a grounded answer.

schemaQuery pipeline

User question

Workers AI: embed question

Vectorize: search top-k

Top-k context chunks

Build prompt: context + question

Workers AI LLM: generate

Grounded answer

warning

Same model, both sides

Documents and questions must be embedded by the exact same model, into the same number of dimensions. Mixing models (or index sizes) puts the vectors in different coordinate systems, so 'nearest' becomes meaningless and search returns garbage.

swap_vert

One question, end to end

Here is exactly what happens inside the Worker when a user asks one question — every hop between Workers AI, Vectorize and D1:

schemaRequest sequence
D1Vectorize"Workers AI"WorkerUserD1Vectorize"Workers AI"WorkerUserPOST /ask (question)embed the questionquestion vectorquery(vector, topK=5)top-5 matches (id, score)fetch original text by idchunk textschat (context + question)answerJSON (answer)
construction

Build it

Below is a complete, runnable Worker with two routes: POST /ingest to index a document, and POST /ask to answer a question. Create the resources, define the table, wire the bindings, then add the code.

  1. Create the index & database

    The index dimensions (768) must match the embedding model below. cosine is the recommended distance metric for bge models.

    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. Create the chunks table

    Save this as schema.sql, then run: 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. Wire the bindings

    Bindings expose AI, Vectorize and D1 to your code as env.AI, env.VECTORIZE and env.DB — no API keys in your source. Replace database_id with the id printed by 'wrangler d1 create'.

    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. Ingest: chunk, embed, store

    One env.AI.run() call embeds every chunk at once. We upsert vectors into Vectorize (idempotent, so re-ingesting a doc is safe) and INSERT OR REPLACE the text into D1 under the same id.

    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. Ask: embed, retrieve, generate

    This is the heart of RAG. Embed the question, query() Vectorize for the top-5, load those chunks' text from D1, and pass them to the LLM as context. The system prompt forces the model to answer only from that context.

    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. Route the requests

    The default export dispatches POST /ingest and POST /ask to the two functions above and returns 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. Deploy & try it

    Deploy, ingest a document, then ask about it. The answer should quote your document, not the model's training data.

    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

Key concepts

scatter_plot

Embedding 嵌入

The act of turning text into a vector with an AI model. Pieces with similar meaning get similar vectors. Same model, same dimensions, on both documents and questions.

social_distance

Vector & similarity 向量與相似度

A vector is a list of numbers (a point in space). 'Similarity' is how close two points are — cosine distance here. Closest points mean closest meaning, which is how retrieval works.

auto_stories

RAG 檢索增強生成

Retrieve relevant chunks first, then let the LLM generate from them. It gives the model fresh, private knowledge and cuts down on made-up answers (hallucination).

filter_list

Top-k 前 k 名

How many of the closest chunks you retrieve (here k=5). Too few may miss the answer; too many add noise and cost. Start at 3-8 and tune.

crop

Context window 上下文視窗

The maximum amount of text an LLM can read at once. Your retrieved chunks plus the question must fit inside it — another reason to retrieve only the top-k, not everything.

tips_and_updates

Tips & pitfalls

format_quote

Return sources to build trust

Store a title or URL in each vector's metadata, and return the matched chunks' sources alongside the answer. Users can verify, and you can debug bad answers by seeing exactly what was retrieved.

  • Dimensions must match: the index size, the embedding model, and the vectors you upsert all have to agree (768 here).
  • Always embed questions with the same model used for documents — mismatches silently return irrelevant results.
  • Vectorize stores numbers, not text. Keep the readable text in D1 (small chunks) or R2 (whole files) and join by id.
  • Tune chunk size and top-k: smaller chunks are more precise, a higher top-k is more thorough but costs more and risks overflowing the context window.
  • Put AI Gateway in front of Workers AI to cache, rate-limit and observe both the embedding and LLM calls.
  • Re-ingest when documents change; upsert by a stable id so old vectors are replaced, not duplicated.