Part 1 of 3. This part is the requirements and the isolation model. Part 2 is the cost model with the arithmetic. Part 3 is the pipeline: ingestion, query, erasure and audit, with code.

We build a multi-tenant SaaS platform for public-sector organisations in the EU. Each tenant is a separate data controller under GDPR, and the assistant inside the product answers questions from that tenant’s own documents: case files, internal guidance, applicable law. A retrieval-augmented generation (RAG) system with hundreds of tenants, where a wrong answer is not an embarrassment but a data breach that nobody notices.

The failure mode is simple to describe. One shared vector index holds every tenant’s chunks. A caseworker in tenant B asks about eligibility thresholds for a benefit in cases of reduced work capacity. The nearest neighbours to that query include chunks from an applicant’s case notes in tenant A, because to a similarity search “reduced work capacity” is the same topic wherever it appears. The model answers correctly and cites its sources. Source three is a citizen’s medical history from another organisation. HTTP 200. Every dashboard green.

Nothing in that sequence is a model problem. The model did what it was given. This article is about what it is given, and about who enforces that.

The Requirements

We took the laws the data is protected by and asked what each one demands of a retrieval system specifically, once the data is chunks in a vector index rather than rows in a table. Six requirements came out. Each is stated as something that can be tested and handed to an auditor.

Illustration: The Requirements

Two of these are routinely confused. Provenance looks backward from an answer (where did this come from? — for the user on the screen). Auditability looks backward from the data (who has read this? — for the auditor afterwards). Same system, opposite directions.

Why One Tenant is Free and Hundreds are Not

Illustration: Why One Tenant is Free and Hundreds are Not

With hundreds of tenants in one index, 1–5 hold only if the application code is right, and each fails differently:

  • Isolation becomes a WHERE tenant_id = ? on every query. Miss it, resolve the wrong tenant, or let a bug rewrite it, and the search runs anyway.
  • Residency becomes one region for everyone. A tenant whose contract names a different region needs a second store, sized and billed for one customer; in practice the contract gets a footnote.
  • Deletability becomes “find and delete chunks tagged with this tenant and document”. Erasure is only as good as your tags or your search; approximate-nearest-neighbour search is not proof of absence.
  • Auditability becomes your application log. The provider can log that the index was queried; it cannot log whose chunks came back, because the tenant was a value inside the request, not a resource. The only log that knows is yours, and yours is the one under suspicion.
  • Provenance becomes a tenant tag written at ingest. Hundreds of tenants have a document titled "Benefit guidance 2026"; a citation is only true with the tenant on it, and a wrong tag looks exactly like a right one.

The fix is the one every vector store’s documentation already recommends: a partition, collection, index or schema per tenant. Part 2 is about why almost nobody does it (cost) and what changed. This part is about what kind of per-tenant boundary actually satisfies the table above, because not all of them do.

Three Ways to Draw the Boundary

Illustration: Three Ways to Draw the Boundary

There are three places a tenant boundary can live. They differ in who enforces it and how they fail.

A filter in your code

Shared index, tenant_id metadata, filter on every query.

await client.send(new QueryVectorsCommand({
  indexArn: SHARED_INDEX,
  queryVector: { float32: embedding },
  topK: 10,
  filter: { tenant_id: { $eq: "org-b" } },   // ← the boundary
}));
  • Enforced by: your code.
  • Failure: a plausible answer, HTTP 200. Nothing outside your process can refuse or log it.

Structure your code resolves

Index (or table, schema, collection) per tenant; the application picks which one.

const index = indexFor(session.tenantId);           // ← the boundary
await client.send(new QueryVectorsCommand({ indexArn: index, ... }));
  • Enforced by: your code (which index) and, on a database, by grants you configured.
  • Failure: indexFor resolves wrong, the query runs against another tenant's index, and you get the wrong customer's data with HTTP 200.

Quieter than a filter, exactly as wrong. On Postgres with a schema per tenant the database refuses a wrong-schema read. An improvement, but the grant is still yours, and the cloud provider sees the cluster as one resource: nothing to refuse, nothing to log per tenant.

A resource the provider enforces.

