You need: vector composed.
Mark what you will filter on
Metadata indexes is why there are only ten slots, provisioning vector is when the decision becomes permanent, semantic search is what the filters serve, and reprocessing is the way back if you get it wrong.
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 read, and it brands the type.
So filtering on title is a compile-time error, with vector/unfilterable_field as the runtime guard for callers arriving through an untyped boundary — an HTTP body, a job payload, plain JavaScript.
A bare .meta({ filterable: true }) works too. Everything provisions and filters; only the compile-time narrowing is lost, because .meta() returns the same type it was called on.
Ten slots, and that is why this is a decision
A Vectorize index takes at most ten metadata indexes.
That hard cap 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.
Choosing which ten
Ask what a query narrows by, not what a result displays.
ownerId is a filter — every query scopes to a tenant. title is not — it comes back on the hit and nothing searches by it.
A field you filter on rarely still costs a slot permanently. Slots are spent at provisioning and reclaiming one means deleting a live index, which is destructive.
Two ceilings around the filter
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 reads its number from one file, so the number an error quotes is the number the check used.
Adding one later does not fix what is written
The fix for a slot you wish you had is a re-embed — see reprocessing vector metadata.
Drift is checked in two places
Live, in the CLI, during provisioning. Listing metadata indexes is a Cloudflare REST call needing an account token — and giving a Worker one so it could self-check at boot would put a control-plane token on the request path of every search.
Offline, in the Worker, against what provisioning recorded. That proves the config declares nothing provisioning did not see the last time it ran — 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 reported, not deleted
It may predate your config, or belong to another consumer of the same index, and deleting it is destructive.
It is still spending one of your ten, which is why it is surfaced.
A type mismatch is fatal — a field indexed as one type and declared as another compares against nothing and matches nothing.