Module 6 · 7 min read
Embeddings, vector stores and retrieval isolation
Why an index of vectors derived from personal data is itself personal data, and how to make retrieval enforce permissions and tenant boundaries instead of hoping.
A team argues that its vector database holds no personal data because it stores only arrays of floating point numbers rather than the source text. The reasoning has the same shape as the hashing argument from the de-identification module: a transform that is hard to invert by hand is treated as a transform that destroys information. It does not. An embedding is a lossy but deliberately information-rich encoding, optimised to preserve exactly the semantic content that makes the source text meaningful, because that is what makes retrieval work at all.
Two properties break the argument. Inversion: published research has demonstrated that substantial portions of the original text can be reconstructed from embeddings in a range of settings, particularly where the embedding model is known or can be queried, which it usually can be. Linkage: even with no inversion at all, nearest-neighbour comparison is enough to link records. An adversary who can embed a candidate text and search the index learns whether something closely matching it is present, which is membership inference by another route. An index of embeddings derived from personal data is therefore itself personal data, and it inherits the classification, retention, access control and deletion obligations of its source.
What actually reduces risk in a vector store
The controls that work constrain who can reach which vectors and what leaves the boundary. Enforce the requesting principal's document permissions as a pre-filter on the search itself, so unauthorised vectors are never candidates for retrieval. Apply the same classification, retention and deletion controls to the index and its metadata as to the source documents, including propagating deletions. And treat the chunk text and metadata stored beside each vector as plaintext confidential data, because in almost every deployment it literally is: the chunk is stored so it can be put into the prompt, and metadata fields routinely carry titles, author names, customer identifiers and file paths in clear text.
Two controls that sound protective are not. Reducing embedding dimensionality does not anonymise a vector: it reduces fidelity somewhat, degrades retrieval quality, and leaves the record linkable. Raising the similarity threshold so that only highly relevant chunks are returned is a relevance knob; it changes what a permitted query returns, not who is permitted to query, and an attacker simply crafts a query that clears the threshold.
Try it first
Why is raising the similarity threshold not an access control?
Because it changes what a query returns, not who may query. The threshold is applied to every requester identically, so an unauthorised user simply phrases a query that clears it, and a well-targeted query about confidential content is exactly the query that scores highest. Access control has to answer the question of which vectors are candidates at all, and that answer depends on the requesting principal, not on the score.
Pre-filter, not post-filter, and use live entitlements
An enterprise search assistant indexes every document library and wiki page nightly, then applies access control by asking the model in its system prompt not to reveal restricted material. This fails on two counts. Prompted discretion is an instruction sharing a channel with retrieved content that an attacker may have authored, so it is overridable. And by the time the model is deciding what to reveal, the restricted content is already in context, where it can leak through a summary, a citation, a trace, a log line or an injected instruction.
The fix is to enforce the requesting user's current entitlements at retrieval time, so restricted documents are never returned into context at all, and to evaluate permissions on every query rather than freezing them at index time. Index-time snapshots go stale the moment someone changes a group membership or revokes a share, and the failure is silent. Post-processing redaction of the model's answer is better than nothing but still allows the content into context first. Indexing restricted documents into a separate collection and instructing the model to consult it only for authorised users repeats the original mistake: the model is not an access control system. Having the model cite sources so users can self-report anything they should not have seen is not a control at all.
Authorise before retrieval
- Entitlements resolved from the requesting principal at query time
- Unauthorised vectors are never candidates, so they never enter context
- Permission changes take effect on the next query
- Nothing restricted appears in prompts, traces, logs or caches
- Enforced below the caller, so a forgotten filter fails closed
Authorise after retrieval
- Content retrieved first, then filtered, redacted or summarised
- Restricted text is already in the context window and in the trace
- Index-time permission snapshots go stale after a revocation
- Leaks through citations, summaries and injected instructions
- Depends on the model or the application behaving correctly every time
Check yourself
A retrieval index is rebuilt nightly, and each chunk carries the list of groups permitted to see the source document at the moment of indexing. On Monday morning a user is removed from a sensitive project group. What happens for the rest of the day?
Permissions frozen at index time do not observe the revocation until the next rebuild, so the removed user keeps retrieving the project's content until that night. Entitlements have to be evaluated against the live authorisation system on every query, which is also why permission changes must not depend on a batch job to take effect.
Tenant isolation is a boundary question
A multi-tenant retrieval service stores every tenant's chunks in one collection and appends a tenant filter to the query that the application builds. A reviewer calls this the weakest link, and the reason is structural rather than theoretical: isolation now depends on every query path constructing that filter correctly, forever. One refactor that builds a query object a different way, one code path added for a batch job, one parameter that reaches the filter from user-controllable input, and the filter is dropped or widened. The failure mode is silent cross-tenant disclosure: no error, no alert, just another tenant's data in the response. Encryption at rest does not help, because every tenant's data is decrypted on the same query path for legitimate reads.
Note which objections are not the point. Metadata filtering at scale is a normal, well-supported feature of vector search engines. A shared collection does not force tenants onto one embedding model in a way that leaks between them, and encryption at rest can be applied to shared collections perfectly well. The objection is about where the boundary is enforced.
# Weak: isolation depends on the caller assembling the filter correctly
results = index.search(
vector=embed(user_query),
filter={"tenant_id": request.tenant_id}, # one refactor from absent
top_k=8,
)
# Stronger: the boundary is enforced below the caller
# 1. Per-tenant namespace or collection, so a missing filter cannot widen scope
# 2. Credential scoped to that namespace, so the datastore refuses cross-tenant reads
# 3. Entitlement pre-filter derived from the request principal, not from parameters
session = index.for_tenant(principal.tenant_id) # scoped credential
results = session.search(
vector=embed(user_query),
acl=entitlements.current_for(principal), # live, per-query
top_k=8,
)