Aller au contenu
Développement medium

RAG en production : sécurité, évaluation et observabilité

20 min de lecture

Votre RAG fonctionne en local, mais êtes-vous prêt pour la prod ? Ce guide couvre les trois piliers d’un RAG production-ready : sécurité (ACL, isolation), évaluation (métriques, CI/CD) et observabilité (monitoring, alertes).

  • Implémenter le filtrage ACL et le multi-tenant
  • Ajouter les métadonnées de sécurité aux chunks
  • Évaluer avec RAGAS (Recall, Faithfulness, Relevance)
  • Intégrer l’évaluation en CI/CD
  • Monitorer avec métriques et alertes

Un RAG non sécurisé peut exposer des documents confidentiels à des utilisateurs non autorisés.

Sans filtrage ACL, le retriever retourne les chunks les plus pertinents sans vérifier les droits. Un utilisateur peut voir des docs auxquels il n’a pas accès.

❌ Utilisateur "dev-junior" pose une question
→ Le RAG retourne un runbook confidentiel "SRE-only"
→ Fuite d'information

Chaque chunk doit porter des métadonnées de gouvernance. Voici la structure complète — vous n’avez pas besoin de tous les champs dès le départ, mais prévoyez-les dans votre schéma :

# Structure d'un chunk avec métadonnées de sécurité
chunk = {
"text": "Le contenu du chunk...",
"metadata": {
# === IDENTIFICATION ===
# D'où vient ce chunk ? Pour les citations et le debug.
"source": "docs/runbook-kubernetes.md",
"title": "Runbook Kubernetes",
"section": "Déploiement",
# === TRAÇABILITÉ ===
# Quand a-t-il été indexé ? Pour invalider le cache si le doc change.
"date": "2026-01-15",
"author": "team-platform",
"doc_version": "2026-02-10", # Date de dernière modification
"hash": "sha256:a1b2c3d4e5...", # Hash du contenu pour détecter les changements
# === GOUVERNANCE / SÉCURITÉ ===
# Qui peut voir ce chunk ? Filtrage ACL obligatoire.
"tenant": "org-acme", # Isolation multi-tenant
"acl": ["team-platform", "sre"], # Groupes autorisés
"sensitivity": "internal", # public | internal | confidential | restricted
"product": "k8s-prod",
"environment": "production"
}
}

Champs prioritaires à implémenter en premier :

  1. tenant — obligatoire en multi-tenant
  2. acl — obligatoire si accès différencié
  3. source — pour les citations
  4. hash — pour savoir quand réindexer
ChampUsageExemple
tenantIsolation multi-tenantorg-acme, org-beta
aclListe des groupes autorisés["sre", "team-platform"]
sensitivityNiveau de classificationinternal, confidential
doc_versionInvalidation de cache2026-02-10
hashDétection de modificationsha256:...

Le filtrage ACL doit être appliqué avant l’injection dans le prompt, au niveau du retriever :

def secure_search(query: str, user: User, top_k: int = 50) -> list[Chunk]:
"""Recherche avec filtrage ACL."""
# Construire le filtre selon les droits de l'utilisateur
acl_filter = {
"tenant": user.tenant, # Isolation tenant
"acl": {"$in": user.groups}, # Groupes autorisés
"sensitivity": {"$in": get_allowed_sensitivity(user)}
}
# Recherche filtrée
results = retriever.search(
query=query,
filter=acl_filter,
top_k=top_k
)
return results
def get_allowed_sensitivity(user: User) -> list[str]:
"""Détermine les niveaux de sensibilité autorisés."""
if user.role == "admin":
return ["public", "internal", "confidential", "restricted"]
elif user.role == "employee":
return ["public", "internal"]
else:
return ["public"]

En multi-tenant, chaque organisation ne doit voir que ses propres documents :

# ❌ DANGEREUX : pas d'isolation
results = retriever.search(query)
# ✅ SÉCURISÉ : isolation par tenant
results = retriever.search(
query,
filter={"tenant": user.tenant}
)

Pour une isolation forte, utilisez des collections séparées :

# Collection par tenant
collection_name = f"docs_{user.tenant}"
retriever = get_retriever(collection_name)

Un RAG sans métriques, c’est piloter à l’aveugle. Vous devez mesurer deux axes :

  1. Retrieval : est-ce que les bons documents sont retrouvés ?
  2. Génération : est-ce que la réponse est fidèle au contexte ?

Métriques d'évaluation RAG : retrieval et génération

MétriqueCe qu’elle mesureCibleFormule
Recall@K% de docs pertinents dans le top-K> 85%pertinents_trouvés / total_pertinents
Precision@K% de top-K réellement pertinents> 70%pertinents_trouvés / K
MRRRang moyen du 1er doc pertinent> 0.81 / rang_premier_pertinent
MétriqueCe qu’elle mesureComment
FaithfulnessRéponse fidèle au contexte (pas d’hallucination)LLM-as-judge
Answer RelevanceRéponse pertinente vs questionLLM-as-judge
GroundednessChaque affirmation traçableCitation matching

Pour des évaluations fiables, constituez un dataset annoté :

