skip to content

Why does Qdrant apply payload filters during HNSW traversal instead of after the search?

level: seniorimportance: must knowfreq 66%

answer

  1. the survivors problem
  2. over-fetching is a guess, not a fix
  3. the planner needs a cardinality estimate
  4. tiny set: scan it exactly
  5. big set: filter inside the walk

basics

~20 s

Post-filtering discards results after the fact, so a selective filter can leave a top-10 query with one hit or none. Qdrant instead estimates the filter's cardinality from payload indexes, then either scans the small matching subset exactly or traverses the graph accepting only matching points.

solid answer

~50 s

Post-filtering means running the approximate search first and dropping the non-matching results afterwards. With a filter that matches 1% of the collection, most of the retrieved neighbours vanish and a request for 10 results returns one or zero — the classic "my filtered search is empty" bug. Over-fetching (asking for 100x more) hides it at unpredictable cost and still has no guarantee. Qdrant filters *inside* the search. Payload indexes give the planner a cardinality estimate, and it picks a strategy: if the filter matches a small subset, it scans exactly those points — cheap and perfectly recalled; if it matches most of the collection, it walks the HNSW graph accepting only matching points as candidates. The switch point is governed by `full_scan_threshold` in the collection's `hnsw_config`, expressed in kilobytes. Filtered graph traversal alone would risk the matching points being poorly connected, so Qdrant also builds additional payload-aware links for indexed fields.

code

python · 14 lines
python
from qdrant_client import models

flt = models.Filter(
    must=[models.FieldCondition(key="customer_id", match=models.MatchValue(value="acme"))]
)

# Is the filter genuinely too narrow, or is this a recall problem?
matching = client.count(collection_name="docs", count_filter=flt, exact=True).count
returned = len(
    client.query_points(
        collection_name="docs", query=vec, query_filter=flt, limit=10
    ).points
)
print(matching, returned)

go deeper

for a junior

Be able to state the core distinction: filtering after the search can throw away almost all of the results, so the filter has to be part of the search itself.

for a middle

Explain both failure modes — post-filter recall loss and unbounded brute-force pre-filtering — and that Qdrant chooses between exact scan and filtered traversal from a cardinality estimate.

for a senior

Diagnose live: separate 'not enough matching points' from lost recall with an exact count, tie missing recall back to a missing payload index, and benchmark filtered traffic on its own.

for a principal

Own the recall budget — decide where full_scan_threshold sits for your filter selectivity profile, and whether heavily-filtered workloads deserve their own collection rather than sharing tuning with unfiltered ones.

## The problem post-filtering creates Approximate nearest-neighbour search returns the top *k* by distance. If you then discard everything that fails a payload predicate, the survivors are whatever fraction of those *k* happened to match. When the predicate is selective — one language out of thirty, one customer out of ten thousand — that fraction rounds to nothing. A user asks for 10 results and gets two. The results that come back are correct; the ones that are missing were never retrieved, because the graph walk had no idea the filter existed and spent its budget on points destined to be thrown away. The usual patch is over-fetching: request `k * 10` or `k * 100` and filter down. It is a guess. There is no *k* that guarantees enough survivors, the cost grows with the multiplier, and the more selective the filter the worse both problems get. For a filter matching one point in a million, no practical multiplier works. ## Pre-filtering has its own failure mode The naive opposite — compute the matching set first, then search only within it — is exact but potentially enormous: for a filter matching most of the collection you have just described a brute-force scan. And applying the filter *during* graph traversal is not automatically safe either. HNSW navigates by hopping between connected neighbours. If you refuse to visit non-matching points, the matching points may form a sparsely connected or outright disconnected subgraph, and the walk gets stranded in a local region — recall collapses even though nothing was discarded after the fact. ## What Qdrant actually does Qdrant treats this as a planning problem. Payload indexes let it **estimate the cardinality** of a filter before searching. From that estimate it chooses: - **Small matching set** — do an exhaustive scan over just those points, using the payload index to enumerate them. This is exact: recall is 1.0 by construction, and it is fast precisely because the set is small. - **Large matching set** — traverse the HNSW graph normally but only accept matching points as results. When most points match, the graph stays well connected under the filter and traversal behaves close to unfiltered search. The boundary between the two is `full_scan_threshold` in the collection's `hnsw_config`, expressed in kilobytes of vector data rather than a point count — it asks "is the filtered subset small enough that scanning it is cheaper than navigating?" Raising it pushes more filtered queries into exact-scan territory (better recall, more CPU on medium-sized subsets); lowering it does the reverse. ## Filterable HNSW For the middle ground — filters that are selective enough to fragment the graph but too large to scan — Qdrant builds **additional links** in the HNSW graph based on indexed payload values, so that points sharing a value stay reachable from one another. This is the "filterable HNSW" idea, and it is why a payload index is not merely a lookup accelerator: it changes what the graph looks like for filtered traversal. ## Diagnosing it in production When a filtered query returns fewer results than requested, resolve the ambiguity first: run `count(count_filter=flt, exact=True)`. If the count is below your limit, the collection genuinely does not contain enough matching points and nothing is wrong. If the count is comfortably above the limit and search still returns fewer, you are looking at a recall problem, and the usual causes are a missing payload index on the filtered field (no cardinality estimate, no payload-aware links) or a filter whose semantics are narrower than you intended. Second habit: benchmark filtered queries separately from unfiltered ones. Their latency profiles are different by construction, and a P99 measured on unfiltered traffic tells you nothing about the filtered path. ## The interview point The short version worth saying out loud: post-filtering is correctness-preserving but recall-destroying; brute-force pre-filtering is recall-preserving but potentially unbounded in cost; and the interesting engineering is the planner that estimates cardinality and chooses between them per query. Payload indexes are what make that estimate possible, which is why "filtering is slow" and "filtering loses results" are usually the same missing index.

  • When would raising full_scan_threshold help, and what does it cost?
    Raising it pushes more filtered queries into the exhaustive-scan branch, which gives exact results for medium-sized filtered subsets that graph traversal would handle poorly. The cost is CPU: scanning a subset that is larger than it looks is linear work per query. It is worth tuning when filters are consistently selective and recall matters more than tail latency, and it should be measured, not guessed.
  • Why does a payload index affect recall and not just speed?
    Because it feeds two mechanisms beyond lookup. It gives the planner the cardinality estimate that decides between exact scan and filtered traversal, and it lets Qdrant build additional payload-aware links so points sharing an indexed value stay reachable in the graph. Without it, a selective filter can fragment traversal, and results go missing rather than merely arriving late.
  • A filtered query returns 3 results when the limit is 10. What do you check first?
    Run `count` with the same filter and `exact=True`. If fewer than ten points match, the data is simply that way and the query is correct. If many match, treat it as recall: confirm a payload index exists on every filtered field, check the filter's boolean structure for an unintended narrowing such as a stray `must_not`, and only then look at search-time tuning.

Post-filtering is asking for the ten nearest restaurants and then crossing off the ones that are closed — on a quiet night you cross off all ten. Checking opening hours while you walk finds ten that are actually open.

saying these in an interview costs you the question

  • Fixing empty filtered results by over-fetching and hoping
  • Assuming filters are always applied after the ANN search
  • Thinking pre-filtering is free because it "searches less"
  • Believing payload indexes only affect speed, never recall
  • Benchmarking filtered latency using unfiltered traffic

context