Runbooks/RAG RunbookTrack C · Serving at scaleLLM Inference Runbook →0%
  1. 00 Start
  2. /
  3. 01 Chunking
  4. 02 Parsing
  5. 03 Identity
  6. 04 Access
  7. /
  8. 05 Models
  9. 06 Vectors
  10. 07 Limits
  11. 08 Model ops
  12. 09 Index I
  13. 10 Index II
  14. 11 Tuning
  15. 12 Capacity
  16. /
  17. 13 Sharding
  18. 14 Filtering
  19. 15 Hybrid
  20. /
  21. 16 Proof
RAG Runbook · Document 14 of 16 · Track C — Serving at scale

Track C · Document 14 · Serving at scale

Filtered Search and Multi-Tenancy

One number decides every filtered query, and the failure mode is silence. Multi-tenancy is the same problem with the filter always present and the values wildly unequal.

Reads in about 55 minutes · 9 figures, 3 of them live calculators · 11 interview questions · prints to clean A4

What is in this document

  1. Selectivity decides everything
  2. Two words that must stay apart
  3. Five ways to answer a filtered query
  4. Post-filtering and the silent failure
  5. Brute force and the crossover
  6. Filtering during the walk
  7. The connectivity problem
  8. Anatomy of one filtered query
  9. What each engine does
  10. Filtering meets sharding
  11. Three places the boundary can live
  12. Tenant skew and recall
  13. The tiered design
  14. Two kinds of isolation
  15. Never trust the filter alone
  16. Lifecycle and deletion
  17. Symptom to cause
  18. Interview questions
  19. FAQ
  20. Cheat sheet

1 · Selectivity: the one number that decides everything

Selectivity is the fraction of the table that passes the filter. Nothing else about filtered search matters until you know it — not the engine, not the parameters, not the index type. Two filters on the same table can need opposite strategies, and the only thing separating them is this number.

SURVIVORS = CANDIDATES × SELECTIVITY. NOTHING ELSE MATTERS UNTIL YOU KNOW THAT NUMBER. selectivity 1% 100,000 of 10,000,000 — one row in every 100 expected survivors 0.4 out of the 40 candidates the walk handed to the filter candidates needed 1,000 k ÷ selectivity — and the walk has to actually reach that many tight · brute force middle · filter during the walk loose · plain walk 0.001% ~0.1% ~20% 100% A plain walk returns 0 rows here. The query does not fail — it just comes back short. At 1% selectivity you would need to walk to about 1,000 candidates to expect 10 survivors — 25× the default work. The two band boundaries are rules of thumb for this stack, not constants. They move with dimension, hardware and each engine’s own thresholds.

The failure is silent, and that is the whole reason this topic gets asked. A tight filter does not produce an error. The query runs, returns three rows instead of ten, and the application shows three. Nothing in the logs says “the other seven are in the table, the walk just never reached them”. It is a footgun, not a crash — and it is the single most common filtered-search bug in production RAG.

FilterRows passingSelectivity One in every…Candidates for 10 results
tenant = A, the largest tenant4,000,000 40%2.525
department = radiology1,000,00010% 10100
published in the last 30 days100,0001% 1001,000
published since yesterday5,000 0.05%2,00020,000
document = one specific file50 0.0005%200,0002,000,000

Read the last row. With fifty matching rows in ten million, one candidate in every two hundred thousand is a survivor — so you would need to walk to roughly two hundred thousand candidates before you expected to see one. No sensible ef gets there, and the graph is simply the wrong tool for that query.

2 · Two words that must stay apart

Under interview pressure people say “add an index on tenant” and then reason about the HNSW graph. Those are different structures with different costs and different failure modes, and the whole topic collapses if they are confused.

WordWhat it means hereNot to be confused with
tableThe store: ten million rows, each a vector plus its payload The index
indexThe ANN structure — the HNSW graph over the vectorsThe payload index
payloadThe non-vector fields stored with each row: tenant, doc_id, published, departmentThe filter
payload index A secondary structure over one payload field, so a predicate can be answered without scanning ten million rows — a B-tree, hash, inverted list or bitmap The ANN index. It does nothing to the graph
filter / predicateThe WHERE clause that arrives with the query. Not stored anywhereThe payload
selectivityThe fraction of rows passing the filter — Postgres uses the same word the same way
candidateA row the graph walk reached and scoredA survivor
survivorA candidate that also passes the filterA candidate
bitmap / allow-listOne bit per row, set if the row passes. Ten million bits is 1.25 MBThe payload index, which is what builds it
over-fetchAsking the walk for more than k so that enough survive the filter

3 · Five ways to answer a filtered query

Every engine in this document ships two or three of these five. Knowing which five exist — and which one a given engine reached for — is more useful than knowing any particular parameter name.

TEN MILLION BOOKS. “THE TEN MOST LIKE THIS ONE, BUT ONLY ONES THE PATELS DONATED.” post-filter walk, then check loose filters only brute force ledger, then score all exact · tight filters during the walk bitmap-guided, capped the middle band partial index a graph per big value tens, not thousands shard by the key filtering becomes routing free, but skewed And the trap all five are coping with The Patel books are scattered by topic, so there is no path made of Patel books. You can reach one on floor two, find none of its neighbours are Patel, and be stranded while thirty sit together on floor four. The sentence that explains every mechanism in this document The graph was built for one geometry — embedding similarity. The filter imposes a second geometry the graph knows nothing about. Everything here is a way of coping with that mismatch, and the differences between vendors are differences in when they pay for it: at build time, at query time, or by not building a shared graph at all.

Keep two words apart under pressure. The index is the ANN structure — the HNSW graph over the vectors. The payload index is a secondary structure over one metadata field. “Add an index on tenant” means the second one, and it does nothing whatsoever to the graph.

4 · Post-filtering, and the silent failure

Run the ordinary walk with no knowledge of the filter. It hands back ef candidates in distance order. Apply the predicate to those. Return the first k survivors. The graph is untouched, no engine support is needed, and the cost is exactly one walk plus ef predicate checks.

It is also where most production filtered-search bugs live.

THE WALK DOES NOT KNOW THERE IS A FILTER the walk runs exactly as an unfiltered query would and returns its 40 nearest candidates, in distance order The filter is applied to those forty only. At 1 percent selectivity the expected number passing is 0.4 — here, by luck, one passes. survivors = candidates × selectivity = 40 × 0.01 = 0.4 The result set is one row. The application shows one row. Nothing reports the shortfall. There is no error, no warning and no log line. In pgvector the tell is in EXPLAIN ANALYZE — “Rows Removed by Filter: 39” — and almost nobody is looking at EXPLAIN ANALYZE when the bug report says “search feels a bit thin for some customers”.
  1. The walk runs exactly as an unfiltered query would and returns the 40 nearest candidates.
  2. The filter is applied to those 40 only. At 1 percent selectivity the expected number passing is 0.4.
  3. Selectivity times candidates is the whole story: 40 × 0.01 = 0.4 survivors on average.
  4. The result set is one row, the application shows one row, and nothing reports the shortfall.

Where post-filtering is the right answer: above roughly 20 percent selectivity. Over-fetch by 1 ÷ selectivity with a safety factor of two and the cost is a slightly wider walk. pgvector’s default plan for a WHERE plus an ORDER BY on the vector column is exactly this, and for a tenant owning 40 percent of the table it works well with ef_search left at its default of 40.

