Skip to main content

Book 4 · The AI Solutions Architect · 8 min read

Multi-Tenant RAG: How to Isolate Data Between Customers

A metadata filter is a rule — rules can be forgotten. A per-tenant Qdrant collection is topology: there is no connecting path between tenants regardless of what the query code says. The scroll-and-upsert migration moved 47,291 chunks across six tenants in 43 minutes with zero downtime.

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.

This is the concept. The book is the system.

Book 4: The AI Solutions Architect

Articles give you understanding. The book gives you a working system — full production code, RAGAS evaluation scores, and the patterns that hold up at 11 PM when something breaks.