Vector databases from the ground up
Multi-tenant operations
A tenant is a promise about blast radius, not a column. Shared filters, per-tenant collections and separate clusters trade cost for isolation, and every one of them needs quotas, a delete path and a test with two tenants.
Isolation is a promise about blast radius
A tenant is not a column. It is a promise that one customer’s data, load and mistakes cannot reach another’s. The design decides how much of that promise the database enforces and how much your code has to.
The three common shapes are a shared collection with a tenant filter, a collection per tenant, and a database or cluster per tenant. They sit on a line from cheap and shared to expensive and isolated. The right point depends on the contract you signed rather than on the size of the corpus.
Shared is the default because one index serves everyone: one backup, one rebuild, one set of settings to tune. The price is that a single missing filter is a cross-tenant leak, and one large ingest is everyone’s latency. Isolation is bought with operational work, not with a keyword. A tenant that leaves takes its share of the index with it, and a tenant that grows changes the shape of the cluster for everyone.
Make the scope impossible to omit
Make the scope an argument the query cannot be built without. A data-layer function takes the tenant id first, resolves it from the logged-in request, and builds the filter itself. That is harder to misuse than a client that feature code is trusted to filter. The function can also log the tenant with the query, which makes an incident review possible without guessing.
Defence in depth is worth the duplication here. Use a payload filter in the vector database and row-level security in Postgres (a rule that hides other tenants’ rows). Add a credential that can only see one tenant where the product allows it. Each layer assumes the one above it failed.
Scope the derived copies too. A cache keyed by the query text alone can leak across tenants without touching the index. So can a reranker that sees the global candidate list, or a log line with a document body. The test is the same: two tenants, identical queries, and a check that the result sets never overlap.
A filter the caller cannot forget
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_payload_index(
collection_name="documents",
field_name="tenant_id",
field_schema=models.PayloadSchemaType.KEYWORD,
)
hits = client.query_points(
collection_name="documents",
query=query_vector,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="tenant_id",
match=models.MatchValue(value=tenant),
)
]
),
limit=10,
).pointsThe filter that must not be optional
Run both searches and count the rows that cross a tenant boundary. Then add a third tenant and confirm the naive search leaks its documents too.
Noisy neighbours and quotas
A shared cluster makes every tenant sensitive to every other tenant’s traffic. A bulk re-embedding job competes with live search for the same CPU and memory. A large delete triggers compaction while someone else is measuring p99 (the slow end of the range). A fair scheduler should also age requests, or a tenant that is always busy will still win.
Quotas are the control: vectors stored, queries per second, ingestion bytes per hour, and a maximum request size. Enforce them at the edge so abusive traffic never reaches the index, and again in the engine so a direct client cannot bypass the edge. The two limits should agree. An edge that allows more than the engine is a queue with a polite name.
Fairness is separate from quotas. A tenant under its limit can still hog a queue, so schedule requests round-robin (one per tenant in turn) or by weight, and keep bulk ingestion on its own queue. Measure p99 per tenant; a global p99 is an average that hides the customer being starved.
A tenant filter that lives in a handler is one refactor away from a breach. Put it in the data access layer and prove it with a test that queries as tenant A and asserts tenant B never appears.
Delete, export and migrate
Offboarding is a delete with evidence. Delete by tenant, count what remains, and check the backups, snapshots, caches and exported files that the index does not own. A deletion that leaves a copy in last week’s snapshot is not a deletion. Keep a list of every place the tenant appears, because the index is rarely the only one.
Per-tenant encryption keys make the hard case easier: destroying the key shreds the data everywhere it was copied, including backups, without rewriting every segment. It is the one operation that survives an incomplete list of copies.
Migrations run tenant by tenant. Re-embed a pilot tenant, compare recall and cost, then move the rest in batches with a dual-write window and a rollback path. Onboarding needs the same care. A new tenant should get its index settings, quotas and retention (how long data is kept) from a template rather than from a runbook someone remembers.
Fair scheduling under a burst
Run the two schedules, then give acme forty requests and compare the average wait per tenant. Decide which schedule you would put in front of an interactive search endpoint.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What is the main risk of one shared collection with a tenant filter?