[
{
"question": "Comment configurer SSL sur Nginx ?",
"expected_sources": ["nginx-ssl.md", "tls-guide.md"],
"expected_answer_contains": ["ssl_certificate", "listen 443"],
"category": "configuration"
},
{
"question": "Quelle est la procédure de rollback sur staging ?",
"expected_sources": ["runbook-staging.md"],
"expected_answer_contains": ["kubectl rollout undo"],
"category": "operations"
}
]

RAGAS est le framework standard pour évaluer les RAG. Il calcule automatiquement les métriques de qualité sur votre dataset de référence.

Ce que fait RAGAS :

  1. Compare les réponses générées aux réponses attendues
  2. Vérifie que les réponses sont fidèles au contexte (pas d’hallucination)
  3. Mesure si les bons chunks ont été retrouvés
from ragas import evaluate
from ragas.metrics import (
faithfulness, # La réponse est-elle fidèle au contexte ?
answer_relevancy, # La réponse répond-elle à la question ?
context_precision, # Les meilleurs chunks sont-ils en haut du ranking ?
context_recall # Tous les chunks pertinents ont-ils été trouvés ?
)
from datasets import Dataset
# 1. Préparer le dataset d'évaluation
# Chaque clé est une liste avec un élément par question testée
eval_data = {
"question": ["Comment configurer SSL ?", "Comment rollback staging ?"],
"answer": [rag_response_1, rag_response_2], # Réponses générées par votre RAG
"contexts": [[chunk1, chunk2], [chunk3]], # Chunks utilisés pour générer
"ground_truth": ["Pour SSL...", "Pour rollback..."] # Réponses idéales (manuelles)
}
# 2. Convertir en Dataset HuggingFace
dataset = Dataset.from_dict(eval_data)
# 3. Exécuter l'évaluation
# RAGAS utilise un LLM (GPT-4 par défaut) pour juger la qualité
results = evaluate(
dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall
]
)
# 4. Afficher les scores (0.0 à 1.0, plus haut = mieux)
print(results)
# {'faithfulness': 0.92, 'answer_relevancy': 0.88, 'context_precision': 0.75, 'context_recall': 0.85}

Interprétation des scores :

Métrique< 0.70.7-0.85> 0.85
faithfulness⚠️ Hallucinations fréquentes✅ Acceptable🌟 Excellent
context_recall⚠️ Chunks manquants✅ Acceptable🌟 Excellent

Automatisez l’évaluation avant chaque déploiement :

.github/workflows/rag-eval.yml
name: RAG Quality Gate
on:
pull_request:
paths:
- 'src/rag/**'
- 'docs/**'
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements-eval.txt
- name: Run RAG evaluation
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/eval_rag.py \
--dataset tests/golden_dataset.json \
--threshold-recall 0.85 \
--threshold-faithfulness 0.90
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: rag-eval-results
path: eval_results.json
scripts/eval_rag.py
import argparse
import json
import sys
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dataset", required=True)
parser.add_argument("--threshold-recall", type=float, default=0.85)
parser.add_argument("--threshold-faithfulness", type=float, default=0.90)
args = parser.parse_args()
# Charger et évaluer
results = run_evaluation(args.dataset)
# Vérifier les seuils
if results["context_recall"] < args.threshold_recall:
print(f"❌ Recall {results['context_recall']:.2f} < {args.threshold_recall}")
sys.exit(1)
if results["faithfulness"] < args.threshold_faithfulness:
print(f"❌ Faithfulness {results['faithfulness']:.2f} < {args.threshold_faithfulness}")
sys.exit(1)
print("✅ All quality gates passed")
# Sauvegarder les résultats
with open("eval_results.json", "w") as f:
json.dump(results, f, indent=2)

Les métriques automatiques ne suffisent pas. Planifiez une revue humaine régulière :

  • Échantillonnage : 10-20 questions/semaine
  • Annotateurs : domain experts + utilisateurs finaux
  • Critères : utilité, précision, ton, citations correctes
  • Feedback loop : enrichir le golden dataset

Un RAG en production nécessite du monitoring continu.

MétriqueCe qu’elle surveilleSeuil d’alerte
Latence P95Temps de réponse total> 3s
Retrieval latencyTemps du retriever seul> 500ms
Rerank latencyTemps du reranker> 300ms
LLM latencyTemps de génération> 2s
Cache hit-rateEfficacité du prompt caching< 60%
Empty resultsRequêtes sans contexte> 10%
User feedbackThumbs up/down< 70% positif
Error rateErreurs API/timeout> 1%

Les logs structurés (JSON) permettent de corréler les requêtes, mesurer la latence par étape et détecter les problèmes. Voici un exemple complet avec structlog :

