Multi-Tenant RAG: Isolating Data Per Customer at Scale
The first time a customer support engineer forwards you a screenshot showing "Customer A" reading snippets from "Customer B's" internal documents, you understand why multi-tenant RAG is a different engineering problem than single-tenant RAG. It is not a bigger version of the same system. It is a system where a single retrieval bug becomes a security incident, a compliance violation, and a very uncomfortable email to your biggest account. Building retrieval-augmented generation for one knowledge base is mostly a search and prompting problem. Building it for hundreds or thousands of tenants sharing infrastructure is an access-control problem that happens to involve embeddings. If you get the isolation wrong, better prompts and bigger context windows will not save you.
This article walks through how to actually isolate tenant data in a shared RAG stack, the tradeoffs between the common isolation patterns, and the mistakes that let data cross tenant boundaries even when everyone swears the filters are in place.
Why Multi-Tenant RAG Is Harder Than It Looks
In a single-tenant RAG pipeline, your retrieval quality problems are things like: chunks are too big, the embedding model doesn't understand domain jargon, or the reranker is discarding the right passage. Annoying, but they degrade answer quality. Nobody gets hurt.
In multi-tenant RAG, you have all of those problems plus a new category entirely: isolation failures. These are bugs where tenant A's query returns tenant B's chunks. The failure mode isn't "the answer was mediocre" — it's "we just showed a customer's support ticket to a different customer." That changes how you have to think about the system.
A few things make multi-tenant RAG structurally different:
- The blast radius of a bug is unbounded. A missing
WHERE tenant_id = ?clause in a normal CRUD app might leak one row to a slightly-wrong user session. In a vector database, a missing filter can surface the *most semantically relevant* chunk from every other tenant, which is often the most sensitive one — the incident, the churn conversation, the pricing exception. - Isolation has to survive every layer, not just the database. Embeddings, chunk storage, retrieval, reranking, caching, logging, and the LLM's context window all need to respect tenant boundaries independently, because any one of them can leak.
- Cost and latency pressure push you toward shared infrastructure, which is exactly what makes isolation harder. Nobody wants to run a separate vector database cluster per customer when you have 3,000 customers with an average of 40 documents each.
- "It worked in testing" is not evidence. Isolation bugs are often invisible until a tenant asks a question that happens to overlap semantically with another tenant's content. Your test suite has to be adversarial, not just functional.
The rest of this article is about the concrete architecture choices that determine whether your system fails safe or fails open.
The Three Isolation Models
There are three broad patterns for isolating tenant data in RAG, and most production systems end up choosing based on tenant count, compliance requirements, and how much operational overhead the team can absorb.
1. Database-per-tenant. Every tenant gets a dedicated vector store (a separate Pinecone index, a separate Qdrant collection, a separate Postgres schema with pgvector). This is the strongest isolation guarantee because there is no shared query path — a bug in your filter logic cannot leak data that physically isn't in the same index. The cost is operational: provisioning, migrations, and scaling now happen per tenant, and at a few thousand tenants this becomes its own distributed systems problem.
2. Namespace-per-tenant within shared infrastructure. Most vector databases support a namespace or collection concept that partitions vectors logically while sharing the underlying cluster. Pinecone namespaces, Qdrant collections, Weaviate tenants (yes, Weaviate has literal multi-tenancy support built in), and Milvus partitions all fit here. You get much better resource utilization than database-per-tenant, and the isolation boundary is enforced by the vector database engine itself rather than by your application code.
3. Shared index with metadata filtering. Every tenant's vectors live in the same index, and every query includes a mandatory metadata filter like tenant_id = "acme-corp". This is the cheapest to operate and the easiest to scale to a very large number of small tenants, but it pushes the entire isolation guarantee onto your application code being correct, every single time, in every code path. This is where most of the leaks happen in practice, and it's worth spending the rest of this article on why.
Here's a rough way to think about which model fits your situation:
- Fewer than ~50 tenants, each large (enterprise B2B) → database-per-tenant or namespace-per-tenant
- Hundreds to thousands of tenants, small-to-medium data per tenant (SMB SaaS) → namespace-per-tenant if your vector DB supports it well, otherwise shared index with filtering
- Tens of thousands of tenants (prosumer, freemium) → shared index with metadata filtering, because per-tenant namespaces at that scale become their own management headache
Metadata Filtering: The Pattern Everyone Uses, and Where It Breaks
Shared-index-with-filtering is the most common pattern because it's the cheapest to reason about and scale, so it's worth being precise about how to do it correctly.
The naive version looks like this:
def search(query: str, tenant_id: str, top_k: int = 5):
query_vector = embed(query)
results = vector_db.query(
vector=query_vector,
filter={"tenant_id": tenant_id},
top_k=top_k,
)
return resultsThis looks correct, and in the happy path it is correct. The failure modes are all about what happens *around* this function, not inside it:
- A caller forgets to pass `tenant_id`. If your
searchfunction has a default value or allowstenant_id=Noneto mean "search everything" (useful for admin tooling), someone will eventually call it without a tenant scope in a customer-facing code path. - The filter is applied post-retrieval instead of pre-retrieval. Some naive implementations retrieve the top-k globally and then filter by tenant in application code. This is catastrophic — if tenant B has more content than tenant A, tenant A's top-k results can be entirely filtered away, returning empty results, or worse, the filter is applied to a subset and a few of tenant B's chunks slip through before filtering runs.
- Caching keys don't include tenant_id. If you cache embeddings or retrieval results for performance, and the cache key is a hash of the query text alone, tenant A's cached results can be served to tenant B when they happen to ask a similar question.
- The reranker sees unfiltered candidates. If you retrieve broadly and then rerank, and the reranking step queries a shared index without reapplying the tenant filter, you've reintroduced the leak one layer downstream.
- Chunk metadata is inconsistent. If some documents were ingested before you added tenant tagging, or an ingestion job silently defaults to a placeholder tenant_id on error, those chunks become permanently unfilterable or wrongly attributed.
The fix isn't a single check, it's defense in depth: enforce tenant scoping at the API boundary, enforce it again in the retrieval layer, and enforce it a third time as an assertion right before you assemble the prompt.
class TenantScopedRetriever:
def __init__(self, vector_db, tenant_id: str):
if not tenant_id:
raise ValueError("tenant_id is required and cannot be empty")
self.vector_db = vector_db
self.tenant_id = tenant_id
def retrieve(self, query: str, top_k: int = 5):
query_vector = embed(query)
results = self.vector_db.query(
vector=query_vector,
filter={"tenant_id": self.tenant_id},
top_k=top_k,
)
# Defense in depth: never trust the DB filter alone
for r in results:
assert r.metadata["tenant_id"] == self.tenant_id, (
f"Isolation violation: expected {self.tenant_id}, "
f"got {r.metadata.get('tenant_id')}"
)
return resultsThat assertion looks paranoid until it fires once in staging because someone re-ingested a document without a tenant tag. Then it looks like the reason you didn't have an incident.
Row-Level Security as a Second Line of Defense
If your vector store sits on top of Postgres (pgvector is the common case), you get an extra isolation layer almost for free: Row-Level Security (RLS). Instead of relying entirely on application code to remember the WHERE tenant_id = ? clause, you push the constraint into the database itself.
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON document_chunks
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);Then, at the start of every request, your application sets the session variable:
def get_db_connection(tenant_id: str):
conn = pool.getconn()
with conn.cursor() as cur:
cur.execute("SET app.current_tenant_id = %s", (tenant_id,))
return connNow, even if an engineer writes a query that forgets the tenant_id filter entirely, Postgres refuses to return rows outside the current tenant's scope. This is the single highest-leverage change you can make in a Postgres-backed RAG system, because it converts an "always remember to do this" problem into an "impossible to forget" problem. The database enforces it whether or not the application code is careful.
The catch: RLS only protects you if every connection actually sets the session variable, and if you're using a connection pool, a stale or reused connection can carry the wrong tenant context into the next request. Reset the session variable explicitly when a connection returns to the pool, and write a test that opens two "tenants" in sequence on the same pooled connection and confirms the second one can't see the first's rows.
Chunking and Ingestion: Where Isolation Starts
Isolation isn't just a retrieval-time concern — it starts at ingestion. A few practices that matter more in multi-tenant systems than in single-tenant ones:
- Tag tenant_id on the chunk, not just the document. If your ingestion pipeline stores
tenant_idon the parent document record but the vector database only stores chunk-level metadata, you need to make sure that tag propagates to every chunk during the chunking step. It's an easy thing to lose when refactoring an ingestion pipeline. - Namespace your document IDs. Use composite IDs like
{tenant_id}:{doc_id}:{chunk_index}rather than a bare UUID. This makes it trivial to spot cross-tenant contamination during debugging — if you ever see a chunk ID prefix that doesn't match the query's tenant, that's a five-second bug to find instead of a forensic investigation. - Isolate ingestion queues per tenant tier, if you can. A noisy tenant re-ingesting a 10,000-page document shouldn't delay another tenant's real-time chat ingestion. This isn't a security issue, but it's the kind of "noisy neighbor" problem that multi-tenant systems accumulate if you don't plan for it.
- Version your embeddings model per tenant carefully. If you upgrade your embedding model, old chunks embedded with the previous model and new chunks embedded with the new model will not be comparable in vector space. In a multi-tenant system, a partial re-embedding rollout (some tenants migrated, some not) needs to be tracked in metadata, or you'll get silently degraded retrieval for whichever tenants haven't been re-embedded yet.
Prompt-Level Isolation: Don't Forget the Last Mile
Even with perfect retrieval isolation, there's one more place data can cross tenant boundaries: the prompt assembly step, especially if you maintain any kind of shared context, cache, or conversation history infrastructure.
Two patterns worth calling out:
Session and conversation history must be tenant-scoped, not just user-scoped, if your product allows a single user to belong to multiple tenants (common in B2B tools where one person consults for several client accounts). If your chat history table keys on user_id alone, a user who's active in two tenant workspaces can have tenant A's retrieved context bleed into a conversation that's supposedly scoped to tenant B, because the conversation history object was built before the tenant switch was accounted for.
Never let the LLM see a system prompt that mentions other tenants, even for legitimate reasons like few-shot examples. It is tempting to build your few-shot prompt library from real (anonymized, you assume) examples pulled from production data. If the anonymization is imperfect, or if a support engineer built a few-shot example directly from a customer transcript "just this once," you've hard-coded a leak into your prompt template that will fire for every tenant, forever, until someone notices.
def build_prompt(query: str, retrieved_chunks: list, tenant_id: str):
for chunk in retrieved_chunks:
if chunk.metadata["tenant_id"] != tenant_id:
raise SecurityError(
f"Attempted to include chunk from tenant "
f"{chunk.metadata['tenant_id']} in prompt for tenant {tenant_id}"
)
context = "\n\n".join(c.text for c in retrieved_chunks)
return f"""Answer using only the context below.
Context:
{context}
Question: {query}
"""This is the same defense-in-depth idea as the retriever assertion above, just one layer further downstream — right before the text touches the LLM call. It's cheap insurance and it catches bugs introduced anywhere upstream.
Testing for Isolation, Not Just Correctness
Standard RAG evaluation asks "did we retrieve the right chunks and generate a good answer." Multi-tenant RAG needs a second evaluation suite that asks "is it structurally impossible to retrieve the wrong tenant's chunks." These are different tests and most teams only write the first kind.
A minimal isolation test suite should include:
- Adversarial cross-tenant queries. Seed two tenants with semantically overlapping content (e.g., both have a document about "refund policy") and confirm tenant A's query never returns tenant B's refund policy chunk, even though it's likely to score highly on cosine similarity.
- Missing-tenant-id fuzzing. Call every retrieval code path with
tenant_id=None, empty string, and a tenant_id that doesn't exist, and confirm each one fails closed (returns nothing or raises) rather than failing open (returns unfiltered results). - Cache poisoning checks. If you cache anything — embeddings, retrieval results, LLM responses — write a test that populates the cache under tenant A and then confirms a request from tenant B for a similar query does not hit that cache entry.
- Connection pool leakage, if using RLS. As mentioned above, simulate two tenants using the same pooled connection back-to-back and confirm no data crosses.
- Ingestion attribution tests. Run your ingestion pipeline with a deliberately malformed or missing tenant_id and confirm it rejects the document rather than defaulting to a shared or null tenant bucket that becomes globally visible.
Run this suite on every deploy that touches retrieval, ingestion, or caching code, not just once during initial development. Isolation bugs are the kind of thing that get reintroduced by an unrelated refactor six months later, when nobody remembers the original threat model.
Observability: Knowing When Isolation Breaks
You want to find out about an isolation bug from your own monitoring, not from a customer's security team. A few observability practices that are specific to multi-tenant RAG:
- Log tenant_id on every retrieval call and every returned chunk's tenant_id, and run an offline job that flags any request where they don't match. This is the single cheapest detection mechanism available and it should exist from day one.
- Alert on retrieval requests with a null or missing tenant_id hitting production code paths, rather than silently defaulting them.
- Track per-tenant retrieval latency and result counts separately. A sudden spike in results returned for a small tenant is a decent proxy signal for a filter that stopped applying correctly.
- Keep an audit trail of which document chunks were included in which generated response, tied to tenant_id. If an isolation incident does happen, you need to answer "which tenants were affected and for how long" within minutes, not days.
None of this is exotic engineering. It's the same operational discipline you'd apply to any system handling regulated or sensitive customer data — the RAG pipeline just needs to be included in that discipline rather than treated as a separate, exempt subsystem because it "just does search."
Putting It Together: A Reference Architecture
A reasonably safe default architecture for a mid-size multi-tenant RAG product looks like this:
- Ingestion: documents tagged with
tenant_idat the document level, propagated to every chunk, using composite chunk IDs (tenant_id:doc_id:chunk_index). Ingestion pipeline rejects untagged documents rather than defaulting them. - Storage: namespace-per-tenant in the vector database if the tenant count is manageable (low thousands or fewer), or a shared index with mandatory metadata filtering plus RLS if using pgvector, for larger tenant counts.
- Retrieval: a
TenantScopedRetrieverobject instantiated per-request with a non-optionaltenant_id, that reapplies the filter as an assertion after the vector database returns results. - Caching: every cache key (embeddings, retrieval results, LLM responses) includes
tenant_idas part of the key, never derived from query text alone. - Prompt assembly: a final guard that raises if any chunk's
tenant_iddoesn't match the request's tenant before the prompt is sent to the LLM. - Observability: tenant_id logged on every retrieval and generation event, with an automated job comparing requested vs. returned tenant_id and alerting on any mismatch.
- Testing: an isolation-specific test suite (adversarial cross-tenant queries, fuzzed tenant_id inputs, cache poisoning checks) that runs on every deploy touching retrieval or ingestion code.
None of these pieces are individually hard to build. The discipline is in treating isolation as a first-class requirement enforced at every layer, rather than a single filter clause you write once and trust forever.
If you're building your first multi-tenant RAG system, or retrofitting isolation into one that grew without it, it's worth stepping back from the specific vector database or framework you're using and asking, layer by layer, "what happens here if the tenant_id is wrong or missing." That question, asked at ingestion, storage, retrieval, caching, prompt assembly, and logging, will surface almost every leak before a customer does. If you want the foundational retrieval concepts this article builds on — chunking strategy, embedding choice, reranking — our course Introduction to RAG covers those fundamentals in depth before layering on the multi-tenant concerns discussed here.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.