5 · Brute force, and where the crossover sits

Use the payload index to find the exact set of rows passing the filter. Compute the distance from the query vector to every one of them. Sort. Take k. The graph is not consulted at all, and that single fact gives this strategy a property no other one in this document has.

TWO COST CURVES. THE BAND BOUNDARY IS WHERE THEY CROSS. brute force 7.7M MACs 5,000 survivors × 1,536 multiply-adds — and recall is exactly 1.00 one graph walk 2–8M MACs ef 40, M 32 over 10M vectors — roughly 1,300 to 5,000 distances, whatever the filter Brute force wins, and it is exact. Score the 5,000 survivors and never touch the graph. Two things fall out of this, and the second one is the interview answer At fifty or five hundred survivors brute force is faster than the graph, not merely more accurate. And approximation only enters through the graph — brute force never touches it, so its top 10 is the top 10.

Every engine bakes its own version of this crossover into a knob. Qdrant’s full_scan_threshold defaults to 10,000 KB, which is about 1,700 vectors at 1536 dimensions. Weaviate’s flatSearchCutoff defaults to 40,000 objects. Lucene decides live: if the walk has visited more nodes than there are survivors, brute force is provably cheaper and it switches mid-query. pgvector alone has no such knob — it lets the Postgres planner estimate both costs from table statistics, which is why stale statistics are pgvector’s most common filtered-search failure.

Recall is exactly 1.00, and that sentence is worth saying out loud

Approximation only enters a vector search through the graph. Brute force never touches the graph, so it looked at every possible answer and the top 10 it returns is the top 10. It is the only technique in this document with a recall guarantee.

The precondition: no payload index, no brute-force plan

“Find the 50 survivors” means a payload index lookup. Without one it means reading ten million payloads, which at a few hundred nanoseconds each is seconds — and in pgvector the planner will not even consider the plan.

Two rules follow, and they are in tension. Index every field you filter on — Qdrant’s own FAQ calls this the single biggest speed-up for filtered queries, larger than any HNSW tuning. And do not index fields you never filter on, because each one is resident memory that could have been vectors.

What a payload index costs, and why cardinality picks the structure

tenant · 2,000 distinct, keyword ~40–80 MB on 10M rows
doc_id · 200,000 distinct ~80–120 MB
published · datetime, sorted ~120–150 MB
department · 20 distinct, bitmap 20 × 1.25 MB = ~25 MB
six or seven fields at this scale lands near the 3 GB payload line in the reference stack, once you include per-engine overhead

Twenty departments means twenty bitmaps of 1.25 MB, ANDed in microseconds. Two hundred thousand document ids means a bitmap per value would be 250 GB, so it has to be an inverted list or a tree. Milvus makes the rule explicit — bitmap index below roughly 500 distinct values, inverted index above. If an interviewer asks which payload index type, cardinality is the answer.

6 · Filtering during the walk, and the bitmap

One percent of ten million is a hundred thousand rows: too many to score by brute force, too few for a plain walk to find ten of. The engine keeps walking but checks the filter as it goes. Two variants exist and they are genuinely different mechanisms.

VariantHow it worksWho
Stepping-stone The walk visits non-matching neighbours to keep moving through the graph, but only matching ones may enter the result list. Non-matches are used as roads, never as destinations Weaviate sweeping, Milvus bitset, Lucene pre-filter
Walk–filter–walk A normal walk returns a batch; the batch is filtered; if short of k the walk resumes from where it stopped and returns the next batch. Repeat until k, or until a cap pgvector iterative scan

Both produce the same shape of cost: a walk roughly 1 ÷ selectivity longer than an unfiltered one, capped by a limit the engine imposes so that a pathological filter cannot walk the entire table.

The bit check is free. The extra steps are not.

The mechanism that makes “check the filter as you go” affordable is a bitmap: one bit per row, set if the row passes. Ten million rows is ten million bits is 1.25 MB, small enough to live in L2 cache. The walk tests one bit per neighbour visited — a fraction of a nanosecond, against the roughly 500 ns of the distance computation sitting beside it.

So the answer to “does the filter live in the graph node?” is no. The payload lives with the row, the filter arrives with the query, and the bitmap is built per query from the payload index and thrown away. The filter is effectively free per step; the entire cost is in the extra steps.

What is actually cached

The payload index is resident, and the per-value lists inside it are resident. A bitmap for a single hot predicate — tenant A, asked five hundred times a second — can be cached, because it only changes when tenant A’s rows change. Combined predicates are rarely cached; the AND is cheap enough to redo.

That is the bridge into the second half of this document: a tenant filter is the one predicate every query carries, so it is the one worth keeping hot — or baking into the structure entirely.

7 · The connectivity problem

This is the part that makes filtered vector search genuinely hard rather than merely fiddly, and it is the thing most candidates have never thought about.

THE FILTERED SUBGRAPH IS USUALLY NOT CONNECTED match match match HNSW edges connect vectors that are close in embedding space. A tenant’s chunks are scattered across that space by topic. So the subgraph made only of matching nodes is typically disconnected — islands of a few nodes, joined only through nodes that fail the filter. The dilemma, stated properly A walk that refuses to step on non-matching nodes reaches one island and stops. A walk that ignores the filter wastes almost every step. Every engine’s filtered-search feature is a way out of that dilemma. Build-time answer · Qdrant’s filterable HNSW adds extra edges between nodes that share an indexed payload value, so the islands are joined by a road that exists only for that filter. Query-time answer · ACORN keeps the original graph and lets the walk pass through non-matching nodes for two or three hops to reach the next match, without returning them.
  1. The walk lands on a node that passes the filter. Three matching nodes exist here, spread far apart, and the edges between them run through non-matching nodes.
  2. The dilemma: a walk that only steps on matches gets stranded on one island; a walk that ignores the filter wastes almost every step.
  3. Build-time answer: Qdrant’s filterable HNSW adds payload-aware edges when the graph is built — the payload_m parameter controls how many.
  4. Query-time answer: ACORN (Weaviate v1.27+, Qdrant 1.16+) evaluates neighbours-of-neighbours so the walk can jump over a ring of non-matches.

Three shapes of answer, and the trade is when the work happens. Extra edges at build time means a bigger graph and a hard ordering constraint — the payload index must exist before the graph is built. Multi-hop at query time means more neighbour visits per step and a small recall cost, but nothing to rebuild. A bitset handed to the walk, with non-matches used as roads only, means walk length grows with 1 ÷ selectivity and you need a brute-force fallback underneath it.

8 · Anatomy of one filtered query

Everything in the last five sections, in one pass, inside the engine.

