DocsDatabases

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

Pre-filter
Filter first, then ANN
Post-filter
ANN first, then filter top-k
Integrated
Filter checked during graph traversal
Partition
Predicate is the shard key

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.

python
# 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.

python
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 walk
Always index the filter field
Integrated filtering needs the predicate to be cheap to evaluate per node. Build a payload index on any field you filter on (Qdrant calls this create_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:

>50% match
Post-filter is fine
1–50% match
Integrated HNSW with payload index
<1% match
Pre-filter / brute force on subset
Tenant isolation
Make it a partition key

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.

python
# 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 questions
Q1.

Why is naive post-filtering risky for selective predicates?

Q2.

What does integrated (filter-during-search) HNSW do differently?

Q3.

When should you use a namespace/partition instead of a filter?