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" },
// customMetadata is copied onto every indexed chunk, so you can scope
// searches by it later (see "Scope a search" below).
customMetadata: { type: "handbook", dept: "hr" },
index: true,
});
// Later, search across everything you've indexed
const { matches } = await env.SEARCH.query("how many vacation days do I get?");
// …or only within HR handbooks
const hr = await env.SEARCH.query("vacation days", {
where: { type: "handbook", dept: "hr" },
});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.
fileKey (and its position on chunk), so re-uploading the same key re-indexes cleanly. Any customMetadata you set on the put is copied onto every chunk, so you can scope searches with where exactly like documents you upsert yourself, e.g. where: { type: "handbook" } or where: { fileKey: "docs/handbook.pdf" }. 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" },
});
// Match a MISSING key with null, e.g. find/audit docs that have no "type" tag
await env.SEARCH.count({ where: { type: null } });
await env.SEARCH.clear({ type: null }); // and clean them upnull means “this key is absent” (handy for count / clear to audit documents missing a tag); any other value matches documents whose metadata contains it. 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
upserttakes up to 100 items per call; split larger batches into chunks. - Deleting a file you indexed with
{ index: true }(viaenv.STORAGE.delete(key)) automatically removes its indexed text , no orphaned entries to clean up. - Each
env.SEARCHoperation (upsert,query,delete,clear,count) 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.
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.