top 10 WHERE tenant = 'A' AND published > '2026-03-01' 1 · the request carries the vector, the filter and k together. Nothing is pre-computed by the caller. 2 · tenant index → 4,000,000 ids a keyword or B-tree lookup, not a table scan 2 · published index → 800,000 ids a range scan over a sorted structure 3 · the two id sets become bitmaps and are ANDed. 10M bits is 1.25 MB — small enough to sit in L2 cache. popcount = 320,000 set bits = 3.2% selectivity · cost: two index lookups plus one AND, a few milliseconds 4 · the planner reads the popcount and picks the strategy. Above the brute-force threshold, so: a bitmap-guided graph walk. 5 · the walk extends until ten matching candidates are found — about 310 candidates in, roughly eight times an unfiltered walk. 6 · ten ids return. With a filter leaving 200 rows the same pipeline skips step 5 entirely and scores the 200 directly.
  1. The request carries the vector, the filter and k together. Nothing is pre-computed by the caller.
  2. Each predicate is answered by its own payload index: 4,000,000 ids for the tenant, 800,000 for the date range.
  3. The two id sets are turned into bitmaps and ANDed. 320,000 bits set — 3.2 percent selectivity.
  4. The planner reads the popcount. Above the brute-force threshold, so it chooses a bitmap-guided walk.
  5. The walk extends until ten matching candidates are found — about 310 candidates, eight times an unfiltered walk.
  6. Ten ids return. A filter leaving 200 rows would skip step 5 and score the 200 directly.

Step 4 is the one that matters. The strategy is chosen per query, from the popcount — so the same collection answers a 40 percent filter and a 0.0005 percent filter by completely different mechanisms without the application knowing either happened. It also answers the question people ask about step 3: no, the filter does not live in the graph node. The payload lives with the row, the filter arrives with the query, and the bitmap is built per query and thrown away. The bit test itself is a fraction of a nanosecond against the ~500 ns of the distance computation beside it — the filter is effectively free per step, and the entire cost is in the extra steps.

9 · What each engine actually does

pgvector is worth its own figure, because it is the one engine with no vector planner — the ordinary Postgres planner chooses between three plans using ordinary row-count statistics, and it has no idea what selectivity means for HNSW.

PLAN A · THE DEFAULT, AND THE CLASSIC FOOTGUN HNSW index scan ef_search rows, in order filter applied LIMIT takes survivors Correct and cheap for loose filters. For tight ones it is the silent shortfall from the earlier figure, and the tell is right there in the query plan: Rows Removed by Filter: 39 Chosen whenever the planner’s row estimate says the filter is loose. It is exactly as smart as its statistics: after a bulk load with no ANALYZE it can still believe there are 200 matching rows when there are now 200,000 — or the reverse. The lever: hnsw.ef_search, default 40. At 200 the filter receives five times as many candidates for roughly five times the walk cost and no schema change. PLAN B · EXACT, AND ONLY AVAILABLE IF YOU BUILT THE B-TREE B-tree on the filter the exact matching rows score every one sort, take ten The graph is never consulted, so there is no approximation anywhere in the path. Recall is exactly 1.00, and at fifty or five hundred survivors it is also faster than the graph. Chosen only if the B-tree exists and the statistics say the filter is tight. pgvector 0.8 improved the cost estimates so it is chosen more often when it should be. No payload index, no brute-force plan. Without a B-tree, “find the 50 survivors” means reading ten million payloads, so the planner will not even consider this plan. That is the precondition to state out loud. PLAN C · ITERATIVE SCAN, OFF BY DEFAULT, pgvector 0.8+ walk a batch filter it short of LIMIT? re-enter the graph from where it stopped until LIMIT, or the cap strict_order returns rows in exact distance order and reaches less of the graph. relaxed_order may return a farther row before a nearer one but explores more, so recall is better — wrap it in a CTE and re-sort if order matters. Two constraints that catch people The ORDER BY and the WHERE must be at the same query level — a CTE boundary between them disables the iterative scan. And max_scan_tuples defaults to 20,000, which at 0.05% selectivity is ten expected survivors.

Postgres has no query hints, so you steer rather than choose. Three levers: indexes — Plan B exists only if a B-tree exists on the filter column; statisticsANALYZE after any large load, because autovacuum’s threshold on a ten-million-row table is 10 percent by default and a 500,000-row load can leave estimates stale for hours; and session settings, which shape Plan A. Use SET LOCAL inside a transaction, never plain SET — behind a connection pool the session outlives your request and the next borrower silently inherits your ef_search.

The order of escalation in pgvector

1. raise hnsw.ef_search — no schema change, roughly linear cost
2. enable hnsw.iterative_scan — 0.8+, capped at 20,000 tuples
3. add a B-tree on the filter column — this is what unlocks Plan B for tight filters
4. ANALYZE after loads, so the planner actually picks it
5. a partial HNSW index per big value — a graph in which everything qualifies
6. partition or shard by the key — at which point the filter has become routing

Partial indexes, and where they stop

A partial HNSW index WHERE tenant = 'A' gives that tenant a graph in which every node qualifies: no over-fetch, no bit checks, and in-filter recall identical to unfiltered recall. The planner matches the query’s WHERE to the index’s WHERE automatically.

On the reference stack a partial index for a 4-million-row tenant at int8 is about 6.1 GB of vectors plus 1.0 GB of graph, and roughly 35 minutes to build. Ten big tenants means ten graphs plus the shared one, each rebuilt on its own schedule, each competing for maintenance memory. It does not scale to ten thousand values — and that limitation is the doorway into the second half of this document.

The other five engines

EngineGraph-side mechanismBrute-force switch What you must declareThe gotcha
Qdrant Filterable HNSW: payload-aware edges via payload_m; ACORN since 1.16 full_scan_threshold, 10,000 KB — about 1,700 vectors at 1536-d A payload index per filtered field, before ingest An index declared after ingest needs a graph rebuild — it does not catch up on its own
Weaviate Sweeping or ACORN over a Roaring allow-list flatSearchCutoff, 40,000 objects indexFilterable on filtered properties — on by default Older clients still default to sweeping; check the collection config
Elasticsearch Bitset pre-filter inside the kNN walk, per Lucene segment Live: matches ≤ num_candidates, or nodes explored > matches keyword / date mappings; the filter inside the knn clause A bool.filter wrapped around a knn query is a post-filter and can return fewer than k
Milvus Bitset pre-filter (standard), or iterative filtering for expensive expressions Segment-level, internal Scalar index per field: bitmap under ~500 distinct, inverted above Iterative filtering processes one entity at a time and is slow when many must be checked
Pinecone Single-stage; per-cluster metadata statistics skip clusters with no possible match InternalStore unordered numerics (ids) as strings, or the min/max statistics are defeated A very selective filter can leave the chosen clusters unable to fill top_k

The rule Lucene implements that everyone else bakes into a constant

Per segment: if the matching document count is small, skip the graph and brute-force the matches. Otherwise walk the graph with the bitset — but if the number of nodes explored exceeds the number of survivors, abandon the walk and brute-force them instead, because at that point brute force is provably cheaper.

That is a live version of the crossover from section 5, decided mid-query rather than by a threshold set at configuration time. It is also why Elastic warns that, unlike ordinary queries, a more restrictive kNN filter can make the query slower.

10 · Filtering meets sharding

Document 13 flagged this; here is the full picture. A filter’s selectivity is computed per shard, and hash sharding scatters every filter value evenly — so a 1 percent filter is 1 percent on each of four shards. Each shard walks its own graph under the same tight filter. The over-fetch and the connectivity problem are paid four times.

4 hashed shards, filter passes 100,000 rows per shard: 25,000 survivors of 2.5M — still 1%
each shard an iterative walk, ~1,000 candidates for 10 hits
coordinator merges 4 × 10 → 10 · total walk work ~4× one shard
4 tenant-keyed shards, filter = tenant A routed to one shard, where selectivity is 100% — a plain walk, no filter cost, three shards not touched at all

