Skip to main content

Data Poisoning Defence

Prompt injection (see Prompt Injection) manipulates the model at inference time via the input. Data poisoning is a different attack: corrupting the Qdrant knowledge base so the model gives systematically wrong answers — to every user who retrieves that data, indefinitely, with no injection patterns to detect.

The core problem: every existing quality control (Ragas faithfulness, citation check, LLM-as-judge) measures model output against retrieved context. If the context is poisoned, all controls are poisoned with it. A document stating the wrong franchise amount scores faithfulness=1.0, passes the citation check, and looks correct in every Langfuse dashboard.


Attack Vectors​

1. Factual corruption​

Upload a document with wrong policy terms. No injection patterns, no imperative sentences — just incorrect facts stated in normal document language:

[Appears to be a legitimate policy wording document — passes all injection scans]

"La franchise applicable en cas de dégât des eaux est de 2 500 €."
[Actual franchise in every legitimate policy document: 150 €]

Every claims adjuster who asks about water damage excesses receives €2,500. Ragas faithfulness = 1.0. Citation check passes. LLM-as-judge correctness = 0.9. All dashboards look healthy.

2. Retrieval manipulation​

A document crafted to be semantically close to many common queries — not because it is relevant, but because it is designed to fool the nomic-embed-text embedding model. The attacker's document gets retrieved for "what is the indemnity for..." regardless of context, injecting wrong content into every claims query.

3. Feedback poisoning​

