Home / AI & GenAI / Vector Databases

AI & GenAIHow-toIntermediate

Multi-tenant RAG on Pinecone: one namespace per tenant vs metadata filters vs index per tenant

Serving many customers or departments from one Pinecone project needs strict isolation, predictable cost and easy offboarding. Compare namespaces, metadata filters and separate indexes, then implement namespace-per-tenant with enforced routing, offboarding and monitoring.

Deepak Bhardwaj5 min read

Applies toPinecone serverless indexes, Python SDK; applies to any application layer that queries Pinecone on behalf of tenants

The design question

You're building a RAG assistant for 300 customers. Each customer must only see its own documents. Some have 500 chunks, a few have 5 million. How do you lay this out in Pinecone?

The three options

Option Isolation Query cost Offboarding Good for
Namespace per tenant in one index Physical partition; a query only reads one namespace Scales with that tenant's data, not the whole index Delete the namespace Most multi-tenant SaaS
Metadata filter (tenant_id) in one namespace Logical only; relies on every query having the filter Can read more than the tenant's data Delete by filter A few tenants that sometimes need cross-tenant search
Index per tenant Strongest separation; separate config per tenant Separate index overhead Delete the index A handful of large or regulated tenants

Namespace per tenant is the pattern Pinecone recommends for multi-tenancy on serverless. Queries are scoped to one namespace, so a small tenant's query doesn't pay for a large tenant's data, and you can't accidentally return another tenant's records just by forgetting a filter (you'd have to pick the wrong namespace).

Step 1: name namespaces from a trusted value

def tenant_namespace(tenant_id: str) -> str:
    # tenant_id comes from the authenticated session, never from the request body
    return f"t-{tenant_id}"
  • Use an internal immutable ID, not a display name that can change.
  • Never accept a namespace or tenant ID from the client request.

Step 2: write and query through one gateway function

from pinecone import Pinecone

pc = Pinecone(api_key=API_KEY)
index = pc.Index(host=INDEX_HOST)

def upsert_chunks(session, records):
    index.upsert(vectors=records, namespace=tenant_namespace(session.tenant_id))

def search(session, query_vector, top_k=10, filters=None):
    return index.query(
        vector=query_vector,
        top_k=top_k,
        namespace=tenant_namespace(session.tenant_id),
        filter=filters,                 # optional extra filters inside the tenant
        include_metadata=True,
    )

Make this the only code path that talks to the index. Code review and a lint rule (no direct index.query elsewhere) keep it that way.

Warning

The Pinecone API key can read every namespace in the project. Isolation is enforced by your application, not by Pinecone permissions per namespace. Keep the key server-side only, and treat the gateway function as security-critical code with tests.

Step 3: document-level permissions inside a tenant

Namespaces separate tenants. Inside a tenant, different users may see different documents. Add a metadata filter for that:

filters = {"acl_groups": {"$in": session.group_ids}}
search(session, qvec, filters=filters)

Store acl_groups as a list of strings on each record. When a document's permissions change, update the metadata of its chunks (index.update(id=..., set_metadata={...}, namespace=...)) or re-upsert them.

Step 4: tests that prove isolation

Automate these in CI against a test index:

Test Expected
Tenant A searches for a phrase that exists only in tenant B's documents No B records returned
Request with a forged tenant_id in the body Ignored; session tenant used
User without group X searches for a group-X document Not returned
Namespace function with empty or missing tenant Raises, never queries the default namespace

The last one matters: an empty string namespace is the default namespace, and a bug that drops the tenant ID could otherwise write or read there.

Step 5: offboarding and data deletion

When a customer leaves or requests deletion:

index.delete(delete_all=True, namespace=tenant_namespace(tenant_id))

Or delete the namespace itself through the namespace API (newer SDK versions expose namespace management; check your SDK version). Then confirm:

stats = index.describe_index_stats()
assert tenant_namespace(tenant_id) not in stats.namespaces

Stats are eventually consistent, so check again after a short wait before closing the request. Record the deletion with a timestamp for your data-protection log. Remember backups: records stay in any backups taken before the deletion until those backups expire, so align backup retention with your deletion commitments.

Step 6: large tenants

If one tenant holds most of the data or has very different needs (a different embedding model, strict data residency), give it its own index:

def index_for(session):
    return dedicated_indexes.get(session.tenant_id, shared_index)

Keep the routing table in configuration. Different indexes can be in different regions, which helps with residency requirements.

Step 7: monitor per tenant

  • Record count per namespace from describe_index_stats(); alert on sudden drops (accidental delete) or jumps (runaway ingestion).
  • Query latency and count per tenant in your application metrics; Pinecone usage is billed per index/project, so per-tenant usage must come from your logs.
  • Ingestion errors per tenant.

Decision summary

If… Use
Many tenants, each searches only its own data Namespace per tenant
Need cross-tenant search for some queries Shared namespace + tenant_id filter, with strict gateway enforcement
A few tenants need separate region, model or contract terms Dedicated index for those tenants

Prevent problems next time

  • One gateway function for all reads and writes, with tenant resolved from the session.
  • Isolation tests in CI, including the empty-namespace case.
  • Written offboarding runbook that covers backups.
  • Per-tenant record counts on a dashboard.