Two cases where sharding helps, and one where it is the worst combination available

When the filter key is the shard key, filtering becomes routing and costs nothing — Qdrant shard keys, Weaviate multi-tenancy, Pinecone namespaces, Postgres partitions by tenant.

When a filter is tight on the whole table but each shard can independently choose to brute-force its own survivors — Lucene’s per-segment rule — sharding parallelises the brute force.

Everything else, sharding makes worse. Fan-out plus a tight filter is the worst combination in this track: you pay the slowest-shard tail and the extended walk on every one of them.

Postgres partitioning is the partial index without the proliferation

Table partitioning by tenant gives one HNSW index per partition, and a query with WHERE tenant = 'A' is pruned to one partition and walks a graph in which everything qualifies. The planner manages the set, so you do not hand-maintain ten indexes.

The cost is the same skew problem: tenant A’s partition is four million rows and tenant Z’s is four hundred. And partition pruning needs the literal tenant in the WHERE — thousands of partitions also raise planning cost, so keep it to tens or low hundreds.

11 · Multi-tenancy: three places the boundary can live

Everything about multi-tenancy follows from one decision: at what layer of the store is “tenant” enforced? There are exactly three places it can go, and they are the same three answers the first half of this document gave for any filter, moved one layer up.

What makes tenancy its own topic is that the filter value is always present, always equality, always the same column — and the values are wildly unequal in size. That last property is what turns a filter problem into a design problem.

MODEL 1 · ONE GREAT HALL, A NAME TAG ON EVERY BOOK one HNSW graph over all 10,000,000 vectors · 95.5 GB per replica tenant_id is a payload field with an index; every query carries WHERE tenant_id = X nothing extra per tenant The librarian walks the same shelves for everyone and checks the tag on each book. The Shah family’s twelve books are scattered across a hall of ten million, and the librarian may walk a very long way without finding one. what it buys Zero marginal cost per tenant · instant onboarding · trivial cross-tenant search · one graph to operate, whatever the count what it costs Isolation is only as good as one predicate · recall depends entirely on tenant size · deletion is tombstones, not removal MODEL 2 · ONE HALL, EACH FAMILY’S BOOKS KEPT TOGETHER ON THEIR OWN BAY bay 3 4,375,000 rows bay 0375kbay 1375kbay 2375kbay 3375kbay 4375kbay 5375kbay 6375kbay 7375kbay 8375kbay 9375kbay 10375kbay 11375kbay 12375kbay 13375k the tenant is a routing key: the request goes to one bay before any distance is computed And notice the second effect: routing does not remove the filter, it raises the selectivity. A 138-row tenant inside a bay of 12,000 is 1.15% — not 0.0014%. The skew moves to disk — it does not go away Hash ten thousand tenants into sixteen bays and the whale lands in one of them. That bay is 4.4 million rows and takes roughly 44 percent of the traffic; the other fifteen are idle. This is document 13’s hot-shard problem with a name on it, and it takes document 13’s fixes: pin the big ones, or add bays. MODEL 3 · A PRIVATE ROOM PER FAMILY, WITH ITS OWN CATALOGUE Each tenant gets its own table, graph, entry point, layers, files, payload indexes and memory-mapped region. A query walks that graph unfiltered. Isolation is total, because there is no filter to forget. Recall is whatever the graph parameters give — the same as the whale gets. Then multiply by ten thousand, and every engine says the same thing in its own words Qdrant Cloud caps collections at 1,000 per cluster. Milvus maintainers advise staying under about 1,000. Elasticsearch’s guidance is no more than 20 shards per GB of heap — roughly 620 on a 31 GB heap — and its forum answer to “index per tenant for 8,000 tenants?” is that it will not scale. A collection is the unit of schema and operations, not the unit of tenancy. Ten thousand of them means ten thousand things to create, migrate, back up, monitor and rebuild.

The “native” versions are Model 3 with the operational cost engineered down. Weaviate’s tenant, Pinecone’s namespace and Qdrant’s dedicated shard are all a private room that is cheap to keep, can be turned off when the family is away, and shares one front desk and one schema with every other room. That is why the vendor answer to “how many tenants?” moved from “a few hundred” to 100,000 namespaces per index (Pinecone) and a stated design target of millions (Weaviate).

The four things a tenant needs

NeedIn plain EnglishWhich model delivers it for free
Data isolationI never see your rows 3, and 2 physically. 1 only if the filter is never omitted
Recall parity My search works as well as yours, whatever my size 3. 1 only with a brute-force fallback
Performance isolationYour traffic does not slow me down None fully — this lives above the store
LifecycleOnboard me in seconds, delete me completely 3, where deletion is a DROP. 1 and 2 pay for deletion later

An interviewer will usually ask about the first two. Raising the last two unprompted is what separates an answer from a good answer.

12 · Tenant skew, and what it does to recall

Nothing about multi-tenancy makes sense until you hold one table in your head: twenty tenants own two thirds of the data, and nearly ten thousand share the remaining seventh.

10,000 TENANTS · 10,000,000 CHUNKS · SCALAR INT8, 29.2 GB PER REPLICA TIER TENANTS ROWS EACH SHARE SELECTIVITY WHERE IT LIVES whale 1 4,000,000 40% 40% own graph large 19 ~150,000 28.5% 1.5% own graph medium 180 ~10,000 18% 0.1% shared · filtered walk small 9,800 ~138 13.5% 0.0014% shared · brute force, exact tenants promoted 20 graphs to operate 21 every one of them is a thing to rebuild, back up, monitor and migrate fixed overhead 0.21 GB 0.7% on top of 29.2 GB — the vectors were going to be somewhere regardless Twenty graphs is nothing. Ten thousand would have been the problem.

The line is not “when the tenant has more data than a graph’s overhead”. It is when the tenant leaves the brute-force band — because below that a shared table answers it exactly, in microseconds, and above it a filtered walk starts costing recall. Qdrant’s ~20,000-point recommendation and Weaviate’s 10,000-object flat-to-HNSW default both sit at exactly that boundary. They are the crossover from earlier in this document, applied per tenant.

The same query, the same index, four different outcomes

TenantRowsSelectivity Survivors in 40 candidatesCandidates for 10 Verdict
whale4,000,00040% 1625A plain walk is fine
large150,0001.5%0.6 667Needs an iterative scan
medium10,0000.1% 0.0410,000Right at the brute-force line
small1380.0014% 0.00055724,638A walk is useless — and brute force is exact in microseconds

The counter-intuitive result: the tail is not the hard part

A 138-row tenant is 138 × 1,536 = about 212,000 multiply-adds. That is microseconds, and it is exact. The tail is easy once the engine has a brute-force fallback.

The hard band is the middle — tenants of ten thousand to a couple of million rows, too big to brute-force cheaply and too small for a plain walk. On the reference stack that is 180 tenants carrying 18 percent of the data, and they are precisely the ones nobody notices.

Recall is no longer one number, and the dashboard shows the whale’s

“Our recall@10 is 0.96” is a fine sentence for a single-tenant store. Here it hides everything: the tail is 1.00 because it is brute-forced, the whale is 0.96 because it gets a plain walk, and the middle tier can sit between 0.85 and 0.97 depending on connectivity.

