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@hdiseguros.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@hdiseguros.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@hdiseguros.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)