Articles / Multi-tenant isolation
Isolation is four dials.
Everyone ships it as one switch.
A field study of multi-tenant isolation models for retrieval systems — why the relational playbook misprices them, where filtering quietly stops being isolation, and the four-axis model I use to pick a configuration instead of a slogan.
Abstract
Multi-tenancy is usually taught as a single decision with three answers: give every tenant their own stack (silo), share one stack between all of them (pool), or split the difference (bridge). That framing was built for relational systems, where the marginal cost of a tenant is rows on disk and the argument for separation is mostly regulatory.
Retrieval systems break the framing. In a vector store the dominant resident cost is not the data — it is the approximate-nearest-neighbour index built over the data, and that index is rebuilt in full for every partition you create. Isolation stops being a compliance conversation and becomes a capacity-planning one.
This piece argues that isolation is not one dial but four independent ones — storage, compute, enforcement, and operations — and that the useful architectures are the ones that set those dials to different values. It ends with the configuration I run in production across 100+ tenant environments, the measurements that justified it, and the three things it cost me.
Key findings
- 01Isolation is four independent axes, not one slider. The canonical silo/pool/bridge patterns are just the diagonal of a four-dimensional space; the defensible designs are usually off-diagonal.
- 02In vector retrieval the per-partition fixed cost dominates below roughly 50k vectors per tenant. Above it, per-tenant indexes are affordable; below it, they are the single largest line item in your memory bill.
- 03Pooled storage does not require pooled enforcement. Storage isolation is an efficiency decision; enforcement isolation is a security decision. Conflating them is how pooled architectures get their bad reputation.
- 04Filtering is not free isolation. A tenant predicate over a shared proximity graph degrades recall in a way that scales with how selective the predicate is — the failure is silent and does not show up in latency graphs.
- 05Operational isolation is the axis that gets discovered last and hurts most. Per-tenant restore, per-tenant erasure, and per-tenant cost attribution are trivial in a silo and are genuine engineering projects in a pool.
4
Isolation axes
storage · compute · enforcement · ops
100+
Tenant environments
single pooled collection
80%
Resident memory saved
vs. collection-per-tenant
~50k
Vectors/tenant crossover
below it, pooling wins on cost
1,000
Collections per cluster
vendor default ceiling [6]
3
Things it cost me
documented in §9
§2 · The model
Four questions,
asked separately.
Teams pick a word — pool, silo — and inherit four decisions from it. Ask them one at a time and most of the interesting architectures turn out to be the ones that answer them differently.
Axis A
Storage & index
Where do a tenant's bytes live, and who else's bytes share the structure built over them?
The axis everyone means when they say 'isolation'. In a relational system this is about rows and schemas. In a retrieval system it is about the proximity graph — because the graph, not the payload, is what sits resident in RAM.
A0
Shared index, tenant as an attribute
One collection, one graph. Tenant identity is a payload field, applied as a predicate at query time. Cheapest possible configuration and the default recommendation for large fleets of small tenants [6].
1 graph · O(1) fixed overhead
A1
Shared index, tenant-aware physical layoutshipped
Still one logical index, but the engine is told which field is the tenant key and co-locates each tenant's vectors so a scan reads sequentially instead of scattering. Qdrant exposes this as `is_tenant=true` on the payload index [6].
1 graph · + layout metadata
A2
Dedicated index, shared process
Each tenant gets its own shard/namespace/graph inside a shared cluster. Pinecone's serverless namespaces and Weaviate's shard-per-tenant model both land here [8][10]. Isolation is physical; the process, node, and control plane are still shared.
T graphs · O(T) fixed overhead
A3
Dedicated database or cluster
A full stack per tenant. The only configuration where a software defect in the query path cannot return another tenant's data, because the other tenant's data is not reachable from the process.
T clusters · O(T) everything
Why A1 in production
Tenants averaged well under the crossover point. Per-tenant graphs would have spent 80% of the memory budget on fixed overhead for partitions holding a few thousand vectors each.
§3 · Coordinates
Silo and pool are
two points on a diagonal.
Once isolation is four numbers instead of one word, the canonical patterns stop being categories and become coordinates — and the space between them stops being a compromise. AWS already concedes this: the bridge model is described as the common case, not the fallback [5].
The off-diagonal configuration this article argues for: share the index because sharing is cheap, and over-invest in enforcement because enforcement is what pooling actually costs you.
Fits
Large fleets of small-to-medium tenants where per-partition overhead dominates, and where a leak is existential but a shared graph is not.
Breaks
Requires discipline that does not survive staff turnover unless it is encoded in types and policies rather than in review culture.
The one claim to take away from this section
Storage isolation and enforcement isolation are answers to different questions. The first asks what it costs to keep tenants apart; the second asks how many independent mechanisms must fail before they aren’t. Pooling the first while siloing the second is not a compromise between them — it is the configuration that gets you the cost profile of a pool and the failure profile of a silo.
§4 · Economics
The relational playbook
misprices retrieval.
Salesforce has run thousands of tenants against one shared schema for two decades, scoping every operation by an org identifier [13]. That works because in a relational engine a partition is nearly free. In a vector engine a partition is an entire ANN index, and the arithmetic inverts.
4.1
In relational systems the marginal cost of a partition is near zero. In vector systems it is not.
A schema-per-tenant Postgres database adds catalog entries and a connection pool. A collection-per-tenant vector store adds an entire ANN index: its own graph, its own segments, its own identifier and version trackers, its own optimiser working set. The data is the small part.
4.2
HNSW's memory cost is structural, not incidental.
The index is a multi-layer proximity graph in which each node keeps up to m outbound links, and roughly 2m at the base layer [1]. Every link is a 4-byte identifier. The graph therefore costs on the order of m × n × 4 bytes over and above the vectors themselves — and it is rebuilt in full, per partition, every time you split the corpus.
graph ≈ 1.5 · m · n · 4 bytes
4.3
Below a crossover point, per-partition fixed overhead is the largest line item in the bill.
Fixed per-collection cost is constant in tenant size but linear in tenant count. Vector payload is linear in total corpus size regardless of how you split it. There is therefore a corpus size per tenant below which splitting costs more than the data you are splitting. In the 768-dimension regime that crossover sits in the tens of thousands of vectors per tenant.
silo − pool ≈ T · F
4.4
Vendors encode this in their defaults, and in hard ceilings.
Qdrant's documentation states plainly that 'creating a separate collection for each tenant is rarely the most efficient approach' because 'each collection carries its own resource overhead', and caps Cloud clusters at 1,000 collections by default [6]. Pinecone and Weaviate reach the opposite conclusion by making their per-partition unit cheap enough to hand out a million times — namespaces and per-tenant shards respectively [8][10].
1,000 collections · 1M+ namespaces
4.5
Which means 'per-tenant index' is not one architecture — it is two, with different economics.
A per-tenant Qdrant collection and a per-tenant Pinecone namespace both read as A2 on the storage axis, but the first is a heavyweight object with an explicit fleet ceiling and the second is designed to be created a million times. Read your engine's per-partition cost before you read anyone's architecture diagram, including this one.
§5 · The silent failure
A filter is not
a partition.
If you pool, tenancy becomes a predicate. In a B-tree that is a cheap lookup. In a proximity graph it is a connectivity problem — and it fails by returning worse answers, not by returning errors, which is the worst way for anything to fail.
5.1
A tenant predicate over a shared graph is a graph-connectivity problem, not a WHERE clause.
HNSW answers a query by greedily walking a proximity graph. Apply a tenant filter and most nodes become ineligible mid-walk — but they were the edges connecting the eligible ones. The traversal can strand itself in a region of the graph with no remaining path to the true neighbours. Qdrant states the failure directly: 'vector search can't cross the grayed out area and it won't reach the nearest neighbor' [7].
5.2
The failure is a recall failure, so your latency dashboards stay green.
This is the property that makes it dangerous. The query returns quickly and returns k results. They are simply worse results than the index could have produced, for the tenants whose slice of the corpus is smallest — which are usually your newest customers, evaluating you during a trial.
5.3
Naive mitigations fail at opposite ends of the selectivity range.
Post-filtering searches globally then discards non-matching hits: with a selective tenant predicate you discard nearly everything and return short. Pre-filtering restricts the candidate set first: correct, but degenerates toward a brute-force scan as the tenant grows. Neither is uniformly right, which is why the choice has to be made per query rather than per system [7].
5.4
The production answer is a cardinality-aware planner over a filter-aware index.
Estimate how many points the predicate admits, then pick a strategy: below a threshold, retrieve directly through the payload index and skip the graph; above it, traverse a graph that has been built with the filter in mind. Qdrant's filterable HNSW adds extra links between points sharing an indexed payload value so the filtered subgraph stays connected, and switches strategies on estimated cardinality against a full-scan threshold [7].
planner switch on estimated cardinality
5.5
This is an active research area, not settled engineering.
Filtered-DiskANN builds label-aware connections at index construction time, forming edges from the geometry of the vectors and the associated label sets together [2]. ACORN takes the opposite tack — predicate-agnostic subgraph traversal over a standard HNSW, reporting 2–10× higher throughput on low-cardinality predicates and over 30× on complex high-cardinality ones [3]. Both exist because filtering a proximity graph is genuinely hard.
[2] WWW '23 · [3] SIGMOD '24
5.6
Corollary: never ship pooled retrieval without a per-tenant recall test.
Freeze a labelled evaluation set per tenant size decile. Measure Recall@k against exhaustive search — not against your own previous release. The smallest decile is the one that will regress, and it is the only one that will tell you the planner's threshold is set wrong.
# A1 — declare the tenant key to the engine at collection-creation time.
# Without this index the planner cannot estimate filter cardinality and
# cannot choose a strategy; with is_tenant it also co-locates on disk. [6][7]
client.create_payload_index(
collection_name="documents",
field_name="tenant_id",
field_schema=KeywordIndexParams(
type="keyword",
is_tenant=True, # co-locate this tenant's points → sequential reads
),
)§6 · The boundary
Pool the bytes.
Never pool the boundary.
In a silo, a bug in the query path returns nothing, because the other tenant's data is not reachable from the process. In a pool, the same bug returns everything. That asymmetry is the real price of pooling, and it is payable in engineering rather than in RAM.
| Layer | Mechanism | Stops | Still fails if | |
|---|---|---|---|---|
L1 | Identity, not input | Tenant scope is read from a verified token claim at the edge and attached to a request context. No handler accepts a tenant identifier as a parameter. | Forged or enumerated tenant identifiers in the request body — the entire IDOR class. | A background job or admin path that constructs its own context and gets it wrong. |
L2 | Unrepresentable, not merely forbidden | The repository exposes no method that returns unscoped results. Scope is a constructor argument, not an optional filter. The escape hatch exists, is named for what it is, and is greppable in CI. | The forgotten `WHERE tenant_id = ?`, in the hands of a developer who has never read this document. | Anyone who reaches past the repository to the driver. |
L3 | A second mechanism the app cannot bypass | Per-tenant database credentials plus row-level security policies bound to the session role, so the engine re-derives the scope independently of the query [11]. Or a centralised authorisation service in the Zanzibar lineage, which at Google served trillions of ACLs under 10 ms at p95 [12]. | A compromised or simply wrong application tier. | Superuser and BYPASSRLS roles bypass row security entirely [11] — so the service role must never hold either, and that must be asserted in a test, not a runbook. |
L4 | Proof, not assertion | A cross-tenant probe running continuously in production: tenant A's credentials issued against tenant B's known-present document identifiers, asserting empty results and alerting on any hit. | Silent regressions in every layer above, including ones introduced by a dependency upgrade. | Nothing, if it is genuinely running. Everything, if it was disabled during an incident and never re-enabled. |
# C0/C1 — the shape almost every tutorial ships.
# The filter is a keyword argument, which means it is optional,
# which means it is one refactor from being absent.
def search(query: str, tenant_id: str, limit: int = 10):
return client.query_points(
collection_name="documents",
query=embed(query),
query_filter=Filter(must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]),
limit=limit,
).points
# Three ways this leaks:
# 1. tenant_id arrives from the request body instead of the token
# 2. a new call site omits query_filter and gets the whole corpus
# 3. an aggregate/scroll/recommend path is added without the filter# C2 — scope is a property of the object, not an argument to the call.
# There is no method on this class that can return another tenant's points.
class TenantScopedIndex:
"""The only sanctioned door to the collection.
Constructed from a verified token claim, never from request input.
Direct QdrantClient use outside this module is blocked in CI.
"""
_COLLECTION = "documents"
def __init__(self, client: QdrantClient, claims: VerifiedClaims):
# Not a parameter the caller chooses — a claim the edge verified.
self._client = client
self._tenant = claims.tenant_id
def _scope(self, extra: Filter | None = None) -> Filter:
must = [FieldCondition(key="tenant_id",
match=MatchValue(value=self._tenant))]
if extra and extra.must:
must.extend(extra.must)
return Filter(must=must) # tenant clause cannot be displaced
def search(self, query: str, limit: int = 10, where: Filter | None = None):
return self._client.query_points(
collection_name=self._COLLECTION,
query=embed(query),
query_filter=self._scope(where),
limit=limit,
# Tenant-aware layout: co-locates this tenant's vectors so the
# filtered walk reads sequentially instead of scattering. [6]
search_params=SearchParams(hnsw_ef=128),
).points-- C3 — a second enforcement mechanism, below the application.
-- The engine re-derives tenant scope from the session, so a missing
-- WHERE clause in application code returns zero rows instead of everyone's. [11]
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- applies to the owner too
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- Non-negotiable, and asserted by a test rather than a runbook:
-- superusers and roles holding BYPASSRLS skip row security entirely. [11]
REVOKE ALL ON documents FROM PUBLIC;
ALTER ROLE app_service NOBYPASSRLS;§7 · Instrument
Find your own
crossover point.
Every recommendation above is a function of tenant count, tenant size, and your engine's per-partition overhead. Rather than assert where the line falls, here is the model — set it to your fleet and read the answer off it. The fixed-overhead slider is the term that decides everything; measure yours before trusting the default.
Corpus: 500,000 vectors. The model is stated in full in src/data/multitenancy.ts — disagree with it by changing the fixed-overhead slider, which is the term everything hinges on.
75%
Memory saved by pooling
4.69 GiB
Replicated fixed overhead
1.43 GiB
Actual vector payload
76%
Of silo spent on splitting
Recommended configuration
Pooled bytes, siloed boundary
A1 · B1 · C3 · D1A pooled index is 75% cheaper in resident memory at this shape, because 100 partitions × fixed overhead dominates a corpus of this size. Take the saving on storage and spend it on the enforcement axis — that is the trade this article argues for.
Per-partition fixed overhead (4.7 GiB) exceeds the vector payload itself (1.4 GiB). You would be spending more memory on the act of splitting than on the data being split.
At 5,000 vectors per tenant, every tenant sits behind a highly selective predicate over the shared graph. Declare the tenant payload index and hold a per-tenant Recall@k test, or §5 happens to you silently.
§8 · Field report
100+ environments,
one collection.
The production system this model came out of: a multi-tenant retrieval service for industrial fault-log search, where each tenant is a physically separate plant environment. It started as the textbook design and stopped being that after the memory attribution came back.
Baseline
100+ collectionsOne collection per environment
The obvious first design, and the one every tutorial endorses: each of 100+ industrial environments got its own Qdrant collection. Isolation was trivially explainable and the memory curve was linear in tenant count rather than in data — because most environments held a few thousand vectors and paid full per-collection overhead regardless.
Measurement
overhead > payloadFixed overhead, not payload, was the bill
Attributing resident memory by component showed the corpus itself was a minority of the footprint. The majority was per-collection structure replicated 100+ times: segment metadata, identifier and version trackers, per-collection index scaffolding and optimiser working set. Splitting the corpus was costing more than the corpus.
Change
A2 → A1 · C1 → C3Collapse to one collection, keep the boundary
All environments moved into a single collection partitioned on a tenant keyword payload index declared with `is_tenant=true`, so the engine co-locates each tenant's vectors and can estimate filter cardinality [6][7]. Storage isolation dropped from A2 to A1 — and enforcement was simultaneously raised to C3, because pooled bytes are only defensible behind a boundary stronger than the one you gave up.
Result
−80% memory80% less resident memory, boundary unchanged
Eliminating replicated per-collection overhead cut resident memory by roughly 80% against the per-tenant baseline, at equal corpus size. No tenant's queries returned another tenant's points before or after — the difference is that afterwards, three independent mechanisms had to fail for that to become possible rather than one.
Second-order
150 ms → <5 ms p99The retrieval path got faster for an unrelated reason
Consolidation forced a rewrite of the query path, which is where the intent-classification fast path was added: direct-lookup queries for exact alphanumeric codes bypass vector search entirely. That cut p99 retrieval latency from 150 ms to under 5 ms for that query class. Worth separating honestly — pooling did not cause the speedup, it caused the rewrite that contained it.
§9 · What it cost
Three things pooling took, in exchange.
A write-up without this section is marketing. None of these were visible at design time, and all three are operational — the axis that gets scored last.
Per-tenant restore became a project. In the collection-per-tenant design, restoring one environment was a snapshot restore. Pooled, it is a filtered export, a scoped delete, and a re-ingest — with a correctness argument attached. This is the single largest thing pooling cost, and it was not visible at design time.
Erasure needed proof, not a DELETE. A scoped delete removes points, but the vectors' contribution to graph structure and to any index statistics is not obviously gone until the segment is rewritten. Satisfying an Art. 17 erasure request [14] honestly meant forcing optimisation and verifying, not just issuing the delete and closing the ticket.
Recall for the smallest tenants needed its own test. Pooling put small tenants behind a selective predicate over a large shared graph — exactly the regime described in §5. It did not regress, because the payload index and planner thresholds were configured for it. It would have regressed silently if they hadn't been, and no latency or error-rate alarm would have fired.
§10 · Decision matrix
Six fleet shapes,
six configurations.
Read across: the shape of the fleet on the left, a coordinate on each of the four axes, and the reasoning that connects them. Nothing here is a rule — the arithmetic in §7 is the rule, and this is what it returns for six shapes I have actually had to build for.
| Scenario | Fleet shape | A | B | C | D | Reasoning |
|---|---|---|---|---|---|---|
| Self-serve B2B SaaS | 10k+ tenants, median a few thousand vectors, heavy long tail of dormant accounts | A1 | B1 | C2–C3 | D1–D2 | Pool hard. Per-partition overhead would exceed the corpus itself. Spend the savings on enforcement and on offloading dormant tenants to object storage rather than RAM [9]. |
| Enterprise B2B, mid-market | 50–500 tenants, hundreds of thousands of vectors each, contractual uptime | A2 | B2 | C2 | D2 | Past the crossover: per-tenant indexes now pay for themselves in isolation and per-tenant restore. Tier the fleet and promote tenants across the boundary automatically [15]. |
| Regulated — health, finance, defence | Any size; auditor asks where the bytes physically are | A3 | B3 | C3 | D2–D3 | Silo, and stop optimising. AWS names compliance pushback as a first-class reason pooling is unavailable regardless of the technical controls you can demonstrate [4]. Argue with the model, not with the auditor. |
| Consumer app, per-user memory | Millions of 'tenants', tens to hundreds of vectors each | A0–A1 | B1 | C1–C2 | D0–D1 | The user is not a tenant in the SaaS sense. Pool everything; the entire per-user corpus is smaller than one partition's fixed overhead. Enforcement is the only axis worth money here. |
| Internal platform, one company | 10–100 teams, trusted-ish, wildly uneven sizes | A1 | B1–B2 | C1 | D1 | The real risk is a noisy neighbour, not exfiltration. Buy quotas and cost attribution first; buy storage isolation last. Most internal platforms get this backwards. |
| Hybrid: long tail + whales | 5,000 small tenants and 8 that are 40% of the corpus | A1 + A2 | B2 | C2–C3 | D2 | The bridge model, and the most common real shape [5]. Pool the tail, pin the whales to dedicated shards, and build the promotion path before a whale forces you to build it live. |
§11 · Failure modes
Seven ways
I have seen this go wrong.
Including two I shipped myself. Ordered roughly by how expensive they are to discover late.
Treating isolation as one slider
The failure this whole article is about. Teams pick 'pool' or 'silo' and inherit all four axes from that one word — usually inheriting a weak enforcement boundary from a storage decision that was made purely on cost.
Trusting a tenant id from the client
If the caller can name the tenant, the caller can name a different tenant. Scope belongs to the verified token, and there should be no parameter for it anywhere in the handler signature.
Filtering without a payload index on the tenant key
Without the index the planner cannot estimate cardinality, so it cannot choose between graph traversal and direct retrieval — and picks badly [7]. The symptom is inconsistent latency across tenants of different sizes, which is usually misdiagnosed as a scaling problem.
Benchmarking on the median tenant
Pooled systems fail at the tails. Benchmark the 5th and 95th percentile tenant by corpus size; the median is the one size that is guaranteed to look fine in every configuration.
Deferring per-tenant erasure until a customer asks
Erasure and export are the two operations with a legal clock attached [14]. In a pooled store they are engineering work. Building them under a thirty-day deadline is a bad time to discover your delete path doesn't reclaim graph structure.
Assuming per-partition cost is portable across engines
A collection, a namespace, and a shard are not the same unit. One engine caps you at 1,000 of them [6]; another is built to hand out a million [8][10]. An architecture that is obviously correct on one is obviously wrong on the other.
No cross-tenant probe in production
Every layer in §6 is a claim until something continuously tries to violate it with real credentials against real data and alerts when it succeeds. Staging does not count; staging has one tenant and it is you.
§12 · What I’d put on the whiteboard
Isolation is four decisions. Write down all four, or three of them will be made for you by the first one.
Pool the bytes if the arithmetic says so. Never pool the boundary.
Storage isolation is an efficiency decision. Enforcement isolation is a security decision. They share a word and nothing else.
A filter is a query-planner input, not a security control — even when it happens to be both.
The isolation model you can restore a single tenant from is the one you actually have.
References
Primary sources only — peer-reviewed papers, first-party engine documentation, and the regulation itself. Where a vendor is cited for a claim about their own engine, that is deliberate: they are the authority on their own per-partition costs, and not on anyone else’s.
- [1]Paper
Malkov, Y. A., & Yashunin, D. A.
Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World GraphsIEEE TPAMI 42(4), 824–836, 2020 · arXiv:1603.09320
- [2]Paper
Gollapudi, S., Karia, N., Sivashankar, V., Krishnaswamy, R., et al.
Filtered-DiskANN: Graph Algorithms for Approximate Nearest Neighbor Search with FiltersProceedings of the ACM Web Conference 2023 (WWW '23)
- [3]Paper
Patel, L., Kraft, P., Guestrin, C., & Zaharia, M.
ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured DataProc. ACM Manag. Data 2(3) — SIGMOD 2024 · arXiv:2403.04871
- [4]Docs
Amazon Web Services
Pool isolation — pros, cons, noisy neighbour, and compliance pushbackAWS Well-Architected Framework, SaaS Lens
- [5]Docs
- [6]Docs
Qdrant
Multitenancy — payload partitioning, is_tenant, user-defined sharding, collection limitsQdrant Documentation
- [7]Engineering
Qdrant
A Complete Guide to Filtering in Vector Search — pre/post filtering and filterable HNSWQdrant Engineering Articles
- [8]Docs
- [9]Docs
- [10]Engineering
Weaviate
Rethinking Vector Search at Scale: Native, Efficient and Optimized Multi-TenancyWeaviate Engineering Blog
- [11]Docs
The PostgreSQL Global Development Group
Row Security Policies — CREATE POLICY, FORCE ROW LEVEL SECURITY, BYPASSRLSPostgreSQL Documentation, §5.9
- [12]Paper
Pang, R., Cáceres, R., Burrows, M., Chen, Z., et al.
Zanzibar: Google's Consistent, Global Authorization SystemUSENIX Annual Technical Conference (ATC '19)
- [13]Docs
Salesforce
Platform Multitenant Architecture — metadata-driven shared schema, OrgID scopingSalesforce Architects, Fundamentals
- [14]Standard
European Parliament and Council
Regulation (EU) 2016/679 (GDPR), Article 17 — Right to erasure ('right to be forgotten')Official Journal of the European Union
- [15]Engineering