The human feedback loop (issue #43) wires 👍/👎 into Langfuse as quality scores. Coordinated accounts systematically click 👍 on wrong answers. Ragas scores are low, LLM-as-judge scores are low — but human_feedback is high and suppresses alerts. Over time this degrades the golden datasets and raises quality thresholds to accept wrong answers.

4. Bulk ingestion spike​

An attacker with document upload access bulk-ingests hundreds of subtly wrong documents overnight, shifting the retrieval landscape before anyone notices. Every query the next morning returns poisoned context.


Risk by Department​

DepartmentRiskMost likely vector
SinistresCRITICALFactual corruption of policy terms → wrong indemnity advice
FinanceCRITICALWrong IFRS17 / Solvabilité II interpretations
JuridiqueCRITICALInvented article references in legal docs
SouscriptionHIGHWrong underwriting criteria → mispriced risk
ActuariatHIGHCorrupted statistical assumptions → bad pricing models
RéassuranceHIGHWrong treaty terms → uncovered exposure
ITLOWWrong procedure docs → inconvenience only

Defence Architecture​

DOCUMENT INGESTION (before Qdrant indexing)
├── Injection scan (issue #46 — existing)
├── Collection write permission check (dept-scoped)
├── Bulk ingestion rate limit (50 docs/hour via Redis)
├── Cross-document consistency check
│ (new factual claims vs existing chunks on same topic)
└── Chunk provenance metadata attached to every indexed chunk
(uploaded_by, doc_hash, uploaded_at, page_number, source_doc)

QUERY PIPELINE
└── RAG sources logged to Langfuse on every response
(rag-sources-used event: doc_id, uploaded_by, doc_hash per chunk)

FEEDBACK VALIDATION (extends issue #43 feedback-bridge)
├── Flag: 👍 on response where judge_correctness < 0.60
└── Flag: 5+ accounts like same response within 10 minutes

WEEKLY INTEGRITY SCAN (Sunday 03:00 UTC CronJob)
├── Full Qdrant scan across all 10 collections
├── Injection pattern check on every chunk
├── Provenance gap check (chunks missing metadata)
└── Results → Langfuse event + Grafana alert if flagged > 0

INCIDENT RESPONSE
└── Document removal by doc_hash (surgical — no other chunks affected)

Layer 1 — Chunk Provenance​

What it does​

Every chunk indexed into Qdrant carries a provenance payload. This enables a complete chain of custody from any response back to the person who uploaded the source document.

Langfuse trace (RAG response)
└── event: rag-sources-used
└── sources[0]:
doc_id: "claims-docs/a3f7b91c2d..."
source_doc: "expert-assessment-claim-892.pdf"
page_number: 4
uploaded_by: "adjuster.martin@ktayl.fr"
uploaded_at: "2026-06-21T09:14:32Z"
doc_hash: "a3f7b91c2d4e5f6..."

Audit query for any suspicious response:

Langfuse → Traces → open trace → Events → rag-sources-used
→ sources[].uploaded_by who contributed to this response
→ sources[].doc_hash fingerprint to find all chunks from same document
→ sources[].uploaded_at when the document entered the knowledge base

Duplicate detection​

The same document cannot be indexed twice. At ingestion time, safe_ingest() queries Qdrant for existing chunks with the same doc_hash. If found, the ingestion is skipped with a log message — no silent double-indexing.


Layer 2 — Collection Write Permissions​

Permission matrix​

Upload access is scoped by department and enforced at the LiteLLM hook layer before safe_ingest() is called.

CollectionAuthorised departments
claims-docsdirection-sinistres, platform-team
finance-docsdirection-finance, direction-actuariat, platform-team
legal-docsdirection-juridique, platform-team
underwriting-docsdirection-souscription, platform-team
reinsurance-docsdirection-réassurance, platform-team
actuarial-docsdirection-actuariat, direction-finance, platform-team
audit-docsdirection-audit, platform-team
it-docsdirection-it, platform-team
hr-docsdirection-rh, platform-team
commercial-docsdirection-commercial, platform-team
general-docsany authenticated user

Response to an unauthorised upload attempt​

HTTP 403:
"La direction direction-it n'est pas autorisée à ingérer des documents
dans la collection claims-docs.
Contactez l'équipe plateforme pour obtenir l'accès."

Event collection-write-denied logged to Langfuse with department, collection, and API key hash.

Bulk ingestion rate limit​

More than 50 documents per hour from the same department triggers a rate limit and an alert. Normal document upload behaviour does not approach this threshold — it indicates either automated tooling or a poisoning attempt.

HTTP 429:
"Limite d'ingestion dépassée (50 docs/heure).
Contactez l'équipe plateforme pour les ingestions en masse."

Layer 3 — Cross-Document Consistency Check​

What it does​

When a new document is ingested, factual sentences are extracted and embedded. Each is compared against existing chunks in the same collection covering the same topic. If the same topic returns a different claimed value (high topic similarity but low claim similarity), a document-consistency-conflict WARNING is logged.

Factual sentences detected​

# Patterns that identify factual claims worth cross-checking:
"La franchise applicable est de 150 €" → extracted
"Le délai de déclaration est de 5 jours ouvrés" → extracted
"Article L113-2 du Code des assurances" → extracted
"Couvert jusqu'à hauteur de 50 000 €" → extracted

What happens on detection​

New document: expert-update-franchise-2026.pdf
Factual sentence: "La franchise dégât des eaux est de 2 500 €"
Query existing collection → finds 5 chunks stating "franchise: 150 €"
Topic similarity: 0.91 (same topic — water damage franchise)
Claim similarity: 0.41 (different value stated)

Result: document-consistency-conflict WARNING in Langfuse
metadata:
new_sentence: "La franchise dégât des eaux est de 2 500 €"
existing_sentence: "La franchise applicable en cas de dégât des eaux est de 150 €"
existing_doc: "policy-wording-mrh-v12.pdf"
existing_uploaded_by: "team.platform@ktayl.fr"
topic_similarity: 0.91
claim_similarity: 0.41

The document is not blocked — it may represent a legitimate policy update. A domain expert reviews the conflict and either approves the new document or rejects it. For finance-docs and legal-docs the behaviour can be set to blocking (quarantine until reviewed).


Layer 4 — Feedback Anomaly Detection​

Pattern 1 — Thumbs-up on a low-quality response​

When a user gives 👍 to a response that the LLM-as-judge scored below 0.60 on correctness, the feedback-bridge (issue #43) flags it. This catches individual accounts confirming wrong answers — possibly unwitting (user didn't read carefully) or deliberate (feedback poisoning).

Event: feedback-quality-mismatch (WARNING)
human_rating: positive
judge_score: 0.43
user: adjuster.x@ktayl.fr
interpretation: User rated positive a response the judge scored < 0.60

Pattern 2 — Coordinated positive feedback​

Five or more distinct accounts give 👍 to the same response within a 10-minute window. Normal behaviour does not produce this pattern — it indicates coordinated manipulation.

Event: coordinated-positive-feedback (ERROR)
trace_id: "abc123..."
like_count: 6
window_secs: 600
action: possible-feedback-poisoning

Counter resets after 10 minutes. A single popular response that receives likes over hours is not flagged — only simultaneous bursts.


Layer 5 — Weekly Integrity Scan​

What it scans​

Every Sunday at 03:00 UTC a CronJob scans all chunks across all 10 collections:

CheckWhat it catches
Injection patternsDocuments ingested before issue #46 was deployed that contain instruction-like content
Provenance gapsChunks missing uploaded_by, doc_hash, or uploaded_at (ingested before this issue)

Results are written to a single Langfuse event weekly-integrity-scan-complete with:

  • Total chunks scanned
  • Flagged chunk count and details (document, uploader, pattern matched)
  • Provenance gap count

Triggering a manual scan​

kubectl create job --from=cronjob/qdrant-integrity-scan integrity-scan-$(date +%Y%m%d) -n ai
kubectl logs -n ai job/integrity-scan-$(date +%Y%m%d) -f

Reviewing scan results​

Langfuse → Events → filter: name = "weekly-integrity-scan-complete"
→ metadata.flagged_chunks > 0 → see metadata.flagged_details for chunk list
→ metadata.no_provenance_chunks > 0 → chunks needing backfill

Incident Response — Removing a Poisoned Document​

When a poisoned document is confirmed (via Langfuse event or domain expert review):

Step 1 — Find the doc_hash​

Langfuse → Events → name = "indirect-injection-in-document" or "document-consistency-conflict"
→ metadata.doc_hash = "a3f7b91c2d4e5f6..."

Step 2 — Delete all chunks with that hash​

DOC_HASH="a3f7b91c2d4e5f6..."
COLLECTION="claims-docs"

kubectl exec -n ai deployment/qdrant -- /bin/sh -c "
curl -s -X POST http://localhost:6333/collections/${COLLECTION}/points/delete \
-H 'Content-Type: application/json' \
-d '{\"filter\": {\"must\": [{\"key\": \"doc_hash\", \"match\": {\"value\": \"${DOC_HASH}\"}}]}}'
"

Step 3 — Verify deletion​

kubectl exec -n ai deployment/qdrant -- /bin/sh -c "
curl -s -X POST http://localhost:6333/collections/${COLLECTION}/points/count \
-H 'Content-Type: application/json' \
-d '{\"filter\": {\"must\": [{\"key\": \"doc_hash\", \"match\": {\"value\": \"${DOC_HASH}\"}}]}}'
"
# → {"result": {"count": 0}}

Step 4 — Audit the uploader's other documents​

Langfuse → Events → filter: name = "document-ingested", metadata.uploaded_by = "<user>"
→ review all doc_hash values they uploaded
→ re-ingest any documents that need verification

Step 5 — Revoke upload access if attack confirmed​

# Revoke the user's virtual key in LiteLLM admin
curl -X DELETE http://litellm.ai.svc:4000/key/delete \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-d '{"keys": ["<virtual-key>"]}'

Deletion is surgical — doc_hash uniquely identifies the poisoned document's chunks without affecting any other content in the collection.


Langfuse Event Reference​

Event nameLevelWhen fired
document-ingestedINFOEvery successful ingestion — records uploader + hash
indirect-injection-in-documentERRORInjection pattern found at ingestion — document quarantined
document-consistency-conflictWARNINGNew document contradicts existing chunk on same topic
collection-write-deniedWARNINGDepartment not authorised to write to collection
bulk-ingestion-rate-exceededERROR> 50 documents in 1 hour from same department
rag-sources-usedINFOEvery RAG response — provenance of each contributing chunk
feedback-quality-mismatchWARNING👍 on response where judge_correctness < 0.60
coordinated-positive-feedbackERROR5+ accounts like same response within 10 minutes
weekly-integrity-scan-completeINFO / ERRORWeekly CronJob result — ERROR if flagged_chunks > 0

Grafana Alerting​

AlertConditionSeverity
DataPoisoningChunkFlaggedInjection found in document at ingestionCritical, fires immediately
IntegrityScanFlaggedWeekly scan finds suspicious chunks in QdrantCritical, fires immediately
FeedbackPoisoningDetectedCoordinated positive feedback detectedWarning, fires immediately
BulkIngestionSpike> 50 docs/hour from one departmentWarning, fires immediately

Operational Checks​

# Documents ingested today (with uploader)
# → Langfuse → Events → filter: name = "document-ingested" → metadata.uploaded_by

# Check consistency conflicts this week
# → Langfuse → Events → filter: name = "document-consistency-conflict"

# Verify integrity scan ran last Sunday
kubectl get jobs -n ai | grep integrity-scan
kubectl logs -n ai job/integrity-scan-<date> | tail -5

# Check collection chunk counts per department
kubectl exec -n ai deployment/qdrant -- /bin/sh -c "
for coll in claims-docs finance-docs legal-docs underwriting-docs; do
echo -n \"\$coll: \"
curl -s http://localhost:6333/collections/\$coll | jq '.result.points_count'
done
"

# Check bulk ingestion rate counters in Redis
kubectl exec -n ai deployment/redis -- \
redis-cli KEYS "ingestion_rate:*" | xargs -I{} sh -c 'echo -n "{}: "; redis-cli GET {}'

What These Controls Do Not Cover​

GapReasonPath
Embedding model adversarial examplesCrafted documents that fool nomic-embed-text at a mathematical level require ML-specific defences beyond pattern matchingDeferred; monitor retrieval score distributions for anomalies
Insider threat with platform-team rolePlatform team has write access to all collectionsCovered by Authentik RBAC audit logging (issue #33) + controller audit logs
Slow drift poisoningA single incorrect document ingested months ago may not trigger consistency checks if it predates the authoritative versionWeekly integrity scan + provenance audit catches this once controls are live
Feedback poisoning below the 5-account thresholdSingle account consistently giving 👍 to wrong answersCorrelate human_feedback with judge_correctness in monthly ACPR report (issue #45)