This is the pgvector footgun I've watched bite the most people, exactly because it fails quiet. The model that made it click for me: HNSW hands you ef_search candidates from the unfiltered graph, then the WHERE runs. If tenant 42 is a small slice, the true filtered-nearest may never have been in that candidate pool, so you silently get fewer than ten — or ten worse ones — and the plan says nothing.
Worth adding to the mitigation list, in rough order of cost: bump hnsw.ef_search first (widens the pool, buys margin, no schema change); then reach for iterative scan (hnsw.iterative_scan = strict_order/relaxed_order in 0.8+), which keeps walking the graph until it has enough rows that pass the filter — the closest thing to an actual fix. For a handful of large tenants, a partial HNSW index per tenant restores in-filter recall outright, at the cost of index proliferation. And the cheapest detector for anyone who can't adopt a tool yet: when the tenant has ≥LIMIT rows, assert you got LIMIT back — a short result set is the alarm the query plan won't raise.
The reproducible-from-fresh-checkout benchmark is what makes this land; most "recall collapsed" posts hand-wave exactly the part you measured.
Yeah, you nailed it. "The true filtered-nearest was never in the pool" is the part that makes it invisible, and honestly you said it cleaner than I did.
That ordering is right, and it's basically the set of options the tool picks between. The thing I kept running into - and why I gave up on just writing down the ladder - is that the rung you want depends on how sparse the filter is right around the query, and that changes query to query. ef_search helps, but if the filter is basically absent near the query you'd need it around k / local_selectivity to get enough survivors, which is just a scan with extra steps. Iterative scan is the real fix most of the time, agreed, but its cost climbs the same ~1/selectivity way and max_scan_tuples caps it, so once you're sparse enough a plain exact filtered scan is actually faster and can't come back short. So there's a crossover, and it moves around. Working out which side of it you're on for a given query is the whole thing I'm trying to automate.
And yeah - the LIMIT check is the best free canary there is. Tenant has at least LIMIT rows and you got fewer back? Something's off. That's literally one of the two numbers I report (returns-k rate) for exactly that reason. Mind if I steal that framing for the README as the "no tool needed" check? Happy to credit you.
Anyway, thanks. Comments like this are why it's worth writing these up.