Multi-Tenant RAG Isolation
10 min read
Design a RAG system that serves multiple customers from shared infrastructure while guaranteeing that no tenant can access another tenant's data.
Building a RAG-powered product for multiple customers introduces a class of design problems that single-tenant systems never encounter. Every retrieval query must be strictly scoped to the requesting tenant's documents. A design failure here is not a quality bug — it is a data breach, where one customer receives information from another's knowledge base. Correctness under multi-tenancy deserves the same engineering rigor as security.
Isolation strategies
There are three primary approaches to tenant isolation in a vector store, each with different tradeoffs between operational simplicity, query performance, and isolation strength.
- Separate collections per tenant: the strongest isolation; each tenant's vectors live in a completely separate namespace or index; easy to delete a tenant's data, but operational overhead scales with tenant count
- Shared collection with metadata filtering: all tenants share one index; every document carries a tenant_id metadata field; every query filters on tenant_id before or alongside the vector search
- Separate deployments: each enterprise customer gets a dedicated instance; maximum isolation and customization but highest infrastructure cost
Implementing tenant-scoped retrieval
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
client = QdrantClient(url="http://localhost:6333")
def tenant_search(
tenant_id: str,
query_vector: list[float],
top_k: int = 10,
) -> list:
"""Retrieve documents scoped strictly to the given tenant."""
tenant_filter = Filter(
must=[
FieldCondition(
key="tenant_id",
match=MatchValue(value=tenant_id),
)
]
)
results = client.search(
collection_name="shared_docs",
query_vector=query_vector,
query_filter=tenant_filter,
limit=top_k,
)
# Defensive check: verify every result belongs to this tenant
for r in results:
if r.payload.get("tenant_id") != tenant_id:
raise RuntimeError(f"Isolation violation: result tenant_id mismatch for tenant {tenant_id}")
return resultsDefense in depth for isolation
Relying solely on a metadata filter is a single point of failure. If a bug drops the filter argument, every tenant's documents become accessible. Add a defensive check after every query that verifies the tenant_id on each returned result matches the requesting tenant. Log and alert on any mismatch immediately — this should never happen in correct code, so any occurrence is a serious incident requiring investigation.
Ingestion isolation
Isolation must be enforced at write time too, not just read time. Every document ingested into the shared collection must have the correct tenant_id stamped at the point of ingest, before it reaches the embedding model. Use server-side validation in the ingestion pipeline to reject any document payload missing a valid tenant_id rather than relying on callers to supply it correctly.
Tenant data deletion
When a tenant cancels or requests data deletion, you must be able to remove all of their vectors reliably. With separate collections, drop the collection. With shared collections, delete by tenant_id filter and verify the count of remaining vectors with that tenant_id is zero. Store a manifest of document IDs per tenant at ingest time so you can audit completeness of deletion independently of the vector store.
Prompt-level isolation is not sufficient. Instructing the model to only discuss documents belonging to the current tenant does not prevent the model from surfacing information from wrongly retrieved cross-tenant chunks. Isolation must be enforced at the retrieval layer before the model ever sees the results.
Add a dedicated integration test that ingests documents for two tenants, queries as tenant A, and verifies zero results belong to tenant B — and vice versa. Run this test on every change to retrieval code. Isolation failures are too serious to rely on code review alone.