Pre-filter and post-filter are both wrong, and understanding why is the most useful thing in vector search. What breaks when a filter and a graph disagree, what Qdrant does about it, and why a more selective filter can be either much faster or much slower.
Part 6 ended on a cliff: a filtered search is not “search then filter” nor “filter then search”, and how selective the filter is decides which algorithm runs.
This part is why that’s true. It is the deepest idea in the series, and it is the thing that separates people who can operate a vector database from people who can only configure one.
Try this first
Ten million documents. A query vector. A filter that matches exactly 50 of them.
Design the search. You have an HNSW graph over all ten million, and a payload index that can give you those 50 ids instantly.
Now do it again for a filter that matches nine million.
Write both down. If you wrote the same algorithm twice, that’s the lesson.
Two obvious approaches, both broken
Post-filtering: search, then discard
Ask HNSW for the nearest k, throw away the ones that fail the filter, return what’s left.
# DON'T
hits = client.query_points("docs", query=vector, limit=10).points
hits = [h for h in hits if h.payload["tenant"] == "acme"] # 0 results, probably
This breaks the moment the filter is selective. Ask for 10, and if only one in a thousand
points belongs to acme, you will almost certainly get zero survivors. The fix people reach
for — ask for 10,000 and filter those — is worse: you’ve made the search enormously more
expensive and you still have no guarantee of getting 10.
Post-filtering cannot guarantee k results. There is no over-fetch factor that is both
safe and affordable, because the right factor depends on a selectivity you don’t know in
advance.
Pre-filtering: find the eligible set, then search it
Use the payload index to get every eligible id, then find the nearest among those.
That’s exactly right for the 50-document case. For the nine-million case it is a brute-force scan over nine million vectors — precisely the cost Part 1 built an index to avoid.
Pre-filtering is correct and its cost is proportional to how much the filter keeps. At low selectivity that cost is the whole corpus.
So one approach fails on selective filters and the other fails on unselective ones. And they fail at opposite ends, which is the shape of the problem.
The third approach, and why it’s hard
The obvious middle ground: walk the HNSW graph as usual, but skip nodes that fail the filter.
Keep walking until you have k eligible results.
This is called filtered graph search, and it is what Qdrant does in the middle of the range. It has a failure mode that is not obvious until you picture it.
The graph’s links were built from vector proximity only. They know nothing about your filter. So when you traverse with a filter applied, you are walking a structure whose connectivity was designed for a different question.
If eligible points are a small, scattered minority, the eligible sub-graph may not be connected at all. You can be standing on an eligible node whose every neighbour is ineligible, with more eligible nodes elsewhere that nothing in your reachable set points at. The walk stops, not because it found the best answer, but because it ran out of road.
Schematic. The layout and edges are fixed so the fragmentation is genuine: under the selective filter no edge joins the two eligible groups, which is exactly what happens to a real graph when a filter keeps a scattered minority. Switch scenario to see the loose case, the fragmented case, and what payload_m puts back.
That is the genuinely hard part. A filter can disconnect the graph, and when it does, the search doesn’t return a slightly worse answer — it returns whatever corner it was trapped in.
What Qdrant actually does
Two mechanisms, and they address different ends of the range.
full_scan_threshold — bail out to exact when the filter is narrow
The collection’s HNSW config carries full_scan_threshold, default 10,000.
Qdrant estimates the filter’s cardinality from the payload index. If the eligible set looks smaller than the threshold, it abandons the graph entirely and compares the query directly against those points. That is exact, has no connectivity problem, and at small cardinality it is genuinely fast.
This is why a very selective filter can be faster and more accurate than no filter at all: you’ve turned an approximate graph search over ten million into an exact scan over fifty.
client.update_collection(
"docs",
hnsw_config=models.HnswConfigDiff(full_scan_threshold=20000),
)
Raising it widens the range where Qdrant takes the exact route: more accuracy, more work per query, and a larger cardinality at which you’re effectively brute-forcing.
payload_m — build extra links that respect the filter
The subtler one, and the one most people never touch. The HNSW config has a payload_m field,
null by default.
When set, Qdrant builds additional graph links within groups of points sharing a payload value. The eligible sub-graph for that field is then connected by construction rather than by luck, so filtered traversal has somewhere to go.
# Points sharing a tenant get their own links, so a per-tenant search
# traverses a connected sub-graph rather than a scattering.
client.update_collection(
"docs",
hnsw_config=models.HnswConfigDiff(payload_m=16, m=16),
)
client.create_payload_index(
"docs", field_name="tenant", field_schema=models.PayloadSchemaType.KEYWORD,
)
This is the proper fix for the multi-tenant case, and Part 8 comes back to it as the multitenancy pattern. It costs memory — you are building more links — and it only helps for the specific field you built them on.
There is a matching trick for very skewed tenants: a tenant with a handful of points doesn’t
need graph links at all, because it falls under full_scan_threshold anyway. The two
mechanisms cover different sizes of tenant, and a well-configured system uses both.
The selectivity curve, and why it isn’t monotonic
Put the three regimes together and you get behaviour that surprises people:
| filter keeps | what happens | speed, roughly | accuracy |
|---|---|---|---|
| almost everything | graph walk, nearly every neighbour eligible | like an unfiltered search | like an unfiltered search |
| a moderate fraction | filtered graph walk, some neighbours skipped | somewhat slower | slightly lower |
| an awkward middle | filtered walk on a sparsening graph | slowest, and least predictable | can degrade sharply |
| very little | below full_scan_threshold, exact scan of the eligible set |
fast | exact |
Tightening a filter does not monotonically speed anything up. Going from “keeps 50%” to “keeps 1%” usually makes things worse, and going from 1% to 0.01% makes them better again. The worst place to be is the middle, and that is where a lot of real workloads sit.
The direction of the argument is what matters here; the exact boundaries depend on your
corpus, your m, and how your eligible points are distributed through the graph. Which is
measured, with Part 4’s procedure, sweeping selectivity
instead of ef.
Explain it like I’m ten
Imagine a huge party where you only know people through friends-of-friends. To find someone who likes the same music as you, you ask your friends, they point you to their friends, and you hop through the party.
Now add a rule: you may only talk to people wearing a red badge.
If nearly everyone is wearing red, nothing changes. You hop as normal.
If only three people in the whole party are wearing red, don’t hop at all — ask the door staff for the list of three and go straight to them.
The bad case is in between. Say one person in fifty wears red. You find a red-badged person, but none of their friends wear red, so you’re stuck. There are better matches somewhere across the room, and no chain of red-badged friends leads there from where you’re standing. You give up and report the best person in your little corner, and you have no idea you missed anyone.
The fix: get the red-badged people to introduce themselves to each other in advance, so
there’s always a chain between them. That’s payload_m — extra friendships built on purpose,
inside the group you’ll be filtering by.
Where the analogy breaks: at a party you can see across the room and notice you’re stuck. The search can’t. It has no way to know whether it stopped because it found the best answer or because it ran out of eligible neighbours, and both look identical from the inside. That’s why this failure is silent, and why it shows up as quietly poor results rather than an error.
The precise version
Let G be the HNSW graph over corpus C, and F a filter with eligible set E = {p ∈ C : F(p)}, selectivity s = |E|/|C|.
Filtered traversal searches the induced subgraph G[E]. HNSW’s construction gives guarantees about G‘s navigability; it gives none about G[E], because E is defined by an attribute the construction never saw.
For E sampled roughly uniformly at rate s, a node of degree d retains about s·d eligible neighbours in expectation. Once s·d falls near 1 the induced subgraph approaches a percolation threshold and fragments into components. The search is then confined to the component containing its entry point, and returns that component’s best rather than E‘s best. Recall collapses, and it collapses without any error being raised.
The two mitigations map onto this directly:
full_scan_thresholdavoids G[E] entirely when |E| is small, replacing approximate traversal with exact evaluation over E. Cost Θ(|E|·ddim), accuracy exact.payload_madds edges within payload-value groups at build time, so for that field G[E] is constructed to be navigable rather than left to chance. It raises E‘s effective degree independently of s.
Note the asymmetry: payload_m helps only for the field it was built on. A filter on any other
attribute faces the original problem untouched.
Trade-offs
full_scan_threshold higher against per-query cost. Raising it makes more queries exact
and correct, and makes those queries scan more points. It is a ceiling on how much brute force
you will tolerate.
payload_m against memory and build time. Extra links cost bytes and construction work,
per field you enable it on. Worth it for the one or two fields that dominate your filtering —
usually tenant — and not worth it for everything.
Filtered search against separate collections. You can sidestep the whole problem by giving each tenant its own collection, so no filter is needed. That trades one problem for another, and Part 8 works through when it’s the right call.
Accuracy against knowing about it. The worst property of this failure is silence. You can
buy visibility by periodically running the same filtered query with exact=True and comparing
— which costs a scan, and tells you whether your filtered recall is where you think.
Common mistakes
Post-filtering in application code. Fetch 10, filter, get 0. Then fetch 1,000, filter, get 3, and now the query is slow and wrong. Pass the filter to the database.
Assuming a tighter filter is always faster. The middle of the selectivity range is the slow, inaccurate part. Tightening a filter can move you into it.
Filtering on a field with no payload index. Without one, Qdrant cannot estimate cardinality and cannot make a sensible strategy choice — quite apart from the scan cost from Part 6.
Multi-tenant filtering without payload_m. This is the classic. Per-tenant filters are
exactly the selectivity that fragments the graph, and the fix exists and is one config field.
Trusting filtered recall because unfiltered recall was fine. They are different searches over different graphs. Measure filtered recall separately, at the selectivities you actually serve.
Treating a filter as a ranking signal. It decides eligibility, not order. If you want “this matters more”, that’s reranking, not filtering.
Interview questions
1. Why can’t you just search and then filter the results?
Answer
Because you can’t guarantee `k` results. If the filter keeps one point in a thousand, asking for 10 and filtering gives you zero almost every time. Over-fetching doesn’t fix it: the factor you’d need depends on the filter’s selectivity, which varies per query and isn’t known in advance. And a large over-fetch makes the search dramatically more expensive while still not guaranteeing anything. **Follow-up:** so why not filter first and search the result? That’s correct, and its cost is proportional to how much the filter keeps — at low selectivity you’re brute-forcing most of the corpus.2. What actually goes wrong when you traverse a graph with a filter applied?
Answer
The graph’s links encode vector proximity and know nothing about the filter. Applying a filter means searching the induced subgraph over eligible points, and that subgraph has no navigability guarantee. When eligible points are a scattered minority, the subgraph fragments. The walk gets confined to whichever component it started in and returns that component’s best, while better eligible points sit in a component nothing reachable points at. The dangerous part is that this is silent — the search can’t distinguish “found the best” from “ran out of eligible neighbours”. **Follow-up:** roughly when does it start? When the average number of eligible neighbours per node approaches one, so it depends on selectivity and on `m` together.3. What is full_scan_threshold for?
Answer
It’s the cardinality below which Qdrant abandons the graph and evaluates the filter’s eligible set exactly. Default 10,000. It exists because at small eligible-set sizes the graph is both unnecessary and unreliable: scanning 50 points is fast, exact, and immune to the fragmentation problem. A useful consequence: a very selective filter can make a query *faster and more accurate* than the same query unfiltered. **Follow-up:** what does raising it cost? More queries take the exact route, so more per-query work at the top of that range.4. What does payload_m do?
Answer
It builds additional HNSW links *within groups of points sharing a payload value*, so the subgraph for a filter on that field is connected by construction instead of by luck. It’s the proper fix for multi-tenant search, where per-tenant filters sit exactly in the selectivity range that fragments the graph. It costs memory and build time, and it only helps for the field it was built on. **Follow-up:** so what about tenants with very few points? They fall under `full_scan_threshold` and get the exact route anyway. The two mechanisms cover different tenant sizes, and a well-configured system uses both.5. Is a more selective filter faster or slower?
Answer
Either, and that’s the point. The relationship isn’t monotonic. Keeping nearly everything behaves like an unfiltered search. Keeping almost nothing drops below `full_scan_threshold` and becomes a fast exact scan. The middle is the worst of both: the graph walk is degraded by skipping, without the eligible set being small enough to just evaluate. **Follow-up:** where do real workloads sit? Frequently in the bad middle — a filter on category, or a date range covering a few percent of a corpus, lands squarely there.6. How would you detect that filtered recall has degraded?
Answer
Measure it directly, because nothing will tell you otherwise. Take a representative set of filtered queries, run each with `exact=True` to get ground truth for that filter, and compute recall exactly as in Part 4 — but per selectivity band, not in aggregate. Aggregate recall hides this completely: unfiltered and loosely filtered queries can carry the average while the tightly filtered ones return junk. **Follow-up:** what would you monitor continuously? A sampled shadow query with `exact=True` against a fraction of real filtered traffic, comparing result sets.7. Would you use one collection with a tenant filter, or a collection per tenant?
Answer
One collection with a tenant filter and `payload_m` set on the tenant field is the usual answer. It scales to many tenants, shares memory efficiently, and the graph problem has a direct fix. A collection per tenant removes the filter entirely, which is attractive for a handful of large tenants, and becomes an operational problem at thousands — each collection carries its own segments and overhead. **Follow-up:** what breaks the one-collection answer? A hard requirement for physical isolation between tenants, or wildly uneven tenant sizes where a few dominate. Part 8 goes through the options.8. Why is this problem specific to graph indexes?
Answer
Because a graph index answers queries by *traversal*, and traversal depends on connectivity that a filter can destroy. The structure was built for one question and is being used for another. An inverted-file or clustering index behaves differently: you can evaluate which partitions matter and scan them, so a filter reduces the work more predictably. And a brute-force scan is completely immune — it just evaluates the predicate per vector. **Follow-up:** so is HNSW the wrong choice for heavily filtered workloads? Not usually — its unfiltered performance is why you chose it, and `payload_m` plus `full_scan_threshold` cover most of the range. But it’s the right question to ask.Sources
- Qdrant documentation, https://qdrant.tech/documentation/concepts/filtering/ and
https://qdrant.tech/documentation/concepts/indexing/ — filterable HNSW,
payload_m, and the cardinality estimate that drives the strategy choice. - The
full_scan_thresholddefault of 10,000 and the presence ofpayload_m(defaultnull) inhnsw_configwere read from a collection on Qdrant 1.19.1. - Malkov, Y. A. and Yashunin, D. A., Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs (arXiv:1603.09320) — the navigability properties that a filter’s induced subgraph does not inherit.
- The connectivity argument above is the standard percolation reasoning about induced subgraphs; the numbers at which it bites on any particular corpus have to be measured.
What to remember
Post-filtering can’t guarantee you k results. Pre-filtering costs whatever the filter keeps.
Filtered graph traversal is the middle path, and it breaks when the filter fragments the graph
into pieces the walk can’t cross.
Qdrant handles the ends: full_scan_threshold turns very selective filters into fast exact
scans, and payload_m builds the links that keep a filtered subgraph navigable for the field
you care about most.
What’s left is the middle, where a filter is selective enough to hurt the graph and not selective enough to escape it. That region is slow, its recall is low, and nothing will tell you you’re in it.
A filter changes which algorithm runs, not just which results come back. Tightening one can make your search faster, slower, or quietly wrong, and only measurement distinguishes them.