En 2026, construire un système RAG (Retrieval-Augmented Generation) avec des API cloud coûte facilement 500-2000€/mois pour une utilisation modérée. Entre les coûts d'embeddings, de stockage vectoriel (Pinecone, Qdrant Cloud), et d'inférence LLM (OpenAI, Anthropic), la facture explose rapidement à l'échelle.
La solution : déployer un système RAG 100% local avec Ollama (LLM open-source auto-hébergé) et ChromaDB (base vectorielle open-source). Résultat : 0€ de coûts API, latence réduite (pas de round-trip réseau), et contrôle total sur vos données sensibles. Seul coût : le serveur GPU (89-180€/mois selon puissance).
Ce guide vous montre comment passer d'un prototype RAG avec API propriétaires à un système production autonome, avec exemples complets, benchmarks réels, et retour d'expérience de migration.
Pourquoi RAG Local en 2026 ?
Analyse de Coûts : API Cloud vs Infrastructure Locale
Prenons un cas réel : entreprise B2B SaaS avec chatbot de support client intelligent. 1000 utilisateurs actifs, 50 questions/jour en moyenne, base de connaissances de 500 documents (documentation produit, FAQ, guides).
| Composant | Solution Cloud | Coût/mois | Solution Locale | Coût/mois |
|---|
| Embeddings | OpenAI text-embedding-3-small (1.5M tokens/mois) | 30€ | nomic-embed-text (local) | 0€ |
| Base vectorielle | Pinecone Serverless (500k vecteurs) | 150€ | ChromaDB (Docker) | 0€ |
| LLM Inférence | GPT-4 Turbo (50k questions × 1k tokens avg) | 600€ | Llama 3.3 70B (Ollama) | 0€ |
| Infrastructure | Application hosting | 50€ | Hetzner GPU AX102 (2× RTX 4090, 128GB RAM) | 89€ |
| Backup / Monitoring | Logs, metrics | 20€ | S3 backups, Prometheus | 20€ |
| TOTAL | — | 850€/mois | — | 109€/mois |
Économie réalisée : -87% (741€/mois)
ROI : récupération de l'investissement migration en moins de 2 semaines
Cas d'Usage Idéaux pour RAG Local
- Support client interne : base de connaissances entreprise (documentation technique, procédures, FAQ). Données sensibles qui ne doivent pas quitter l'infrastructure.
- Analyse de contrats juridiques : recherche dans des milliers de contrats, clauses, jurisprudence. RGPD strict, données ultra-confidentielles.
- Documentation technique searchable : ingénieurs qui interrogent la codebase, architecture decisions, runbooks. Volume élevé de requêtes.
- Recherche académique : question-answering sur corpus de publications scientifiques, thèses, articles. Pas de budget API, besoin de reproductibilité.
- Assistant médical privé : recherche dans dossiers patients, guidelines médicales. HIPAA/RGPD compliance strict.
Architecture RAG Locale : Vue d'Ensemble
Un système RAG local se compose de 3 briques principales, toutes auto-hébergées :
┌──────────────────────────────────────────────────────────────────┐
│ LOCAL RAG ARCHITECTURE │
└──────────────────────────────────────────────────────────────────┘
OFFLINE INDEXATION (exécuté une fois, puis à chaque mise à jour docs)
─────────────────────────────────────────────────────────────────────
┌─────────────┐
│ Documents │ PDF, Markdown, HTML, DOCX
│ (500 docs) │
└──────┬──────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CHUNKING │
│ LangChain RecursiveCharacterTextSplitter │
│ - chunk_size: 800 tokens │
│ - chunk_overlap: 100 tokens │
│ Output: ~50,000 chunks │
└──────┬──────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ EMBEDDING (LOCAL) │
│ Model: nomic-embed-text (768 dimensions) │
│ Sentence Transformers (GPU accelerated) │
│ Speed: ~500 chunks/sec on RTX 4090 │
│ Total time: ~2 minutes for 50k chunks │
└──────┬──────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CHROMADB STORAGE │
│ Collection: "knowledge_base" │
│ Vectors: 50,000 × 768 dimensions │
│ Metadata: source, page, timestamp │
│ Storage: ~150MB on disk (compressed) │
└─────────────────────────────────────────────────────────────┘
ONLINE QUERY (temps réel, latence critique)
─────────────────────────────────────────────
[User Question]
│
▼
┌─────────────────────────────────────────────────────────────┐
│ EMBED QUERY │
│ Same model: nomic-embed-text │
│ Latency: 20-40ms (GPU) / 150-300ms (CPU) │
└──────┬──────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CHROMADB SIMILARITY SEARCH │
│ Cosine similarity, top_k=5 │
│ Latency: 15-30ms (50k vectors in RAM) │
└──────┬──────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CONTEXT CONSTRUCTION │
│ Format: "Based on these documents:\n{chunk1}\n{chunk2}..."│
└──────┬──────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ OLLAMA LLM GENERATION │
│ Model: Llama 3.3 70B (Q8 quantization) │
│ Context window: 128k tokens │
│ Generation speed: 12-15 tokens/sec (RTX 4090) │
│ Latency: 2-5s pour réponse complète │
└──────┬──────────────────────────────────────────────────────┘
│
▼
[Réponse à l'utilisateur avec sources citées]
STACK COMPLÈTE (Docker Compose)
─────────────────────────────────
- Ollama (LLM inference) : port 11434
- ChromaDB (vector database) : port 8000
- FastAPI (API application) : port 8080
- Prometheus (monitoring) : port 9090
- Grafana (dashboards) : port 3000
Installation : Docker Compose Complet
Toute l'infrastructure RAG locale tient dans un seul fichier Docker Compose. Démarrage en une commande.
docker-compose.yml
version: '3.8'
services:
# Ollama : serveur LLM local
ollama:
image: ollama/ollama:latest
container_name: rag-ollama
volumes:
- ollama_models:/root/.ollama
ports:
- "11434:11434"
environment:
- OLLAMA_HOST=0.0.0.0
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 30s
timeout: 10s
retries: 3
# ChromaDB : base vectorielle
chromadb:
image: chromadb/chroma:latest
container_name: rag-chromadb
volumes:
- chromadb_data:/chroma/chroma
ports:
- "8000:8000"
environment:
- IS_PERSISTENT=TRUE
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/heartbeat"]
interval: 30s
timeout: 5s
retries: 3
# Application RAG (FastAPI)
rag-api:
build:
context: ./app
dockerfile: Dockerfile
container_name: rag-api
ports:
- "8080:8080"
environment:
- OLLAMA_URL=http://ollama:11434
- CHROMADB_URL=http://chromadb:8000
- EMBEDDING_MODEL=nomic-embed-text
- LLM_MODEL=llama3.3:70b
depends_on:
- ollama
- chromadb
restart: unless-stopped
# Prometheus : monitoring
prometheus:
image: prom/prometheus:latest
container_name: rag-prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- "9090:9090"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
restart: unless-stopped
# Grafana : dashboards
grafana:
image: grafana/grafana:latest
container_name: rag-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
- GF_INSTALL_PLUGINS=grafana-piechart-panel
volumes:
- grafana_data:/var/lib/grafana
depends_on:
- prometheus
restart: unless-stopped
volumes:
ollama_models:
chromadb_data:
prometheus_data:
grafana_data:
Démarrage et Configuration
# 1. Cloner le projet (ou créer la structure)
mkdir rag-local && cd rag-local
# Copier le docker-compose.yml ci-dessus
# 2. Démarrer les services
docker-compose up -d
# 3. Attendre que Ollama soit prêt (~20s)
docker-compose logs -f ollama
# Attendre le message "Ollama is running"
# 4. Télécharger les modèles nécessaires
# LLM pour génération
docker exec -it rag-ollama ollama pull llama3.3:70b
# Modèle d'embeddings (optimisé RAG)
docker exec -it rag-ollama ollama pull nomic-embed-text
# 5. Vérifier que ChromaDB est prêt
curl http://localhost:8000/api/v1/heartbeat
# Output: {"nanosecond heartbeat": 1712140800000000000}
# 6. Vérifier l'utilisation GPU
watch -n 1 nvidia-smi
# GPU 0 devrait afficher "ollama" avec ~45GB VRAM utilisée (modèle 70B chargé)
# 7. Test rapide du LLM
curl http://localhost:11434/api/generate -d '{
"model": "llama3.3:70b",
"prompt": "Explique le RAG en 2 phrases simples.",
"stream": false
}'
# Output attendu en ~3-4s :
# {
# "model": "llama3.3:70b",
# "response": "Le RAG (Retrieval-Augmented Generation) récupère des documents pertinents depuis une base de connaissances avant de générer une réponse avec un LLM. Cela permet au modèle de répondre avec des informations à jour sans réentraînement."
# }
Pipeline d'Ingestion : De PDF à Vecteurs
L'ingestion transforme vos documents bruts (PDF, Markdown, DOCX) en vecteurs stockés dans ChromaDB. Ce pipeline s'exécute une fois au démarrage, puis à chaque mise à jour de la base de connaissances.
Code Complet : ingest.py
#!/usr/bin/env python3
"""
Pipeline d'ingestion de documents pour RAG local.
Lit des PDF/Markdown → Chunking → Embeddings → ChromaDB
Usage:
python ingest.py --docs-dir ./documents --collection knowledge_base
"""
import argparse
import os
from pathlib import Path
from typing import List, Dict
import time
# Chargement de documents
from langchain_community.document_loaders import (
PyPDFLoader,
UnstructuredMarkdownLoader,
TextLoader,
)
# Chunking
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Embeddings locaux
from sentence_transformers import SentenceTransformer
# ChromaDB
import chromadb
from chromadb.config import Settings
class LocalRAGIngestion:
def __init__(
self,
chromadb_host: str = "localhost",
chromadb_port: int = 8000,
embedding_model: str = "nomic-ai/nomic-embed-text-v1.5",
):
"""
Initialise le pipeline d'ingestion.
Args:
chromadb_host: Hôte ChromaDB
chromadb_port: Port ChromaDB
embedding_model: Modèle d'embeddings (Hugging Face)
"""
# Client ChromaDB
self.chroma_client = chromadb.HttpClient(
host=chromadb_host,
port=chromadb_port,
settings=Settings(anonymized_telemetry=False),
)
# Modèle d'embeddings (chargé en GPU si disponible)
print(f"Chargement du modèle d'embeddings: {embedding_model}")
self.embedding_model = SentenceTransformer(
embedding_model,
device="cuda", # ou "cpu" si pas de GPU
)
print(f" Dimensions: {self.embedding_model.get_sentence_embedding_dimension()}")
# Text splitter
self.text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", ". ", " ", ""],
length_function=len,
)
def load_documents(self, docs_dir: str) -> List[Dict]:
"""
Charge tous les documents depuis un répertoire.
Supporte: .pdf, .md, .txt
Returns:
Liste de documents avec metadata
"""
documents = []
docs_path = Path(docs_dir)
for file_path in docs_path.rglob("*"):
if not file_path.is_file():
continue
try:
if file_path.suffix == ".pdf":
loader = PyPDFLoader(str(file_path))
docs = loader.load()
elif file_path.suffix == ".md":
loader = UnstructuredMarkdownLoader(str(file_path))
docs = loader.load()
elif file_path.suffix == ".txt":
loader = TextLoader(str(file_path))
docs = loader.load()
else:
continue
# Ajouter métadonnées
for doc in docs:
doc.metadata["source"] = str(file_path)
doc.metadata["file_type"] = file_path.suffix
documents.extend(docs)
print(f"✓ Chargé: {file_path} ({len(docs)} pages/sections)")
except Exception as e:
print(f"✗ Erreur sur {file_path}: {e}")
return documents
def chunk_documents(self, documents: List) -> List[Dict]:
"""
Découpe les documents en chunks sémantiquement cohérents.
Returns:
Liste de chunks avec metadata
"""
all_chunks = []
for doc in documents:
chunks = self.text_splitter.split_text(doc.page_content)
for i, chunk_text in enumerate(chunks):
all_chunks.append({
"text": chunk_text,
"metadata": {
**doc.metadata,
"chunk_index": i,
"chunk_length": len(chunk_text),
}
})
return all_chunks
def embed_chunks(self, chunks: List[Dict]) -> List[List[float]]:
"""
Génère les embeddings pour tous les chunks.
Utilise batch processing pour optimiser le throughput GPU.
"""
texts = [chunk["text"] for chunk in chunks]
print(f"Génération de {len(texts)} embeddings...")
start_time = time.time()
# Batch encoding (optimal pour GPU)
embeddings = self.embedding_model.encode(
texts,
batch_size=32,
show_progress_bar=True,
normalize_embeddings=True,
)
elapsed = time.time() - start_time
print(f" ✓ Terminé en {elapsed:.1f}s ({len(texts)/elapsed:.0f} chunks/sec)")
return embeddings.tolist()
def ingest_to_chromadb(
self,
chunks: List[Dict],
embeddings: List[List[float]],
collection_name: str = "knowledge_base",
):
"""
Insère les chunks et embeddings dans ChromaDB.
Args:
chunks: Liste de chunks avec metadata
embeddings: Vecteurs d'embeddings
collection_name: Nom de la collection ChromaDB
"""
# Créer ou récupérer la collection
try:
collection = self.chroma_client.get_collection(collection_name)
print(f"Collection '{collection_name}' existe déjà, sera mise à jour")
except:
collection = self.chroma_client.create_collection(
name=collection_name,
metadata={"description": "RAG knowledge base"}
)
print(f"Collection '{collection_name}' créée")
# Préparer les données pour insertion
ids = [f"chunk_{i}" for i in range(len(chunks))]
documents = [chunk["text"] for chunk in chunks]
metadatas = [chunk["metadata"] for chunk in chunks]
# Insertion par batch (ChromaDB limite à 41666 items/batch)
batch_size = 5000
total_batches = (len(ids) + batch_size - 1) // batch_size
print(f"Insertion dans ChromaDB ({total_batches} batches)...")
for i in range(0, len(ids), batch_size):
batch_end = min(i + batch_size, len(ids))
collection.upsert(
ids=ids[i:batch_end],
embeddings=embeddings[i:batch_end],
documents=documents[i:batch_end],
metadatas=metadatas[i:batch_end],
)
print(f" Batch {i//batch_size + 1}/{total_batches} inséré")
print(f"✓ {len(ids)} chunks insérés dans '{collection_name}'")
def run(self, docs_dir: str, collection_name: str = "knowledge_base"):
"""
Exécute le pipeline complet.
"""
print("=" * 60)
print("PIPELINE D'INGESTION RAG LOCAL")
print("=" * 60)
# 1. Charger documents
print("\n[1/4] Chargement des documents...")
documents = self.load_documents(docs_dir)
print(f" ✓ {len(documents)} documents chargés")
if len(documents) == 0:
print(" ✗ Aucun document trouvé. Arrêt.")
return
# 2. Chunking
print("\n[2/4] Découpage en chunks...")
chunks = self.chunk_documents(documents)
print(f" ✓ {len(chunks)} chunks créés")
# 3. Embeddings
print("\n[3/4] Génération des embeddings...")
embeddings = self.embed_chunks(chunks)
# 4. Insertion ChromaDB
print("\n[4/4] Insertion dans ChromaDB...")
self.ingest_to_chromadb(chunks, embeddings, collection_name)
print("\n" + "=" * 60)
print("INGESTION TERMINÉE")
print("=" * 60)
print(f"Collection: {collection_name}")
print(f"Documents: {len(documents)}")
print(f"Chunks: {len(chunks)}")
print(f"Stockage ChromaDB: http://localhost:8000")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Ingestion de documents pour RAG local")
parser.add_argument(
"--docs-dir",
type=str,
required=True,
help="Répertoire contenant les documents (PDF, MD, TXT)"
)
parser.add_argument(
"--collection",
type=str,
default="knowledge_base",
help="Nom de la collection ChromaDB (défaut: knowledge_base)"
)
parser.add_argument(
"--chromadb-host",
type=str,
default="localhost",
help="Hôte ChromaDB (défaut: localhost)"
)
parser.add_argument(
"--chromadb-port",
type=int,
default=8000,
help="Port ChromaDB (défaut: 8000)"
)
args = parser.parse_args()
ingestion = LocalRAGIngestion(
chromadb_host=args.chromadb_host,
chromadb_port=args.chromadb_port,
)
ingestion.run(
docs_dir=args.docs_dir,
collection_name=args.collection,
)
Exécution du Pipeline
# 1. Installer les dépendances Python
pip install langchain langchain-community sentence-transformers \
chromadb unstructured pypdf
# 2. Préparer les documents
mkdir -p documents
# Copier vos PDF, Markdown, TXT dans ./documents/
# 3. Lancer l'ingestion
python ingest.py --docs-dir ./documents --collection knowledge_base
# Output attendu :
# ============================================================
# PIPELINE D'INGESTION RAG LOCAL
# ============================================================
#
# [1/4] Chargement des documents...
# ✓ Chargé: documents/product_guide.pdf (127 pages/sections)
# ✓ Chargé: documents/api_reference.md (1 pages/sections)
# ✓ Chargé: documents/faq.txt (1 pages/sections)
# ✓ 129 documents chargés
#
# [2/4] Découpage en chunks...
# ✓ 4,847 chunks créés
#
# [3/4] Génération des embeddings...
# Chargement du modèle d'embeddings: nomic-ai/nomic-embed-text-v1.5
# Dimensions: 768
# Génération de 4847 embeddings...
# 100%|██████████████████████████████████| 4847/4847 [00:09<00:00, 512.34it/s]
# ✓ Terminé en 9.5s (510 chunks/sec)
#
# [4/4] Insertion dans ChromaDB...
# Collection 'knowledge_base' créée
# Insertion dans ChromaDB (1 batches)...
# Batch 1/1 inséré
# ✓ 4847 chunks insérés dans 'knowledge_base'
#
# ============================================================
# INGESTION TERMINÉE
# ============================================================
# Collection: knowledge_base
# Documents: 129
# Chunks: 4847
# Stockage ChromaDB: http://localhost:8000
# 4. Vérifier dans ChromaDB
curl http://localhost:8000/api/v1/collections/knowledge_base | jq
# Output:
# {
# "name": "knowledge_base",
# "id": "...",
# "metadata": {"description": "RAG knowledge base"},
# "count": 4847
# }
API de Requête : FastAPI avec Recherche Sémantique
L'API expose un endpoint /query qui orchestre la recherche vectorielle (ChromaDB) et la génération (Ollama).
Code Complet : app/main.py
#!/usr/bin/env python3
"""
API RAG Locale avec FastAPI + ChromaDB + Ollama
Endpoints:
POST /query - Poser une question
GET /health - Health check
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Optional
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
import ollama
import time
app = FastAPI(title="Local RAG API")
# Configuration (à externaliser en env vars en production)
CHROMADB_URL = "http://chromadb:8000"
OLLAMA_URL = "http://ollama:11434"
EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1.5"
LLM_MODEL = "llama3.3:70b"
COLLECTION_NAME = "knowledge_base"
# Initialisation des clients (au démarrage)
chroma_client = chromadb.HttpClient(
host="chromadb",
port=8000,
settings=Settings(anonymized_telemetry=False),
)
embedding_model = SentenceTransformer(EMBEDDING_MODEL, device="cpu")
ollama_client = ollama.Client(host=OLLAMA_URL)
class QueryRequest(BaseModel):
question: str
top_k: int = 5
include_sources: bool = True
class QueryResponse(BaseModel):
answer: str
sources: Optional[List[Dict]] = None
latency_ms: Dict[str, float]
@app.post("/query", response_model=QueryResponse)
async def query_rag(request: QueryRequest):
"""
Endpoint principal RAG : recherche sémantique + génération.
Args:
question: Question de l'utilisateur
top_k: Nombre de chunks à récupérer (défaut: 5)
include_sources: Inclure les sources dans la réponse
Returns:
Réponse générée avec sources et métriques de latence
"""
timings = {}
start_total = time.time()
try:
# 1. Embed la question
start_embed = time.time()
question_embedding = embedding_model.encode(
[request.question],
normalize_embeddings=True,
)[0].tolist()
timings["embed_query"] = (time.time() - start_embed) * 1000
# 2. Recherche vectorielle dans ChromaDB
start_search = time.time()
collection = chroma_client.get_collection(COLLECTION_NAME)
results = collection.query(
query_embeddings=[question_embedding],
n_results=request.top_k,
include=["documents", "metadatas", "distances"],
)
timings["vector_search"] = (time.time() - start_search) * 1000
# 3. Construire le contexte pour le LLM
if len(results["documents"][0]) == 0:
raise HTTPException(
status_code=404,
detail="Aucun document pertinent trouvé dans la base de connaissances"
)
context_chunks = []
sources = []
for i, (doc, metadata, distance) in enumerate(zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
)):
context_chunks.append(f"[Document {i+1}]\n{doc}")
if request.include_sources:
sources.append({
"rank": i + 1,
"source": metadata.get("source", "unknown"),
"similarity": 1 - distance, # Convertir distance en similarité
"preview": doc[:200] + "..." if len(doc) > 200 else doc,
})
context = "\n\n".join(context_chunks)
# 4. Générer la réponse avec Ollama
start_llm = time.time()
prompt = f"""Tu es un assistant technique qui répond aux questions en te basant UNIQUEMENT sur les documents fournis.
Documents de référence :
{context}
Question de l'utilisateur : {request.question}
Instructions :
- Réponds de manière concise et précise
- Base-toi UNIQUEMENT sur les documents fournis
- Si l'information n'est pas dans les documents, dis "Je ne trouve pas cette information dans la base de connaissances"
- Cite les numéros de documents utilisés (ex: "Selon le Document 2...")
Réponse :"""
response = ollama_client.chat(
model=LLM_MODEL,
messages=[
{
"role": "user",
"content": prompt
}
],
options={
"temperature": 0.1, # Peu de créativité pour rester factuel
"num_ctx": 4096, # Context window
}
)
answer = response["message"]["content"]
timings["llm_generation"] = (time.time() - start_llm) * 1000
timings["total"] = (time.time() - start_total) * 1000
return QueryResponse(
answer=answer,
sources=sources if request.include_sources else None,
latency_ms=timings,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
async def health_check():
"""
Health check endpoint.
Vérifie que ChromaDB et Ollama sont accessibles.
"""
health = {
"status": "healthy",
"chromadb": "unknown",
"ollama": "unknown",
}
try:
chroma_client.heartbeat()
health["chromadb"] = "ok"
except:
health["chromadb"] = "error"
health["status"] = "degraded"
try:
ollama_client.list()
health["ollama"] = "ok"
except:
health["ollama"] = "error"
health["status"] = "degraded"
return health
@app.get("/")
async def root():
return {
"service": "Local RAG API",
"version": "1.0.0",
"endpoints": {
"query": "POST /query",
"health": "GET /health",
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)
Test de l'API
# 1. L'API devrait déjà tourner via Docker Compose
# Sinon, démarrer manuellement :
# cd app && uvicorn main:app --host 0.0.0.0 --port 8080
# 2. Health check
curl http://localhost:8080/health | jq
# Output:
# {
# "status": "healthy",
# "chromadb": "ok",
# "ollama": "ok"
# }
# 3. Poser une question
curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"question": "Comment configurer l'''authentification JWT ?",
"top_k": 5,
"include_sources": true
}' | jq
# Output (après 2-4s) :
# {
# "answer": "Selon le Document 1, pour configurer l'authentification JWT, vous devez d'abord installer la bibliothèque PyJWT (...suite de la réponse...)",
# "sources": [
# {
# "rank": 1,
# "source": "documents/api_reference.md",
# "similarity": 0.847,
# "preview": "## Authentification JWT\n\nNotre API utilise des JSON Web Tokens (JWT) pour l'authentification. Voici comment configurer..."
# },
# {
# "rank": 2,
# "source": "documents/security_guide.pdf",
# "similarity": 0.812,
# "preview": "Les tokens JWT doivent être stockés de manière sécurisée côté client..."
# }
# ],
# "latency_ms": {
# "embed_query": 42.3,
# "vector_search": 18.7,
# "llm_generation": 2847.5,
# "total": 2908.5
# }
# }
# 4. Questions complexes
curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"question": "Quelle est la différence entre les plans Pro et Enterprise en termes de limites de rate limiting et de SLA ?",
"top_k": 3
}' | jq '.answer'
# Le LLM va synthétiser les informations de plusieurs documents
# pour répondre de manière complète
Benchmarks : Performance RAG Local vs API Cloud
Comparaison sur un corpus de 500 documents (50,000 chunks), 1000 questions de test, GPU RTX 4090.
Latence (p50 / p95)
| Étape | RAG Local (Ollama + ChromaDB) | RAG Cloud (OpenAI + Pinecone) |
|---|
| Embedding query | 25ms / 45ms (GPU) 180ms / 320ms (CPU) | 120ms / 280ms (API + latence réseau) |
| Vector search | 18ms / 32ms | 65ms / 140ms (serverless + réseau) |
| LLM generation | 2.8s / 4.5s (Llama 3.3 70B) 0.9s / 1.6s (Llama 3.3 8B) | 2.1s / 3.8s (GPT-4 Turbo) |
| Total end-to-end | 2.85s / 4.6s (70B GPU) 1.1s / 1.8s (8B GPU) | 2.3s / 4.2s |
Observation : RAG local est 20-30% plus lent en p50 avec Llama 70B (principalement dû à la génération), mais équivalent ou plus rapide avec Llama 8B. En p95, performances similaires.
Qualité de Récupération et Génération
| Métrique | Local (nomic-embed + Llama 70B) | Cloud (text-emb-3-small + GPT-4) |
|---|
| Recall@5 (retrieval) | 89.3% | 91.7% |
| MRR (Mean Reciprocal Rank) | 0.81 | 0.84 |
| Exactitude réponse (human eval) | 87% | 91% |
| Hallucinations (% réponses inventées) | 8% | 5% |
Conclusion : GPT-4 reste légèrement supérieur en qualité absolue (~4% d'écart), mais Llama 3.3 70B est largement suffisant pour 85% des cas d'usage. Le trade-off coût/qualité penche massivement en faveur du local.
Coûts à l'Échelle
| Volume (requêtes/mois) | Local (Ollama + ChromaDB) | Cloud (OpenAI + Pinecone) | Économie |
|---|
| 10,000 | 109€ (serveur fixe) | 180€ | -39% |
| 50,000 | 109€ | 850€ | -87% |
| 200,000 | 180€ (upgrade GPU cloud) | 3,400€ | -95% |
| 1,000,000 | 450€ (2 serveurs GPU) | 17,000€ | -97% |
Point de bascule : à partir de 10,000 requêtes/mois, le local devient plus rentable. À 50,000 requêtes/mois, l'économie atteint 87% (741€/mois).
Cas Réel : Migration d'un Support Client RAG
Contexte : SaaS B2B (gestion de projet), chatbot de support client alimenté par une base de connaissances de 800 articles. 1200 utilisateurs actifs, ~80 questions/jour.
Infrastructure initiale (API cloud) :
- Embeddings : OpenAI text-embedding-3-small
- Vector DB : Pinecone Serverless (800k vecteurs)
- LLM : GPT-4 Turbo
- Coût mensuel : 920€ (650€ GPT-4, 190€ Pinecone, 80€ embeddings)
Migration vers local :
- Embeddings : nomic-embed-text (self-hosted)
- Vector DB : ChromaDB (Docker)
- LLM : Llama 3.3 70B via Ollama
- Infra : Hetzner AX102 (89€/mois) + backups S3 (15€/mois)
- Coût mensuel : 104€
Résultats après 3 mois :
| Métrique | Avant (Cloud) | Après (Local) | Évolution |
|---|
| Coût mensuel | 920€ | 104€ | -89% ✅ |
| Latence p50 | 2.4s | 2.9s | +21% ⚠️ |
| Taux de résolution | 84% | 82% | -2% ⚠️ |
| Satisfaction utilisateurs (CSAT) | 4.2/5 | 4.1/5 | -2% ≈ |
| Uptime | 99.8% | 99.9% | +0.1% ✅ |
| Conformité RGPD | Partielle (data aux US) | Totale (EU only) | ✅ |
Retour CTO :
"La migration vers Ollama + ChromaDB nous a fait économiser 2 448€ sur 3 mois, avec un ROI immédiat (temps de migration : 4 jours d'ingénieur). La légère baisse de qualité (-2% de taux de résolution) est imperceptible pour nos utilisateurs — confirmé par A/B test sur 2 semaines. Bonus inattendu : conformité RGPD simplifiée, toutes les données restent en EU. Nous gardons une instance GPT-4 en fallback pour <5% des questions ultra-complexes."
Optimisations Production
1. Cache Redis pour Requêtes Fréquentes
import redis
import hashlib
import json
# Client Redis
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def get_cached_response(question: str, ttl: int = 3600):
"""
Vérifie si la réponse à cette question est en cache.
Args:
question: Question de l'utilisateur
ttl: Time-to-live en secondes (défaut: 1h)
Returns:
Réponse en cache ou None
"""
# Hash de la question pour créer la clé cache
cache_key = f"rag:query:{hashlib.md5(question.encode()).hexdigest()}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
return None
def cache_response(question: str, response: dict, ttl: int = 3600):
"""
Met en cache une réponse.
"""
cache_key = f"rag:query:{hashlib.md5(question.encode()).hexdigest()}"
redis_client.setex(
cache_key,
ttl,
json.dumps(response)
)
# Utilisation dans l'endpoint /query
@app.post("/query")
async def query_rag(request: QueryRequest):
# Vérifier le cache d'abord
cached = get_cached_response(request.question)
if cached:
cached["cache_hit"] = True
return cached
# ... pipeline RAG normal ...
# Mettre en cache avant de retourner
cache_response(request.question, response)
response["cache_hit"] = False
return response
# Impact mesuré :
# - 30% des requêtes sont des duplicatas exacts → cache hit
# - Latence sur cache hit : 5-10ms vs 2-4s pour RAG complet
# - Économie coût LLM : ~200€/mois sur volume élevé
2. Load Balancing Multi-GPU
# docker-compose.production.yml avec 2 workers Ollama
services:
ollama-worker-1:
image: ollama/ollama:latest
environment:
- CUDA_VISIBLE_DEVICES=0 # GPU 0
volumes:
- ollama_models:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['0']
capabilities: [gpu]
ollama-worker-2:
image: ollama/ollama:latest
environment:
- CUDA_VISIBLE_DEVICES=1 # GPU 1
volumes:
- ollama_models:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['1']
capabilities: [gpu]
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "11434:80"
depends_on:
- ollama-worker-1
- ollama-worker-2
# nginx.conf : round-robin entre workers
upstream ollama_backend {
least_conn; # Envoyer vers le worker le moins chargé
server ollama-worker-1:11434 max_fails=2 fail_timeout=30s;
server ollama-worker-2:11434 max_fails=2 fail_timeout=30s;
}
server {
listen 80;
location / {
proxy_pass http://ollama_backend;
proxy_set_header Host $host;
proxy_read_timeout 120s;
proxy_buffering off; # Streaming support
}
}
# Avec 2× RTX 4090 :
# - Throughput : 20-25 requêtes/min (vs 10-12 avec 1 GPU)
# - Latence p95 réduite de 35% (moins de queueing)
3. Monitoring avec Prometheus + Grafana
# app/main.py - ajouter métriques Prometheus
from prometheus_client import Counter, Histogram, generate_latest
from fastapi import Response
# Métriques
query_counter = Counter('rag_queries_total', 'Total RAG queries')
query_latency = Histogram('rag_query_latency_seconds', 'Query latency')
cache_hits = Counter('rag_cache_hits_total', 'Cache hits')
retrieval_recall = Histogram('rag_retrieval_recall', 'Retrieval recall score')
@app.post("/query")
async def query_rag(request: QueryRequest):
query_counter.inc()
with query_latency.time():
# ... pipeline RAG ...
if cached:
cache_hits.inc()
# Tracker le recall si golden test
if is_golden_test(request.question):
recall = calculate_recall(results, expected_docs)
retrieval_recall.observe(recall)
return response
@app.get("/metrics")
async def metrics():
"""
Endpoint Prometheus pour scraping.
"""
return Response(generate_latest(), media_type="text/plain")
# Prometheus scrape cette URL toutes les 15s
# Grafana affiche les dashboards :
# - Queries/sec, latence p50/p95/p99
# - Cache hit rate
# - Recall moyen sur golden tests
Checklist Production : RAG Local
- ✅ GPU suffisant : minimum RTX 4090 24GB pour Llama 70B, ou 2× RTX 3090 en parallèle
- ✅ Backup automatique : snapshots quotidiens ChromaDB vers S3/Backblaze
- ✅ Monitoring actif : Prometheus + Grafana avec alertes sur latence > 5s et recall < 85%
- ✅ Golden test set : minimum 100 questions avec réponses attendues, évaluation hebdomadaire
- ✅ Cache Redis : pour réduire charge LLM sur requêtes fréquentes
- ✅ Rate limiting : 60 requêtes/min par IP, protection DDoS
- ✅ Gestion erreurs : retry logic sur Ollama (timeout 30s), fallback gracieux si ChromaDB down
- ✅ Logs structurés : JSON logs avec trace IDs, intégration avec ELK/Loki
- ✅ CI/CD : pipeline de réingestion automatique à chaque commit sur docs/
- ✅ Documentation : architecture diagram, runbook incidents, guide de migration
Ressources et Formation
Pour maîtriser RAG en production et optimiser votre infrastructure IA locale, notre formation Claude API pour Développeurs couvre les architectures RAG avancées (reranking, hybrid search, multi-modal), les stratégies de migration cloud→local, et les patterns de monitoring. Formation de 3 jours, finançable OPCO.
Nous proposons aussi un module spécialisé "RAG Production : De Prototype à Scale" (2 jours) avec hands-on sur Ollama, ChromaDB, et optimisations GPU. Contactez-nous via le formulaire de contact.
Questions Fréquentes
Pourquoi ChromaDB plutôt que Pinecone ou Qdrant pour un RAG local ?
ChromaDB est conçu pour être embarqué (embedded) dans votre application Python, sans serveur séparé nécessaire en développement. Pour production locale, il offre un mode client-serveur léger avec Docker. Contrairement à Pinecone (cloud uniquement), ChromaDB est 100% gratuit et open-source. Par rapport à Qdrant, ChromaDB a une API plus simple pour démarrer, mais Qdrant est plus performant à très grande échelle (>10M vecteurs).
Ollama + ChromaDB vs API OpenAI + Pinecone : quelle différence de coûts réelle ?
Pour 1M tokens/mois (500 utilisateurs actifs) : API OpenAI + Pinecone = ~800€/mois (600€ tokens GPT-4 + 150€ Pinecone + 50€ embeddings). Ollama + ChromaDB local = ~109€/mois (serveur GPU Hetzner 89€ + 20€ backup). Économie : 86%. Pour 10M tokens/mois : 8000€/mois vs 180€/mois (GPU cloud L4). ROI immédiat dès 100k tokens/jour.
Quel modèle d'embedding utiliser avec Ollama en local ?
Pour embeddings locaux : nomic-embed-text (768 dimensions, optimisé RAG, fonctionne sur CPU). Pour meilleure qualité : BAAI/bge-large-en-v1.5 (1024 dimensions, nécessite GPU pour bonne latence). Pour multilingue : intfloat/multilingual-e5-large. Tous sont gratuits et tournent via sentence-transformers. Performance : nomic-embed-text atteint ~90% de la qualité de text-embedding-3-small d'OpenAI pour 0€.
ChromaDB peut-il gérer combien de documents en production ?
ChromaDB gère confortablement jusqu'à 1M de vecteurs sur un serveur avec 8GB RAM. Pour 1-10M vecteurs : 16GB RAM recommandé. Au-delà de 10M : considérez Qdrant ou Weaviate pour meilleures performances. Pour référence : 500 documents PDF (200 pages chacun) = ~500k chunks après découpage = ~2GB de stockage vectoriel ChromaDB.
Quelle latence attendre d'un RAG 100% local vs API cloud ?
RAG local (Ollama + ChromaDB, GPU RTX 4090) : recherche vectorielle 15-30ms, génération LLM 2-5s (Llama 3.3 70B), total 2-5.5s. RAG cloud (OpenAI + Pinecone) : recherche 50-80ms (latence réseau incluse), génération 1.5-3s (GPT-4 Turbo), total 1.6-3.5s. Trade-off : local = 30-40% plus lent mais coût 95% moindre et données 100% privées. Pour latence critique : utilisez Llama 3.3 8B (génération <1s).