Multi-Tenant RAG: How to Isolate Data Between Customers
A metadata filter is a rule. Rules can be forgotten, misconfigured, or bypassed by a query edge case. A per-tenant collection is topology — there is no connecting path between tenants regardless of what the query code says.
The Valid Failure
ShopBot had grown to six tenants on one shared Qdrant collection. Each tenant's chunks were tagged with a tenant_id field in the payload. Retrieval filtered by that field:
results = client.search(
collection_name="shopbot",
query_vector=vector,
query_filter=Filter(must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]),
limit=5,
)
During a RAGAS evaluation run, a test query from tenant "Vastralaya" accidentally ran without the filter — the team was debugging a filter issue and temporarily removed it to see all results.
The retriever returned chunks from all six tenants. The RAGAS dataset captured this. A "Vastralaya" query returned a "Jaipur Threads" product recommendation. The data was not leaked to a customer — it was caught in evaluation. But the architecture had no protection against it happening in production.
A 0.02 cross-encoder margin had, on two production queries the prior month, ranked a wrong-tenant chunk above the correct-tenant chunk. The metadata filter was the only protection. And metadata filters can be omitted.
Physical Isolation vs Logical Isolation
Logical isolation (metadata filter): one collection, all tenants' data, filter applied at query time.
Collection: "shopbot"
chunk: {text: "...", tenant_id: "vastralaya", ...}
chunk: {text: "...", tenant_id: "jaipur_threads", ...}
chunk: {text: "...", tenant_id: "mumbai_silk", ...}
Query: filter must=tenant_id=="vastralaya"
Failure mode: filter omitted, filter bug, filter misconfiguration → cross-tenant data returned.
Physical isolation (per-tenant collection): one collection per tenant, query only runs against that tenant's collection.
Collection: "shopbot_vastralaya" → only Vastralaya chunks
Collection: "shopbot_jaipur_threads" → only Jaipur Threads chunks
Collection: "shopbot_mumbai_silk" → only Mumbai Silk chunks
Query: search "shopbot_vastralaya" → impossible to return Jaipur Threads chunks
Failure mode: collection name bug → KeyError, not cross-tenant data leak.
Implementation: Per-Tenant Collections
KNOWN_TENANTS = {
"vastralaya",
"jaipur_threads",
"mumbai_silk",
"chennai_weaves",
"delhi_couture",
"pune_fashion",
}
def get_collection_name(tenant_id: str) -> str:
if tenant_id not in KNOWN_TENANTS:
raise ValueError(f"Unknown tenant: {tenant_id}")
return f"shopbot_{tenant_id}"
def retrieve_for_tenant(query: str, tenant_id: str) -> list[dict]:
collection = get_collection_name(tenant_id) # raises on unknown tenant
vector = embed_model.encode(query).tolist()
results = client.search(
collection_name=collection,
query_vector=vector,
limit=5,
score_threshold=0.72,
)
return [{"text": r.payload["text"], "score": r.score} for r in results]
An unknown tenant_id raises a ValueError immediately — before the search runs. A missing filter in the logical isolation model would return wrong-tenant data. A wrong collection name in the physical isolation model raises an error on first call, which is caught in development.
Adding Cryptographic Isolation with AWS KMS
For regulated industries (healthcare, finance, legal), per-tenant collections provide data separation at the storage level — but a compromised Qdrant instance could read all payloads. Customer Managed Keys (CMK) via AWS KMS add a second layer: each tenant's data is encrypted with a key that the tenant controls.
import boto3
import json
kms = boto3.client("kms", region_name="ap-south-1")
def encrypt_payload(payload: dict, tenant_id: str, cmk_key_id: str) -> bytes:
plaintext = json.dumps(payload).encode()
response = kms.encrypt(
KeyId=cmk_key_id,
Plaintext=plaintext,
EncryptionContext={"tenant_id": tenant_id}, # binds key to tenant
)
return response["CiphertextBlob"]
def decrypt_payload(ciphertext: bytes, tenant_id: str) -> dict:
response = kms.decrypt(
CiphertextBlob=ciphertext,
EncryptionContext={"tenant_id": tenant_id},
)
return json.loads(response["Plaintext"])
The EncryptionContext binds the key to the tenant: a key for tenant_id="vastralaya" cannot decrypt data encrypted with tenant_id="jaipur_threads". Even if both ciphertexts were accessible, they are not cross-decryptable.
The scroll-and-upsert Migration
Moving from one shared collection to six per-tenant collections requires migrating 47,291 chunks without downtime.
def migrate_tenant(tenant_id: str, batch_size: int = 200):
source_collection = "shopbot" # old shared collection
target_collection = get_collection_name(tenant_id)
client.recreate_collection(
collection_name=target_collection,
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
offset = None
migrated = 0
while True:
records, next_offset = client.scroll(
collection_name=source_collection,
scroll_filter=Filter(must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]),
limit=batch_size,
offset=offset,
with_vectors=True,
with_payload=True,
)
if not records:
break
points = [
PointStruct(id=r.id, vector=r.vector, payload=r.payload)
for r in records
]
client.upsert(collection_name=target_collection, points=points)
migrated += len(records)
if next_offset is None:
break
offset = next_offset
print(f"Migrated {migrated} chunks for {tenant_id}")
# Run for all six tenants
for tenant in KNOWN_TENANTS:
migrate_tenant(tenant)
Total migration time for 47,291 chunks across six tenants: 43 minutes. The shared collection remained live during migration. After verification, the application was switched to per-tenant collections and the shared collection was deleted.
The Valid Knowledge
- A metadata filter is a rule; a separate collection is topology: rules can be omitted; topology cannot. Physical isolation is stronger than logical isolation for any multi-tenant system handling sensitive data.
- The KNOWN_TENANTS whitelist is the first line of defence: an unknown tenant_id must raise an error before the retrieval query runs — not return results from a default collection.
- KMS encryption context binds the key to the tenant: without the encryption context, you have per-tenant encryption with a shared key — which provides no isolation. The context ensures a key for tenant A cannot decrypt tenant B's data.
- scroll-and-upsert is the standard migration pattern: Qdrant's
scroll()API paginates over a collection with optional filtering. Use it for any migration that moves data between collections or re-indexes with a new model. - Zero-downtime migration requires the old collection to stay live: keep the shared collection readable during migration. Switch the application to per-tenant collections only after all six tenant migrations are verified.