Querying Cloudflare Vectorize From a Worker
9 min read · updated August 11, 2026
A Vectorize query from a Worker is one method call on a binding. The two things worth knowing before you write it are that the maximum number of results depends on what else you ask for, and that the score you get back means something different under each distance metric.
The query call
Cloudflare documents two entry points on the binding: query(queryVector, options), which takes a vector you supply, and queryById(vectorId, options), which uses a vector already in the index as the query. The second is how you build “more like this” without re-embedding anything.
For a text search, the query vector has to be produced by the same embedding model that produced the stored vectors, which means one AI call before the Vectorize call:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const q = new URL(request.url).searchParams.get("q");
if (!q) return new Response("missing q", { status: 400 });
const embedded = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: q });
const queryVector = embedded.data[0];
const results = await env.DOCS.query(queryVector, {
topK: 5,
returnValues: false,
returnMetadata: "all",
});
return Response.json({
count: results.count,
matches: results.matches.map((m) => ({
id: m.id,
score: m.score,
source: m.metadata?.source,
})),
});
},
} satisfies ExportedHandler<Env>;Cloudflare documents the options as topK defaulting to 5, returnValues defaulting to false, and returnMetadata defaulting to "none" and accepting "all". Note that returnMetadata is a string rather than a boolean — passing true is the single most common mistake in this call, and it does not necessarily error, it just leaves metadata undefined on every match.
Leave returnValues at false unless you genuinely need the raw numbers. Turning it on adds the full vector to every match, which for a 768-dimension index and 20 matches is over 15,000 floats in the response for data you are almost certainly going to discard.
Reading the result
The response is an object with count and matches. Each match carries id and score always, values when returnValues was true, and metadata when returnMetadata was "all".
count is the number of matches actually returned, and it can be lower than topK for the obvious reason — the index does not contain that many vectors yet — and for a less obvious one. Vectorize commits writes asynchronously: Cloudflare documents that a mutation goes to a write-ahead log and an asynchronous job rebuilds the index before the vectors become queryable. A query issued immediately after an upsert can legitimately return nothing. If your integration test inserts and then queries in the same run, it will fail, and the code is not the reason.
The two different topK ceilings
This is the limit that surprises people, because it is not one number. Cloudflare documents a maximum topK of 50 when you request values or metadata, and 100 when you request neither.
So a query with topK: 80 works while you are prototyping with returnMetadata: "none" and stops working the moment you add metadata to the response. The mechanism is straightforward enough — values and metadata have to be fetched per match, so the per-request work scales with the number of matches, and the ceiling moves accordingly.
The pattern that avoids the whole problem is a two-phase query: fetch up to 100 ids and scores with no metadata, apply whatever filtering or re-ranking you do in the Worker, then call getByIds() on the survivors to hydrate the ones you kept. Cloudflare documents getByIds() as returning the specified vectors including values and metadata, which is exactly the hydration step.
What a score means
The score is produced by the distance metric the index was created with, and the metrics do not agree on which direction is better. Cloudflare documents cosine as ranging from −1 (most dissimilar) to 1 (identical), euclidean as an L2 distance where 0 indicates identical vectors, and dot-product as a negative dot product score.
The practical consequences are two. First, a threshold is not portable: score > 0.7 is a sensible cosine filter and a nonsensical euclidean one, where the same intent is written score < some_distance. Second, absolute scores are not comparable between indexes even at the same metric, because they depend on the embedding model’s geometry. Any threshold you pick is empirical for your corpus and model, and it has to be revisited if either changes.
If you cannot verify the metric from memory, call describe() on the index — Cloudflare documents it as returning the configured dimensions and metric. That is one line, and it is better than reading scores backwards.
When a query returns nothing
An empty matches array with no error is the most common complaint about Vectorize, and it has four distinct causes that look identical from the calling code. Work through them in this order, because they get progressively more expensive to check.
- The write has not been committed yet. Cloudflare documents mutations as asynchronous: the vectors go to a write-ahead log and become queryable only after an indexing job runs. Wait and query again before changing anything. If a test inserts and queries in the same run, this is almost certainly the answer.
- You are filtering on a property with no metadata index. A filter on an unindexed property does not raise; it simply does not select. Confirm the metadata index exists for that exact property name before suspecting the vectors.
- You are querying the wrong namespace. A vector written with a namespace is not visible to a query scoped to a different one, or to none if the query specifies one. Namespaces are supplied per vector at write time, so a partial rollout that added namespaces to new writes leaves the old vectors somewhere else.
- The index is empty for the reason you think it is not. Call
describe(). It returns the configured dimensions and metric, which is also the fastest way to confirm you are bound to the index you meant rather than to a similarly named one in the same account.
There is a fifth case that does raise rather than return empty, and it is worth recognising by shape: a query vector whose length does not match the index dimension. That is a hard error, not a silent empty result, and it is the failure you want — it means the guard described in the embeddings tutorial did its job at the boundary instead of letting a mismatched model quietly poison the index.
Wiring it into a retrieval step
The retrieval step of a RAG pipeline is this query plus two decisions. The first is how many matches to feed the model, which is a token budget question rather than a relevance one: five 500-token chunks is 2,500 input tokens, and the neuron arithmetic scales with that directly.
The second is whether to include a match whose score is weak. Passing low-relevance context to a model does not produce a cautious answer; it produces a confident answer grounded in the wrong passage. A score floor that returns zero matches, and a prompt that says what to do when there is no context, is a better failure than a full context window of near-misses.
When retrieval needs to be scoped — one customer’s documents, one language, one date range — that is a filter rather than a score threshold, and it needs a metadata index to exist first. That is what the metadata filtering page covers.