Talki Academy
Technique28 min de lecture

RAG Local avec Ollama et ChromaDB : €0/mois vs €800/mois de Coûts API

Tutoriel complet pour construire un système RAG (Retrieval-Augmented Generation) production avec Ollama pour l'inférence LLM locale et ChromaDB pour le stockage vectoriel. Installation Docker Compose, pipeline d'ingestion de documents, recherche sémantique, exemples Python complets, benchmarks de performance vs API cloud (OpenAI, Pinecone). Réduisez vos coûts IA de 95% tout en gardant 100% de contrôle sur vos données.

Par Talki Academy·Mis à jour le 3 avril 2026

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

ComposantSolution CloudCoût/moisSolution LocaleCoût/mois
EmbeddingsOpenAI text-embedding-3-small
(1.5M tokens/mois)
30€nomic-embed-text (local)0€
Base vectoriellePinecone Serverless
(500k vecteurs)
150€ChromaDB (Docker)0€
LLM InférenceGPT-4 Turbo
(50k questions × 1k tokens avg)
600€Llama 3.3 70B (Ollama)0€
InfrastructureApplication hosting50€Hetzner GPU AX102
(2× RTX 4090, 128GB RAM)
89€
Backup / MonitoringLogs, metrics20€S3 backups, Prometheus20€
TOTAL850€/mois109€/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)

ÉtapeRAG Local (Ollama + ChromaDB)RAG Cloud (OpenAI + Pinecone)
Embedding query25ms / 45ms (GPU)
180ms / 320ms (CPU)
120ms / 280ms (API + latence réseau)
Vector search18ms / 32ms65ms / 140ms (serverless + réseau)
LLM generation2.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-end2.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étriqueLocal (nomic-embed + Llama 70B)Cloud (text-emb-3-small + GPT-4)
Recall@5 (retrieval)89.3%91.7%
MRR (Mean Reciprocal Rank)0.810.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,000109€ (serveur fixe)180€-39%
50,000109€850€-87%
200,000180€ (upgrade GPU cloud)3,400€-95%
1,000,000450€ (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étriqueAvant (Cloud)Après (Local)Évolution
Coût mensuel920€104€-89% ✅
Latence p502.4s2.9s+21% ⚠️
Taux de résolution84%82%-2% ⚠️
Satisfaction utilisateurs (CSAT)4.2/54.1/5-2% ≈
Uptime99.8%99.9%+0.1% ✅
Conformité RGPDPartielle (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).

Formez Votre Équipe à l'IA

Nos formations sont éligibles OPCO — reste à charge potentiel : 0€.

Voir les FormationsVérifier Éligibilité OPCO