AAI Agentic Handbook
Retrieval & Search

Metadata filtering (pre, post, filtered ANN)

Mid levelReviewed Oct 2026In 8 interview questionsIn 2 posts

Restricting search to items whose fields match conditions (tenant, date, ACL, language) — and doing it without wrecking ANN recall.

In plain English

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).

Analogy

Asking a librarian for books like this one, but only from the reference section and only ones published after 2020.

Common pitfall

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.

How it actually works

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.

What I'd ship on day one

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.

Worked example

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.

In the interview

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 keepsStrategyRecall@10Latency
50% of rowsfiltered HNSW0.972 ms
5%filtered HNSW, ef 2000.954 ms
0.5%post-filter (naive)0.202 ms
0.5%exact scan of matches1.006 ms
one big tenantown partition0.972 ms
Pick the strategy by filter selectivity (30M × 768-d, illustrative measurements).

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

WhenDo 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 rowsExact scan over the matching ids.
One tenant is > 10% of the corpus or has its own SLASeparate 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

Time to scale up when…
  • 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
What to monitor
  • 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

What users saw

A user of a multi-tenant HR knowledge base saw a snippet from another company's handbook in an answer citation.

How it was caught

Customer support ticket; audit logs showed the retrieval call had no tenant filter.

Root cause

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'.

Fix

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.

Sources

  1. Qdrant documentation: Filtering — Qdrant, 2026docs
  2. pgvector: open-source vector similarity search for Postgres — pgvector project, 2026docs
  3. 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.

1ConceptWith naive post-filtering, a filter that keeps 1% of rows often returns fewer than k=10 results. What is the mechanism?
2ScenarioClinical-notes search over 30M notes. A query filters clinic_id = X, which matches 6k notes. Latency budget is 10 ms and recall must be high. Which execution strategy fits?
3Calculationpgvector without iterative scans post-filters HNSW results. You set ef_search = 200, k = 10, and the tenant filter keeps 2% of rows. Roughly how many matching rows come back?