Metadata filtering (pre, post, filtered ANN)
Restricting search to items whose fields match conditions (tenant, date, ACL, language) — and doing it without wrecking ANN recall.
Metadata filtering means only searching documents that match some rules, such as 'only this customer's files', 'only English', or 'only contracts signed after 2024'. Each chunk carries Chunk metadata fields, and the search engine applies the conditions alongside the similarity search. It is how you enforce permissions (ACL (access control list)) and keep tenants apart (Multi-tenancy).
Asking a librarian for books like this one, but only from the reference section and only ones published after 2020.
Assuming a filter only narrows results. In graph indexes, a selective filter can silently reduce recall to near zero and return fewer than k rows. Test recall per filter selectivity bucket, not just overall.
Three execution strategies. Post-filter: ANN returns top N, then drop non-matching rows — fast, but if the filter keeps 1% of data, ef_search=100 yields ~1 match and you return fewer than k (pgvector < 0.8 behaves this way). Pre-filter: compute the matching id set, then exact-scan it (good when < ~10–50k matches) or search the graph restricting traversal to matches — restrictive filters disconnect the HNSW graph and recall collapses unless the index adds filter-aware links (Qdrant builds extra links per indexed payload value; ACORN expands neighbourhoods during search). Partition: separate index or partition per tenant/category, so the filter is free. Engine behaviour as of Oct 2026: Qdrant chooses between payload-index exact scan and filtered HNSW using cardinality estimates (requires a payload index on the field); pgvector 0.8+ hnsw.iterative_scan keeps walking until k matches or hnsw.max_scan_tuples; Elasticsearch/OpenSearch apply the filter during the HNSW search (Lucene) and fall back to exact search when the filtered set is small; Vespa does the same with a configurable threshold. Security filters (ACL, tenant) must be enforced by the engine on every query, never by the LLM.
Payload/metadata index on every filtered field (Qdrant create_payload_index, B-tree in Postgres). Tenant id as a mandatory filter injected server-side from the auth context. pgvector ≥ 0.8 with hnsw.iterative_scan = relaxed_order and ef_search ≥ 100 for filtered queries. Tenants above ~1M vectors get a dedicated partition or collection.
Clinical-notes search across 400 clinics, 30M notes, Qdrant. Filter clinic_id = X AND note_date >= 2025-01-01. A clinic with 6k matching notes: Qdrant's planner estimates cardinality below full_scan_threshold and exact-scans the payload index → 4 ms, recall 1.0. A clinic with 2M notes: filtered HNSW → 3 ms, recall 0.97. Without a payload index on clinic_id, the same small-clinic query walked the global graph and returned 2 of 10 results.
Give the decision by selectivity: "Above ~10% of data I filter during HNSW traversal; below ~0.1% I exact-scan the matching ids; tenants big enough to matter get their own partition." Then the security line: ACL filters are applied by the engine from the authenticated identity — never constructed by the model — and fail closed.
| Filter keeps | Strategy | Recall@10 | Latency |
|---|---|---|---|
| 50% of rows | filtered HNSW | 0.97 | 2 ms |
| 5% | filtered HNSW, ef 200 | 0.95 | 4 ms |
| 0.5% | post-filter (naive) | 0.20 | 2 ms |
| 0.5% | exact scan of matches | 1.00 | 6 ms |
| one big tenant | own partition | 0.97 | 2 ms |
Numbers worth knowing
- Post-filter expected matches
- ef_search × selectivitye.g. 100 × 1% = 1 row for k=10
- Exact-scan crossover
- ≈10k–50k matching vectors768-d on CPU, < 10 ms; above that use filtered ANN
- Filtered-HNSW recall without filter-aware links
- can fall below 0.5filters keeping < 1% of points
How to decide
| When | Do this |
|---|---|
| Filter is a security boundary (tenant, ACL) | Enforce in the engine from the auth context; Fail closed if missing. |
| Filter keeps < ~0.1% of rows | Exact scan over the matching ids. |
| One tenant is > 10% of the corpus or has its own SLA | Separate partition/collection for that tenant. |
Cost maths
Partitioning cost: each HNSW partition repeats graph overhead and RAM headroom. 400 tenants in one collection: 30M × 3.2 KB ≈ 96 GB. 400 separate collections add per-collection overhead (segments, caches) — fine for tens of large tenants, wasteful for thousands of small ones. Rule: partition the top ~20 tenants by size, keep the long tail in a shared collection with a payload index.
Operating it
- Rows returned < k on > 1% of filtered queries — add payload index / iterative scan
- One tenant's queries dominate p99 — give it a partition (Noisy neighbour)
- ACL lists > ~1,000 ids per user — move to group-based ACL fields instead of id lists
- Queries executed without a tenant/ACL filter — page on any non-zero count
- Rows returned < k by filter-selectivity bucket — alert above 1%
- Recall@10 vs exact for the smallest-tenant bucket — alert below 0.9
When it goes wrong
A user of a multi-tenant HR knowledge base saw a snippet from another company's handbook in an answer citation.
Customer support ticket; audit logs showed the retrieval call had no tenant filter.
A new 'search all my workspaces' feature built the filter in application code from a list that was empty for one code path; an empty list was treated as 'no filter'.
Moved tenant scoping into a mandatory server-side query wrapper that rejects requests without a tenant (Fail closed), added a cross-tenant canary query to CI, and disclosed the incident to the affected tenant.
Where it shows up
Sources
- Qdrant documentation: Filtering — Qdrant, 2026docs
- pgvector: open-source vector similarity search for Postgres — pgvector project, 2026docs
- ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Data — Patel et al., 2024paper
Check yourself
Three questions. Every option is explained once you answer.