The dashboard number is the whale’s number, because the whale is 40 percent of the queries. The medium tier is 1.8 percent of traffic, so it can be measurably worse forever and never move the average.

Measure per tenant and report the distribution — p50 and p10 across tenants, not the mean. An interviewer who hears “we track p10 recall across tenants” knows you have run one of these systems.

13 · The tiered design, and the promotion threshold

Rooms for the few families with thousands of books; one shared hall with name tags for everyone else; a rule at the door about how many requests each family may make. Every major vendor has now built some version of exactly that.

EngineNameMechanismThreshold and limits
QdrantTiered multitenancy, v1.16 Custom sharding; a shared fallback shard holds small tenants, large tenants get a dedicated shard, and tenant promotion moves one from fallback to dedicated using the shard-transfer mechanism — reads and writes continuing throughout. Every request carries a shard-key selector naming both Recommended promotion at ~20,000 points. Stay under about 1,000 dedicated shards per cluster
WeaviateNative multi-tenancy + dynamic index Every tenant is its own shard. With a dynamic index a tenant starts flat — vectors on disk, brute force — and converts, one-way, to HNSW when it crosses the threshold. Idle tenants go INACTIVE (disk) or OFFLOADED (S3) Default conversion at 10,000 objects. Dynamic index requires async indexing
pgvectorHand-built Shared table with a B-tree on tenant_id and iterative scan for the middle; the planner picks Plan B for tiny tenants; a partial HNSW index or a LIST partition per whale The threshold is yours. A partial index per tenant stops being sane past a few dozen
MilvusPartition key + Partition Key Isolation Hash tenants into num_partitions (default 16); with isolation enabled Milvus builds a separate sub-index per key value and searches only that 1,024 manual partitions per collection; collections advised under about 1,000
PineconeNamespaces One namespace per tenant on a serverless index, physically separate storage. No shared graph, so no size tiering is needed on the read path 100,000 namespaces per index on Standard and Enterprise

The sentence for the interview

“Promote a tenant to its own graph when it leaves the brute-force band — around ten to twenty thousand rows — because below that a shared table answers it exactly in microseconds, and above that a filtered walk starts costing recall. Both Qdrant and Weaviate default to that line.”

Note what that is not: it is not “when the tenant has more data than a graph’s overhead”. Overhead sets an upper bound on how many graphs you can afford; the crossover sets where each one earns its place.

What one more tenant costs, which is the engineering-manager version

Resident memory, per replica at int8: a small tenant is 138 × 1,536 = 0.2 MB plus about a kilobyte of bitmap. A medium tenant is 15 MB. A large tenant with its own graph is about 240 MB plus fixed overhead. The whale is 6.4 GB.

Compute per query: the small tenant is 212 thousand multiply-adds. The whale is two to eight million. A tail tenant is cheaper per query than the whale, not more expensive.

Operations per tenant: in the shared graph, none — a row is a row. With its own graph, one more thing in every loop: rebuild, backup, health check, monitoring, migration. That is the real cost of the tail, and it is the whole argument for the shared hall.

The tenant bitmap, and why the numbers work

An uncompressed bitmap over ten million rows is 1.25 MB per tenant regardless of its size — ten thousand of those is 12.5 GB, which is why nobody stores them uncompressed. Roaring compression makes a 138-row tenant about a kilobyte, a 10,000-row tenant about 20 KB, and the whale under a megabyte. All ten thousand tenants come to roughly 20 MB, resident.

That is why dedicated engines can afford to keep the membership sets persistent, and why the Postgres approach — materialise the bitmap per query from the B-tree — is fine for one tenant filter but adds work on every single call.

14 · Isolation is two different things

People say “tenant isolation” and mean either of two unrelated guarantees. An architect answer names both. An engineering-manager answer adds who owns each.

Data isolationPerformance isolation
The guaranteeTenant Z never receives a row of tenant A Tenant A’s traffic never slows tenant Z
Where it is enforced In the store — filter, route or collection — and in the read-time join Above the store: API gateway, queue, quota. Or by physical separation
Failure modeA leak — a security incident A slowdown — an SLA incident
Which model helps3 > 2 > 1 None fully. Dedicated replicas help; rate limits are the real fix
Who owns itThe platform team, and they must be able to prove it The API team — it is a product policy, not a store property

The noisy neighbour, and what the stores actually offer

Every tenant’s query lands in the same queue and competes for the same CPU and page cache. A whale at high QPS with wide walks fills the queue, and a tail tenant’s microsecond query waits tens of milliseconds behind it. A private room fixes what you find; only a rule at the door fixes how long you wait.

EnginePerformance-isolation primitiveWhat it actually gives you
QdrantA dedicated shard per large tenant True for I/O and graph pages. CPU and network on a shared node are still shared unless the shard is placed on its own node
WeaviateTenant states — an inactive tenant consumes nothing Protects RAM. Does nothing for QPS
PineconeNamespaces isolate storage Read and write unit limits apply per index, not per namespace. Enforce per tenant yourself
ElasticsearchIndex per tenant on separate node roles Physical, coarse and expensive
PostgresNothing per tenant Connection pools, statement_timeout, and separate replicas. Route the whale’s read traffic to its own replica

None of them gives a per-tenant CPU share inside a shared collection. That is the statement to make, and then qualify — because the honest fix is a per-tenant rate limit and a fair scheduler at the API layer, plus physical separation for the whale.

15 · Never trust the filter alone

In the shared-table model the entire security boundary is one predicate that application code must remember to add to every query. Code paths multiply — admin tools, batch jobs, a new endpoint, a debugging script — and one of them will forget.

1 · tenant-scoped credential the store itself refuses other tenants — Weaviate requires a tenant per call, Pinecone a namespace, Qdrant a JWT with a payload claim, Postgres a role
2 · row-level security the database adds the predicate, so forgetting it is impossible
3 · read-time join the final SELECT against the system of record re-checks the tenant on the ids that came back
4 · the filter in the query necessary for recall and cost — and insufficient for security

Postgres row-level security, and the two things an interviewer will probe

The policy attaches the tenant predicate to the table; the application only sets a session variable. ALTER TABLE ... ENABLE ROW LEVEL SECURITY, then FORCE ROW LEVEL SECURITY so the table owner is subject to it too, then a policy using current_setting('app.tenant_id').

First probe: RLS gives correctness, not recall. The policy is inlined into the plan as an ordinary Filter, so the nearest-neighbour query still walks the index broadly and filters afterwards — it is Plan A with a predicate you cannot forget. Pair it with iterative scan, a B-tree on tenant_id, and partial indexes or partitions for the big tenants.

Second probe: SET against SET LOCAL. A plain SET survives the transaction and leaks to the next borrower of a pooled connection — which in a multi-tenant system is a cross-tenant read waiting to happen. Always SET LOCAL, inside a transaction.

The read-time join is the only layer that is correct by construction

Whatever the vector store returns, the last step re-selects those ids from the relational system of record with the tenant predicate applied. If the store leaked, the join drops the leak. The vector store is a cache of candidate ids; the database is the source of truth for which tenant may see them.

And it fixes the other drift problem too: a document deleted in Postgres leaves its vectors behind, and a tenant reassignment leaves the old tenant in the payload. Search then returns ids that no longer exist or that the user must not see — and the join catches both.

Tenant context must travel with the request

