Gradient-guided embedding inversion against BAAI/bge-m3 — a small, self-contained
demonstration that a single stored embedding vector is not anonymized data.
A common assumption in RAG/vector-search systems: "we only store embeddings, not the original text, so it's safe / anonymized." This tool tests that assumption directly against an open-weight embedding model.
The reconstruction search only ever looks at the target embedding vector — no
training phase, no access to the original system, no black-box API calls. The
current CLI takes plaintext as input and reconstructs against that text's own
bge-m3 embedding (self-test mode), rather than an externally supplied raw
vector.
Requires Podman or Docker —
run.sh detects whichever is installed and uses the right GPU flag syntax
for each (--device nvidia.com/gpu=all for Podman via CDI, --gpus all for
Docker via nvidia-container-toolkit).
An NVIDIA GPU is optional but strongly recommended — the original experiments
took a few minutes per run on a consumer GPU (RTX 3060). run.sh checks for
nvidia-smi and reports which mode it's using (GPU detected, running on GPU or no GPU detected, starting on CPU); without a GPU it falls back to
CPU automatically, just much slower — consider lowering MAX_ITERS and/or
CANDIDATE_LENGTH in .env for a CPU-only run.
git clone <this repo>
cd vector-unmask
# input.txt ships with an example sentence — overwrite it with your own target
echo "Confidential: quarterly revenue grew by 12 percent." > input.txt
./run.shrun.sh detects whichever of podman/docker is available, builds the image on
first run (cached afterwards), runs a single inversion pass in a throwaway
container (--rm — nothing is left running once it's done), and prints the
result.
Extra invert.py flags can be passed straight through, e.g.:
./run.sh --seed 42run.sh creates .env from .env.example automatically on first run if it
doesn't exist yet — just edit .env afterwards to override the defaults
(nothing secret in there, it's only search hyperparameters):
#CANDIDATE_LENGTH=18 # reconstructed sequence length, in tokens — commented out by
# default: auto-inferred from input.txt's own token count.
# Uncomment to force a fixed length instead.
TARGET_COS_SIM=0.90 # stop early once this cosine similarity is reached (0 disables early stop)
MAX_ITERS=2000 # hard cap — the search stops here even if the target was never reachedThe search stops as soon as either condition is hit: the target cosine similarity, or the iteration cap — whichever comes first.
Progress prints to the console as it runs, and a compact progression trace is printed once the run stops:
0.10/200 -> 0.57/500 -> 0.76/1000 -> 0.901/1200
(best_cos_sim/iteration, one entry per --log-every checkpoint.) The full
result — target text, final reconstruction, whether the target was reached,
and the complete progression list — is also written as JSON to
data/result_<timestamp>.json on the host, so it survives after the
throwaway container (--rm) is gone.
Target: "CONFIDENTIAL: the password to the founders' private server is hidden somewhere inside this very sentence."
515 iterations, a couple of minutes on a consumer GPU (RTX 3060) — the search stopped as soon as it crossed the 0.90 cosine similarity target:
sudar компаниятаရုံး創業자들의 အဲဒီ unterschiedlichprywat़phen passwordកំពុងតែ inside antud sentेंสസിลെ hidden
Not fluent text — but decodable, fragment by fragment, across 7+ languages
(bge-m3 is multilingual, and the search has no language prior to constrain it):
| fragment | language | meaning |
|---|---|---|
password |
English | literal word, unchanged |
inside |
English | literal word, unchanged |
hidden |
English | literal word, unchanged |
sent... + ... силе |
English + Malayalam | "sent-" → sentence; Malayalam suffix ≈ "in/at" — reinforces "inside this sentence" |
prywat... |
Polish | root of "prywatny" → private |
創業자들의 |
Chinese + Korean hybrid | "founder"(創業者) + Korean plural/possessive → "of the founders" |
ရုံး |
Burmese | office — server/workplace context |
компанията |
Bulgarian | "the company" — founders'/company context |
အဲဒီ |
Burmese | "that/this" — echoes "this very" |
Cosine similarity between the reconstructed vector and the original: 0.9005
(1.0 = perfect match). Three whole English content words — password,
inside, hidden — survived reconstruction completely unchanged, along with
a Chinese/Korean compound landing almost exactly on "founders'".
bge-m3 is open-weight — we have gradients, not just query access. That makes
a different family of attacks available than the black-box approach used by
Vec2Text (which needs a trained
hypothesis+corrector model per target embedding model). Instead this uses a
GCG/HotFlip-style gradient-guided discrete optimization, run fresh for
every target vector, with no training phase at all:
- Relax the candidate token sequence to one-hot vectors, run a forward pass, backprop the cosine-distance loss against the one-hot indicators — this gives a first-order estimate of which token swap, at which position, would most reduce the loss.
- Take the top-k most promising (position, token) candidates per position.
- Actually evaluate (real forward pass, no gradient) a random batch of these candidate swaps, and keep whichever one most reduces the real loss — the gradient is only a first-order hint, not exact for a discrete swap.
- Repeat. When the search plateaus (no improvement for
--patienceiterations), a few positions get randomly re-rolled outside the gradient's suggestions (basin-hopping-style) to escape the local optimum.
Full algorithm details and derivation are in the docstring of
scripts/invert.py.
Because there's no trained language-model prior over fluent text (unlike Vec2Text), the output doesn't read as a natural sentence — but the semantic content still lands, which is what actually matters for a data leak.
- Threat model: white-box access to the embedding model's weights is the only real requirement — trivially satisfied for any open-weight model. The attacker never contacts the system that produced the vector: no API calls, no logs, no rate limits to hit.
- Partial reconstruction is enough to cause harm. A name, a number, or a keyword surviving in the fragments (as above) is as damaging as a fully fluent sentence when it comes to a data leak.
- Defense has to happen at the vector database level — encryption at rest and access control as strict as you'd apply to the plaintext equivalent. "We only stored embeddings" is not a substitute for that.
- Legitimate use of the same technique: auditing your own vector database ("is this actually anonymized?"), or checking how much information a RAG pipeline's chunking/embedding step is really encoding.
- Morris et al., Text Embeddings Reveal (Almost) As Much As Text (Vec2Text)
- Ebrahimi et al., HotFlip: White-Box Adversarial Examples for Text Classification
- Zou et al., Universal and Transferable Adversarial Attacks on Aligned Language Models (GCG)
- Jha et al., Harnessing the Universal Geometry of Embeddings (vec2vec) — even the specific embedding model isn't necessarily a defense, since embedding spaces across models turn out to share similar geometry and can be translated between each other.
Václav Voborník — vobornik.eu
MIT — see LICENSE.
Built and shared as a security research / auditing tool, to demonstrate a real gap between common assumptions about vector databases and how they actually behave. Use it to test systems you own or are authorized to test.