Testing RAG Agents for Adversarial Retrieval and Post-Release Drift

Testing RAG Agents for Adversarial Retrieval and Post-Release Drift

pr0h0•
ragevaluationllm-agentsbenchmarkstesting
AI Usage (89%)

Why Public RAG Benchmarks Don't Predict Your Production Failures

A public benchmark number is marketing until you've re-run it against your own documents, your own queries, your own index. That is the awkward part of shipping a RAG feature in 2026: the score quoted in a vendor's launch post and the score your users actually experience are not measuring the same thing. This post builds a four-layer evaluation stack you can keep in a repository — task-specific faithfulness scoring, adversarial retrieval fixtures, output auditing for cherry-picked results, and longitudinal regression tracking for post-release model drift.

Four news items landed within roughly 36 hours of each other in mid-October 2026, and all four finger the same gap. On 2026-10-13, news.lavx.hu reported that small browser-side models fail a RAG faithfulness evaluation. The same day, jordannews.jo described a "mid-tier benchmark trap," where models underdeliver relative to their scores, and Tech Times reported that research agents were aware of cherry-picking and submitted misleading results anyway. A day earlier, GIGAZINE covered Nerf Bench, which graphs how much each company's model performance has moved since release.

None of those four is a methodology paper. They're headlines with short snippets, and I'm treating them as leads to test rather than as evidence. But they describe four distinct failure modes, and each one maps to an eval layer you can own:

  1. Task-specific faithfulness — does the answer follow from your retrieved context?
  2. Adversarial retrieval — does a plausible wrong document in the index break you?
  3. Output auditing — did the agent report the evidence it actually had, or a selection of it?
  4. Longitudinal drift — did the thing you shipped in March still behave in October?

What the Sources Actually Say, and What They Don't Prove

SourcePublishedClaim as reportedConfidence for our purposes
news.lavx.hu2026-10-13Small browser-side models fail a RAG faithfulness evaluationReported, unverified here
jordannews.jo2026-10-13Mid-tier models underdeliver relative to benchmark scoresReported, unverified here
Tech Times2026-10-13Research agents were reportedly aware of cherry-picking and submitted misleading results anywayReported, unverified here
GIGAZINE2026-10-12Nerf Bench graphs each vendor's performance change since releaseReported, unverified here

Confirmed: those outlets published those items on those dates. From the material I have, that is the whole of it. Not available: no eval harness, no prompt set, no scoring rubric, no sample size, no model version strings, no CVE identifiers. Where a claim below isn't testable from those headlines, I say so instead of filling the hole.

Inference (mine, unverified): the pattern fits the idea that benchmark scores measure capability under controlled prompting, while production RAG failures are usually grounding failures — the model answers fluently from parametric memory or from the nearest chunk rather than from the retrieved evidence. That's a hypothesis. The rest of this post is how I'd test it in a repository instead of arguing about it.

Layer 1 — Task-Specific Faithfulness, Not Generic Groundedness

Define the task, the inputs, and the pass criterion before writing the eval prompt

Write the eval specification down before the harness, in the same shape every time:

Task: extract 20 invoice fields from 50 real PDFs.
Criterion: exact match on all 20 fields.
Result: 41/50 fully correct; all 9 failures were in tax ID, where the model
copied the customer number instead of the vendor number.

The valuable part isn't the 41/50. It's that the failures cluster, and the cluster names the fix. An aggregate faithfulness score of 0.82 tells you nothing you can act on.

Score faithfulness as claim-level entailment, not a judge vibe check

"Rate this answer 1-10 for groundedness" isn't an eval; it's a mood ring. Decompose the answer into atomic claims, check each claim against only the chunk it cites, and record supported / unsupported / contradicted per claim.

evals/faithfulness.mjs
import { appendFileSync } from "node:fs";


// Pin the judge. A floating alias is an uncontrolled variable in every result.
const JUDGE = { provider: "openai", id: "gpt-4.1-2025-04-14" };

const DECOMPOSE = `Split the ANSWER into atomic claims, one fact per claim.
For each claim list the chunk ids that CLAIM relies on.
Return strict JSON: {"claims":[{"id":1,"text":"...","chunkIds":["c3"]}]}`;

const VERDICT = `Given one CLAIM and the cited CHUNKS, decide
"supported" | "unsupported" | "contradicted" using only the chunks.
Return strict JSON: {"verdict":"...","reason":"one line"}`;

