The answer. Semantic search on Workers is three pieces: an embedding model turns text into a float array, Vectorize stores those arrays with an id and small metadata, and a query embeds the question and asks for the nearest topK. The index is created once with the exact dimensions of the model (768 for bge-base, 1024 for bge-large or qwen3-embedding-0.6b) and a distance metric (cosine for text); neither can change later, so a model swap is a new index. Writes are asynchronous: insert and upsert return a mutationId and the vectors become queryable seconds later. Query results are ids and scores; ask for returnMetadata to get the fields you stored, and filter on indexed metadata to scope by tenant or language before ranking.
The pattern.
npx wrangler vectorize create articles --dimensions=768 --metric=cosine
npx wrangler vectorize create-metadata-index articles --property-name=site --type=string # filterable
// index
const { data } = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: [article.body] });
await env.VECTORS.upsert([{ id: article.id, values: data[0], metadata: { site: siteId, lang: "en", title: article.title } }]);
// search, scoped to one site
const q = await env.AI.run("@cf/baai/bge-base-en-v1.5", { text: [question] });
const hits = await env.VECTORS.query(q.data[0], { topK: 5, filter: { site: siteId }, returnMetadata: "all" });
// hits.matches -> [{ id, score, metadata }], cosine score closer to 1 = closer
Watch out.
insertignores an existing id;upsertreplaces it. Re-indexing a changed document withinsertleaves the old vector in place.- Vectors live in one index for the whole account's Worker, so tenant isolation is a metadata filter you must never forget, not a table boundary.
- Chunk long documents before embedding; a 4,000-token article as one vector matches everything and nothing.
Related: workers-ai-run-models-from-a-binding · ai-gateway-in-front-of-every-provider-call · d1-migrations-prepared-statements-and-batch