Semantic search & RAG

env.SEARCH lets your app find text by meaning, not just keywords. You upsert documents; Cloudrizz turns each into an embedding and stores it. A query returns the most relevant documents — even when they don't share any words with the question. It's the retrieval half of RAG: pull the right context, then hand it to env.AI to answer.

env.SEARCH is a paid-plan feature. Reference it in any api/*.js file and it's wired up on deploy — no index to create, no keys to manage. Embeddings are generated for you.

Index documents

upsert takes one item or an array. Each item is { id?, text, metadata? }. The text is embedded automatically; metadata is free-form JSON you can filter on later. Re-using an id overwrites that document (so re-indexing is safe). If you omit id, one is generated.

await env.SEARCH.upsert([
  { id: "note_1", text: "Client wants a north-facing balcony.",
    metadata: { projectId: "p_42", userId: "u_9" } },
  { id: "note_2", text: "Budget approved for phase two.",
    metadata: { projectId: "p_42", userId: "u_7" } },
]);

Chunk long text before indexing. Split a big document into passages (a few sentences to a paragraph each) and upsert one item per chunk — retrieval is far more precise on small chunks, and each has its own id like doc123#0, doc123#1.

Index a file from storage (PDF, Word, text…)

If your content lives in file storage, you don't have to extract and chunk it yourself — pass { index: true } when you store it. Cloudrizz reads the file, pulls out its text, chunks it, and indexes it automatically.

// Store a PDF and make it searchable in one call
await env.STORAGE.put("docs/handbook.pdf", pdfBytes, {
  httpMetadata: { contentType: "application/pdf" },
  index: true,
});

// Later — search across everything you've indexed
const { matches } = await env.SEARCH.query("how many vacation days do I get?");

Supported file types:

  • Text.txt, .md, .csv, .json, .html (tags stripped).
  • Word.docx.
  • PDF — text-based PDFs. Scanned PDFs (images of text) have no extractable text and are skipped — those need OCR, which isn't supported yet.
Indexing runs in the background, so a file is searchable a moment after upload (not instantly). Files are chunked automatically and tagged with their storage key, so re-uploading the same key re-indexes cleanly. Indexing only happens on paid plans; on other plans the flag is ignored and the upload still succeeds. Files up to 10 MB are indexed.

Search

query returns the best matches, most relevant first. Each match carries its text, your metadata, and a score (higher = closer in meaning).

const { matches } = await env.SEARCH.query("outdoor space facing the sun", {
  topK: 5,
});
// matches: [{ id, score, text, metadata }, ...]
// note_1 ranks first even though it shares no words with the query.

Scope a search with metadata

Pass where to restrict the search to documents whose metadata contains every key/value you give — “search within this project”, “only this user’s notes”. The filter is applied inside the search, so you get the top matches within that scope, not the global top matches filtered down.

// Only documents in project p_42
await env.SEARCH.query("budget", { topK: 5, where: { projectId: "p_42" } });

// Narrow further: this project AND this user
await env.SEARCH.query("balcony", {
  where: { projectId: "p_42", userId: "u_9" },
});
You can filter on any metadata keys you set — there is no fixed list to declare and no cap on how many keys your app uses. Each app's documents are fully isolated from every other app's.

Build a RAG chat

Retrieval-Augmented Generation is just: search, then ask. Pull the most relevant context with env.SEARCH, put it in the prompt, and stream the answer with env.AI. The model now answers from your data.

export async function POST(request, env) {
  const { question, projectId } = await request.json();

  // 1. Retrieve the most relevant passages (scoped to the project)
  const { matches } = await env.SEARCH.query(question, {
    topK: 6,
    where: { projectId },
  });
  const context = matches.map((m) => m.text).join("\n---\n");

  // 2. Ask the model, grounded in that context — streamed back to the client
  const ai = await env.AI.anthropic.stream({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    messages: [{
      role: "user",
      content: `Answer using only this context:\n${context}\n\nQuestion: ${question}`,
    }],
  });
  return new Response(ai.body, { headers: { "content-type": "text/event-stream" } });
}

API

  • upsert(items) — index one item or an array of { id?, text, metadata? }. Returns { upserted }.
  • query(text, { topK = 5, where }) — semantic search; returns { matches: [{ id, score, text, metadata }] }.
  • delete(ids) — remove documents by id.
  • clear(where?) — delete everything, or only documents matching a metadata filter (e.g. clear({ projectId: "p_42" })).
  • count(where?) — number of documents, optionally filtered → { count }.

Notes

  • One upsert takes up to 100 items per call; split larger batches into chunks.
  • Deleting a file you indexed with { index: true } (via env.STORAGE.delete(key)) automatically removes its indexed text — no orphaned entries to clean up.
  • An upsert or a query counts as one search operation against your plan's monthly allowance — see Limits & quotas.
  • Search finds meaning, not exact strings. For exact lookups (an email, an order number) query your database directly.
Your AI assistant can seed and test the index for you with the query_app_search tool — run a query to check retrieval, or upsert sample documents so a search/RAG feature isn't empty when you try it.