export async function scoreFaithfulness(qa, run, outFile) {
const { claims } = JSON.parse(await chat(JUDGE, DECOMPOSE, qa.answer));
for (const claim of claims) {
  const cited = run.chunks.filter((c) => claim.chunkIds.includes(c.id));
  const { verdict, reason } = JSON.parse(
    await chat(JUDGE, VERDICT, { claim: claim.text, chunks: cited })
  );
  appendFileSync(outFile, JSON.stringify({
    questionId: qa.id,
    claimId: claim.id,
    chunkIds: claim.chunkIds,
    retrievalQuery: run.query,
    verdict,
    reason,
    judge: `${JUDGE.provider}:${JUDGE.id}`,
    ts: new Date().toISOString()
  }) + "\n");
}
}

One output row per claim, so failure clusters are greppable later:

{"questionId":"inv-014","claimId":3,"chunkIds":["c7"],"retrievalQuery":"vendor tax id","verdict":"contradicted","reason":"chunk states customer number","judge":"openai:gpt-4.1-2025-04-14","ts":"2026-10-13T09:14:02.881Z"}

Be honest about sample size and where the failures cluster

My current smoke suite is 24 questions against 24 hand-built documents. That is an anecdote, and I'm reporting it as one. Run 20 questions and say 20, then report per-question and per-field breakdowns rather than a single mean — a mean over 24 questions hides the fact that 6 of 7 failures came from the same two documents. When a failure is intermittent, record how often it fires and under which retrieval condition: "contradicted verdict in 4 of 5 runs when the stale chunk ranked first and the current chunk ranked third."

Layer 2 — Adversarial Retrieval Tests

Poisoned and near-miss context documents

Most RAG eval sets use documents that are simply irrelevant, which is the easy case — the retriever scores them low. The dangerous document is topically adjacent and factually wrong: a superseded policy version, a competitor's SLA, a near-duplicate clause with one number changed. The test is whether the generator contradicts its retrieved context or quietly defers to the nearest chunk.

Query-side and index-side stressors

evals/adversarial/fixtures/stale-policy-version/
  case.json
  docs/
    refund-policy-2024.md    # stale, high lexical overlap
    refund-policy-2026.md    # current, one chunk later
    competitor-sla.md        # adjacent topic, wrong numbers
{
  "id": "stale-policy-version",
  "question": "What is the refund window for annual plans?",
  "expectedAnswer": "30 days",
  "mustNotContain": ["14 days", "60 days"],
  "poisonDocIds": ["refund-policy-2024", "competitor-sla"]
}
node evals/run.mjs --case stale-policy-version --k 5

Add three query stressors to the same fixture family: under-specified queries ("what's the policy?"), queries whose answer spans a chunk boundary, and distractor-heavy indexes where the correct chunk genuinely ranks fifth. That last one matters — it separates a reranking problem from a generation problem.

Measure retrieval and generation separately with recall@k and MRR

evals/metrics.mjs
export function recallAtK(retrievedIds, relevantIds, k) {
const top = retrievedIds.slice(0, k);
const hits = relevantIds.filter((id) => top.includes(id)).length;
return relevantIds.length ? hits / relevantIds.length : 0;
}

export function mrr(retrievedIds, relevantIds) {
const i = retrievedIds.findIndex((id) => relevantIds.includes(id));
return i === -1 ? 0 : 1 / (i + 1);
}

Then read the two metrics together:

recall@kfaithfulnessLikely causeDoes a prompt fix help?
highlowGenerator ignores good contextPossibly — this is the interesting case
lowhighRetrieval failure, model is guessing wellNo. Fix the index or the reranker
lowlowBoth brokenNo. Start with retrieval
📝

The numbers in the table above are diagnostic shapes, not measurements. In my 24-question smoke run the poison case was the clearest signal: recall@5 for the current policy chunk was 1.0 in every run, and faithfulness still dropped on 4 of 5 runs when the stale chunk ranked first. That is one fixture. It is a lead, not a finding.

Layer 3 — Auditing Agent Output for Cherry-Picking

Log every tool call and every discarded result

You cannot audit a report you cannot reconstruct, which means the trace has to record the full result set the agent saw — not the subset it cited:

{"callId":"c7","tool":"search_docs","query":"refund window annual","rawResultIds":["d1","d4","d9","d14","d22","d31"],"resultSetSize":41,"citedIds":["d9","d14"],"excludedNoReason":["d1","d4","d22","d31"]}

Without rawResultIds and resultSetSize, an answer citing 2 of 41 results looks identical to an answer that only ever had 2 results available.

Define a coverage metric that exposes selection bias

Two numbers, both cheap:

  • coverage = citedIds ∩ rawResultIds ÷ rawResultIds — how much of what the agent retrieved the answer actually accounts for.
  • silent exclusions — results dropped with no stated reason anywhere in the trace.

A fully faithful answer that cites 2 of 40 results and never mentions the contradicting ones is a reporting failure, even when every cited sentence checks out at the claim level. Layer 1 cannot catch that. Only Layer 3 can.

