Workflow RAG — Architecture & mise en œuvre
Le Retrieval-Augmented Generation (RAG) permet d’enrichir une réponse LLM avec des documents pertinents sans re-entraîner le modèle. C’est le pattern de référence quand on veut doter une application AI d’une base de connaissances sans engineering de données massif.
Ce guide couvre l’architecture complète : de l’indexation d’un document brut à la génération finale, en passant par l’embedding, le stockage vectoriel et le retrieval.
Architecture d’un pipeline RAG
Un pipeline RAG fonctionne en deux phases : indexation (one-shot ou incrémentale) et inférence (répétée par requête).
┌─────────────── INDEXATION ───────────────┐
│ │
│ Document brut │
│ │ │
│ ▼ │
│ Text splitter (chunks) │
│ │ │
│ ▼ │
│ Embedding model → vecteurs │
│ │ │
│ ▼ │
│ Stockage vectoriel (Chroma / Qdrant ...) │
│ │
└─────────────── INFÉRENCE ────────────────┘
│ │
│ Requête utilisateur │
│ │ │
│ ▼ │
│ Embedding de la requête │
│ │ │
│ ▼ │
│ Similarity search → top-k chunks │
│ │ │
│ ▼ │
│ Prompt enrichi (requête + contexte) │
│ │ │
│ ▼ │
│ LLM → réponse │Règle n°1 : la qualité de la sortie RAG dépend 80 % de la qualité du retrieval, pas du modèle. Un mauvais chunk ne se rattrape pas avec un bon prompt.
Choix d’un embedding model
Le choix de l’embedding model impacte directement la pertinence du retrieval. Trois options majeures :
| Modèle | Taille | Précision (MTEB) | Coût | Usage recommandé |
|---|---|---|---|---|
text-embedding-3-small (OpenAI) | 1536 dims | 62,3 % | ~0,02 USD / 1M tokens | Production, multi-langue |
text-embedding-3-large (OpenAI) | 3072 dims | 64,6 % | ~0,13 USD / 1M tokens | Cas critiques, précision maximale |
all-MiniLM-L6-v2 (sentence-transformers) | 384 dims | 56,5 % | Gratuit (local) | Prototypage, contraintes de latence, offline |
bge-large-en-v1.5 (BAAI) | 1024 dims | 63,1 % | Gratuit (local) | Recherche anglophone, équilibre vitesse/précision |
Pour un projet en production, démarrer avec
text-embedding-3-small. Pour un prototype ou une contrainte d’offline, utiliserall-MiniLM-L6-v2en local.
Initialisation des deux approches
# OpenAI (API call)
from openai import OpenAI
client = OpenAI()
embedding = client.embeddings.create(
input="Document à indexer",
model="text-embedding-3-small"
).data[0].embedding# sentence-transformers (local, gratuit)
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
embedding = model.encode("Document à indexer").tolist()Découpage en chunks — La clé du retrieval
Un document brut doit être découpé en fragments (chunks) avant embedding. La taille et le chevauchement (overlap) sont les deux paramètres à régler.
| Technique | Taille chunk | Overlap | Quand l’utiliser |
|---|---|---|---|
| Fixed-size | 256–512 tokens | 50–100 tokens | Textes longs non structurés (articles, logs) |
| Recursive (hiérarchique) | 512–1024 tokens | 100–200 tokens | Documents structurés (code, docs Markdown) |
| Semantic (par paragraphe) | Variable | N/A | Quand les paragraphes sont naturellement délimités |
Règle n°2 : un chunk de 256 tokens est généralement le sweet spot. Au-delà de 512, on dilue la pertinence. En dessous de 128, on perd le contexte.
Exemple avec LangChain (recursive splitter)
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=100,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(
"Contenu long du document à indexer ici..."
)
# chunks : liste de strings de max 512 tokens, overlap de 100Stockage vectoriel
Trois options majeures, classées par complexité d’installation :
| Solution | Installation | Persistance | Scale | Idéal pour |
|---|---|---|---|---|
| Chroma | pip install chromadb | File système locale | 10K–500K docs | Prototypage, solo dev |
| Qdrant | Docker / cloud | Redis/MQ memory | 100K–10M docs | Production, scale |
| pgvector | Extension Postgres | PostgreSQL | 1M+ docs | Quand Postgres est déjà la BDD principale |
Exemple complet : Chroma (prototypage)
import chromadb
from chromadb.utils import embedding_functions
# 1. Configurer l'embedding function
ef = embedding_functions.OpenAIEmbeddingFunction(
api_key="sk-...",
model_name="text-embedding-3-small"
)
# 2. Créer ou ouvrir la collection
client = chromadb.Client()
collection = client.get_or_create_collection(
name="documents",
embedding_function=ef
)
# 3. Indexation
collection.add(
documents=[
"Premier document chunké...",
"Deuxième document chunké...",
],
ids=["doc_001", "doc_002"],
metadatas=[
{"source": "doc1.pdf", "page": 1},
{"source": "doc1.pdf", "page": 2},
]
)Exemple complet : pgvector (production avec Postgres)
# SQL : créer l'extension et la table
# CREATE EXTENSION IF NOT EXISTS vector;
# CREATE TABLE documents (
# id SERIAL PRIMARY KEY,
# content TEXT,
# embedding vector(1536),
# metadata JSONB
# );
# Python : indexation
import psycopg2
import psycopg2.extras
conn = psycopg2.connect("dbname=rag_dev user=postgres")
cur = conn.cursor()
cur.execute(
"INSERT INTO documents (content, embedding, metadata) "
"VALUES (%s, %s, %s)",
("Chunk de document...", embedding, {"source": "doc.pdf"})
)
conn.commit()
# Python : recherche
cur.execute(
"""SELECT content, metadata, 1 - (embedding <=> %s::vector) AS similarity
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT 5""",
(query_embedding, query_embedding)
)
results = cur.fetchall()Le fallback Pgvector est pertinent quand l’application a déjà Postgres. Éviter Chroma en production multi-instance — la persistance file system ne se scale pas.
Inférence RAG — Du retrieval à la réponse
La phase d’inférence suit ce schéma pour chaque requête utilisateur :
import openai
import chromadb
from chromadb.utils import embedding_functions
client = openai.OpenAI()
client_chroma = chromadb.Client()
ef = embedding_functions.OpenAIEmbeddingFunction(
api_key="sk-...",
model_name="text-embedding-3-small"
)
collection = client_chroma.get_or_create_collection(
name="documents",
embedding_function=ef
)
def rag_query(user_question: str, top_k: int = 3) -> str:
"""Pipeline RAG complet : retrieve → augment → generate."""
# 1. Embedder la requête
query_embedding = client.embeddings.create(
input=user_question,
model="text-embedding-3-small"
).data[0].embedding
# 2. Retrieval : recherche similarity
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k
)
# 3. Construire le contexte à injecter dans le prompt
context = "\n\n".join(results["documents"][0])
# 4. Génération avec contexte
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": (
"Tu es un assistant qui répond uniquement à partir "
"des documents fournis. Cite tes sources. "
"Si la réponse n'est pas dans les documents, dis-le."
)
},
{
"role": "user",
"content": (
"Documents de référence :\n---\n"
f"{context}\n---\n\n"
f"Question : {user_question}"
)
}
],
max_tokens=1024
)
return response.choices[0].message.contentRègle n°3 : toujours demander au LLM de citer ses sources. Sans citation, impossible de vérifier que la réponse vient bien du retrieval et pas d’une hallucination.
Gestion du token limit
Le contexte LLM a une taille finie. Un chunk trop long + une requête longue peut dépasser le max_context_length (typiquement 128K tokens pour GPT-4o, 200K pour Gemini).
Stratégies de protection
| Stratégie | Mécanisme | Quand |
|---|---|---|
| Truncation contrôlée | Couper le contexte à une taille max avant envoi | Toggle rapide, perte d’information |
| Reranking | Reranker le contexte pour garder uniquement les chunks les plus pertinents | Quand top-k est trop large |
| Résumé hiérarchique | Résumer les chunks non retenus en un seul bloc | Documents très longs avec contexte secondaire |
| Chunking adaptatif | Ajuster la taille du chunk selon le budget token restant | Settings dynamiques |
Exemple de truncation sécurisée
MAX_CONTEXT_TOKENS = 4096 # Budget réservé au contexte
def build_context_safely(contexts: list[str], max_tokens: int = MAX_CONTEXT_TOKENS) -> str:
"""Assembler le contexte en respectant le budget token."""
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(chunk_size=1, chunk_overlap=0)
total = ""
for chunk in contexts:
chunk_tokens = len(splitter.split_text(chunk))
if len(splitter.split_text(total + "\n" + chunk)) > max_tokens:
break
total = (total + "\n" + chunk).strip()
return totalFallback si le retrieval est insuffisant
Parfois, les documents indexés ne contiennent pas l’information demandée. Un bon pipeline RAG gère ce cas explicitement.
Pattern de fallback à trois niveaux
Niveau 1 : RAG (retrieval + génération)
│
├── Si confiance > 0.7 → réponse avec sources
│
├── Si confiance basse OU "ne pas trouver" détecté
│ │
│ ▼
│ Niveau 2 : RAG élargi (top-k=10, threshold relaxé)
│ │
│ ├── Si nouvelle confiance acceptable → réponse élargie
│ │
│ └── Si toujours insuffisant
│ │
│ ▼
│ Niveau 3 : Réponse générale (sans documents)
│ avec avertissement : "Cette réponse n'est pas issue de votre base de connaissances."Détection de “ne pas trouver”
def has_relevant_answer(response_text: str, confidence_threshold: float = 0.7) -> dict:
"""Analyser si la réponse du LLM est vraiment ancrée dans le contexte."""
vague_markers = [
"je ne suis pas sûr", "je ne sais pas", "les documents",
"ne contient pas", "pas dans les informations fournies"
]
flagged = any(marker in response_text.lower() for marker in vague_markers)
return {
"vague": flagged,
"confidence": 1.0 if flagged else confidence_threshold,
"fallback_needed": flagged,
}Pièges courants
| Piège | Symptôme | Solution |
|---|---|---|
| Chunks trop grands | Le LLM noie dans du bruit, retrieval peu pertinent | Réduire à 256–512 tokens, augmenter l’overlap |
| Embedding model inadapté | Résultats de recherche aléatoires | Tester 2 modèles, choisir selon MTEB ou benchmark interne |
| Pas de metadata | Impossible de filtrer ou comprendre l’origine | Toujours stocker source, page, date |
| Embedding non mis à jour | Nouveaux documents pas indexés | Pipeline d’indexation incrémental + versionning |
| Token limit violé | Erreur API ou réponse coupée | Budget token strict + truncation |
| Hallucination malgré retrieval | Le LLM invente des détails absents des chunks | Prompt system strict + détection de vague |
| Stockage non persisté | Perte des données entre redémarrages | Chroma persistant (persist_directory) ou Qdrant/Pgvector |
Liaisons
- Prompts LLM — Structure, Techniques & Bonnes Pratiques
- Structured Output — JSON, Schémas & Tool Calling
- Outils IA & Workflow de développement
Références : LangChain Text Splitters , ChromaDB Docs , pgvector , MTEB Leaderboard