Attaching metadata to a vector and filtering a query by it look like one feature. They are two, and the step in between — creating a metadata index for the specific property you intend to filter on — is the one that is easy to miss and produces no error when you do.
Metadata is not filterable until you index it
Storing metadata on a vector makes it available on a match when you request it. It does not make it filterable. Filtering requires a metadata index, created per property, with the property’s type declared up front:
npx wrangler vectorize create-metadata-index docs-768 \
--property-name=tenant --type=string
npx wrangler vectorize create-metadata-index docs-768 \
--property-name=published_at --type=number
npx wrangler vectorize create-metadata-index docs-768 \
--property-name=is_public --type=boolean
Cloudflare documents the supported types as string, number and boolean, and a maximum of 10 metadata indexes per Vectorize index. Ten is not many, and unlike the vector dimension it is a budget you spend gradually, so decide deliberately which properties are filter keys and which are merely payload returned with a match.
The type is fixed per property. A property indexed as a string will not answer a numeric range query, so storing a timestamp as an ISO string and later wanting $gte on it means re-indexing. Store epoch milliseconds as a number if you intend to range over it.
Attaching metadata at insert time
Metadata is a plain object on the vector, alongside id and values:
await env.DOCS.upsert([
{
id: "doc-1041#chunk-3",
values: embedded.data[0],
metadata: {
tenant: "acme",
published_at: 1754870400000,
is_public: false,
title: "Refund policy",
},
},
]);
Cloudflare documents a limit of 10 KiB of metadata per vector, and rules on key names: they cannot be empty, cannot contain a dot (which is reserved for nesting), cannot begin with $, and cannot exceed 512 characters.
title in the example above is deliberately not indexed. It is returned with the match so the caller can render a result, but nobody filters on it, so it does not consume one of the ten index slots. That division — filter keys indexed, display fields not — is the one to hold in mind.
Because metadata travels with the vector, changing it means upserting the vector again with its values. There is no partial metadata update, which is a reason to keep volatile fields out of metadata entirely and look them up by id in D1 or KV after the query returns.
The filter syntax
The filter is a JSON object passed as the filter option on the query. Cloudflare documents these operators: $eq, $ne, $in, $nin, $lt, $lte, $gt and $gte.
const results = await env.DOCS.query(queryVector, {
topK: 10,
returnMetadata: "all",
filter: {
tenant: "acme",
is_public: true,
published_at: { $gte: 1735689600000, $lt: 1767225600000 },
},
});
A bare value is implicit equality, so { tenant: "acme" } and { tenant: { $eq: "acme" } } are the same filter. Multiple keys are combined with AND — there is no documented top-level OR, and the way to express one is $in over a set of values on a single property. Nested properties are addressed with dot notation, which is why a dot is forbidden in a key name.
Cloudflare documents a maximum of 2,048 bytes for the filter JSON. That is generous for a handful of clauses and tight for a long $in list — a filter of a few hundred document ids will exceed it. Where you find yourself building one, the right structure is usually a namespace or an indexed tenant key rather than an enumeration.
The 64-byte trap
This is the behaviour worth reading the page for. Cloudflare documents that string values are indexed on their first 64 bytes, truncated at a UTF-8 boundary, and that the maximum indexed data per metadata index per vector is 64 bytes.
So two distinct values that share their first 64 bytes are, to the filter, the same value. A filter on a URL path, a full title, or a concatenated composite key will match documents you did not intend whenever the prefix collides. Nothing errors; the result set is simply wrong in a way that looks like a relevance problem.
It is also 64 bytes, not characters. A string of CJK characters at three bytes each hits the boundary after roughly 21 characters, which makes this substantially more likely to bite in non-Latin content than an English-language test corpus will ever reveal.
The defence is to keep indexed string values short and discriminating-from-the-front. Identifiers, slugs, enum values and locale codes are safe. If you must filter on something long, store a hash of it — the first 64 bytes of a hex digest are as discriminating as the whole thing — and keep the human-readable version as unindexed metadata for display.
What filtering does not save you
It is natural to assume that a filter which cuts the candidate set from 200,000 vectors to 400 makes the query cheaper. It does not, and the reason is visible in the pricing formula Cloudflare publishes: queried vector dimensions are computed from the vectors in the index plus the number of queries, multiplied by the dimension. The filter is not a term in it.
So filtering is a relevance and correctness tool, not a cost-control one. If the bill is the problem, the levers are the dimension you chose at creation and the number of vectors you keep — deleting stale vectors reduces both billed units on every future query, while adding a filter reduces neither.
Filtering has a second, subtler effect that does matter to results. The filter is applied in the course of the similarity search, so a very selective filter over a large index can return fewer than topK matches even when many more documents satisfy the filter — the search explores by similarity and the surviving candidates are whatever passed. The practical rule is to ask for more matches than you need when a filter is narrow, and to treat a partition that is always queried alone as a namespace rather than a filter, so the selection happens on the index rather than within it.
Metadata or namespaces
Vectorize offers a second way to partition an index. A vector can carry a namespace string, documented at up to 64 bytes, and a query can be scoped to one. Cloudflare documents 50,000 namespaces per index on Workers Paid and 1,000 on Free.
They are not interchangeable. A vector has exactly one namespace and any number of metadata properties, so a namespace is the right tool for the one partition that is definitional — usually the tenant — and metadata is the right tool for every attribute you might combine. Namespaces also do not consume one of your ten metadata index slots, which is a real consideration given how few of those there are.
The practical arrangement for a multi-tenant retrieval system is therefore: tenant as the namespace, everything else as indexed metadata, display fields as unindexed metadata. That keeps the strongest isolation on the axis where a leak matters most, and leaves the flexible mechanism for the axes where flexibility is what you need. For how those choices flow into the bill, see Vectorize pricing and dimension limits.
Top comments (0)