import structlog
import time
logger = structlog.get_logger()
def rag_query(query: str, user: User) -> str:
"""
Pipeline RAG avec logging structuré.
Chaque étape est chronométrée pour identifier les goulots.
"""
start_time = time.time()
# === ÉTAPE 1 : RETRIEVAL ===
# Mesurer la latence du retriever seul
retrieval_start = time.time()
chunks = retriever.search(query, filter={"tenant": user.tenant})
retrieval_latency = time.time() - retrieval_start
# === ÉTAPE 2 : RERANKING ===
# Le reranker est souvent le 2e poste de latence
rerank_start = time.time()
reranked = reranker.rerank(query, chunks)
rerank_latency = time.time() - rerank_start
# === ÉTAPE 3 : GÉNÉRATION LLM ===
# Généralement l'étape la plus longue
llm_start = time.time()
response = llm.generate(build_prompt(query, reranked[:8]))
llm_latency = time.time() - llm_start
total_latency = time.time() - start_time
# === LOG STRUCTURÉ ===
# Toutes les infos utiles pour le monitoring et le debug
logger.info(
"rag_request", # Type d'événement (pour filtrer dans Grafana/Kibana)
# Contexte de la requête
query=query[:100], # Tronqué pour éviter les logs trop gros
user_tenant=user.tenant,
# Métriques de retrieval
chunks_retrieved=len(chunks),
chunks_injected=min(8, len(reranked)),
rerank_top_score=reranked[0].score if reranked else None,
# Latences en millisecondes (plus lisible)
retrieval_latency_ms=int(retrieval_latency * 1000),
rerank_latency_ms=int(rerank_latency * 1000),
llm_latency_ms=int(llm_latency * 1000),
total_latency_ms=int(total_latency * 1000),
# Indicateurs de problèmes
cache_hit=check_cache_hit(), # Prompt caching utilisé ?
empty_context=len(chunks) == 0 # Aucun chunk trouvé = problème
)
return response

Exemple de log généré :

{
"event": "rag_request",
"query": "Comment configurer SSL sur Nginx ?",
"user_tenant": "org-acme",
"chunks_retrieved": 12,
"chunks_injected": 8,
"retrieval_latency_ms": 145,
"rerank_latency_ms": 280,
"llm_latency_ms": 1850,
"total_latency_ms": 2275,
"empty_context": false,
"timestamp": "2026-02-17T10:30:45Z"
}

Ce format permet de créer des dashboards Grafana avec des percentiles (P95), des alertes sur empty_context=true, etc.

Métriques à visualiser :

# Prometheus metrics (exemple avec prometheus_client)
rag_requests_total:
type: counter
labels: [tenant, status]
rag_latency_seconds:
type: histogram
labels: [stage] # retrieval, rerank, llm, total
buckets: [0.1, 0.25, 0.5, 1, 2, 5]
rag_chunks_retrieved:
type: histogram
buckets: [0, 5, 10, 20, 50, 100]
rag_empty_results_total:
type: counter
labels: [tenant]
rag_user_feedback:
type: counter
labels: [tenant, feedback] # positive, negative
AlerteConditionSévérité
Latence élevéeP95 > 5s pendant 5minWarning
Latence critiqueP95 > 10s pendant 2minCritical
Taux d’erreur> 5% pendant 5minCritical
Empty results> 20% pendant 10minWarning
Feedback négatif> 40% sur 1hWarning
Cache miss élevé< 40% hit-rateWarning
OutilUsageNotes
LangSmithTracing LangChain, evalsSaaS, intégré
HeliconeProxy LLM avec analyticsSaaS
LangfuseTracing open-sourceSelf-hosted ou cloud
Weights & BiasesExpérimentations, comparaisonsML-focused
Prometheus + GrafanaMétriques customSelf-hosted
OpenTelemetryTracing distribuéStandard
RisqueDescriptionMitigation
Prompt injectionL’utilisateur manipule le promptSandboxer le contexte, validation
Data exfiltrationExtraire des données via le RAGFiltrage ACL strict
PII leakFuites de données personnellesAnonymisation, classification
JailbreakContourner les instructionsInstructions système robustes
  1. Filtrage ACL au retriever : jamais côté LLM

  2. Validation des inputs : rejeter les requêtes suspectes

  3. Classification des docs : metadata sensitivity

  4. Audit trail : logger qui accède à quoi

  5. Rate limiting : par utilisateur et tenant

  6. Sanitization : nettoyer les outputs avant affichage

  • Métadonnées de sécurité sur tous les chunks (tenant, acl, sensitivity)
  • Filtrage ACL implémenté au niveau retriever
  • Isolation multi-tenant (filtre ou collections séparées)
  • Golden dataset constitué (30+ questions)
  • Évaluation RAGAS intégrée en CI/CD
  • Seuils qualité définis (recall > 85%, faithfulness > 90%)
  • Logs structurés avec latences par étape
  • Dashboard de monitoring opérationnel
  • Alertes configurées (latence, erreurs, empty results)
  • Revue humaine planifiée (hebdomadaire)
  • Rate limiting en place
  • Audit trail activé
  1. ACL au retriever : filtrer AVANT l’injection, jamais côté LLM

  2. Métadonnées obligatoires : tenant, acl, sensitivity, hash, doc_version

  3. Golden dataset : 30-50 questions annotées minimum

  4. RAGAS : faithfulness + context_recall en CI/CD

  5. Seuils : recall > 85%, faithfulness > 90% avant merge

  6. Observabilité : latence par étape, empty results, user feedback

  7. Revue humaine : 10-20 questions/semaine par des experts

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.