RAG en production : architecture simple qui fonctionne vraiment
La plupart des tutoriels RAG vous montrent un prototype qui fonctionne en 20 lignes de code. Puis vous déployez en production et tout s'effondre : latence excessive, réponses incohérentes, coûts qui explosent. Le problème n'est pas le RAG, c'est l'architecture sous-dimensionnée.
Cet article présente une architecture RAG pragmatique, testée en production, qui équilibre performance, fiabilité et coûts.
Architecture de référence
┌─────────────────────────────────────────────────────────────────┐
│ UTILISATEUR │
└─────────────────────────────┬───────────────────────────────────┘
│ Question
▼
┌─────────────────────────────────────────────────────────────────┐
│ API GATEWAY │
│ (Rate limiting, Auth, Cache) │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ QUERY PROCESSOR │
│ (Reformulation, Expansion, Classification) │
└─────────────────────────────┬───────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ VECTOR SEARCH │ │ KEYWORD SEARCH │ │ METADATA │
│ (Embeddings) │ │ (BM25/Elastic) │ │ FILTERS │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────┼───────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ RERANKER │
│ (Cross-encoder, Scoring) │
└─────────────────────────────┬───────────────────────────────────┘
│ Top-K documents
▼
┌─────────────────────────────────────────────────────────────────┐
│ LLM GENERATOR │
│ (Prompt + Context → Response) │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ POST-PROCESSING │
│ (Validation, Citations, Formatting) │
└─────────────────────────────────────────────────────────────────┘Les 5 composants essentiels
1. Query Processor : ne jamais utiliser la question brute
La question de l'utilisateur est rarement optimale pour la recherche. Le query processor la transforme :
class QueryProcessor:
def __init__(self, llm_client):
self.llm = llm_client
def process(self, raw_query: str) -> ProcessedQuery:
# 1. Classification de l'intention
intent = self.classify_intent(raw_query)
# 2. Reformulation pour la recherche
search_query = self.reformulate(raw_query)
# 3. Extraction des filtres métadonnées
filters = self.extract_filters(raw_query)
# 4. Expansion avec synonymes/concepts liés
expanded_terms = self.expand_query(search_query)
return ProcessedQuery(
original=raw_query,
search_query=search_query,
intent=intent,
filters=filters,
expanded_terms=expanded_terms
)
def reformulate(self, query: str) -> str:
prompt = f"""Reformule cette question pour optimiser la recherche documentaire.
Garde les termes techniques importants.
Question: {query}
Reformulation:"""
return self.llm.generate(prompt, max_tokens=100)Exemple de transformation :
- Entrée : "Comment faire pour que mon app Spring soit plus rapide ?"
- Reformulation : "optimisation performance Spring Boot latence throughput"
- Filtres extraits :
{technology: "Spring Boot"}
2. Recherche hybride : vecteurs + mots-clés
La recherche vectorielle seule rate souvent des résultats pertinents. Combinez-la avec une recherche par mots-clés :
class HybridRetriever:
def __init__(self, vector_store, keyword_store):
self.vector_store = vector_store # Qdrant, Pinecone, Weaviate
self.keyword_store = keyword_store # Elasticsearch, OpenSearch
def retrieve(self, query: ProcessedQuery, top_k: int = 20) -> List[Document]:
# Recherche vectorielle
vector_results = self.vector_store.search(
query_embedding=self.embed(query.search_query),
top_k=top_k,
filters=query.filters
)
# Recherche par mots-clés (BM25)
keyword_results = self.keyword_store.search(
query=query.search_query,
expanded_terms=query.expanded_terms,
top_k=top_k,
filters=query.filters
)
# Fusion des résultats (Reciprocal Rank Fusion)
fused = self.rrf_fusion(vector_results, keyword_results)
return fused[:top_k]
def rrf_fusion(self, *result_lists, k=60) -> List[Document]:
"""Reciprocal Rank Fusion - combine plusieurs rankings"""
scores = defaultdict(float)
for results in result_lists:
for rank, doc in enumerate(results):
scores[doc.id] += 1 / (k + rank + 1)
sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [self.get_doc(doc_id) for doc_id, _ in sorted_docs]Pourquoi hybride ?
| Type de requête | Vecteurs seuls | Mots-clés seuls | Hybride |
|---|---|---|---|
| "concept de microservices" | Excellent | Moyen | Excellent |
| "erreur NullPointerException ligne 42" | Faible | Excellent | Excellent |
| "comment implémenter OAuth2" | Bon | Bon | Excellent |
3. Reranker : le filtre de qualité
Le retriever ramène 20-50 documents. Le reranker les classe par pertinence réelle :
from sentence_transformers import CrossEncoder
class Reranker:
def __init__(self, model_name="cross-encoder/ms-marco-MiniLM-L-12-v2"):
self.model = CrossEncoder(model_name)
def rerank(self, query: str, documents: List[Document], top_k: int = 5) -> List[Document]:
# Préparer les paires (query, document)
pairs = [(query, doc.content) for doc in documents]
# Scorer chaque paire
scores = self.model.predict(pairs)
# Trier par score décroissant
ranked = sorted(
zip(documents, scores),
key=lambda x: x[1],
reverse=True
)
return [doc for doc, score in ranked[:top_k]]Impact du reranker :
- Sans reranker : précision ~60%
- Avec reranker : précision ~85%
- Latence ajoutée : 50-100ms
4. Prompt structuré : contexte + instructions claires
Le prompt du LLM doit être précis et structuré :
class PromptBuilder:
SYSTEM_PROMPT = """Tu es un assistant technique expert.
Tu réponds UNIQUEMENT à partir des documents fournis.
Si l'information n'est pas dans les documents, dis-le clairement.
Cite tes sources avec [1], [2], etc."""
def build_prompt(self, query: str, documents: List[Document]) -> str:
# Formater les documents avec numéros de référence
context = self.format_context(documents)
return f"""{self.SYSTEM_PROMPT}
DOCUMENTS:
{context}
QUESTION: {query}
RÉPONSE (avec citations):"""
def format_context(self, documents: List[Document]) -> str:
formatted = []
for i, doc in enumerate(documents, 1):
formatted.append(f"[{i}] {doc.title}\n{doc.content[:1500]}")
return "\n\n".join(formatted)Règles du prompt :
- Limiter le contexte (4-8 documents max)
- Demander explicitement les citations
- Interdire l'invention d'informations
- Spécifier le format de réponse attendu
5. Post-processing : validation et formatage
Ne faites jamais confiance au LLM aveuglément :
class ResponseProcessor:
def process(self, response: str, documents: List[Document]) -> FinalResponse:
# 1. Extraire les citations
citations = self.extract_citations(response)
# 2. Valider que les citations existent
valid_citations = self.validate_citations(citations, documents)
# 3. Détecter les hallucinations potentielles
hallucination_score = self.detect_hallucination(response, documents)
# 4. Formater la réponse finale
formatted = self.format_response(response, valid_citations)
return FinalResponse(
content=formatted,
citations=valid_citations,
confidence=1 - hallucination_score,
sources=[doc.metadata for doc in documents]
)
def detect_hallucination(self, response: str, documents: List[Document]) -> float:
"""Score de 0 (fiable) à 1 (hallucination probable)"""
# Vérifier que les faits clés sont dans les documents
facts = self.extract_facts(response)
verified = sum(1 for f in facts if self.fact_in_docs(f, documents))
return 1 - (verified / len(facts)) if facts else 0.5Choix techniques recommandés
Vector Store
| Solution | Latence P99 | Scalabilité | Coût | Recommandation |
|---|---|---|---|---|
| Qdrant | 10ms | Excellente | Self-host gratuit | Production self-hosted |
| Pinecone | 20ms | Excellente | $70+/mois | Production managée |
| Weaviate | 15ms | Bonne | Self-host gratuit | Polyvalent |
| pgvector | 50ms | Moyenne | Inclus PostgreSQL | Petit volume |
| Chroma | 30ms | Limitée | Gratuit | Prototypes |
Recommandation : Qdrant pour la production (performance + coût), pgvector pour démarrer simple.
Modèle d'embeddings
| Modèle | Dimensions | Performance | Latence | Coût |
|---|---|---|---|---|
| OpenAI text-embedding-3-large | 3072 | Excellente | 100ms | $0.13/1M tokens |
| OpenAI text-embedding-3-small | 1536 | Très bonne | 50ms | $0.02/1M tokens |
| Cohere embed-v3 | 1024 | Excellente | 80ms | $0.10/1M tokens |
| BGE-large-en | 1024 | Très bonne | Local | Gratuit |
| E5-large-v2 | 1024 | Très bonne | Local | Gratuit |
Recommandation : OpenAI text-embedding-3-small (rapport qualité/prix), BGE-large pour self-hosted.
LLM pour génération
| Modèle | Qualité | Latence | Coût/1M tokens | Use case |
|---|---|---|---|---|
| GPT-4o | Excellente | 2-5s | $5 in / $15 out | Qualité maximale |
| GPT-4o-mini | Très bonne | 1-2s | $0.15 / $0.60 | Production standard |
| Claude 3.5 Sonnet | Excellente | 2-4s | $3 / $15 | Alternative premium |
| Claude 3.5 Haiku | Bonne | 0.5-1s | $0.25 / $1.25 | Volume élevé |
| Mistral Large | Très bonne | 1-3s | $2 / $6 | Europe, self-host |
Recommandation : GPT-4o-mini pour 90% des cas, GPT-4o pour les requêtes complexes.
Erreurs courantes à éviter
Erreur 1 : Chunks trop grands ou trop petits
chunker = RecursiveCharacterTextSplitter(chunk_size=16000)
chunker = RecursiveCharacterTextSplitter(chunk_size=400)
chunker = RecursiveCharacterTextSplitter(
chunk_size=2000, # ~500 tokens
chunk_overlap=400, # 20% overlap
separators=["\n\n", "\n", ". ", " "]
)Erreur 2 : Pas de métadonnées sur les chunks
chunk = {"content": "La méthode retourne null si..."}
chunk = {
"content": "La méthode retourne null si...",
"metadata": {
"source": "docs/api-reference.md",
"title": "UserService.findById()",
"section": "Return Values",
"last_updated": "2025-01-15",
"language": "fr",
"doc_type": "api_doc"
}
}Erreur 3 : Ignorer le cache
def answer(query):
embedding = embed(query) # 100ms
docs = search(embedding) # 50ms
response = llm(query, docs) # 2000ms
return response
class CachedRAG:
def __init__(self):
self.embedding_cache = LRUCache(maxsize=10000)
self.response_cache = TTLCache(maxsize=1000, ttl=3600)
def answer(self, query: str) -> str:
# Cache niveau 1 : réponse complète
cache_key = self.hash_query(query)
if cache_key in self.response_cache:
return self.response_cache[cache_key]
# Cache niveau 2 : embeddings
if query not in self.embedding_cache:
self.embedding_cache[query] = self.embed(query)
embedding = self.embedding_cache[query]
docs = self.search(embedding)
response = self.llm(query, docs)
self.response_cache[cache_key] = response
return responseErreur 4 : Pas de fallback
def answer(query):
docs = search(query)
if not docs:
return "Je ne sais pas" # Frustrant pour l'utilisateur
def answer(query):
# Tentative 1 : recherche précise
docs = search(query, threshold=0.8)
# Tentative 2 : recherche élargie
if len(docs) < 3:
docs = search(query, threshold=0.5)
# Tentative 3 : recherche par mots-clés seuls
if len(docs) < 3:
docs = keyword_search(query)
# Fallback : réponse honnête avec suggestions
if not docs:
return suggest_alternatives(query)
return generate(query, docs)Métriques de monitoring
Métriques essentielles
metrics = {
# Latence
"retrieval_latency_p50": 45, # ms
"retrieval_latency_p99": 120, # ms
"generation_latency_p50": 1800, # ms
"total_latency_p50": 2100, # ms
# Qualité
"retrieval_precision": 0.82, # % docs pertinents
"answer_relevance": 0.78, # Score 0-1
"hallucination_rate": 0.05, # % réponses avec hallucinations
# Usage
"cache_hit_rate": 0.35, # % requêtes servies par cache
"empty_retrieval_rate": 0.02, # % recherches sans résultat
# Coûts
"cost_per_query": 0.003, # $ par requête moyenne
"tokens_per_query": 2500, # tokens moyens consommés
}Dashboard de monitoring
from prometheus_client import Histogram, Counter, Gauge
retrieval_latency = Histogram(
'rag_retrieval_latency_seconds',
'Latency of document retrieval',
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0]
)
generation_latency = Histogram(
'rag_generation_latency_seconds',
'Latency of LLM generation',
buckets=[0.5, 1.0, 2.0, 5.0, 10.0]
)
queries_total = Counter(
'rag_queries_total',
'Total number of RAG queries',
['status', 'cache_hit']
)
retrieval_quality = Gauge(
'rag_retrieval_precision',
'Precision of retrieved documents'
)Estimation des coûts
Pour 10 000 requêtes/jour
| Composant | Calcul | Coût mensuel |
|---|---|---|
| Embeddings (queries) | 10K × 30 × 500 tokens × $0.02/1M | $9 |
| LLM (GPT-4o-mini) | 10K × 30 × 3000 tokens × $0.30/1M | $270 |
| Vector store (Qdrant cloud) | 1M vectors | $100 |
| Reranker (API) | 10K × 30 × 20 docs | $50 |
| Total | ~$430/mois |
Optimisations coût
- Cache agressif : -30% de requêtes LLM
- Embeddings locaux (BGE) : -100% coût embeddings
- Reranker local : -100% coût reranking
- Total optimisé : ~$180/mois
Conclusion
Une architecture RAG en production n'est pas un prototype agrandi. Elle nécessite :
- Query processing pour optimiser les recherches
- Recherche hybride pour couvrir tous les types de requêtes
- Reranking pour filtrer le bruit
- Prompts structurés pour des réponses fiables
- Post-processing pour valider et formater
Commencez simple avec cette architecture de référence, puis optimisez en fonction de vos métriques. Le RAG parfait n'existe pas du premier coup, mais avec les bons composants et le bon monitoring, vous pouvez itérer rapidement vers une solution fiable.
Pour comprendre pourquoi certains RAG échouent, consultez notre prochain article : Pourquoi votre RAG échoue (et comment le corriger)
Pour une introduction au RAG : Pourquoi tout le monde parle de RAG en 2025 ?