{"slug": "metadata-filtering-in-vector-search-post-filter-loses-results-measured", "title": "Metadata filtering in vector search: post-filter loses results, measured", "summary": "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.", "body_md": "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?\n\n**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.\n\n100,000 vectors, each with a tenant id. I ran two layouts:\n\nSelectivity 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.”\n\nMethods:\n\n`filter` argument, evaluated while the graph is traversed (\n\n``` python\ndef post_filter(index, mask, q, fetch=100, k=10):\n    labels, _ = index.knn_query(q, k=fetch)\n    return [l for l in labels[0] if mask[l]][:k]\n\ndef in_graph(index, mask, q, k=10):\n    labels, _ = index.knn_query(q, k=k, filter=lambda i: bool(mask[i]))\n    return list(labels[0])\n\ndef pre_filter(X_tenant, ids_tenant, q, k=10):  # X_tenant = X[ids_tenant], built once\n    s = X_tenant @ q\n    top = np.argpartition(-s, k)[:k]\n    return list(ids_tenant[top[np.argsort(-s[top])]])\n```\n\nRecall@10 / average results returned (max 10) / median latency.\n\n| Selectivity | post k=10 | post k=100 | post k=1000 (ef=1000) | in-graph filter | pre-filter (partitioned) | \n|---|---|---|---|---|---|\n| 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 | \n| 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 | \n| 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 | \n| 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 | \n\nPost-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%.\n\nIn-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.\n\nWhen tenants own their own regions of the space, the problem mostly disappears.\n\n| Selectivity | post k=10 | in-graph filter | pre-filter (partitioned) | \n|---|---|---|---|\n| 50% | 1.000 / 0.20 ms | 1.000 / 0.22 ms | 1.000 / 1.34 ms | \n| 10% | 1.000 / 0.20 ms | 1.000 / 0.29 ms | 1.000 / 0.16 ms | \n| 1% | 1.000 / 0.12 ms | 1.000 / 0.61 ms | 1.000 / 0.03 ms | \n| 0.1% | 1.000 / 0.07 ms | 1.000 / 0.16 ms | 1.000 / 0.01 ms | \n\nRecall 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.\n\n`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.\nFor the basics of embeddings and indexes, see the free lesson [Embeddings & vector databases](//../../learn/ai-engineering/06-vector-search.html).\n\n*Originally published on [RecallRun](https://recallrun.dev/posts/rag-metadata-filtering/).* 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](https://recallrun.dev/learn/ai-engineering/?utm_source=devto&utm_campaign=metadata-filter)", "url": "https://wpnews.pro/news/metadata-filtering-in-vector-search-post-filter-loses-results-measured", "canonical_source": "https://dev.to/recallrundev/metadata-filtering-in-vector-search-post-filter-loses-results-measured-4gm2", "published_at": "2026-10-15 19:19:58+00:00", "updated_at": "2026-10-15 19:26:48.840643+00:00", "lang": "en", "topics": ["ai-infrastructure", "mlops", "large-language-models", "ai-search"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/metadata-filtering-in-vector-search-post-filter-loses-results-measured", "markdown": "https://wpnews.pro/news/metadata-filtering-in-vector-search-post-filter-loses-results-measured.md", "text": "https://wpnews.pro/news/metadata-filtering-in-vector-search-post-filter-loses-results-measured.txt", "jsonld": "https://wpnews.pro/news/metadata-filtering-in-vector-search-post-filter-loses-results-measured.jsonld"}}