AI agents

Detecting Poisoned Chunks Before They Reach the Prompt

Antonio Evans9 min read

A vector database is shown as a grid of stacked paper-like chunks.
Key points

Key points

  1. 1Retrieval pipelines should treat every retrieved chunk as untrusted input, not as trusted context.
  2. 2Provenance checks, retrieval-score anomaly detection, cross-source disagreement and hidden Unicode scans catch different attack classes.
  3. 3A quarantine step that holds suspect chunks out of the prompt, plus a log of the decision, makes incidents traceable.
  4. 4Poisoning is cheap for attackers: practical attacks succeed with a single injected document in the knowledge base.

Why the retrieval layer is the soft spot

Prompt injection gets most of the attention because it arrives with the user. Retrieval poisoning is quieter. The attacker writes to a corpus, a wiki, a ticket queue or a shared drive, and the pipeline later pulls that text into the prompt on its own. The model has no way to tell a poisoned chunk from a policy PDF, and it treats retrieved content as authoritative, often placing it in a privileged position in the context window Promptfoo's RAG poisoning guide.

The economics favor the attacker. CorruptRAG, a published attack, shows that injecting a single poisoned text can achieve higher success rates than baselines that assume many poisoned documents per query the CorruptRAG paper. A working defense therefore cannot assume attacks look like bulk spam. It has to catch one document, inserted once, that happens to rank well.

Two failure modes matter here, and they need different detectors. Context poisoning is the case where wrong or malicious information enters the context and then gets reused and compounded across steps Splunk's production context engineering writeup. Retrieval hijacking is the case where an attacker crafts a document specifically so it outranks legitimate ones Promptfoo. The first is a correctness problem you can measure. The second is an adversarial problem you have to filter.

The framing that keeps teams honest is context engineering: the job is deciding which tokens reach the model at all, not just how the instructions are worded Anthropic's context engineering post. Once you accept that every retrieved chunk is a token you chose, a quarantine gate is an obvious part of the selection logic.

Signal 1: provenance and source reputation

Start with where the chunk came from, before you look at what it says. A chunk has a source URI, an ingestion path, an owner and an ingestion timestamp. Each of those is a check.

A useful decision rule: score provenance on a three-level scale. Trusted sources are ones your team controls and reviews (product docs, published policies, signed content). Semi-trusted sources are internal but open-write (wikis, tickets, shared folders). Untrusted sources are anything external or anonymous. Then set your retrieval threshold by level: for untrusted sources, require a higher similarity score before the chunk is even a candidate, and never let an untrusted chunk be the only support for a factual claim.

Content hashes catch a specific attack that source checks miss: the edit after ingestion. Store a hash of each chunk at ingest time and re-hash before use. An unchanged chunk that suddenly contains a new instruction, or a document whose hash changed without a corresponding review event, is a quarantine candidate regardless of its score. This is cheap and it closes the window where an attacker with write access to a wiki edits a previously safe page.

Ingestion timestamps deserve a flag of their own. A chunk ingested in the last few minutes that ranks into a live prompt is a legitimate signal for a review queue, not an automatic reject, because it also catches normal fresh content.

Provenance also has a reporting use. When a bad answer reaches a user, the first question in review is always which source it came from, and a pipeline that never recorded source tiers has no answer. Writing the tier into the chunk metadata at ingestion costs one lookup and saves an hour of guessing LangChain's context engineering docs.

Signal 2: retrieval-score anomalies

Embedding similarity is a number, and numbers have distributions. Most legitimate retrieval sets have a smooth score curve: a few strong matches, then a gradual tail. A poisoned chunk engineered to win retrieval tends to break that shape.

Compute the gap between the top score and the median score of the returned set, and between the top score and the second score. A top result that sits far above its neighbors, especially when it is also from a low-trust source, is the classic hijack signature. A worked rule that holds up in practice: flag when the top score exceeds the second-highest by a large margin and the top chunk's source is untrusted. Both conditions together cut false positives, because a genuinely dominant document from a trusted source should pass.

There's a second anomaly worth logging: the chunk that scores well on many different queries. Poisoned documents crafted for several attack-targeted questions tend to be retrieved repeatedly across unrelated user intents. A per-chunk retrieval counter, sampled over time, surfaces these documents before they do damage. Keep the counter on the chunk ID, not the query text.

SignalWhat it catchesCostTypical false positive
Source reputation tierUnknown or open-write originsLowNew internal docs
Hash change since ingestPost-ingestion editsLowLegitimate edits, flagged for review
Score gap vs neighborsRetrieval hijackingLowGenuinely dominant documents
Cross-source disagreementContradictory or wrong factsMediumFast-changing topics
Entropy and duplicationTemplate spam, near-duplicatesLowBoilerplate legal text
Hidden Unicode scanInvisible instructions, tag charactersLowRare, usually benign
Safety classifier verdictEmbedded instructions, harmful contentMediumFalse positives on quoted text

