The failure this exists to prevent
Choosing your metadata is the walkthrough, provisioning creates the indexes, the embedding model is the other decision fixed at creation, and the reference has the limits.
Filtering on an unindexed field does not error. Vectorize accepts the filter and returns a short, plausible result set.
Nothing is wrong on the screen. The search just quietly matches less than it should, and the bug surfaces weeks later as search feels wrong — which is close to untraceable.
Mark the field, and the filter types itself
metadata: z.object({
ownerId: filterable(z.string().describe("Owner.")),
rank: filterable(z.number().describe("Rank.")),
title: z.string().describe("Title."),
})filterable() does two things: it sets the runtime marker provisioning and the drift check both read, and it brands the field’s type.
So a filter naming title is a compile-time error. vector/unfilterable_field is the runtime guard behind it, for a caller arriving through an untyped boundary — an HTTP body, a job payload, plain JavaScript.
You can mark a field with a bare .meta({ filterable: true }) instead. Everything still provisions and still filters; only the compile-time narrowing is lost, because .meta() returns the same type it was called on and the type system cannot see it.
Ten slots, and that is the whole reason this is a decision
A Vectorize index takes at most ten metadata indexes. That hard ceiling is what makes filterability a provisioning-time decision rather than something you turn on later when you need it.
Nested objects and arrays are not filterable at all. A filterable value is a string, a number, or a boolean.
The operators
$eq $ne | Equal, not equal |
$in $nin | In a set, not in a set |
$lt $lte $gt $gte | Ordering |
A bare value is $eq — Vectorize’s own shorthand — and an operator object is the long form.
Two more ceilings you will meet
A filter’s compact JSON must be under 2,048 bytes. Not at most — 2,048 is already too large. vector/filter_too_large.
A filter key is at most 512 characters, may not be empty, may not contain a dot (reserved for nesting) and may not start with $ (reserved for operators).
Every guard in the package reads its number from one file, so the number an error message quotes is the number the check used. A limit enforced in two places eventually disagrees with itself.
Drift is checked twice, in two different places
The live check runs in the CLI, during pithy vector provision. Listing metadata indexes is a Cloudflare REST call, and the only credential that authorizes it is an account API token — handing a Worker one so it could self-check at boot would put a control-plane token on the request path of every search. That is a far worse trade than the bug it would catch.
The Worker checks offline. Provisioning writes down what it observed; the Worker compares the config’s declarations against that record at boot, reusing the same comparison so both halves apply one rule.
That offline check proves the config declares nothing provisioning did not see the last time it ran. It proves nothing about Cloudflare right now — and it catches the case that actually happens: a metadata schema edited and deployed without re-provisioning. That is vector/metadata_index_drift, and it is a boot refusal.
An extra live index is not fatal
An index Cloudflare has that your config does not declare is reported, not deleted. It may predate the config, or belong to another consumer of the same Vectorize index, and deleting it is destructive.
It is still worth seeing, because it is still spending one of your ten slots.
A type mismatch is fatal, though — a field indexed as one type and declared as another compares against nothing and matches nothing.