Multi-Tenant Vector Search: Keep Each User's Data Apart

Multi-tenant vector search without leaks: tenant IDs on every vector, server-side filters, Qdrant tenant indexes, pgvector RLS and iterative scans, and tests.

a long row of bookshelves filled with lots of books
Photo by Jayanth Muppaneni / Unsplash

Multi-tenant vector search stays safe when every vector carries a tenant ID, the server adds the tenant filter from the signed-in session (never from the request body), that field is indexed, and retrieval is tested as two different users. In Qdrant, that means a tenant payload index. In pgvector, it means row-level security, iterative scans, or partitions.

TL;DR: Retrieval is where multi-tenant AI products leak, because a vector query without a filter still returns plausible answers. Put the tenant ID on every vector, build the filter on the server, index it, delete vectors with their documents, and run a two-tenant leak test on every change. Qdrant and pgvector both have the tools.

Retrieval is the weak point. A chat answer can quote a passage from another customer's contract, and nobody notices until that customer does. This post covers isolation in the two vector stores we run: Qdrant in NoteTube and pgvector in Acasa Sentinel. For picking between them, see Qdrant vs pgvector. This post is only about keeping data apart.

Why does vector search leak more easily than SQL?

Vector search leaks more easily because a query with no tenant filter still works. A SQL query asks for rows by ID or field, so a missing where tenant_id = ... stands out in review. A vector query asks for the nearest passages across the whole collection, and without a filter it returns the nearest passages from anyone, which look perfectly reasonable.

There's a second trap. With approximate indexes, the filter often runs after the index scan. pgvector's own README says so plainly: "With approximate indexes, filtering is applied after the index is scanned" (pgvector README). If one tenant owns a small slice of the collection, a post-filter can return few or no results. Then someone "fixes" the empty answers by loosening the filter, and the leak is born.

So the danger isn't a dramatic exploit. It's a quiet default that returns good-looking text from the wrong customer.

What rules apply in any vector store?

The same six rules apply whether you run Qdrant, pgvector, or something else. They move isolation out of individual queries and into one place you can review, test, and trust.

Rule Why it matters
Every vector carries a tenant ID (plus user or session ID if needed) Without it there is nothing to filter on
The filter is built on the server from the authenticated user A filter sent by the client can be changed by the client
Retrieval goes through one function that always adds the filter One place to review, impossible to forget
The tenant field is indexed Filtered searches stay fast and return enough results
Deleting a document deletes its vectors A deleted document is never retrieved again
Isolation is tested as two users Proves the rules hold after every change

NoteTube scopes every vector per user and per session in Qdrant, so a student's chat only draws on the sources that student uploaded to that session. The scope is applied on the server, behind the same backend that keeps the model keys away from the web and mobile clients, so no client can build its own query. Why that separate AI service exists at all is covered in how we keep AI API keys out of clients.

How do you isolate tenants in Qdrant?

Qdrant's answer is one shared collection with the tenant ID in each point's payload and a filter on it in every query. Its multitenancy guide says a separate collection per tenant "is rarely the most efficient approach" (Qdrant multitenancy guide). Two settings make the shared collection work well.

A tenant index. Create a keyword payload index on the tenant field with is_tenant set to true. The guide notes this is available from v1.11.0 and that it makes Qdrant co-locate each tenant's vectors, which speeds up tenant-filtered search.

Per-tenant graphs, if you never search across tenants. Set payload_m to a non-zero value and the global m to 0, and Qdrant builds the HNSW graph per tenant instead of globally. Searches without the tenant filter then get slow. That's a useful side effect: forgetting the filter shows up as a performance bug before it shows up as a privacy incident.

from qdrant_client import QdrantClient, models

client.create_payload_index(
    collection_name="chunks",
    field_name="tenant_id",
    field_schema=models.KeywordIndexParams(type="keyword", is_tenant=True),
)

def search_for(user, vector, k=8):
    # tenant_id comes from the verified session, never from the request body
    return client.query_points(
        collection_name="chunks",
        query=vector,
        limit=k,
        query_filter=models.Filter(must=[
            models.FieldCondition(key="tenant_id", match=models.MatchValue(value=user.tenant_id)),
        ]),
    )

Application code calls search_for, never the raw client. That one function is the review point.

When does a tenant deserve its own shard?

Give a tenant its own shard only when it's large enough to justify the extra resources. For most products, the shared collection with a tenant index is the right default, and dedicated shards are an optimisation you add later for a handful of big customers.

Qdrant supports user-defined sharding from v1.7.0, routing a tenant to a dedicated shard with a shard key. The guide says this "works best for a modest number of large tenants." From v1.16.0 there's also tiered multitenancy: small tenants share a fallback shard, and big ones get promoted to their own. The guide suggests promotion at roughly 20,000 points and warns against creating more than a thousand dedicated shards per cluster (Qdrant multitenancy guide).

How do you isolate tenants with pgvector?

With pgvector, embeddings live in an ordinary Postgres table, so Postgres's own isolation tools apply to nearest-neighbour queries too. You get three options: row-level security, iterative index scans for filtered queries, and partial indexes or partitions. The guidance below comes from the vendor docs, not from a multi-tenant pgvector product of ours.

