Metadata filtering
Most real RAG queries don't say 'find me similar text' — they say 'find me similar text written after 2024-01-01, in English, for tenant X, that isn't deleted'. Combining ANN with predicates is harder than it looks.
Three places to apply the filter
Post-filter: simple, dangerous
The naive approach: run ANN for top-k, drop rows that don't match. If the filter is selective ("tenant_id = '42'" matching 0.1% of the corpus), your top-100 might contain zero matches. Apps work around this by over-fetching — top-10,000 instead of top-10 — which works until the filter is very selective or the corpus is huge.
# Post-filter (works fine when the predicate matches most rows)
hits = index.search(query_vec, k=1000)
results = [h for h in hits if h.metadata["tenant_id"] == tenant][:10]Pre-filter: correct, slow
Compute the set of IDs matching the predicate, then run brute-force search over just that subset. Recall is perfect but you've thrown away the ANN index. Practical only when the predicate matches a few thousand rows — fine for "tenant has 800 documents", impossible for "documents in English" on a billion-row corpus.
Integrated filtering (the real solution)
Modern vector DBs (Qdrant, Weaviate, Milvus 2.4+, pgvector with iterative scans) push the predicate into the ANN traversal. For HNSW, each candidate node is evaluated for the predicate before it's added to the result heap; nodes that fail are still used as graph hops but never returned. The graph stays connected, recall stays high, and selective filters cost almost nothing extra.
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
client = QdrantClient(url="http://localhost:6333")
client.search(
collection_name="docs",
query_vector=query_vec,
query_filter=Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value=42)),
FieldCondition(key="lang", match=MatchValue(value="en")),
]
),
limit=10,
) # filter is evaluated DURING the HNSW walkcreate_payload_index; pgvector relies on B-tree / GIN indexes).The selectivity trap
Different filter selectivities want different strategies. Most query planners pick automatically based on cardinality estimates:
Partitioning instead of filtering
If a predicate appears on every query (tenant_id, environment, language), don't filter — partition. Pinecone calls these namespaces, Weaviate calls them tenants, Milvus calls them partitions. Each gets its own ANN index, so queries skip the filter step entirely and you can drop a tenant by dropping its partition.
# Pinecone: per-tenant namespace, no filter needed at query time
index.upsert(vectors=batch, namespace=f"tenant-{tenant_id}")
index.query(vector=q, top_k=10, namespace=f"tenant-{tenant_id}")Quick Quiz
Test yourself · 3 questionsWhy is naive post-filtering risky for selective predicates?
What does integrated (filter-during-search) HNSW do differently?
When should you use a namespace/partition instead of a filter?