A subtle class of bug: the tenant is resolved from the session at request start, but a cache key, a background job or an async continuation runs without it. The rule is that tenant id is a required parameter of every retrieval function, never something read from ambient state. Cache keys include it, queues carry it, logs print it.

16 · Onboarding, cold tenants, promotion and deletion

The two lifecycle questions almost nobody prepares for — and the two an experienced interviewer reaches for once the design questions are answered.

Cold tenants

In any SaaS, most tenants are idle most of the time. The shared-table model does not care — an idle tenant is idle rows in a graph that was loaded anyway. A collection per tenant cares a great deal, and Weaviate has the most developed answer.

StateWhere the data isRAMLocal disk A query
ACTIVELoadedyes yesServed
INACTIVELocal disk onlyno yesError, or auto-activate
OFFLOADEDCloud object storageno noError, or auto-activate

Details that show you have read the documentation rather than a blog post

HOT and COLD were renamed ACTIVE and INACTIVE in v1.26. Offloading needs the S3 module. State changes are eventually consistent across a cluster, so data may not be immediately available after reactivation. Backups include only ACTIVE tenants. And auto_tenant_activation reloads a tenant on first read or write, at the price of a cold-start latency on that request.

That last one is the same cold-cache trade as document 12, at tenant granularity: the cheapest idle tenant is the one whose first request is slow.

Promotion, when a tenant grows across the line

EngineHow it promotesDowntime
QdrantTenant promotion from the fallback shard to a new dedicated shard, using shard transfer None — reads and writes continue during the move
WeaviateThe dynamic index converts the tenant’s flat index to HNSW at the thresholdNone; async indexing builds the graph in the background. One-way
pgvectorYou do it: CREATE INDEX CONCURRENTLY for a partial index, or detach and attach into a dedicated partition Concurrent build avoids write locks; moving rows between partitions is a copy
MilvusMove to a manual partition or collection — an application-level re-insertApplication-managed
PineconeNot needed — namespaces never shared a graph

Deletion, which is where the model choice finally bites

“Delete everything about customer Z” arrives from legal with a deadline. The isolation model decides whether that is a one-second DROP or a compaction you have to schedule and then prove.

EngineDelete tenant ZWhen the bytes are actually gone
pgvectorDELETE ... WHERE tenant_id = 'Z', or DROP TABLE for a partition After VACUUM removes the dead tuples and the HNSW index’s dead entries. A partition drop is immediate
QdrantDelete points by filter, or delete the shard key Filter delete: after the optimizer rewrites segments. Shard delete: immediate
Weaviatetenants.remove([Z]) Immediate — the shard is deleted
MilvusDelete by expression, or drop the partition / collection Expression delete: after compaction. Drop: immediate
ElasticsearchDelete-by-query, or delete the tenant’s index Query delete: after a segment merge. Index delete: immediate

The erasure checklist, because “we deleted the rows” is not an answer

Primary store rows deleted, physically compacted, count verified. Every replica compacted — RF 3 means three copies of the tombstones. Payload and bitmap entries for Z removed. Outbox and reconciliation queues drained. Application and result caches purged of Z’s ids. Backups and snapshots: retention window documented, or rebuilt without Z. Logs: Z’s query text and ids under a retention policy. And a signed record of all of it for the request file.

Being able to produce that list is the difference between a plausible answer and one that has survived an audit.

Cross-tenant queries, and why they should not drive the design

Support staff, analytics, abuse scans and duplicate detection all want to search across tenants, and it is the case the collection-per-tenant model handles worst: Model 1 drops the WHERE and it is free; Model 2 scatters to every bay; Model 3 scatters to ten thousand collections, and Pinecone has no single cross-namespace query at all.

The usual answer is a second, thinner shared table for that use case — often a smaller embedding or a sample — or accepting that the cross-tenant path is an offline job. Do not let a rare admin feature force the whole system into Model 1.

17 · Symptom to cause

Almost every symptom here presents as “search is bad for some customers”, which is why the diagnosis has to start from selectivity rather than from the complaint.

SymptomLikely causeWhat to checkFix
A filtered query returns fewer than k rows Post-filtering with a tight filter — the classic silent shortfall Selectivity, and Rows Removed by Filter in EXPLAIN ANALYZE Raise ef_search, enable iterative scan, or add the payload index that unlocks brute force
A more restrictive filter made the query slower Working as designed — the walk has to explore further to gather k that pass Whether the engine has a brute-force fallback and where its threshold sits Lower the threshold so brute force takes over, or route by the key
Filtered queries are slow and no tuning helps No payload index on the filter field Declared payload indexes against the fields actually filtered on Index every field you filter on — and in Qdrant, rebuild the graph afterwards
Qdrant filtered search stayed slow after adding a payload index The index was declared after ingestion, so the payload-aware edges were never built Whether the collection has been rebuilt since Force a rebuild — set m to 0 and back. On a large collection this is a full re-index, which is why the order matters on day one
pgvector picks the wrong plan after a bulk load Stale statistics — the planner is exactly as smart as pg_stats ANALYZE timing against the load; autovacuum’s 10 percent threshold on a 10M-row table ANALYZE after every large load, and put it in the pipeline rather than the runbook
ef_search changes leak between requests Plain SET behind a connection pool Whether the session setting is inside a transaction SET LOCAL, always
Iterative scan is enabled and still returns short The 20,000-tuple cap, or a CTE boundary between the WHERE and the ORDER BY Query shape and max_scan_tuples Below about 5,000 matching rows you want Plan B, which means a B-tree on the filter column
Elasticsearch kNN returns fewer than k with a filter The filter is in bool.filter around the knn query — that is a post-filterWhere the filter clause sits Move it inside the knn clause
Recall is fine on the dashboard, bad for specific customers Per-tenant recall variance hidden by an average the whale dominates Recall per tenant, reported as a distribution Track p10 across tenants; promote the middle band
One tenant’s queries slow everyone down No performance isolation — there is none inside a shared collection Per-tenant QPS and walk width Per-tenant rate limits at the API layer; a dedicated replica for the whale
A tenant was deleted and memory did not drop Tombstones — a filter delete frees nothing until compaction Deleted-vector count against live count Trigger compaction and verify with a count. For an erasure request, work the whole checklist
Cross-tenant admin search times out Model 3: it is a scatter across every collection How many collections the query touches A thin shared table for the admin path, or make it an offline job

18 · Interview questions

ArchitectHow does a filtered vector search work?

The first thing I would establish is selectivity, because nothing else matters until you know it. Survivors equals candidates times selectivity, so a walk returning forty candidates under a one-percent filter yields 0.4 survivors on average — and the query does not fail, it just returns fewer rows than asked for.

From there there are three bands. Above roughly twenty percent, run a plain walk and post-filter, over-fetching by one over selectivity. Below roughly a tenth of a percent, ignore the graph entirely: use the payload index to get the exact survivor set and score them all, which is faster and exact. In between, the engine filters during the walk, using a bitmap built per query from the payload index, and the walk grows by about one over selectivity.

The band boundaries are not constants — they are where two cost curves cross, and every engine bakes its own version into a knob: Qdrant’s full_scan_threshold, Weaviate’s flatSearchCutoff, and Lucene deciding live mid-query.

ArchitectA user reports that search returns three results instead of ten. Where do you look?