Row-level security. Once RLS is enabled on a table, every normal read and write must be allowed by a policy, and with no policy the default is deny (PostgreSQL row security docs). One catch from the same page: table owners normally bypass RLS unless you run FORCE ROW LEVEL SECURITY, and superusers and BYPASSRLS roles always do. If your app connects as the table owner, your policies do nothing. Our Supabase RLS post covers the policy side in depth.

alter table chunks enable row level security;
alter table chunks force row level security;
create policy tenant_isolation on chunks
  using (tenant_id = current_setting('app.tenant_id')::uuid);

-- pgvector 0.8.0+: keep scanning until enough rows pass the filter
set hnsw.iterative_scan = strict_order;

Iterative scans. From pgvector 0.8.0 you can enable iterative index scans with hnsw.iterative_scan, set to strict_order or relaxed_order, so a selective filter doesn't starve the result set (pgvector README). Turn it on for tenant-filtered queries.

Partial indexes or partitions. The README suggests partial indexes when you filter by a few distinct values and partitioning when you filter by many, including list partitioning by tenant. Partitioning suits a small number of large tenants.

What did Sentinel teach us about pgvector?

Sentinel taught us that keeping vectors next to the rest of the data pays off, even though it was a single-site hackathon build rather than a multi-tenant product. It's not evidence for tenant isolation. It is evidence for one database, one set of access rules, and one backup.

Sentinel, built for the Gujarat Police Hackathon Innovation Challenge 2026, stores a 768-dimension DINOv2 embedding for every vehicle sighting in Postgres with pgvector, alongside PostGIS, so a vehicle can be found across cameras even when its plate is unreadable. Because the embeddings sit in the same database as the sightings, any access rule on sightings can cover the vectors as well. There's no second store to keep in sync.

Is deletion part of tenant isolation?

Yes. A deleted document that can still be retrieved is a leak with extra steps. When a user deletes a document or a customer leaves, their vectors must go in the same job, along with anything derived from them. Check these three cases:

  1. Deleting a document deletes its chunks and vectors in the same job.
  2. Deleting a tenant deletes everything with its tenant ID, including caches of past answers.
  3. Re-indexing a document replaces old vectors rather than adding duplicates.

Stale vectors are a privacy problem and a quality problem at once, since old text keeps showing up in answers.

How do you test isolation like an attacker?

Test isolation by trying to break it, with two tenants whose documents are about the same topic. Close vectors are the hard case: if tenant A's question is best answered by tenant B's document, a missing filter shows up immediately. Run these steps on every change to retrieval code:

  1. Create two tenants with documents on the same topic, so their vectors sit close together.
  2. As tenant A, ask a question that tenant B's document answers best.
  3. Assert that no passage from B appears in the retrieved set, at any k.
  4. Repeat with a tampered request that tries to pass B's ID as a filter value.
  5. Delete a document and confirm it can no longer be retrieved.

Add these as permission-scoped questions to your RAG evaluation set, so isolation is graded alongside answer quality.

What should you log about retrieval?

Log enough to answer "could this user have seen that document?" with certainty, without copying customer text into a second place. When a user reports a strange answer, you need the exact passages the model saw. For each request, record:

  1. The user and tenant the request ran as.
  2. The filter that was applied.
  3. The IDs of the passages returned, with their scores.
  4. Which of those passages the answer cited.

Store IDs, not passage text. Otherwise the log becomes a shadow copy of everyone's data, with weaker access rules than the real store.

How do you handle content every user should see?

Some products mix shared content, such as a public course or a help centre, with private uploads. Give shared content its own marker and build the filter as "this tenant's content or shared content." Don't copy shared content into each tenant. It multiplies storage, and every update turns into a migration.

For the rest of the multi-tenant picture (databases, billing, roles), see our multi-tenant SaaS checklist. This post owns only the vector side.

FAQ

Does Qdrant need a separate collection for each tenant?

No. Qdrant recommends one collection with the tenant ID in the payload and a keyword index with is_tenant set to true, available from v1.11.0. Separate collections, or dedicated shards through shard keys, make sense only for a modest number of very large tenants that need stronger separation.

Does a tenant filter hurt vector search recall?

It can. With approximate indexes, filtering often runs after the index scan, so a selective filter may return too few results. Qdrant's tenant index and per-tenant graphs, or pgvector's iterative scans from version 0.8.0, keep filtered searches returning enough passages without loosening the filter.

Is Postgres row-level security enough for pgvector isolation?

Only if the app connects as a role that RLS applies to. Table owners bypass policies unless you enable FORCE ROW LEVEL SECURITY, and superusers or BYPASSRLS roles always bypass them. Combine RLS with a single retrieval function and a two-tenant leak test.

Where should the tenant ID come from in a retrieval request?

From the authenticated session on the server, never from the request body or a query parameter. If the client can send a tenant ID, it can send someone else's. Build the filter inside one server-side retrieval function that every chat, search, and tool call goes through.

Building retrieval for a product with more than one customer? See our vector database work and ask us to review your isolation setup before your users find the gap.