skip to content

In vector search, how do pre-filtering, post-filtering and filtered ANN traversal differ?

level: middleimportance: must knowfreq 72%

answer

  1. three placements of the same predicate
  2. before the search, during it, or after
  3. asked for 50, returned 3
  4. predicate pushed into graph traversal
  5. selectivity decides which one is right

basics

~20 s

Pre-filtering restricts the candidate set first and searches only inside it. Post-filtering runs the ANN search unaware of the predicate and drops non-matching hits afterwards, so results shrink. Filtered traversal evaluates the predicate during graph search, returning a full in-scope top-k without over-fetching.

solid answer

~50 s

**Post-filtering** asks the index for k neighbours, then discards those failing the predicate. It is trivial to bolt on, but the index never knew about the filter, so if you request 50 neighbours and only a few match, you return 3 — the missing 47 in-scope documents were never candidates. Teams paper over this by over-fetching, which is a guess, not a guarantee. **Pre-filtering** resolves the predicate first and searches only the matching subset. Scope is guaranteed and recall is exact when the subset is scored exhaustively, but cost scales with subset size, so it only pays when the predicate is selective. **Filtered traversal** pushes the predicate into the ANN search itself: the engine walks the graph but only counts matching nodes as results, often traversing through non-matching nodes to keep the graph connected. As of mid-2026 this is the default in mainstream vector engines. You get a full in-scope k, at a traversal cost that rises as the predicate gets more selective.

code

python · 9 lines
python
docs = [{"id": i, "tenant": "acme" if i % 100 == 0 else "other"} for i in range(10_000)]

# Post-filter: the ANN index returned its 50 nearest hits, unaware of the predicate.
ann_hits = docs[:50]
print(len([d for d in ann_hits if d["tenant"] == "acme"]))  # 1 -> asked for 50, kept 1

# Scoped search: the predicate is resolved first, so a full 50 come back in scope.
in_scope = [d for d in docs if d["tenant"] == "acme"]
print(len(in_scope[:50]))  # 50

go deeper

for a junior

Know the three placements by name and be able to say that post-filtering can return far fewer results than you asked for, because the search never knew about the filter.

for a middle

Walk through the concrete arithmetic — 50 requested, 3 surviving, 47 in-scope documents never considered — and explain that filtered traversal admits only matching nodes as results while still traversing through non-matching ones.

for a senior

Show you choose per query on estimated selectivity, know that a restricted allow-list can fragment a graph index, and treat a hard scope predicate as something the engine must enforce on every path rather than a strategy you select for speed.

for a principal

Frame it as a platform contract: what recall and latency the retrieval service commits to across the selectivity distribution it actually serves, whether strategy selection is the engine's job or yours, and how a scope guarantee survives every future query path teams add.

## The problem An approximate nearest-neighbour index is built to answer one question fast: which stored vectors are closest to this query vector. It knows nothing about your metadata. A metadata predicate — tenant, version, ACL, status — has to be reconciled with that search, and *where* you reconcile it is the whole design decision. There are three placements, and they differ in recall, in latency, and in whether scope is guaranteed at all. ## Post-filtering Run the ANN search as normal, get back a candidate list, then drop every candidate that fails the predicate. The index is untouched and any store supports it, including doing it in your own application code. The defect is structural: the candidate list was chosen by similarity alone, so the predicate can eat arbitrarily much of it. Ask for 50 neighbours on a corpus where 2% of documents belong to the requesting tenant and you can plausibly return 3 — and the other 47 in-scope documents that *would* have ranked well were never candidates in the first place. This is a recall failure, not a ranking failure, and it is invisible in the response: you see three plausible passages, not the forty-seven you missed. The usual patch is over-fetching — request 500 to keep 50. That trades latency and post-processing for a probability, not a guarantee, and the multiplier you need depends on a selectivity you generally do not know per query. Post-filtering is defensible when the predicate matches most of the collection (a `status = "published"` filter that excludes 3% of documents), and unwise otherwise. One thing post-filtering does *not* do is leak: rejected documents are dropped before the caller sees them. Its sin is silent under-retrieval. ## Pre-filtering Evaluate the predicate first — usually via a payload index or a bitmap of matching ids — and search only inside the matching subset. Scope is guaranteed and, if you score the subset exhaustively, recall is exact rather than approximate. The cost is proportional to the subset. If the predicate admits ten thousand vectors out of fifty million, brute-force scoring those ten thousand is fast and beats any graph traversal. If it admits forty million, you have re-created the full scan that the ANN index existed to avoid. There is also a subtler version of pre-filtering where the restricted id set is handed to the graph as an allow-list; that keeps the index but can fragment the graph, because the nodes that made two regions of it reachable may not be in the allow-list. ## Filtered traversal The modern default is to push the predicate down into the search itself. The engine traverses its graph or probes its partitions as usual, evaluates the payload predicate on nodes it visits, and admits only matching nodes to the result heap — while still traversing *through* non-matching nodes so the graph stays navigable. Some engines additionally build extra links so that same-payload neighbours remain connected under common filters. The result is what you actually wanted: up to a full k of in-scope neighbours, from one query, with no over-fetch multiplier to tune. The cost is that the traversal does more work as the predicate gets more selective — it must visit more nodes before it has collected k matching ones. At extreme selectivity that degenerates, which is why engines typically fall back to exhaustive scoring of the matching subset once the estimated match count drops below a threshold. As of mid-2026, filtered traversal plus an automatic brute-force fallback is standard behaviour in mainstream vector databases rather than something you implement yourself. ## Choosing Selectivity is the deciding variable, and it is a property of the query, not of the system. Predicates that admit most of the corpus: post-filter with light over-fetch is fine. Predicates in the broad middle: filtered traversal. Predicates that admit a tiny slice: pre-filter and score exhaustively. Two caveats sit above the performance argument. First, a hard scope requirement — tenant, ACL — must be enforced by the engine on every query path, not by whichever strategy happens to be selected; post-filtering satisfies that only if the filtering code is inside the trusted path and never skipped. Second, none of the three guarantees *exactly* k results: when fewer than k documents match at all, you get what exists, and the pipeline needs a defined behaviour for the empty and short cases rather than silently presenting a thin context window as a complete one.

  • Does filtered traversal guarantee exactly k in-scope results?
    No — it guarantees *up to* k. If fewer than k documents match the predicate at all, you get however many exist, and if the engine's exploration budget is exhausted before it collects k matching nodes you can also come up short. So the caller still needs defined behaviour for short and empty result sets rather than assuming a full context window arrived.
  • When is post-filtering with over-fetching actually the right call?
    When the predicate is weakly selective — it excludes a small slice, such as unpublished drafts — and when you cannot change the query path. Requesting a modest multiple of k and dropping a handful of misses costs little. It stops being defensible the moment selectivity is high or unknown per query, because the over-fetch multiplier you would need is exactly what you cannot predict.
  • How does handing an allow-list of ids to a graph index differ from letting the engine filter during traversal?
    An allow-list restricts which nodes may be *visited*, so nodes outside it can no longer serve as stepping stones; the graph can fragment and whole regions become unreachable, quietly costing recall. Filtering during traversal keeps navigating through non-matching nodes and only restricts which ones are admitted as results, preserving connectivity.

saying these in an interview costs you the question

  • Says post-filtering only affects latency, not recall
  • Treats over-fetching as a guarantee rather than a guess
  • Assumes pre-filtering is always cheaper because the set is smaller
  • Believes filtered traversal always returns exactly k results
  • Picks one strategy globally, ignoring per-query selectivity

context