cd /news/ai-infrastructure/metadata-filtering-in-vector-search-… · home › topics › ai-infrastructure › article
[ARTICLE · art-150866] src=dev.to ↗ pub= topic=ai-infrastructure verified=true sentiment=· neutral

Metadata filtering in vector search: post-filter loses results, measured

A developer benchmarked metadata filtering strategies in vector search on 100,000 vectors and found that post-filtering silently loses results as filters become more selective, returning only 1.9 of 10 requested results at 1% selectivity even when fetching 100 candidates. In-graph filtering preserved recall but latency grew from 0.44 ms at 50% selectivity to 74.22 ms at 0.1%, while exact search over the filtered subset was both fastest and always correct for selective filters. The results also showed that when tenants occupy distinct regions of the embedding space, all methods achieve perfect recall, meaning post-filtering failures only surface when the filter is independent of the query.

by read4 min views1 publishedOct 15, 2026

Multi-tenant RAG needs a filter: “only search documents this user may see.” Vector indexes are built for nearest neighbours, not for “nearest neighbours among these 1,000.” So where does the filter go: after the search, inside it, or before it?

Short answer: post-filtering silently returns fewer results as the filter gets more selective. In my test, a filter that keeps 1% of the data returned 1.9 results out of 10 when I fetched the top 100 and filtered. In-graph filtering fixed recall but got slow. For selective filters, exact search over the filtered subset was the fastest and always correct.

100,000 vectors, each with a tenant id. I ran two layouts:

Selectivity is the fraction of the corpus the filter keeps: about 50%, 10%, 1% and 0.1%. Ground truth is exact search restricted to the tenant’s vectors, so recall@10 here means “how many of the true filtered top 10 did the method return.”

Methods:

filter argument, evaluated while the graph is traversed (

def post_filter(index, mask, q, fetch=100, k=10):
    labels, _ = index.knn_query(q, k=fetch)
    return [l for l in labels[0] if mask[l]][:k]

def in_graph(index, mask, q, k=10):
    labels, _ = index.knn_query(q, k=k, filter=lambda i: bool(mask[i]))
    return list(labels[0])

def pre_filter(X_tenant, ids_tenant, q, k=10):  # X_tenant = X[ids_tenant], built once
    s = X_tenant @ q
    top = np.argpartition(-s, k)[:k]
    return list(ids_tenant[top[np.argsort(-s[top])]])

Recall@10 / average results returned (max 10) / median latency.

Selectivity post k=10 post k=100 post k=1000 (ef=1000) in-graph filter pre-filter (partitioned)
50% 0.553 / 5.5 / 0.21 ms 1.000 / 10 / 0.24 ms 1.000 / 10 / 1.94 ms 1.000 / 10 / 0.44 ms 1.000 / 10 / 1.38 ms
10% 0.181 / 1.8 / 0.20 ms 0.908 / 9.1 / 0.22 ms 1.000 / 10 / 1.83 ms 1.000 / 10 / 2.19 ms 1.000 / 10 / 0.16 ms
1% 0.110 / 1.1 / 0.20 ms 0.191 / 1.9 / 0.22 ms 0.884 / 9.2 / 1.88 ms 1.000 / 10 / 17.03 ms 1.000 / 10 / 0.03 ms
0.1% 0.100 / 1.0 / 0.14 ms 0.105 / 1.0 / 0.16 ms 0.214 / 2.1 / 1.79 ms 1.000 / 10 / 74.22 ms 1.000 / 10 / 0.01 ms

Post-filtering behaves like a coin flip with the selectivity as the odds: fetch 10, keep about selectivity × 10. The query is never told that most of its neighbours are off-limits. Even fetching 1,000 candidates returned only 2.1 results per query at 0.1%.

In-graph filtering never lost recall, but its cost grows as the filter tightens, from 0.44 ms at 50% to 74.22 ms at 0.1%. The traversal has to wander through many forbidden nodes to find allowed ones. At 0.1% it took 74.22 ms, while the exact scan of the 100-vector subset took 0.01 ms.

When tenants own their own regions of the space, the problem mostly disappears.

Selectivity post k=10 in-graph filter pre-filter (partitioned)
50% 1.000 / 0.20 ms 1.000 / 0.22 ms 1.000 / 1.34 ms
10% 1.000 / 0.20 ms 1.000 / 0.29 ms 1.000 / 0.16 ms
1% 1.000 / 0.12 ms 1.000 / 0.61 ms 1.000 / 0.03 ms
0.1% 1.000 / 0.07 ms 1.000 / 0.16 ms 1.000 / 0.01 ms

Recall is 1.000 for every method. The query’s nearest neighbours are already inside the allowed set, so the filter rejects almost nothing. This is the trap: post-filtering looks fine in a demo where a user asks about their own topic, then fails when a filter is independent of the query. Examples are a date range, a document type, or a tenant whose content overlaps with everyone else’s. Real tenants are somewhere between my two layouts, so test with your own data.

ef to 1,000, which cost 1.88 ms versus 0.22 ms.np.sqrt(D) returns a float64 scalar, and under NumPy 2 this upcast the query, so each dot product converted the whole float32 matrix. I fixed it by casting queries to float32 and reran everything. Check dtypes if your brute-force numbers look too slow.len(results) == k on an independent filter would have caught it here. For the basics of embeddings and indexes, see the free lesson Embeddings & vector databases.

Originally published on RecallRun. I run experiments like this every week at RecallRun. If you want to build RAG systems end to end, the AI Engineering course starts free: https://recallrun.dev/learn/ai-engineering/?utm_source=devto&utm_campaign=metadata-filter

── more in #ai-infrastructure 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/metadata-filtering-i…] indexed:0 read:4min 2026-10-15 · —