Skip to content
Blog

Namespacing Agent Memory Stores Per Tenant: Isolation Without the Leak

Hard namespaces vs. metadata filters for multi-tenant agent memory in LlamaIndex — why filter-only isolation leaks, and how to combine both layers for defense in depth.

Published on • October 9, 2026

AI Assistant

Give your agent a vector store and it remembers everything — which is exactly the problem when “everything” includes data from multiple customers. Multi-tenant agent memory is one of the quietest ways to ship a cross-tenant data leak: one missing filter and tenant A’s conversations surface in tenant B’s answers.

There are two isolation levers, and production systems need both.

The two levers

1. Hard partitioning — a native namespace or collection per tenant.

The store itself holds only one tenant’s data:

from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.vector_stores.pinecone import PineconeVectorStore

vector_store = PineconeVectorStore(
    pinecone_index=pinecone_index,
    namespace="tenant_05_14",   # hard isolation boundary
)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(nodes, storage_context=storage_context)

This is the store-level pattern: Pinecone namespaces, Qdrant collections/shards, or Nile’s multi-tenant PostgreSQL tables. It gives you O(1) bulk deletion when a tenant churns, clean billing attribution, and an isolation boundary enforced inside the database.

2. Soft partitioning — a metadata filter on tenant_id.

Portable across stores, composable with hybrid search:

from llama_index.core.vector_stores import MetadataFilter, MetadataFilters, FilterOperator

filters = MetadataFilters(
    filters=[MetadataFilter(key="tenant_id", operator=FilterOperator.EQ, value="t_05_14")]
)
retriever = index.as_retriever(filters=filters)
retriever.retrieve("What did we decide about pricing?")

MetadataFilters supports FilterOperator (EQ, GT, …) and FilterCondition (AND/OR), and you can drop to store-native syntax per store via vector_store_kwargs={"filter": {...}}.

Why filter-only isolation leaks

A shared index plus a tenant_id filter looks multi-tenant. The failure modes:

  • The filter lives in application code. One code path that builds a retriever without the filter — an admin tool, a background job, a “search all tenants” debug endpoint — returns everyone’s data. Nothing in the database stops it.
  • Filter semantics are store-specific. Every vector store has its own metadata filter page (Pinecone, Qdrant, Chroma, Milvus, Weaviate, Neo4j) because operators and default behavior differ. Unsupported operators may be dropped rather than errored, silently widening results.
  • Chat history is a separate store. Conversation memory flows through chat stores, which need their own per-tenant table/namespace decision. Securing the vector index while sharing a chat store leaves the transcript exposed.

The rule: enforce the tenant boundary at the store or query-construction layer, never in prompts or ad-hoc application filters.

Defense in depth in practice

Combine both layers:

# Layer 1: tenant-scoped namespace (store-level)
vector_store = QdrantVectorStore(collection_name="agent_memory", namespace=tenant_id)

# Layer 2: tenant metadata filter (query-level belt-and-braces)
filters = MetadataFilters(filters=[MetadataFilter(key="tenant_id", value=tenant_id)])
retriever = index.as_retriever(filters=filters)
ConcernNamespace onlyFilter onlyBoth
Cross-tenant leak riskLowMedium — one missing filterLowest
Bulk delete on churnTrivialExpensive scanTrivial
Shared-index cost efficiencyPoor (per-tenant overhead)GoodGood
Hybrid search compositionPer-namespaceNativeNative
Operational overheadHigherLowerModerate

Namespacing conventions that hold up

  • Derive names from stable tenant IDs, never display names — tenant_a91f beats Acme Corp for renames and uniqueness.
  • Separate namespaces for different data classes: raw documents, chunk embeddings, conversation summaries, and tool memories should not share one blob.
  • Include environment (prod/staging) in the namespace so restores never cross environments.
  • Keep index-per-tenant for large or regulated tenants, shared-index-plus-namespace for the long tail — a hybrid keeps cost and isolation balanced.
  • Never rely on in-memory stores for isolation: SimpleVectorStore is dev-only.

Gotchas

  • Per-tenant collections multiply lifecycle work — provisioning, reindexing, and migration all scale with tenant count.
  • Operator support varies by store; test your exact filter expressions against each backend.
  • Local dev stores have no real isolation — do not validate tenancy logic against them.
  • Audit every retriever construction site: that is where the boundary actually lives.

Wrapping up

Treat tenant isolation like any other security boundary: enforce it closest to the data, verify it at every query path, and keep a second layer in case the first fails. Namespaces give you hard partitioning and cheap deletion; metadata filters give you portability and fine control. Together they turn agent memory from a shared liability into a per-tenant asset.

References