Generating Embeddings With Cloudflare Workers AI
9 min read · updated August 11, 2026
Generating an embedding on Workers AI is one call. The decision that actually matters is made before you write any code, because the model you choose fixes a number that the index, the queries and every stored vector must agree on forever.
Pick the model by its dimension
Cloudflare’s model catalogue lists several text-embedding models, and their documented output dimensions differ: @cf/baai/bge-small-en-v1.5 produces 384 values, @cf/baai/bge-base-en-v1.5 produces 768, and @cf/baai/bge-large-en-v1.5 produces 1024. A Vectorize index is created with a fixed dimension and Cloudflare documents that the dimension cannot be changed afterwards, so this choice is the first irreversible one in the project.
Bigger is not automatically better. Dimension is what Vectorize bills on — the pricing model multiplies vector counts by dimensions — so moving from 384 to 1024 makes both the storage and the query line items roughly 2.7 times larger for identical traffic. It also multiplies the bytes each vector occupies in every response you return. Start at the smallest model that retrieves acceptably for your corpus and treat the upgrade as a re-index, because that is what it will be.
The embedding call and its response shape
The input key is text, and it takes either a single string or an array of strings. Cloudflare’s model page documents the output as an object with a shape array and a data array — not a bare array of numbers, which is the shape people assume and then index into by mistake.
const input = ["the cat sat on the mat", "a dog barked at the door"];
const embeddings = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
text: input,
});
// embeddings.shape -> [2, 768]
// embeddings.data -> [[0.013, -0.42, ...], [0.09, 0.21, ...]]
const first: number[] = embeddings.data[0];shape is the pair you should assert against rather than trust. A single guard of embeddings.shape[1] === EXPECTED_DIMENSIONS catches a swapped model id at the moment it happens instead of three weeks later when retrieval quality has quietly degraded. The models also expose a pooling parameter documented with the values mean (the default) and cls; Cloudflare notes cls can be more accurate on larger inputs. Pick one and never change it mid-corpus, for the same reason you would not change the model.
Writing straight into Vectorize
With a Vectorize binding declared alongside the AI binding, the vectors go in without leaving the Worker. Both bindings live in the same Wrangler config:
// wrangler.jsonc
{
"ai": { "binding": "AI" },
"vectorize": [
{ "binding": "DOCS", "index_name": "docs-768" }
]
}type Doc = { id: string; body: string; source: string };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const docs: Doc[] = await request.json();
const embeddings = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
text: docs.map((d) => d.body),
});
const vectors = docs.map((doc, i) => ({
id: doc.id,
values: embeddings.data[i],
metadata: { source: doc.source },
}));
const mutation = await env.DOCS.upsert(vectors);
return Response.json({ mutation });
},
} satisfies ExportedHandler<Env>;Use upsert rather than insert unless you specifically want first-write-wins. Cloudflare documents the difference plainly: if the same vector id is inserted twice the index keeps the first, and if it is upserted twice the index keeps the last. For a re-ingest pipeline — which is what almost every embedding job becomes — first-write-wins means your corrections silently do nothing.
Why batching is not an optimisation here
Passing an array to text and an array to upsert is not a micro-optimisation, it is a correctness-adjacent choice, and the reason is in how Vectorize commits writes. Cloudflare documents that a mutation goes first to a write-ahead log, and that an asynchronous job then reads the index files, produces an updated index and commits it. Vectors are not queryable until that job completes. The docs describe batched writes becoming queryable within minutes, and warn that inserting vectors individually can take over an hour to work through.
So a loop that upserts one vector per iteration is not merely slower — it can push visibility of your data from minutes to hours. Cloudflare documents an upsert batch ceiling of 1,000 vectors per call through the Workers binding and 5,000 through the HTTP API, with a 100 MB cap on the upload size. Chunk to the batch ceiling, not to one.
Chunking, and where the CPU budget goes
Text has to be split before it is embedded, and splitting is the one part of an ingest pipeline that runs entirely in your own code rather than in a model. That distinction matters because of how Cloudflare meters Workers: CPU time is time spent executing, not time spent awaiting a network response. The embedding call itself costs almost nothing against the budget no matter how long it takes; the loop that produced its input can cost all of it.
On the Free plan’s documented 10 ms of CPU per invocation, a regular expression walking a megabyte of documentation is a genuine risk. On Paid, where Cloudflare documents 30 seconds by default and up to five minutes if configured, it is not. This is the reason an ingest job that works perfectly in development can fail on the first real corpus, and why the error will point at your splitter rather than at anything to do with AI.
The shape that avoids the problem entirely is to split where the text arrives — at upload time, or in a queue consumer — and let the Worker that embeds receive chunks rather than documents. A queue consumer buys you something else worth having: retries. An ingest run that fails at document 8,000 of 10,000 has to be resumable, and a single fetch handler has nowhere to record how far it got.
The second ceiling on this path is subrequests. Cloudflare documents 50 per invocation on Free and 10,000 on Paid, and a call to a binding counts against that budget. A handler that embeds one chunk per call and upserts one vector per call spends two subrequests per chunk, so 25 chunks exhausts the Free allowance outright. Passing the array of texts to one run() and the array of vectors to one upsert() spends two subrequests for the whole batch instead of two per item — the same discipline as the previous section, arriving from a completely different direction.
The failure that shows up months later
There is one bug in this territory that is hard to detect and expensive to fix, and it deserves naming. If you embed your documents with one model and your queries with another, nothing errors. The dimensions may even match — bge-base-en-v1.5 and any other 768-dimension model will both satisfy the index. The query runs, scores come back, and they are meaningless, because the two models placed their vectors in unrelated coordinate systems.
The defence is to define the model id in exactly one place and import it into both the ingest path and the query path, the way you would a database schema constant. An index name of docs-768 rather than docs helps too: it makes the dimension visible in every log line and every binding, so a mismatch is obvious in review. From here, the next steps are creating the index with the right dimension and metric and running the similarity query from a Worker.