Vector databases from the ground up
Metadata filtering
A filter decides which vectors are eligible before ranking. Where you apply it decides whether you return k results or k attempts, and a selective filter can make an ANN index useless.
A filter is a constraint, not a cleanup
Metadata is everything you know about a vector that is not the vector: tenant, product, language, date, access level. A filtered search asks for the k nearest vectors among the ones that satisfy a predicate (a true-or-false test on those fields). The predicate is part of the query, not a step after it. That sounds like an implementation detail. It is actually the difference between a search that answers a question and one that answers a different question and then apologises.
The distinction matters because top-k is a promise about the result set. If you rank first and filter second, you have promised ten results and delivered three, or none, while the correct answer sat at position eleven the whole time. The user does not see the eleven. They see an empty page and conclude the product has no data.
Filters also come in two flavours that are easy to confuse. A relevance filter removes results the user would not want; a security filter removes results the user must not see. The first can be approximate, the second cannot. Treating them as the same kind of clause is how a search feature becomes an access-control bug with a relevance bug on top.
Three places to apply it
Pre-filtering selects the eligible ids first and searches only those. It is exact and predictable, and it turns into a scan when the filter is not selective. Post-filtering is the bug above. Filtered traversal pushes the predicate into the index walk, which is what the good databases do. Each has a cost profile, and the profile changes with the shape of the filter.
A filtered graph is the hard case. HNSW needs neighbours to walk through, and a filter that removes most of them can cut the path to the answer. Some engines fall back to a full scan when the filter is too selective, which is the right behaviour and worth knowing about. Others return an empty list and leave you to find the reason in a support ticket.
A filter that travels with the query
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
hits = client.query_points(
collection_name="docs",
query=query_vector,
limit=5,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="tenant",
match=models.MatchValue(value="acme"),
)
]
),
).pointsFilter before you slice
Compare the two functions for the tenant acme with k=3, then with k=2. Notice that post-filtering returns fewer rows than asked for, while pre-filtering keeps the promise.
Selectivity is the number that matters
A filter that keeps ten percent of the corpus is easy. A filter that keeps one percent, or one tenant out of ten thousand, is where indexes stop helping. The eligible set is small enough that scanning it is cheaper than walking a graph that mostly leads to excluded points. The crossover is not a fixed number; it depends on how the engine stores the filter and how the vectors are laid out.
The practical answer is to know the selectivity (the share of rows a filter keeps) of your common filters and to test the worst one. If a filter can match zero rows, decide now whether an empty result is correct or whether it should fall back to a scan. A filter that silently returns nothing looks the same as a corpus that has nothing. Both look like a bug to the person typing the query.
A metadata filter is not an access control. A tenant boundary enforced by a filter is one forgotten clause away from a leak; use a separate collection, namespace or database for data that must not mix.
Partition, then filter
The cheapest filter is the one the query planner never has to evaluate, because the data was already separated. Partition by tenant or by month and the filter becomes a choice of partition, which is a lookup rather than a predicate. That is also the boundary that survives an incident: a missing clause cannot cross a partition that was never queried.
Inside a partition, keep the filters that are about relevance: language, product, a date range. Those narrow the candidate set without carrying a security promise, and they are safe to change as the product changes. The test is whether getting the filter wrong would show the wrong document or merely a worse one. Only the first belongs in the security boundary.
A useful exercise is to list every filter the product exposes and write down its selectivity and what getting it wrong would show. “Language = German” keeps perhaps a tenth of the corpus and shows a worse result if it is wrong. “Tenant = acme” keeps a thousandth and shows another customer’s data if it is wrong. The two belong in different layers, reviewed by different people.
When the filter empties the index
Run the four counts, then raise nprobe until the filtered ANN returns all five. Note how much of the corpus it has to touch to do it.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Why filter before ranking?