At the filter’s selectivity first, because this is almost always post-filtering with a tight predicate. The walk returned its forty candidates without knowing there was a filter, seven of them failed it, and the application showed what survived. There is no error and no log line — in pgvector the tell is Rows Removed by Filter: 39 in EXPLAIN ANALYZE, and nobody is looking at query plans when the report says “search feels thin”.

The escalation is cheapest first: raise ef_search, which is roughly linear and needs no schema change; enable iterative scan if the engine has it; then add a payload index on the filter column, which is what actually unlocks the brute-force plan for tight filters.

And I would treat it as a monitoring gap as much as a bug. A query that returns fewer rows than k is a signal the application can emit, and most systems throw it away.

ArchitectWhy can a more restrictive filter make a query slower?

Because the graph was built for one geometry — embedding similarity — and the filter imposes a second one the graph knows nothing about. A tighter filter means the walk has to explore further to gather k candidates that pass, so work goes up as the result set goes down. Elastic documents this explicitly as a difference from ordinary queries.

Underneath it is the connectivity problem. A tenant’s chunks are scattered across embedding space by topic, so the subgraph of matching nodes is usually disconnected — islands joined only through nodes that fail the filter. A walk that refuses to step on non-matches gets stranded; a walk that ignores the filter wastes almost every step.

The ways out are the three vendors have built: extra payload-aware edges at build time, which is Qdrant’s filterable HNSW; multi-hop traversal through non-matches at query time, which is ACORN; or a fallback to brute force once the walk has visited more nodes than there are survivors, which is what Lucene does.

ArchitectWhen is brute force the right answer for a vector search?

Whenever the survivor set is small enough that scoring it costs less than a graph walk — which on our stack is a few thousand rows. At fifty or five hundred survivors it is not merely acceptable, it is faster than the graph: fifty distances at 1536 dimensions is about 77,000 multiply-adds, against two to eight million for one HNSW walk.

And it has a property nothing else here has: recall is exactly 1.00. Approximation enters a vector search only through the graph, and brute force never touches it.

The precondition is a payload index on the filter column. Without one, finding the survivors means reading ten million payloads, and in pgvector the planner will not even consider the plan. That is why “index every field you filter on” is the highest-leverage move in filtered search — larger, by Qdrant’s own account, than any HNSW tuning.

ArchitectHow would you design multi-tenancy for ten thousand tenants?

Tiered, and the tiers come from the size distribution rather than from a preference. On a realistic stack one tenant owns forty percent of the data, nineteen more own another twenty-eight, a hundred and eighty are around ten thousand rows each, and nearly ten thousand have a couple of hundred rows.

So: the whale and the large tenants get their own graph — a dedicated shard, a partition, or a partial index — and their queries walk unfiltered. Everyone else lives in one shared graph with a tenant payload index. The tail is answered by brute force over a few hundred rows, which is exact and takes microseconds; the middle band gets a filtered walk.

Twenty graphs is nothing to operate. Ten thousand would have been the problem — Qdrant Cloud caps collections at a thousand per cluster, Milvus advises the same, and Elasticsearch’s guidance works out to about 620 shards on a 31 GB heap. A collection is the unit of schema and operations, not the unit of tenancy.

And the promotion line is around ten to twenty thousand rows, because that is where a tenant leaves the brute-force band. Both Qdrant and Weaviate default to exactly that.

ArchitectYour recall dashboard says 0.96 and a customer says search is broken. Both are true. Explain.

The dashboard is showing the whale’s number. In a multi-tenant store recall stops being one number: the tail is 1.00 because it is brute-forced, the whale gets a plain walk at 0.96, and the middle band — tenants of ten thousand to a couple of million rows — can sit anywhere from 0.85 upward depending on how connected their subgraph happens to be.

The whale is forty percent of the queries, so it dominates the average. The medium tier is under two percent of traffic, which means a hundred and eighty tenants can be measurably worse forever without moving the number anyone looks at.

The fix is measurement before mechanism: per-tenant recall against a held-out set, reported as a distribution — p50 and p10 across tenants, not the mean. Then promote the tenants that are failing, because moving a tenant to its own graph turns a filtered walk into an unfiltered one and its recall rises to whatever the graph parameters give.

ArchitectIs a tenant filter enough for security?

No, and I would be firm about that. In the shared-table model the entire boundary is one predicate that application code has to remember on every query, and the code paths multiply — admin tools, batch jobs, a new endpoint, a debugging script. One of them will forget.

There are four layers and the filter is the weakest. Strongest is a tenant-scoped credential, where the store itself refuses other tenants. Then row-level security, where the database adds the predicate and forgetting is impossible. Then the read-time join — the final SELECT against the system of record with the tenant predicate, which is the only layer correct by construction, because if the store leaked, the join drops the leak.

Two details I would raise. RLS gives correctness, not recall — it is inlined as an ordinary filter, so it is Plan A with a predicate you cannot forget, and it still needs the iterative scan and the B-tree underneath it. And SET LOCAL, never plain SET, because behind a connection pool a session setting outlives the request and becomes a cross-tenant read waiting to happen.

ArchitectLegal asks you to delete a tenant completely. Walk me through it.

The first thing I would say is that the answer depends on the isolation model we chose months earlier. With a collection, tenant or namespace per tenant it is a drop — Weaviate deletes the shard and the objects with it. In a shared graph a delete marks rows dead and the vector bytes stay in the index file, exactly like every other soft delete in this system.

So the work is proving the bytes are gone: physical compaction, verified by a count, on the primary and on every replica — replication factor three means three copies of the tombstones. Then payload and bitmap entries, outbox and reconciliation queues, application and result caches, and backups, where the honest answer is usually a documented retention window rather than a rebuild.

And a signed record of all of it for the request file, because the deliverable is evidence, not a delete statement.

Eng managerYour team wants to give every enterprise customer their own collection. How do you respond?

By asking how many customers we expect and what we are actually buying. For twenty large customers it is a reasonable design and the isolation story is genuinely simpler. For ten thousand it is not a design, it is ten thousand things to create, migrate, back up, monitor and rebuild — and every vendor has published a limit that says so.

Then I would reframe it as a tiering question rather than a yes or no, because the tiered answer usually satisfies what the team actually wants: dedicated graphs for the large customers, a shared graph for the long tail, and a documented promotion threshold. That is also a shape the price list can follow, which tends to end the argument.

What I would insist on is that whatever we choose, the security boundary does not rest on the collection choice alone — there is a read-time join or a scoped credential underneath it either way.

Eng managerA customer complains that search is slow only during business hours. What is your first move?

Look at whether it is their query getting slower or their query waiting. Those have completely different owners. If their own latency is stable but their end-to-end time rises, that is queueing behind someone else — the noisy-neighbour problem — and no store in this space gives a per-tenant CPU share inside a shared collection.

So the fix is not usually in the database. It is per-tenant rate limits and a fair scheduler at the API layer, and for a genuinely large tenant, physical separation — its own replica, its own shard, its own node.

The management point is that this is a product policy, not a platform property. Data isolation is something the store team proves; performance isolation is something the API team enforces. If nobody owns the second one, it does not exist, and the incident recurs every quarter with a different customer name on it.

Eng managerHow do you stop filtered-search bugs from reaching customers?

