Skip to content

Security and privacy

Retrieval systems fail at security in ways ordinary applications don’t, because the usual mental model quietly stops applying. Your database has row-level permissions you’ve thought about for years. Your vector index is a pile of floats with no notion of who may see what.

This page is the set of questions worth answering before someone else asks them.

The tempting assumption is that an embedding is anonymised — it’s just numbers, and there’s no decoder.

That assumption is wrong, and it’s the one to correct first. Research on embedding inversion has shown that substantial portions of the original text can be reconstructed from the vector alone. Not perfectly, and not always, but far more than “it’s just numbers” implies.

So: an embedding of personal data is personal data. Under GDPR and similar regimes, treat it as such. Same access controls, same retention rules, same deletion obligations, same answer when someone asks what you hold about them.

The mistake that produces actual data leaks:

# WRONG — searches everything, then hides what the user can't see
results = index.search(query_vector, k=5)
visible = [r for r in results if user.can_read(r.document_id)]

That looks defensive. It leaks two ways.

The results are wrong. You asked for 5, filtered 3 away, and returned 2 — while perfectly good documents the user can see sat at rank 6 and 7, never considered. Quality degrades in proportion to how much the user can’t see.

Confidential content reached your process. It’s in memory, in logs, in your APM traces, and in the exception if something throws mid-request. For some compliance regimes that alone is the incident.

Push the filter into the search:

# RIGHT — the index never considers documents this user can't read
results = index.search(
query_vector,
k=5,
filter={"tenant_id": user.tenant_id, "visibility": {"$in": user.roles}},
)

Every serious vector database supports filtered search — the directory notes which ones do it well. It’s a large part of what distinguishes them from a plain nearest-neighbour library, and it’s why “we’ll just use FAISS” often stops being viable the moment permissions arrive.

Three approaches, in increasing order of isolation and cost:

Approach Isolation Use when
One index, tenant_id metadata filter Logical only Many small tenants; a bug is a leak
One namespace or collection per tenant Strong Moderate tenant count; most SaaS
One database per tenant Complete Few large tenants, or a regulatory requirement

The first is the default and the riskiest: a single forgotten filter in a single code path exposes everything. If you take it, make the filter impossible to omit — a repository layer that takes the tenant from an authenticated context rather than a parameter, so there is no code path where a caller could leave it out.

Namespaces are the sensible middle ground for most SaaS, and the option I’d reach for by default.

If any document in your index contains text a user can write — support tickets, comments, uploaded files, scraped pages, email — then a person can put instructions in a document and wait for it to be retrieved.

Once that chunk lands in your prompt, part of your prompt was written by them. This is prompt injection, it is OWASP’s number one LLM risk, and it has no complete fix.

What actually helps:

  • Delimit retrieved content clearly and tell the model it is reference material, never instructions. Imperfect, but it raises the bar.
  • Never let model output trigger an action unchecked. If it can send email, call an API, or run a query, a successful injection is no longer a bad answer — it’s an action taken by an attacker.
  • Apply the user’s permissions to anything the model does, not the service account’s. An injected instruction should be able to reach nothing the user couldn’t reach anyway.
  • Be most careful with the combination: reading untrusted documents and holding real capabilities is the dangerous pairing. Either alone is manageable.

Every embedding API call sends the text off your infrastructure. That’s a data-flow question your DPO will eventually ask, so answer it early.

Check the provider’s actual policy. OpenAI states that API data isn’t used for training by default (their policy), but policies change and enterprise agreements differ. Read it; don’t repeat what a guide told you, this one included.

Check where it’s processed. Data residency matters under GDPR and similar regimes. Azure OpenAI offers regional deployments where the public API may not.

If the answer must be a firm no, run locally. sentence-transformers in Python or Transformers.js in JavaScript never send anything anywhere. Quality is a step below the frontier APIs and is frequently sufficient — and for genuinely sensitive corpora, “the text never left” is worth more than a few points of retrieval accuracy.

Not a compliance framework. Just the things that are awkward to discover late:

  • If a user is deleted tomorrow, what removes their vectors?
  • Which code paths can query the index, and does every one apply a tenant filter?
  • Can a document a user uploads be retrieved into another user’s prompt?
  • What does the model have permission to do, and whose permissions are those?
  • Where is the text processed, and does the contract say what you think it says?
  • Do your logs contain retrieved chunk text? Should they?

That last one catches people. The advice on the previous page is to log retrieved chunk ids — deliberately, not the text. Ids are enough to debug and don’t turn your log aggregator into an uncontrolled copy of your corpus.