{"id":23812,"date":"2026-09-07T22:00:00","date_gmt":"2026-09-07T22:00:00","guid":{"rendered":"https:\/\/scannn.com\/cosine-similarity-is-not-a-safety-property\/"},"modified":"2026-09-07T22:00:00","modified_gmt":"2026-09-07T22:00:00","slug":"cosine-similarity-is-not-a-safety-property","status":"publish","type":"post","link":"https:\/\/scannn.com\/lv\/cosine-similarity-is-not-a-safety-property\/","title":{"rendered":"Cosine Similarity Is Not a Safety Property"},"content":{"rendered":"\n<div>\n<p>The vector database returns the most <em>relevant<\/em> documents, defined as the ones with the highest cosine similarity to the query. This is a mathematical property with no concept of accuracy, authority or provenance. A document scoring 0.95 against a query can be entirely fabricated. A document scoring 0.60 can be the ground truth.<\/p>\n<p>The attacker\u2019s job in a poisoning attack is not to break into the vector database. It is to write a document whose embedding sits closer to the anticipated query than the legitimate document does, then frame it with enough authority to win the argument once both are in the context window.<\/p>\n<h2 id=\"what-the-embedding-model-is-actually-doing\"><span class=\"me-2\">What the embedding model is actually doing<\/span><a href=\"#what-the-embedding-model-is-actually-doing\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p><code class=\"language-plaintext highlighter-rouge\">sentence-transformers\/all-MiniLM-L6-v2<\/code> turns any string into a 384-dimensional float vector: a point in 384-dimensional space. Semantically similar texts land near each other. \u201cQ4 financial results\u201d sits close to \u201cfourth quarter revenue\u201d and far from \u201ccompany travel policy.\u201d ChromaDB stores the vectors and answers a query by finding the k stored points closest to the query point, under whichever distance function the collection was created with.<\/p>\n<p>ChromaDB\u2019s default is squared L2, not cosine. Cosine has to be set on purpose, with <code class=\"language-plaintext highlighter-rouge\">metadata={\"hnsw:space\": \"cosine\"}<\/code> at collection creation. Everything in this piece assumes a collection configured that way, which is the common choice for text embeddings and the one worth setting deliberately rather than inheriting by accident. If you skip that step, the \u201cangle to the query point\u201d framing below stops describing what your database is actually doing.<\/p>\n<div class=\"language-python highlighter-rouge\">\n<div class=\"highlight\"><code><\/p>\n<table class=\"rouge-table\">\n<tbody>\n<tr>\n<td class=\"rouge-gutter gl\">\n<pre class=\"lineno\">1\n2\n3\n4\n5\n<\/pre>\n<\/td>\n<td class=\"rouge-code\">\n<pre><span class=\"c1\"># The entire retrieval mechanism, conceptually\n<\/span><span class=\"n\">query_vector<\/span> <span class=\"o\">=<\/span> <span class=\"nf\">embed<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">What was Q4 2025 revenue?<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>   <span class=\"c1\"># 384 floats\n<\/span>\n<span class=\"n\">results<\/span> <span class=\"o\">=<\/span> <span class=\"n\">collection<\/span><span class=\"p\">.<\/span><span class=\"nf\">query<\/span><span class=\"p\">(<\/span><span class=\"n\">query_embeddings<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"n\">query_vector<\/span><span class=\"p\">],<\/span> <span class=\"n\">n_results<\/span><span class=\"o\">=<\/span><span class=\"mi\">3<\/span><span class=\"p\">)<\/span>\n<span class=\"c1\"># The 3 stored vectors with smallest cosine distance.\n<\/span><\/pre>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><\/code><\/div>\n<\/div>\n<p>The model does not know what the text <em>means<\/em> in any truth-bearing sense. It knows how text clusters with other text in its training data. \u201cRevenue was $8.3M\u201d and \u201cRevenue was $24.7M\u201d land at nearly identical positions, because they are the same sentence with a different number. The vector database cannot tell them apart. Only the model can, after retrieval, and only if both documents are in front of it.<\/p>\n<p>This is a property of dense retrieval, not a defect in one model. <code class=\"language-plaintext highlighter-rouge\">all-MiniLM-L6-v2<\/code> has aged into a poor production default; <code class=\"language-plaintext highlighter-rouge\">EmbeddingGemma-300M<\/code>, <code class=\"language-plaintext highlighter-rouge\">Qwen3-Embedding<\/code> and <code class=\"language-plaintext highlighter-rouge\">BGE-M3<\/code> are where new work goes in 2026. None of them change the property. Semantic similarity is not factual accuracy at any parameter count.<\/p><figcaption class=\"caption\">The three poisoned documents sit inside the top-3 window. The document holding the real number sits just outside it, at rank 4, and never reaches the model. Similarity values are illustrative; ChromaDB returns cosine distances, and actual values depend on model, phrasing and document length.<\/figcaption><h2 id=\"vocabulary-engineering-moving-a-document-without-gradient-descent\"><span class=\"me-2\">Vocabulary engineering: moving a document without gradient descent<\/span><a href=\"#vocabulary-engineering-moving-a-document-without-gradient-descent\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>The sophisticated form of this attack, as formalised in <a href=\"https:\/\/www.usenix.org\/conference\/usenixsecurity25\/presentation\/zou-poisonedrag\">PoisonedRAG<\/a> (Zou, Geng, Wang and Jia, USENIX Security 2025), uses gradient-based optimisation to find text that minimises cosine distance to a target query vector. That needs white-box access to the embedding model, or query access at scale to approximate it. With that machinery, the paper injected <strong>five crafted texts into a corpus of 2.6 million<\/strong> and drove attacker-chosen answers at above 90% success.<\/p>\n<p>The cheap version needs neither gradient descent nor white-box access. Identify the vocabulary of the query you want to hijack, put that vocabulary in your document, add authority signals for the generation step. In my lab that got to 95% success against an undefended pipeline (twenty runs on a small seeded corpus, one model \u2014 a lab reading, not a base rate). The three documents I used, annotated:<\/p>\n<p><strong>Document one, the CFO correction.<\/strong> Title: <code class=\"language-plaintext highlighter-rouge\">Q4 2025 Financial Results \u2014 CORRECTED FIGURES (Board Update)<\/code>. Against a query like \u201cwhat was Q4 2025 revenue?\u201d, <code class=\"language-plaintext highlighter-rouge\">Q4 2025<\/code> is an exact temporal match, <code class=\"language-plaintext highlighter-rouge\">Financial Results<\/code> clusters with revenue and financials, <code class=\"language-plaintext highlighter-rouge\">CORRECTED FIGURES<\/code> implies it supersedes something, and <code class=\"language-plaintext highlighter-rouge\">Board Update<\/code> is an authority signal. Body states fabricated numbers as plain fact and closes with <code class=\"language-plaintext highlighter-rouge\">Approved by: CFO Office<\/code>.<\/p>\n<p><strong>Document two, the restatement notice.<\/strong> States that Q4 revenue has been restated, actual revenue $8.3M, \u201cnot the previously reported $24.7M.\u201d It names the real figure and reframes it as an acknowledged error, which gives the model a narrative for resolving the contradiction it is about to encounter.<\/p>\n<p><strong>Document three, the board minutes.<\/strong> Emergency session, agenda item three, corrected results, same numbers.<\/p>\n<p>Three documents corroborating one fabrication against one legitimate document telling the truth. In a top-3 retrieval, three crowd out one, and the legitimate content never enters the context window. That is vocabulary engineering: shifting which regions of embedding space are occupied so fabricated documents sit closer to anticipated queries than the documents they are meant to displace.<\/p>\n<h2 id=\"two-conditions-and-which-one-does-the-work\"><span class=\"me-2\">Two conditions, and which one does the work<\/span><a href=\"#two-conditions-and-which-one-does-the-work\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>PoisonedRAG frames the attack as two conditions that must both hold.<\/p>\n<p><strong>Retrieval condition.<\/strong> For a target query q, the poisoned document d_p must make top-k:<\/p>\n<div class=\"language-plaintext highlighter-rouge\">\n<div class=\"highlight\"><code><\/p>\n<table class=\"rouge-table\">\n<tbody>\n<tr>\n<td class=\"rouge-gutter gl\">\n<pre class=\"lineno\">1\n<\/pre>\n<\/td>\n<td class=\"rouge-code\">\n<pre>cos_distance(embed(d_p), embed(q)) &lt; cos_distance(embed(d_legit), embed(q))\n<\/pre>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><\/code><\/div>\n<\/div>\n<p><strong>Generation condition.<\/strong> Once in context, d_p must cause the model to produce the attacker\u2019s answer rather than the correct one, which requires the target answer to be present and framed with enough authority to outweigh contradicting sources.<\/p>\n<p>Research attention went to the retrieval condition, because that is where the elegant optimisation lives. Recent work suggests the framing is doing more of the work. In <a href=\"https:\/\/arxiv.org\/abs\/2605.05632\">\u201cArchitecture Matters: Comparing RAG Systems under Knowledge Base Poisoning\u201d<\/a> (Korn, May 2026), most of the strongest attack variant\u2019s advantage came from adversarial framing rather than retrieval optimisation, and attack success across four architectures with comparable clean accuracy spread nearly 58 points, from 81.9% for vanilla RAG down to 24.4% for a recursive setup.<\/p>\n<p>If framing dominates, then the stage that resolves contradictions between retrieved documents is a more valuable place to invest than another round of retrieval hardening. In my own measurements the legitimate document was often retrieved <em>and still lost<\/em>. The real Q4 figure sat in the context window while the model reported the fabricated one, because two documents said the real figure had been corrected and one document just quietly stated it.<\/p>\n<p><!-- _includes\/inline-subscribe.html &mdash; compact mid-article subscribe callout --><\/p>\n<div class=\"inline-cta\">\n<p class=\"inline-cta-body\">Weekly practitioner-level analysis of AI security \u2014 attack labs, incident breakdowns, and defense patterns for teams actually building these systems. One email per week, no fluff.<\/p>\n<p><a class=\"inline-cta-btn\" href=\"https:\/\/aminrj.com\/subscribe\/\" onclick=\"trackNewsletterSignup()\"> Subscribe \u2014 it&#8217;s free <\/a><\/div>\n<h2 id=\"turning-the-geometry-against-the-attacker\"><span class=\"me-2\">Turning the geometry against the attacker<\/span><a href=\"#turning-the-geometry-against-the-attacker\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>The attacker\u2019s requirement is also their signature. To be retrieved, the poisoned documents must cluster near the target query position, which is near the legitimate documents on that topic. An ingestion-time check can look for this.<\/p>\n<p>Two signals, computed before anything is stored:<\/p>\n<div class=\"language-python highlighter-rouge\">\n<div class=\"highlight\"><code><\/p>\n<table class=\"rouge-table\">\n<tbody>\n<tr>\n<td class=\"rouge-gutter gl\">\n<pre class=\"lineno\">1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n<\/pre>\n<\/td>\n<td class=\"rouge-code\">\n<pre><span class=\"c1\"># Signal 1 \u2014 nearest neighbour in the existing collection\n<\/span><span class=\"n\">existing<\/span> <span class=\"o\">=<\/span> <span class=\"n\">collection<\/span><span class=\"p\">.<\/span><span class=\"nf\">query<\/span><span class=\"p\">(<\/span><span class=\"n\">query_embeddings<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"n\">new_doc_embedding<\/span><span class=\"p\">],<\/span> <span class=\"n\">n_results<\/span><span class=\"o\">=<\/span><span class=\"mi\">3<\/span><span class=\"p\">)<\/span>\n<span class=\"k\">for<\/span> <span class=\"n\">dist<\/span> <span class=\"ow\">in<\/span> <span class=\"n\">existing<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">distances<\/span><span class=\"sh\">\"<\/span><span class=\"p\">][<\/span><span class=\"mi\">0<\/span><span class=\"p\">]:<\/span>\n    <span class=\"nf\">if <\/span><span class=\"p\">(<\/span><span class=\"mf\">1.0<\/span> <span class=\"o\">-<\/span> <span class=\"n\">dist<\/span><span class=\"p\">)<\/span> <span class=\"o\">&gt;<\/span> <span class=\"n\">SIMILARITY_THRESHOLD<\/span><span class=\"p\">:<\/span>      <span class=\"c1\"># 0.85 in the lab\n<\/span>        <span class=\"nf\">flag<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">HIGH_SIMILARITY \u2014 possible content override<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n\n<span class=\"c1\"># Signal 2 \u2014 pairwise similarity within the incoming batch\n<\/span><span class=\"k\">for<\/span> <span class=\"n\">i<\/span><span class=\"p\">,<\/span> <span class=\"n\">e_i<\/span> <span class=\"ow\">in<\/span> <span class=\"nf\">enumerate<\/span><span class=\"p\">(<\/span><span class=\"n\">new_embeddings<\/span><span class=\"p\">):<\/span>\n    <span class=\"k\">for<\/span> <span class=\"n\">j<\/span> <span class=\"ow\">in<\/span> <span class=\"nf\">range<\/span><span class=\"p\">(<\/span><span class=\"n\">i<\/span> <span class=\"o\">+<\/span> <span class=\"mi\">1<\/span><span class=\"p\">,<\/span> <span class=\"nf\">len<\/span><span class=\"p\">(<\/span><span class=\"n\">new_embeddings<\/span><span class=\"p\">)):<\/span>\n        <span class=\"k\">if<\/span> <span class=\"nf\">cosine_similarity<\/span><span class=\"p\">(<\/span><span class=\"n\">e_i<\/span><span class=\"p\">,<\/span> <span class=\"n\">new_embeddings<\/span><span class=\"p\">[<\/span><span class=\"n\">j<\/span><span class=\"p\">])<\/span> <span class=\"o\">&gt;<\/span> <span class=\"n\">CLUSTER_THRESHOLD<\/span><span class=\"p\">:<\/span>   <span class=\"c1\"># 0.90\n<\/span>            <span class=\"nf\">flag<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">TIGHT_CLUSTER \u2014 possible coordinated injection<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<\/pre>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><\/code><\/div>\n<\/div>\n<p>Both fire on the three-document attack. Each poisoned document\u2019s nearest neighbour is the legitimate Q4 report, because both are about Q4 2025 financials. The three cluster tightly with each other, because they are variations on one fabricated narrative. This single layer took poisoning success from 95% to 20%.<\/p>\n<p>The geometric reason it works is the closest thing to a structural guarantee in this area: the attacker cannot fully satisfy both requirements at once. Satisfying the retrieval condition means occupying space near existing content, which is detectable. Evading detection means moving away from that space, which degrades retrieval. The 20% residual is the band where an attacker balances the two, typically with a single document instead of a cluster, or with enough vocabulary variation to fall below the similarity threshold while staying retrievable.<\/p>\n<pre class=\"mermaid\">\n%%{init: {\"theme\": \"base\", \"themeVariables\": {\"primaryColor\": \"#e8f4fd\", \"primaryBorderColor\": \"#3182ce\", \"fontSize\": \"14px\", \"fontFamily\": \"system-ui, -apple-system, sans-serif\"}}}%%\nflowchart LR\n    RC[\"Retrieval condition<br\/>embed close to target query\"] --&gt;|\"same property\"| S1[\"Signal 1<br\/>similarity to existing &gt; threshold\"]\n    CL[\"Coordinated injection<br\/>several docs, one space\"] --&gt;|\"same property\"| S2[\"Signal 2<br\/>pairwise similarity &gt; threshold\"]\n    S1 --&gt; DIL[\"The attacker's dilemma<br\/>evading detection degrades retrieval<br\/>satisfying retrieval triggers detection\"]\n    S2 --&gt; DIL\n    classDef threat fill:#0f172a,stroke:#0f172a,color:#fff,stroke-width:1.5px\n    classDef ok fill:#e8f4fd,stroke:#3182ce,color:#1a202c,stroke-width:1.5px\n    class RC,CL threat\n    class S1,S2,DIL ok\n<\/pre>\n<h2 id=\"access-controlled-retrieval-the-where-clause\"><span class=\"me-2\">Access-controlled retrieval: the <code class=\"language-plaintext highlighter-rouge\">where<\/code> clause<\/span><a href=\"#access-controlled-retrieval-the-where-clause\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>A metadata filter on every vector query that restricts which documents the requesting user is allowed to retrieve. Without it, every document in the collection is reachable by every user through an ordinary question. Ask an assistant what the salary bands are and it retrieves whatever is semantically close, with no idea who is asking.<\/p>\n<p>I ran this against a lab pipeline holding three restricted documents: salary data marked HR-only, litigation detail marked privileged, and an M&amp;A pipeline marked board-level. Queried as a regular engineering user, in natural language, with no evasion. Twenty out of twenty queries returned confidential content.<\/p>\n<div class=\"language-python highlighter-rouge\">\n<div class=\"highlight\"><code><\/p>\n<table class=\"rouge-table\">\n<tbody>\n<tr>\n<td class=\"rouge-gutter gl\">\n<pre class=\"lineno\">1\n2\n3\n4\n5\n6\n7\n8\n9\n<\/pre>\n<\/td>\n<td class=\"rouge-code\">\n<pre><span class=\"c1\"># Vulnerable: no notion of who is asking\n<\/span><span class=\"n\">results<\/span> <span class=\"o\">=<\/span> <span class=\"n\">collection<\/span><span class=\"p\">.<\/span><span class=\"nf\">query<\/span><span class=\"p\">(<\/span><span class=\"n\">query_embeddings<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"n\">query_embedding<\/span><span class=\"p\">],<\/span> <span class=\"n\">n_results<\/span><span class=\"o\">=<\/span><span class=\"mi\">3<\/span><span class=\"p\">)<\/span>\n\n<span class=\"c1\"># Hardened: retrieval scoped to the requester's clearances\n<\/span><span class=\"n\">results<\/span> <span class=\"o\">=<\/span> <span class=\"n\">collection<\/span><span class=\"p\">.<\/span><span class=\"nf\">query<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">query_embeddings<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"n\">query_embedding<\/span><span class=\"p\">],<\/span>\n    <span class=\"n\">n_results<\/span><span class=\"o\">=<\/span><span class=\"mi\">3<\/span><span class=\"p\">,<\/span>\n    <span class=\"n\">where<\/span><span class=\"o\">=<\/span><span class=\"p\">{<\/span><span class=\"sh\">\"<\/span><span class=\"s\">classification<\/span><span class=\"sh\">\"<\/span><span class=\"p\">:<\/span> <span class=\"p\">{<\/span><span class=\"sh\">\"<\/span><span class=\"s\">$in<\/span><span class=\"sh\">\"<\/span><span class=\"p\">:<\/span> <span class=\"n\">user_permitted_classifications<\/span><span class=\"p\">}},<\/span>\n<span class=\"p\">);<\/span>\n<\/pre>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><\/code><\/div>\n<\/div>\n<p>That filter is the only complete defence against cross-tenant leakage, because it is structural. It prevents unauthorised content from entering the context window at all. Output monitoring, prompt hardening and every other heuristic runs after retrieval, by which point the data is already in the prompt.<\/p>\n<p>A minimal access model is enough to start:<\/p>\n<div class=\"table-wrapper\">\n<table>\n<thead>\n<tr>\n<th>Role<\/th>\n<th>Permitted classifications<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Regular employee<\/td>\n<td>public, internal<\/td>\n<\/tr>\n<tr>\n<td>HR business partner<\/td>\n<td>+ confidential (HR)<\/td>\n<\/tr>\n<tr>\n<td>Legal counsel<\/td>\n<td>+ privileged<\/td>\n<\/tr>\n<tr>\n<td>Executive<\/td>\n<td>all<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>You do not need full RBAC. You need a mapping from identity to permitted metadata values, applied on every retrieval path.<\/p>\n<p>Partial implementation of this control is worse than none, because it manufactures confidence. A <code class=\"language-plaintext highlighter-rouge\">where<\/code> filter on the main chat endpoint, with an unfiltered path in the summarisation job or the eval harness, gives you a system that looks access-controlled and is not. Find every retrieval call, not the obvious one.<\/p>\n<h2 id=\"the-threshold-problem\"><span class=\"me-2\">The threshold problem<\/span><a href=\"#the-threshold-problem\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>The lab hardcodes 0.85 and 0.90. Those are not universal values, and copying them into production is the most common way this control fails.<\/p>\n<p>At 0.85 the defence catches the three-document attack because the poisoned documents score around 0.88 to 0.92 against the legitimate financials. Increase vocabulary variation, keeping the financial theme but varying the specific language, and similarity drops to roughly 0.78 to 0.82. Below threshold, not flagged, still retrievable.<\/p>\n<div class=\"table-wrapper\">\n<table>\n<thead>\n<tr>\n<th>Threshold<\/th>\n<th>Poisoning caught<\/th>\n<th>Legitimate updates flagged<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>0.95<\/td>\n<td>Only near-verbatim overrides<\/td>\n<td>Almost none<\/td>\n<\/tr>\n<tr>\n<td>0.90<\/td>\n<td>Strong variants<\/td>\n<td>Occasional, on revised documents<\/td>\n<\/tr>\n<tr>\n<td>0.85 (lab default)<\/td>\n<td>Most variants<\/td>\n<td>Some, on policy revisions and corrections<\/td>\n<\/tr>\n<tr>\n<td>0.80<\/td>\n<td>More variants<\/td>\n<td>Frequent, most updates flagged<\/td>\n<\/tr>\n<tr>\n<td>0.75<\/td>\n<td>Maximum coverage<\/td>\n<td>Impractical, constant manual review<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>The right value comes from your ingestion patterns. An append-only corpus can sit low. A living wiki with versioned policies cannot, because every legitimate amendment looks like an override attempt. Baseline your collection\u2019s actual similarity distribution and set the threshold around mean plus two standard deviations, then revisit as the collection grows.<\/p>\n<p><strong>These thresholds are properties of your embedding model, not of your data.<\/strong> Similarity distributions are not comparable across models. Swap <code class=\"language-plaintext highlighter-rouge\">all-MiniLM-L6-v2<\/code> for <code class=\"language-plaintext highlighter-rouge\">Qwen3-Embedding<\/code> or <code class=\"language-plaintext highlighter-rouge\">BGE-M3<\/code> and every threshold you tuned is now measuring a different geometry. A model upgrade silently invalidates your detector. Pin the embedding model version alongside the threshold, and treat an embedding migration as a re-baselining project.<\/p>\n<h2 id=\"your-vectors-are-not-opaque\"><span class=\"me-2\">Your vectors are not opaque<\/span><a href=\"#your-vectors-are-not-opaque\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>Embedding vectors get treated as one-way functions. You embed text, store the vector, and the original text is not recoverable from the vector alone. That assumption was never proven for dense sentence embeddings. It was inherited by practitioners who thought of embeddings as just numbers.<\/p>\n<p>The research record says otherwise:<\/p>\n<ul>\n<li><strong>Morris, Kuleshov, Shmatikov and Rush, <a href=\"https:\/\/aclanthology.org\/2023.emnlp-main.765\/\">\u201cText Embeddings Reveal (Almost) As Much As Text\u201d<\/a> (EMNLP 2023)<\/strong> built Vec2Text, an iterative correct-and-re-embed method that recovered <strong>92% of 32-token inputs exactly<\/strong>, and recovered full names from a dataset of clinical notes. Not fragments. The text.<\/li>\n<li><strong>Chen, Lent and Bjerva, <a href=\"https:\/\/aclanthology.org\/2024.acl-long.422\/\">\u201cText Embedding Inversion Security for Multilingual Language Models\u201d<\/a> (ACL 2024)<\/strong> extended inversion to multilingual embedding spaces and found that some languages are markedly more exposed than others.<\/li>\n<li><strong><a href=\"https:\/\/arxiv.org\/abs\/2502.11308\">ALGEN<\/a> (Chen, Xu and Bjerva, February 2025)<\/strong> removed the expensive prerequisite. Earlier attacks assumed access to millions of text-embedding pairs to train the inversion model. ALGEN aligns a victim embedding space to the attacker\u2019s space with a one-step linear map, and reports that <strong>a single pair gives partial success while about 1,000 pairs reach optimum<\/strong> across a range of black-box encoders, with ROUGE-L up to about 46 in their main cross-encoder results (a monolingual upper-bound condition in the same paper reaches the low fifties) and cosine similarity around 0.95 on the aligned space. It transfers across encoders and across languages. The authors tested a range of defences and report that none were effective.<\/li>\n<li><strong><a href=\"https:\/\/arxiv.org\/abs\/2505.16008\">LAGO<\/a> (Yu, Chen, Bjerva, Kosta and Li, May 2025)<\/strong> generalises ALGEN, using language-similarity graph optimisation to gain a further 10 to 20% ROUGE-L, with as few as ten samples per language.<\/li>\n<\/ul><figcaption class=\"caption\">The interesting trend is the bottom row. Recovery quality was established in 2023; what changed since is how little the attacker needs to reproduce it.<\/figcaption><p>ALGEN\u2019s contribution is <em>cost<\/em>: it collapses the data requirement from millions of pairs to roughly a thousand, which moves inversion from a research capability to something an ordinary attacker can do. The high word-recovery figures come from the Vec2Text line of work. Both matter together: strong recovery quality from earlier work, near-zero setup cost from the newer work.<\/p>\n<p>Consider the sensitive documents in a multi-tenant scenario: salary bands, litigation detail with settlement authority, an M&amp;A pipeline with named targets and valuations. Access-controlled retrieval stops unauthorised users from retrieving those through normal queries. Encryption at rest stops plaintext exfiltration at the database layer. Neither helps if someone walks off with the raw vector store through a cloud misconfiguration, a compromised admin credential or an unsecured backup. If your embedding model is public, which it usually is because it came off Hugging Face, the thousand pairs needed to align an inversion model are trivially obtainable.<\/p>\n<p>A vector store exfiltration is not a metadata exposure. It is a partial document exposure, even if you never stored the source text.<\/p>\n<pre class=\"mermaid\">\n%%{init: {\"theme\": \"base\", \"themeVariables\": {\"primaryColor\": \"#e8f4fd\", \"primaryBorderColor\": \"#3182ce\", \"fontSize\": \"14px\", \"fontFamily\": \"system-ui, -apple-system, sans-serif\"}}}%%\nflowchart TB\n    BREACH[\"Vector store exfiltrated<br\/>misconfiguration \u00b7 backup \u00b7 insider\"] --&gt; VEC[\"Raw embedding vectors<br\/>assumed opaque\"]\n    TRAIN[\"~1,000 text-embedding pairs<br\/>from a public model<br\/>no insider access needed\"] --&gt; MODEL[\"Aligned inversion model<br\/>transfers across encoders and languages\"]\n    VEC --&gt; APPLY[\"Invert the stored vectors\"]\n    MODEL --&gt; APPLY\n    APPLY --&gt; OUT[\"Substantial text reconstruction<br\/>salary bands \u00b7 settlement figures \u00b7 named targets<br\/>even if source text was never stored\"]\n    classDef ok fill:#e8f4fd,stroke:#3182ce,color:#1a202c,stroke-width:1.5px\n    classDef threat fill:#0f172a,stroke:#0f172a,color:#fff,stroke-width:1.5px\n    class TRAIN,MODEL,APPLY ok\n    class BREACH,VEC,OUT threat\n<\/pre>\n<h2 id=\"what-this-means-for-your-threat-model\"><span class=\"me-2\">What this means for your threat model<\/span><a href=\"#what-this-means-for-your-threat-model\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>Two changes, and the first one is free.<\/p>\n<p><strong>Reclassify vector store compromise in your incident response plan.<\/strong> Most plans treat it as a metadata leak, vectors only, low severity, no notification. That classification is wrong on the current research, and it is wrong in the direction that matters, because it determines who gets told and how fast. The severity floor for a vector store breach is a partial document leak of the most sensitive material in the collection. The practical exercise: take the ten most sensitive documents in your knowledge base, imagine a reconstruction that recovers most of their content in the wrong word order, and ask whether that is a breach you would have to disclose. If yes, the classification needs to change before the incident, not during it.<\/p>\n<p>Most teams classify a vector store breach as a metadata leak, and after the inversion literature it belongs in the partial-document-leak column. That changes your notification analysis and your board briefing, and it costs nothing to fix today.<\/p><figcaption class=\"caption\">The reclassification is a paperwork change you can make this quarter. Making it during an incident, with counsel on the call, is considerably more expensive.<\/figcaption><p><strong>Evaluate per-tenant vector encryption if you are multi-tenant.<\/strong> <a href=\"https:\/\/ironcorelabs.com\/products\/cloaked-ai\/\">IronCore Labs\u2019 Cloaked AI<\/a> uses property-preserving encryption so encrypted vectors remain usable for nearest-neighbour search while being scoped to per-tenant keys. BYOK is available as an add-on through IronCore\u2019s separate SaaS Shield product. Costs are query latency and key management. For a single-tenant deployment with trusted contributors, the inversion risk may be an acceptable residual. For multi-tenant SaaS where tenant isolation is the product promise, this belongs on the roadmap.<\/p>\n<h2 id=\"a-vector-store-security-checklist\"><span class=\"me-2\">A vector store security checklist<\/span><a href=\"#a-vector-store-security-checklist\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>This one treats the vector database as a security asset in its own right, distinct from the pipeline defences covered in <a href=\"https:\/\/aminrj.com\/posts\/semantic-injection-passes-every-filter\/\">the semantic injection article<\/a>.<\/p>\n<p><strong>Access<\/strong><\/p>\n<ul class=\"task-list\">\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>The vector database API requires authentication, and is not simply open on the internal network<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Service accounts have minimum necessary permissions, separated by function<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Admin credentials are vaulted and rotated separately from application credentials<\/li>\n<\/ul>\n<p><strong>Classification<\/strong><\/p>\n<ul class=\"task-list\">\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>The vector store is classified at the sensitivity of the documents it was built from, not lower<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Vector store exfiltration appears in your breach response scenarios, as a document leak<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Highly sensitive source documents are flagged in vector metadata so you can scope an incident<\/li>\n<\/ul>\n<p><strong>Monitoring<\/strong><\/p>\n<ul class=\"task-list\">\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Bulk embedding query volume is logged and alerted, since collecting alignment pairs looks like anomalous query volume<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Ingestion events are logged with contributor identity and timestamp<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Sudden large ingestion from a single source triggers review<\/li>\n<\/ul>\n<p><strong>Recovery<\/strong><\/p>\n<ul class=\"task-list\">\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Point-in-time snapshots of the collection are taken on a schedule<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Restore from a known-good snapshot has actually been tested, not just configured<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Snapshots carry the same access controls as the live store<\/li>\n<\/ul>\n<p><strong>Inversion exposure<\/strong><\/p>\n<ul class=\"task-list\">\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>You know whether your embedding model is publicly available<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>The embedding model version is pinned next to your anomaly thresholds<\/li>\n<li class=\"task-list-item\"><i class=\"far fa-circle fa-fw\"\/>Per-tenant vector encryption has been evaluated, with a documented decision either way<\/li>\n<\/ul>\n<h2 id=\"where-this-leaves-you\"><span class=\"me-2\">Where this leaves you<\/span><a href=\"#where-this-leaves-you\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h2>\n<p>Cosine similarity measures an angle. It has no notion of truth, authority or provenance, and when you build a RAG system you inherit every security assumption of the embedding space along with its retrieval quality.<\/p>\n<p>Poisoning works because vocabulary engineering can move a document\u2019s position without touching the model\u2019s internals. Anomaly detection works because the geometric requirement that makes the attack effective is also what makes it visible at ingestion. Inversion works because dense vectors carry enough of the source text to reconstruct much of it, and the assumption that they did not was folklore rather than a result.<\/p>\n<p>Practitioners adopted the outputs of ML research and deployed them with security properties the underlying math never offered. Understanding the math does not secure the system. It does tell you where a defence can work, and which claims about your defences are well founded rather than hopeful.<\/p>\n<hr\/>\n<p>Lab code, including <code class=\"language-plaintext highlighter-rouge\">defenses\/embedding_anomaly_detection.py<\/code> and the poisoned document set: <a href=\"https:\/\/github.com\/aminrj-labs\/mcp-attack-labs\/tree\/main\/labs\/04-rag-security\">aminrj-labs\/mcp-attack-labs\/labs\/04-rag-security<\/a>. The detector is dependency-light and drops into any ChromaDB pipeline. Measurements were taken March 2026 against ChromaDB with <code class=\"language-plaintext highlighter-rouge\">all-MiniLM-L6-v2<\/code> and Qwen2.5-7B-Instruct at temperature 0.1, twenty runs per configuration on a small seeded corpus. They demonstrate mechanisms and rankings, not production base rates.<\/p>\n<hr\/>\n<h3 id=\"references\"><span class=\"me-2\">References<\/span><a href=\"#references\" class=\"anchor text-muted\"><i class=\"fas fa-hashtag\"\/><\/a><\/h3>\n<ul>\n<li>Zou, Geng, Wang and Jia, <a href=\"https:\/\/www.usenix.org\/conference\/usenixsecurity25\/presentation\/zou-poisonedrag\">\u201cPoisonedRAG: Knowledge Corruption Attacks to Retrieval-Augmented Generation of Large Language Models\u201d<\/a>, USENIX Security 2025 (<a href=\"https:\/\/arxiv.org\/abs\/2402.07867\">arXiv 2402.07867<\/a>)<\/li>\n<li>Korn, <a href=\"https:\/\/arxiv.org\/abs\/2605.05632\">\u201cArchitecture Matters: Comparing RAG Systems under Knowledge Base Poisoning\u201d<\/a>, May 2026<\/li>\n<li>Morris, Kuleshov, Shmatikov and Rush, <a href=\"https:\/\/aclanthology.org\/2023.emnlp-main.765\/\">\u201cText Embeddings Reveal (Almost) As Much As Text\u201d<\/a>, EMNLP 2023<\/li>\n<li>Chen, Lent and Bjerva, <a href=\"https:\/\/aclanthology.org\/2024.acl-long.422\/\">\u201cText Embedding Inversion Security for Multilingual Language Models\u201d<\/a>, ACL 2024<\/li>\n<li>Chen, Xu and Bjerva, <a href=\"https:\/\/arxiv.org\/abs\/2502.11308\">\u201cALGEN: Few-shot Inversion Attacks on Textual Embeddings using Alignment and Generation\u201d<\/a>, February 2025<\/li>\n<li>Yu, Chen, Bjerva, Kosta and Li, <a href=\"https:\/\/arxiv.org\/abs\/2505.16008\">\u201cLAGO: Few-shot Crosslingual Embedding Inversion Attacks via Language Similarity-Aware Graph Optimization\u201d<\/a>, May 2025<\/li>\n<li><a href=\"https:\/\/ironcorelabs.com\/products\/cloaked-ai\/\">Cloaked AI<\/a>, IronCore Labs (property-preserving encryption for vector embeddings)<\/li>\n<li><a href=\"https:\/\/genai.owasp.org\/llmrisk\/llm082025-vector-and-embedding-weaknesses\/\">LLM08:2025 Vector and Embedding Weaknesses<\/a>, OWASP GenAI Security Project<\/li>\n<\/ul>\n<\/div>\n<p><a href=\"https:\/\/aminrj.com\/posts\/cosine-similarity-is-not-a-safety-property\/?utm_source=tldrai\">Source link <\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>The vector database returns the most relevant documents, defined as the ones with the highest cosine similarity to the query. This is a mathematical property with no concept of accuracy, authority or provenance. A document scoring 0.95 against a query can be entirely fabricated. A document scoring 0.60 can be the ground truth. The attacker\u2019s [&hellip;]<\/p>\n","protected":false},"author":16,"featured_media":23813,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[143],"tags":[],"class_list":["post-23812","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai"],"_links":{"self":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/posts\/23812","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/users\/16"}],"replies":[{"embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/comments?post=23812"}],"version-history":[{"count":0,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/posts\/23812\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/media\/23813"}],"wp:attachment":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/media?parent=23812"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/categories?post=23812"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/tags?post=23812"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}