By making the silent failure loud. The defining property of this whole area is that a tight filter returns three rows instead of ten and nothing complains — so the first change I would make is an application-level signal whenever a query returns fewer than k results, tagged with the tenant and the filter shape. That single metric would have caught most of the incidents in this document.

Second, a per-tenant recall test in CI rather than a global one, reported as a distribution. A global average hides exactly the tenants who are suffering, because the largest tenant dominates it.

Third, a checklist item that survives people leaving: index every field we filter on, ANALYZE after every bulk load, and in Qdrant declare payload indexes before ingestion. Those three are cheap, they are easy to forget, and each of them silently costs recall rather than raising an error.

19 · FAQ

Does the filter live inside the graph node?

No. The payload lives with the row, the filter arrives with the query, and the bitmap is built per query from the payload index and thrown away afterwards. The walk tests one bit per neighbour — a fraction of a nanosecond against the roughly 500 ns of the distance computation beside it. The filter is effectively free per step; the entire cost is in the extra steps.

Should I pre-filter or post-filter?

The words are used inconsistently, so answer with selectivity instead. Above roughly twenty percent, post-filtering is right and cheap. Below roughly a tenth of a percent, you want the payload index and brute force. In between you want the filter applied during the walk. Note that Weaviate calls all of its filtered search “pre-filtering” because the allow-list is computed first, and Elasticsearch means something quite different by the same word — which is why the number is a better answer than the label.

Why is my payload index not helping?

Three usual causes. It is on a field you do not actually filter on. Or in Qdrant it was declared after ingestion, so the payload-aware edges were never built and the graph needs a rebuild. Or the filter is loose enough that the engine correctly ignored it — a payload index does nothing for a forty-percent filter, because a plain walk was always going to work.

Can I just raise ef_search until filtered queries work?

Up to a point, and the point arrives fast. At one percent selectivity you need about a thousand candidates for ten results, which is twenty-five times the default work. At 0.05 percent you need twenty thousand, and at 0.0005 percent you need two million — a fifth of the table. Raising ef is the right first move because it is free to test and free to undo; it is not a strategy for tight filters.

How many payload indexes is too many?

The rule is symmetric: index every field you filter on, and index nothing else. Each one is permanently resident memory that could have been vectors — six or seven fields on ten million rows lands near the 3 GB payload line in the reference stack. The growth path is the thing to watch: every new filterable field the product team requests is memory forever, and nobody usually tells them.

Is one tenant per collection ever right?

Yes, for tens of tenants, and increasingly for thousands if the engine has a native lightweight tenant. Weaviate’s tenants, Pinecone’s namespaces and Qdrant’s dedicated shards are all Model 3 with the fixed cost engineered down — which is why the vendor answer moved from “a few hundred” to 100,000 namespaces per index. What is still wrong is ten thousand ordinary collections, which every vendor documents as unsupported in one form or another.

What does one more tenant actually cost?

It depends entirely on which tier they land in, and the counter-intuitive part is that a tail tenant is cheaper per query than the whale: 212 thousand multiply-adds against two to eight million. What the tail costs is not compute — it is the operational and memory overhead if you give each of them a graph. In the shared graph a row is just a row.

Do I need a separate vector database at all?

Under a few million vectors with a handful of filter fields, usually not — pgvector sits next to the tables you are joining against, filters are ordinary columns, and brute force is often fine. The honest framing is: search within an application, pgvector; search as the application, a dedicated engine. pgvector does not break at ten million, it stops being free — a dedicated instance, maintenance_work_mem tuning, builds competing with transactional queries, no forced plan choice, and one graph per table.

What only Postgres can do here?

Joins — vectors plus permissions plus employment status in one statement; Qdrant cannot join. Transactions — insert a document, its chunks and its vectors atomically. And the operational fact that it is already there, with backups, monitoring, access control and people who know it. At scale the common pattern keeps both: Postgres as the system of record, the vector store holding vectors plus a thin payload, and a two-hop query. The vector database proposes; the relational database disposes.

How do the two stores drift, and what do you do about it?

A document deleted in Postgres leaves its vectors behind; a tenant reassignment leaves the old tenant in the payload. Search then returns ids that no longer exist or that the user must not see. Three layers, and mature systems run all three: a transactional outbox so writes propagate reliably, a periodic reconciliation job that compares id sets, and the read-time join, which makes drift harmless at query time even when it exists.

20 · Cheat sheet

The arithmetic

selectivity rows passing ÷ rows in the table
survivors candidates × selectivity
candidates needed k ÷ selectivity
brute-force cost survivors × dimensions multiply-adds
one HNSW walk ~1,300–5,000 distances at ef 40, M 32 on 10M — 2–8M MACs
bitmap one bit per row · 10M bits = 1.25 MB uncompressed
the three bands >20% plain walk · 0.1%–20% filter during the walk · <0.1% payload index and brute force

The defaults worth knowing, with the usual hedge about dates

EngineKnobDefault
pgvectorhnsw.ef_search40
pgvectorhnsw.iterative_scanoff
pgvectorhnsw.max_scan_tuples20,000
Qdrantfull_scan_threshold 10,000 KB ≈ 1,700 vectors at 1536-d
WeaviateflatSearchCutoff40,000 objects
WeaviatefilterStrategy acorn, since v1.34
Elasticsearchnum_candidates 1.5 × k, max 10,000
Weaviateflat → HNSW conversion10,000 objects
Qdranttenant promotion~20,000 points
Milvusbitmap vs inverted payload index ~500 distinct values

The ninety-second version

“Filtered vector search is one number: selectivity. Survivors equals candidates times selectivity, so above about twenty percent you run a plain walk and post-filter, below about a tenth of a percent you skip the graph entirely and score the survivors from the payload index — which is both faster and exact — and in between the engine filters during the walk using a bitmap, with the walk growing by one over selectivity.

The reason it is hard rather than fiddly is connectivity. The graph was built for embedding similarity and the filter imposes a second geometry it knows nothing about, so the matching subgraph is usually disconnected. The three answers are payload-aware edges at build time, multi-hop traversal at query time, and falling back to brute force once the walk has visited more nodes than there are survivors.

Multi-tenancy is the same problem where the filter is always present and the values are wildly unequal. So the design is tiered: dedicated graphs for the twenty tenants that own two thirds of the data, one shared graph with a tenant payload index for the ten thousand that share the rest, and a promotion threshold around ten to twenty thousand rows — which is exactly where a tenant leaves the brute-force band.

Two things I would raise unprompted. The failure mode is silent: a tight filter returns three rows instead of ten with no error anywhere, which is why a ‘returned fewer than k’ metric is worth more than any tuning. And recall stops being one number — the dashboard shows the largest tenant’s figure, so you track the distribution across tenants or you do not know.”

Where this connects

Thread from this documentResolved in
Why deletes leave tombstones and what compaction does 03 · Identity, updates and deletes
Access control at ingestion, and the permission model behind the filter 04 · Access control and freshness
The HNSW walk being filtered, and why insert is a search 09 · Flat, IVF and HNSW
ef_search, k and the parameter budget these filters spend 11 · Parameters and tuning
Why a heavy filter breaks product quantisation’s lookup-table amortisation 12 · Quantisation and capacity
Shard keys, hot shards and the fan-out this section inherits 13 · Sharding and replication
Per-tenant recall measurement, and the CI gate that catches drift 16 · Evaluation and observability

Questions to ask them