The table is also a budget guide. The three low-cost rows should run on every chunk, every request, because they are cheap enough that skipping them is indefensible. The medium-cost rows belong on the retrieved set after the first pass has already removed the obvious junk, so you pay for a classifier on ten chunks rather than on a thousand candidates.

Signal 3: cross-source disagreement

If two trusted sources say different things, the model cannot resolve it, and neither can a keyword filter. Disagreement is a signal in its own right: it tells you the context contains a contradiction that will produce an unreliable answer.

The practical version is a consistency check. Take the retrieved set, ask a cheap model to extract the factual claim each chunk makes about the user's question, and compare. When one chunk asserts a fact that the rest contradict, demote it, not reject it. The RAG-IDS defense work found that soft trust-based demotion outperformed hard filtering while label-embedding consistency checking was the main contributor to robustness under poisoning the RAG-IDS paper. That result matches operational intuition: an aggressive filter throws away good context, while a demotion keeps recall intact and lowers the poison's influence.

There is a second structural version of this check. CleanBase exploits the fact that malicious documents crafted for the same questions tend to be highly similar to each other, so they form cliques in a similarity graph over the knowledge base the CleanBase paper. You can approximate it on each query: run a pairwise similarity check within the retrieved set and flag tight clusters from different, low-trust sources that agree with each other but disagree with your trusted sources. That pattern is coordination, and coordination is rarely an accident in user-generated content.

One operational note: run the consistency check on claims, not on wording. Two chunks can describe the same refund policy in completely different sentences without disagreeing, and a naive text comparison will flag both as suspect. Extracting "what does this chunk assert" first is what keeps the signal clean.

Signal 4: entropy, duplication and instruction patterns

Some poisoned text is not subtle. It repeats a template, stuffs keywords, or ends with a directive. You can detect a lot of this with cheap text statistics before any model is involved.

Rank chunks by compression ratio (a proxy for low entropy and high repetition) and flag those at the extreme low end of your corpus distribution. Legal boilerplate and product terms of service will show up, which is why this signal quarantines for review rather than blocks. Pair it with a near-duplicate check against a corpus of known-good chunks: an exact or near-exact copy of a trusted chunk with one sentence appended is a strong indicator of an edit attack.

Then scan for instruction-shaped content inside data. Retrieved chunks are data. Text like "ignore previous instructions," "system:" prefixes, or markup that mimics your prompt delimiters belongs in a filter. This connects to a discipline most teams already know: keep data and instructions separated with clear delimiters, because sloppy structure invites injection LaunchDarkly's prompt engineering guide. The retrieved chunk should sit in a labeled data block, and anything inside it that looks like an attempt to close that block should be quarantined.

Delimiters do double duty here. Wrapping retrieved text in XML-style tags gives the model an unambiguous boundary between instructions and data, which is the same reason structured prompts parse more reliably in general IBM's prompt engineering techniques. When a chunk contains a closing tag it should never contain, the parser can flag it before the model ever reads it.

Signal 5: hidden Unicode and invisible text

The lowest-effort attack in the playbook is invisible text: zero-width characters, bidirectional control codes, and Unicode tag characters that render as nothing but tokenize as instructions. A human reviewer reading the chunk sees clean prose. The model sees the payload.

A practical scan normalizes Unicode, then flags any chunk containing characters from a small blocklist of invisible and control format characters, plus any sudden spike in non-ASCII ratio. Log the codepoints found, because the specific characters tell you whether this is an attack or a corrupted file. Pair the scan with a strip step at ingestion, and keep the original hash so you can prove what was ingested.

Sanitizing retrieved text before assembly is the same idea the RAG-IDS work applies at the retrieval boundary, where prompt sanitization sits alongside trust scoring and consistency checks the RAG-IDS paper.

A classifier pass catches the cases your rules miss. A cheap safety or instruction-detection classifier run over the retrieved set can score chunks for embedded directives and harmful content, and it belongs on the retrieved set rather than the whole corpus because that is where the volume is already small. Treat its verdict as one signal among several rather than a final answer, since quoted examples in legitimate documentation will trip it honestly.

The quarantine step and what to log

Detection without a gate is theater. Put a quarantine function between retrieval and prompt assembly. Every chunk gets a verdict: pass, demote (included but ranked lower and labeled), or quarantine (excluded). The prompt builder never sees quarantined chunks, and a query whose surviving context is too thin returns a grounded refusal rather than a confident answer built on poison.

def assemble(query, retrieved, trust, classifier):
    passed, demoted, quarantined = [], [], []
    for chunk in retrieved:
        reasons = []
        if trust[chunk.source] == "untrusted" and chunk.score_gap > GAP_LIMIT:
            reasons.append("score_anomaly")
        if chunk.hash != chunk.ingest_hash:
            reasons.append("hash_changed")
        if contains_hidden_unicode(chunk.text):
            reasons.append("hidden_unicode")
        if classifier.flag(chunk.text):
            reasons.append("classifier_flag")
        if len(reasons) >= 2 or "hidden_unicode" in reasons:
            quarantined.append((chunk, reasons))
        elif reasons:
            demoted.append((chunk, reasons))
        else:
            passed.append(chunk)
    log_decision(query.id, passed, demoted, quarantined)
    return build_prompt(query, passed, demoted)

