AnswersFiltering & Multi-Tenancy

Why Does Filtering Break HNSW?

Why does metadata filtering frequently cause queries to time out or return zero results in graph-based vector databases?

3 min readJuly 2026

The short answer

HNSW breaks under filters because the graph only knows the whole corpus; your filter was never part of its construction. A graph index has exactly two bad options when a filter arrives.

Post-filtering searches normally and discards non-matching results afterward: under a selective filter, all k results can be discarded and the query returns nothing. In-traversal filtering skips non-matching nodes during search: under a selective filter, so few nodes qualify that the graph loses connectivity, and the search either wanders (latency spikes, timeouts) or terminates early with poor recall.

The fix is an engine that evaluates filters natively as part of retrieval, adapting its strategy to how selective the filter actually is.

What goes wrong with post-filtering?

The math is unforgiving: fetch top-100 and apply a filter that matches 1% of the corpus, and the expected number of surviving results is about one. Fetch-more-and-hope (top-1,000, top-10,000) burns latency and still offers no guarantee. That is the "zero results" failure mode, and it appears exactly on the queries where the filter mattered most.

Why the same 1% filter empties one engine's results and speeds up the other's.

Post-filtering

Search whole graph

top-100 by similarity

Apply 1% filter

after the search

~1 result survives

Empty or near-empty results

Native filtering

Filter first

1% of corpus matches

Score only matches

Full top-10, correct

Selective queries get faster

What goes wrong with filtering during traversal?

HNSW's speed comes from greedy hops through a well-connected neighborhood graph. Mask off 99% of nodes and the connected structure the search depends on effectively disappears: paths to matching regions run through non-matching nodes. Engines compensate by visiting more of the graph, which is the timeout failure mode: latency grows as selectivity tightens. Both failure modes, and why they worsen with predicate selectivity, are analyzed in ACORN (Patel et al., SIGMOD 2024).

What does a real fix look like?

The fix is selectivity-adaptive execution inside the engine. Broad filters can stay close to normal graph search; highly selective filters should flip to searching the matching subset directly (at the extreme, brute-forcing a few thousand matching vectors beats wandering a masked graph). Demand one thing from any vendor: published filtered-search latency across selectivities, not just unfiltered numbers.

There's a deeper reason bolted-on filtering can't be fixed: as TopK's engineering team puts it, "the distribution of embeddings and the distribution of metadata are not strongly correlated". A vector index organizes data by semantic neighborhood, so the documents matching your filter are scattered uniformly across it, and no traversal order can find them efficiently. Filtering has to be a first-class operation of the query engine, not a mask over a vector index.

TopK's engine was built to invert the usual curve: its stated design goal is that highly selective queries get faster, not slower, since a tight filter shrinks the candidate set the engine actually scores.

The measurements bear it out at both ends of the scale: ~62ms p99 on 1M documents and ~115ms on 10M across filters selecting 100%, 10%, and 1% of the corpus (March 2025), and at one billion documents (July 2025), dense search improves from ~60ms p99 unfiltered to ~30ms with filters, with no degradation in result quality. Fuller selectivity sweeps are on the benchmarks page.

In practice, filters compose directly with the vector query:

from topk_sdk.query import select, field, fn, match
docs = client.collection("books").query(
select(
"title",
similarity=fn.vector_distance("title_embedding", [0.1, 0.2, ...]),
)
.filter(match("catcher")) # keyword predicate
.filter(field("published_year") > 1980) # metadata predicate
.sort(field("similarity"), asc=False)
.limit(10)
)

The query documentation covers the full predicate syntax.