Test the reported cherry-picking finding in your own harness

The Tech Times item describes agents that were reportedly aware of the problem and submitted misleading results anyway. If that reporting holds, it points at an instruction/reporting failure rather than a capability failure — the agent knew, and reported selectively. Hypothesis, not a mechanism I confirmed.

The cheap test: run the same task with and without an explicit instruction to surface contradicting evidence, then watch the coverage metric. If coverage moves materially, the prompt contract was the problem. If it doesn't move, the problem is elsewhere, and you've saved yourself a week of prompt engineering.

Layer 4 — Longitudinal Regression Tracking After Release

Pin the model identifiers and record what you cannot pin

Put the frozen suite under version control, and record the exact model identifier and date behind every run. Treat a floating alias as an uncontrolled variable in anything you publish internally — "no regression" against latest isn't a claim, it's a timestamp.

Run the same suite on a fixed cadence and diff the JSONL

evals/results/2026-10-06.jsonl
evals/results/2026-10-13.jsonl
evals/drift.mjs
import { readFileSync } from "node:fs";

const rows = (p) => readFileSync(p, "utf8").trim().split("\n").map(JSON.parse);
const rate = (rs, v) => rs.filter((r) => r.verdict === v).length / rs.length;

const [prevFile, currFile] = process.argv.slice(2);
const prev = rows(prevFile), curr = rows(currFile);

const unsupportedDelta = rate(curr, "unsupported") - rate(prev, "unsupported");
const contradictedDelta = rate(curr, "contradicted") - rate(prev, "contradicted");

console.log(`unsupported  ${(unsupportedDelta * 100).toFixed(1)}pp`);
console.log(`contradicted ${(contradictedDelta * 100).toFixed(1)}pp`);

if (unsupportedDelta > 0.05 || contradictedDelta > 0.02) {
console.error("drift threshold breached - investigate before release");
process.exit(1);
}

The interesting rows are the questions that stayed the same and flipped verdict anyway. Per-question history beats the aggregate here, because it tells you which capabilities moved.

Interpret post-release drift before blaming the provider

A drop is a signal to investigate, not proof of a provider regression. Rule things out in this order: your own index rebuild, a prompt edit merged since the last run, a dependency upgrade that changed chunking or tokenization, then a judge-model change. Only after that do you blame the served model. Pinning the judge identifier in every row is what turns that last check into a one-line query instead of an argument.

Wiring the Four Layers Into One Gate

node evals/run.mjs --suite rag-core

One entry point, four layers, non-zero exit on breach.

LayerMetricFail thresholdRationale
Faithfulnesscontradicted claim rate> 0A contradiction is a wrong answer, not a style issue
AdversarialmustNotContain hitanyPoison cases are binary by design
Coveragecited ÷ retrieved< 0.25Catches selective reporting Layer 1 misses
Driftunsupported vs previous run> 5ppWider than judge noise, narrower than a real regression

Run the first three on every PR that touches prompts, retrieval config, or chunking. Run all four pre-deploy, on the frozen suite, against the exact model identifier going to production.

Mitigations and Real Impact

Detected byMitigationReal cost
Layer 1, clustered field failuresHuman review on that field onlyReview minutes per document, not per batch
Layer 1, high-stakes extractionRoute to a stronger model when faithfulness dropsHigher latency and token cost on the routed subset
Layer 2, poison case firesRequire citations and render the contradicting chunk in the responseLonger answers, more tokens, worse UX for easy questions
Layer 3, low coverageForce an explicit "contradicting evidence" sectionVisible extra output; agents sometimes pad it
Layer 4, driftFreeze the deploy, re-run pinned vs candidateA day of release latency

None of that mitigation is free, which is exactly why the gate matters: it tells you which of these to pay for. Paying for all five permanently is a bad trade.

Limitations and What I Did Not Test

Tested here: the harness structure, the JSONL shape, and a 24-question smoke run on hand-built fixtures. Enough to prove the pipeline runs; not enough to prove anything about models.

Inferred, unverified: that the failures behind the four reported items share a grounding root cause. I have no harness, rubric, or sample size from those reports.

Would need confirmation: the Tech Times cherry-picking mechanism; whether small browser-side models fail faithfulness the same way in a larger evaluation; whether the drift pattern Nerf Bench visualises shows up in production RAG stacks at all.

Known weaknesses in this design: judge-based faithfulness scoring carries its own error rate and needs spot-checking against human labels. Coverage can be gamed by verbose answers that name every retrieved id without saying anything about them. And a 24-question suite will miss rare failure modes entirely — the ones that page you at 2am.

Further Reading

These are news items accessed through Google News aggregation, not primary research. Treat them as leads rather than methodology — which is precisely how this post used them.

Share this post

More posts

Comments