The two-reason threshold is the decision rule that matters. One soft signal demotes; two independent signals quarantine. Hidden Unicode quarantines alone because it has almost no legitimate cause in prose. Tune the thresholds against your own corpus, but keep the rule shape: independent signals, combined, with a hard override for the unambiguous one.

Log enough that an incident can be reconstructed later. For each chunk, write the chunk ID, source URI, retrieval score, each signal's value, the classifier verdict, the final decision (pass, demote, quarantine) and the prompt assembly ID. For each request, write the full set of decisions so you can replay it. This is the same observability loop production agent teams describe as necessary to catch non-deterministic behavior at scale Splunk. Without it, a bad answer is a mystery; with it, it is a chunk ID and a timestamp.

One caution on caching. If you cache prompt prefixes, and you should for cost, a quarantined chunk must never enter a cached prefix, and a cache hit must never bypass the gate. Keep the static, gate-free content first in the prompt and assemble retrieved chunks after it the OpenAI prompt caching guide. Caching reuses computation for identical prefixes; it does not re-run your safety checks, so the checks belong at assembly time, not in the cache. The prefix mechanics matter for the same reason: any change early in the prompt invalidates everything after it, which is one more argument for putting variable retrieved chunks late how prompt caching works under the hood.

Testing the pipeline before attackers do

You cannot tune thresholds you never see fail. Generate poisoned documents and run them through your pipeline. Promptfoo ships a red-team plugin that poisons documents toward a stated goal, such as extracting an API key, and then runs a scan against the configured system Promptfoo. Use it to build a regression suite: one poisoned PDF, one wiki page with hidden Unicode, one near-duplicate of a trusted policy, one coordinated cluster of three mutually consistent fake docs. Measure what fraction reaches the prompt and what fraction your quarantine catches. Track the two numbers separately, because a demotion that still reaches the model is not a pass.

Then close the loop on the product side. Build the evaluation harness in the same context-engineering discipline the rest of the pipeline uses, where the right context, formatted correctly, is treated as the core engineering job LangChain's context engineering guide and Weaviate's guide. If your team would rather operate one endpoint across the models you test with, Quanta Inference puts text, embedding and classifier calls behind a single OpenAI-compatible key Quanta Inference, which keeps the harness simple when you compare detectors across providers.

FAQ

FAQ

How is retrieval poisoning different from prompt injection?

Prompt injection arrives with the user's input. Retrieval poisoning arrives through the corpus: an attacker writes a document and waits for the pipeline to retrieve it. The detection is different too, since you are inspecting chunks for provenance, score anomalies and hidden content rather than scanning a single user message. The two share a defense philosophy, treating all incoming text as data rather than instructions.

Can I just filter out low-trust sources entirely?

Hard filtering costs recall on legitimate fresh content from wikis and tickets. The research on retrieval-boundary defense found soft trust-based demotion outperformed hard filtering while recovering classification quality under poisoning the RAG-IDS paper. Demote weak signals and reserve quarantine for multiple independent signals or unambiguous ones like hidden Unicode.

What is the single most useful signal to build first?

Hash checking at use time, because it is cheap and catches the most common real-world case: a previously safe document edited after ingestion. Pair it with a source-reputation tier so the hash check runs with the right threshold on external and open-write content. Neither requires a model call, so both fit in the request path.

How do I avoid quarantining good chunks?

Use a two-signal rule for soft indicators and reserve hard quarantine for signals with almost no legitimate cause. Then tune thresholds against a labeled set of your own documents, and track demotions separately from passes so you can see whether the gate is losing useful context. Legal boilerplate and quoted examples are the usual false positives, and both show up in the first week of logging.

What should the log contain to investigate an incident?

At minimum the chunk ID, source URI, retrieval score, each signal's value, the classifier verdict, the final pass, demote or quarantine decision, and the prompt assembly ID for the request. With those, a bad answer becomes a traceable chunk and a reproducible decision rather than a mystery. Keep the log alongside the request trace, because the decision only makes sense with the retrieval set that produced it.

Sources

Sources

  1. RAG Poisoning
  2. Practical Poisoning Attacks against Retrieval-Augmented ...
  3. Context Engineering for AI Agents in Production: Architecture, Failure ...
  4. Effective context engineering for AI agents - Anthropic
  5. Context engineering in agents - Docs by LangChain
  6. Defending Retrieval-Augmented Intrusion Detection ...
  7. CleanBase: Detecting Malicious Documents in RAG ...
  8. Prompt Engineering Best Practices: Tutorial & Examples | LaunchDarkly
  9. Prompt Engineering Techniques | IBM
  10. Prompt caching | OpenAI API
  11. How prompt caching works - Paged Attention and ...
  12. Context Engineering - LLM Memory and Retrieval for AI Agents | Weaviate
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles