Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vector-unmask

Gradient-guided embedding inversion against BAAI/bge-m3 — a small, self-contained demonstration that a single stored embedding vector is not anonymized data.

Why this exists

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.

Quick start

Requires Podman or Dockerrun.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.sh

run.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 42

Configuration

run.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 reached

The search stops as soon as either condition is hit: the target cosine similarity, or the iteration cap — whichever comes first.

Logging & output

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.

Example result

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'".

How it works

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:

  1. 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.
  2. Take the top-k most promising (position, token) candidates per position.
  3. 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.
  4. Repeat. When the search plateaus (no improvement for --patience iterations), 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.

Security implications

  • 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.

References

Author

Václav Voborník — vobornik.eu

License

MIT — see LICENSE.

Disclaimer

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.

About

Gradient-guided embedding inversion against BAAI/bge-m3 — proof that a stored embedding vector is not anonymized data.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages