Embedding Inversion And Reconstruction
Description
Embeddings are routinely handled as if they were anonymised. They are not. A dense vector retains enough of its input that a trained inversion model can rebuild recognisable source text from the vector alone, and a linear probe over the same vector can predict attributes the source never stated. Anywhere a raw float array crosses a boundary you hold an unlabelled copy of the document: an OpenAI-compatible /v1/embeddings route on a shared LiteLLM or vLLM gateway, a Qdrant scroll with with_vector set, a Weaviate read with include=vector, an index snapshot in an object-store prefix, a cached-embeddings store in Redis or on disk, or browser-side semantic search where the whole index ships to the client.
The payoff is read access to documents you were never served. Published recovery rates run from roughly 50 to 70 percent of words out of sentence embeddings up to 92 percent exact reconstruction of short 32-token inputs, and enough of longer texts to identify names, figures and clause language, so an “embeddings only” exposure is a source-document exposure. It is easy to miss because nothing looks like a leak: the endpoint returns numbers, the bucket holds .parquet or .snapshot files with no readable strings, and triage stops at “no plaintext present”. Cross-tenant authorisation on the retrieval path is the Cross-Tenant RAG Retrieval Leakage page under LLM02; this page is about what a bare vector gives up once you hold it.
Examples
Reach an embedding endpoint and confirm you get raw vectors
Find the embed route behind the assistant - the model gateway, the ingest worker, or the client’s own network traffic - and call it with a marker string using the weakest credential you hold.
curl -s http://gateway.internal:4000/v1/embeddings \
-H "Authorization: Bearer $LOW_PRIV_KEY" -H 'Content-Type: application/json' \
-d '{"model":"text-embedding-3-small","input":"CANARY-1234 restructuring memo"}' \
| python3 -c 'import json,sys; d=json.load(sys.stdin)["data"][0]["embedding"]; print(len(d), d[:5])'
Confirmed when a full float array comes back. The length identifies the encoder family - 384 for all-MiniLM-L6-v2, 768 for bge-base-en-v1.5 or gtr-base, 1536 and 3072 for the OpenAI text-embedding-3 pair - which tells you which corrector to point at the dump.
Harvest stored vectors from the index, a snapshot, or the browser
Ask the store for vectors rather than payloads. Against Qdrant, a scroll with with_vector returns the geometry directly:
curl -s http://qdrant.internal:6333/collections/kb_shared/points/scroll \
-H 'Content-Type: application/json' \
-d '{"limit":200,"with_vector":true,"with_payload":false}' > /tmp/poc-vectors.json
The Weaviate equivalent is GET /v1/objects?class=Document&include=vector&limit=200; Chroma uses a POST to the collection’s /get route with include set to embeddings. For in-browser semantic search, open the network tab and save the index bundle the page fetches - vectors in client-side storage are already public. Confirmed when you hold vectors without having been authorised to read a document.
Invert the vectors back to text
Run a published corrector against the harvested arrays. vec2text ships pretrained correctors for gtr-base and text-embedding-ada-002, one per encoder; zero-shot inversion such as ZSInvert needs no encoder-specific training and stays effective against differential-privacy noise added at storage. The path below assumes one unnamed vector per point, and .cuda() assumes a GPU.
import json, torch, vec2text
vecs = torch.tensor(json.load(open("/tmp/poc-vectors.json"))["result"]["points"][0]["vector"]).unsqueeze(0)
corrector = vec2text.load_pretrained_corrector("gtr-base")
print(vec2text.invert_embeddings(embeddings=vecs.cuda(), corrector=corrector, num_steps=20))
Confirmed when the output contains recognisable source content - the CANARY-1234 marker, a name, a figure, or clause wording matching a document you can verify in the lab corpus. Report coverage, not anecdote: score token overlap and exact-match rate over a set of known lab documents, for example 60 of 100 single-sentence chunks recovered well enough to identify the subject.
Probe without inversion: nearest-neighbour and attribute oracles
Where you can query but not read, the score itself leaks. Embed a candidate sentence and ask for its neighbours with payloads suppressed.
curl -s http://qdrant.internal:6333/collections/kb_shared/points/query \
-H 'Content-Type: application/json' \
-d '{"query":[0.011,-0.043,0.377],"limit":5,"with_payload":false,"with_vector":false}'
A score close to 1.0 for a sentence lifted from a document you should not know about confirms membership. Extend it to attribute inference by fitting a small logistic-regression probe on a labelled lab set of your own vectors, then applying it to harvested vectors; a probe that predicts a sensitive label well above the base rate is the finding.
Remediation
- Never return raw vectors to a caller
- Strip vector fields from every application-facing response: with_vector false, no include=vector, no embeddings in client bundles or browser storage.
- Run semantic search server-side and ship results, not the index.
- Authenticate and meter the embedding API
- Treat /v1/embeddings as a first-class authenticated API with per-tenant rate and volume limits, and its keys as secrets.
- Alert on bulk embed traffic and large vector reads, which are inversion precursors.
- Classify vectors at source-document sensitivity
- Encrypt embeddings at rest with separately managed keys, and hold snapshots, backups and third-party exports in the documents’ tier and retention policy.
- Treat an embeddings-only exposure as a source-data breach in incident response.
- Bound the lifecycle
- Delete embeddings when the source is deleted and verify by audit rather than assuming the reindex covered it.
- On encoder rotation, re-embed the whole collection instead of mixing generations.
- Suppress the score oracle
- Withhold or coarsen raw similarity scores for untrusted callers, and rate-limit similarity queries per identity so membership probing is not free.