Index per tenant where each index is a first-class cloud resource with its own ARN, and the caller’s credentials name exactly one.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3vectors:QueryVectors", "s3vectors:GetVectors"],
    "Resource": "arn:aws:s3vectors:eu-west-1:123456789012:bucket/kb-eu-west-1/index/org-b"
  }]
}
  • Enforced by: IAM, on every call, before the service runs.
  • Failure: the same indexFor bug now produces AccessDeniedException — nothing back. And CloudTrail records the refused attempt against the ARN that was asked for.

The further right you go, the louder it fails. And rung 3 requires one property of the store that is easy to overlook: the tenant unit must have an ARN.

Why the ARN is the Whole Argument

Every IAM statement is action on resource, and the resource is an ARN (or *). IAM can only guard things that have one. CloudTrail follows the same rule: it records API calls by resource ARN.

Illustration: Why the ARN is the Whole Argument

Amazon S3 Vectors gives every index its own ARN of the form arn:aws:s3vectors:<region>:<account>:bucket/<bucket>/index/<index>, and every data-plane call — PutVectors, QueryVectors, GetVectors, DeleteVectors, ListVectors — is an IAM action on that ARN and a CloudTrail data event against it. That single fact is what moves the tenant boundary from rung 2 to rung 3, and it is why the rest of this series uses S3 Vectors rather than a filter or a schema.

The Resource Model, and How Each Requirement Maps to it

Illustration: The Resource Model, and How Each Requirement Maps to it

S3 Vectors is a bucket type, not a database. A vector bucket is created like an S3 bucket and is regional. It holds indexes; each index has a fixed dimension, distance metric, and a declared set of non-filterable metadata keys. Each index holds vectors: a key you choose, a float array, and metadata (filterable and non-filterable). One data-plane call, QueryVectors, returns the top-k nearest vectors with their metadata.

// Tenant onboarding = one call. No capacity to choose.
await client.send(new CreateIndexCommand({
  vectorBucketName: "kb-eu-west-1",
  indexName: "org-b",
  dataType: "float32",
  dimension: 1024,                          // Titan Text Embeddings V2
  distanceMetric: "cosine",
  metadataConfiguration: {
    nonFilterableMetadataKeys: ["chunk_text"],   // stored and returned, never scanned or filtered
  },
}));

Mapping the six requirements onto that model:

  1. Isolation — the tenant is the index. Credentials name the index ARN; a query cannot reach a different index regardless of what the application asks for.
  2. Residency — the bucket is regional, and the region is the second segment of the index ARN. One vector bucket per region served (kb-<region> for each; S3 Vectors is also available in the AWS European Sovereign Cloud for tenants whose contracts require an EU-operated partition); a tenant's index is created in the bucket its contract names. ListIndexes on each bucket is the residency report.
  3. Deletability — vector keys are chosen by the writer. Choose them from the document id (<doc-id>-k<nn>), record them at ingest, and erasure is DeleteVectors(keys) followed by GetVectors(keys) returning nothing. Strong consistency means the absence is true from the next call. A whole tenant is DeleteIndex.
  4. Auditability — enable CloudTrail data events for the index resource type; every call carries the principal, the time, the index ARN and the outcome. “Who read A?” is resources.ARN = '.../index/org-a'.
  5. Provenance — a chunk returned from org-b is B's because it cannot have come from anywhere else; no tenant tag is written. Document id and version are filterable metadata and appear on the citation; the chunk text is non-filterable metadata returned with the vector, so the cited passage is the passage the model was given.
  6. Honest limits — unchanged by any of this. The model is downstream of the folder.

What the Boundary Does Not Do

Rung 3 answers which tenant can reach which data. It says nothing about roles inside a tenant, about what the model does with a restricted document’s existence, or about answer quality across languages. Those are application concerns, and they are where most of our engineering time went. They are in Part 3, after the pipeline that makes rungs 1–3 concrete.

Part 2 first, because the objection to everything above is the same one it has always been: an index per tenant is the expensive design. We measured it.

Code is illustrative and uses the AWS SDK for JavaScript v3 (@aws-sdk/client-s3vectors); parameter names should be checked against the current SDK. Tenants, documents, regions and account ids are placeholders. No production volumes appear in this series; worked examples use round, stated inputs.