Retour aux articles

Système RAG complet : architecture avec FastAPI, HuggingFace, Ollama et Docker

Sommaire
  1. Introduction
  2. Concepts clés
  3. Implémentation technique
  4. Bonnes pratiques
  5. Sécurité
  6. Cas d'usage réel
  7. Conclusion

Code source du projet : Tous les fichiers (backend, frontend, docker-compose, configuration) sont disponibles sur GitLab : poc_rag - BLOG_V1

1. Introduction

Le Retrieval-Augmented Generation (RAG) est devenu incontournable pour interroger des corpus de documents spécialisés avec de l'IA. Plutôt que de ré-entraîner un modèle de langage (coûteux et chronophage), le RAG combine recherche vectorielle et génération de texte pour obtenir des réponses pertinentes basées sur vos propres documents.

Dans cet article, je partage la construction complète d'un système RAG en production, démontré avec 193 PDFs (5 961 pages) de documentation variée. Cette architecture est applicable à tout domaine spécialisé : juridique, médical, technique, commercial, RH, finance, etc. L'architecture repose sur :

  • FastAPI pour l'API REST
  • HuggingFace embeddings (all-MiniLM-L6-v2) pour la vectorisation
  • Chroma pour la base vectorielle
  • Ollama (llama3.1:8b) pour la génération de réponses
  • Docker Compose pour l'orchestration
~200 PDFs traités
~6 000 Pages analysées
~40 000 Chunks générés
384 Dimensions (embeddings)

Ce POC illustre les défis réels d'une architecture RAG : isolation réseau Docker, limites de contexte des embeddings, optimisation des performances CPU, et gestion des volumes persistants.

2. Concepts clés

Qu'est-ce que le RAG ?

Architecture RAG - Retrieval-Augmented Generation
Architecture d'un système Retrieval-Augmented Generation (RAG)

Le Retrieval-Augmented Generation combine deux étapes :

  1. Retrieval (récupération) : recherche de documents pertinents dans une base vectorielle via similarité sémantique
  2. Generation (génération) : un LLM utilise ces documents comme contexte pour générer une réponse précise
Question utilisateur
↓
Embedding de la question (HuggingFace)
↓
Recherche de similarité (Chroma DB)
↓
Top K documents pertinents (K=3-5)
↓
Contexte enrichi + Prompt template
↓
LLM (Ollama llama3.1:8b)
↓
Réponse générée avec sources citées

Pourquoi le RAG pour les domaines spécialisés ?

Les corpus spécialisés évoluent constamment : legislation, standards techniques, documentation interne, recherche médicale, etc. Un modèle de langage classique (même GPT-4) ne connaît pas ces documents spécifiques, n'est pas à jour, ou ne peut pas accéder à vos documents confidentiels.

Le RAG permet de :

  • Indexer vos propres documents (privés et confidentiels)
  • Garantir la traçabilité des réponses avec sources citées
  • Mettre à jour le corpus sans ré-entraînement coûteux
  • Fonctionner 100% en local avec des LLM open-source (Ollama)
  • Adapter la stratégie de vectorisation à votre domaine

Embeddings : transformer du texte en vecteurs

Les embeddings sont des représentations vectorielles du texte. Deux textes similaires auront des vecteurs proches dans l'espace vectoriel, permettant une recherche de similarité sémantique rapide.

Exemple d'embeddings vectorisés :

  • "Quelle est la procédure de création SARL?" → Vector de 384 dimensions [0.23, -0.45, 0.12, ...]
  • "SARL : 2 associés minimum, capital 1€" → Vector proche du précédent
  • "Comment configurer Docker?" → Vector très différent (domaine différent)

Dans ce projet, j'utilise all-MiniLM-L6-v2 (HuggingFace) :

  • 384 dimensions (léger et rapide)
  • Support multilingue (français inclus)
  • Fonctionne sur CPU (pas besoin de GPU pour l'embedding)
  • ~1 token ≈ 4 caractères (512 tokens max ≈ 2 048 caractères)

3. Implémentation technique

Architecture du système

Architecture complète
┌─────────────────────────────────────────────┐
│          POC RAG - Architecture             │
├─────────────────────────────────────────────┤
│                                             │
│  Frontend (nginx:80)                        │
│  ┌───────────────────────────────┐          │
│  │ Interface chat HTML/CSS/JS    │          │
│  └───────────────────────────────┘          │
│                ↓                            │
│  Backend (FastAPI:8000)                     │
│  ┌───────────────────────────────┐          │
│  │ • /api/query (POST)           │          │
│  │ • /health (GET)               │          │
│  │ • /api/refresh (POST)         │          │
│  └───────────────────────────────┘          │
│                ↓                            │
│  RAG System (rag_system.py)                 │
│  ┌───────────────────────────────┐          │
│  │ PyPDFLoader → PDFs            │          │
│  │ CharacterTextSplitter → Chunks│          │
│  │ HuggingFaceEmbeddings         │          │
│  │ Chroma Vector DB              │          │
│  │ RetrievalQA Chain             │          │
│  └───────────────────────────────┘          │
│                ↓                            │
│  Volumes persistants                        │
│  • data/docs/ (193 PDFs)                    │
│  • data/chroma_db/ (base vectorielle)       │
│  • data/huggingface_cache/ (modèle)         │
│                                             │
└─────────────────────────────────────────────┘
                 ↓
    ┌──────────────────────────┐
    │ Ollama LLM (distant)     │
    │ localhost:11434          │
    │ • llama3.1:8b            │
    └──────────────────────────┘

Configuration Docker Compose

Le système est orchestré via Docker Compose avec des volumes persistants pour les données.

docker-compose.yml
version: '3.8'

services:
  backend:
    build: ./backend
    container_name: rag_backend
    ports:
      - "8000:8000"
    environment:
      - OLLAMA_HOST=http://localhost:11434
      - OLLAMA_MODEL=llama3.1:8b
      - DOCS_FOLDER=/app/data/docs
      - DB_FOLDER=/app/data/chroma_db
      - HF_HOME=/root/.cache/huggingface
    volumes:
      - ./data/docs:/app/data/docs
      - ./data/chroma_db:/app/data/chroma_db
      - ./data/huggingface_cache:/root/.cache/huggingface
    command: python -u -m uvicorn main:app --host 0.0.0.0 --port 8000 --log-level debug
    networks:
      - rag_network

  frontend:
    build: ./frontend
    container_name: rag_frontend
    ports:
      - "8080:80"
    depends_on:
      - backend
    networks:
      - rag_network

networks:
  rag_network:
    driver: bridge

Système RAG (rag_system.py)

Le cœur du système gère le chargement des PDFs, le chunking, les embeddings et les requêtes.

Python - rag_system.py
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain.vectorstores import Chroma
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.llms import Ollama
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

class MyRag:
    def __init__(self, docs_folder, db_folder, ollama_host, ollama_model,
                 chunk_size=800, chunk_overlap=150,
                 hf_model_name="all-MiniLM-L6-v2",
                 hf_home="/root/.cache/huggingface",
                 hf_device="cpu",
                 llm_temperature=0.7, llm_num_predict=512,
                 top_k_results=3):
        self.docs_folder = docs_folder
        self.db_folder = db_folder
        self.ollama_host = ollama_host
        self.ollama_model = ollama_model
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.hf_model_name = hf_model_name
        self.hf_home = hf_home
        self.hf_device = hf_device
        self.llm_temperature = llm_temperature
        self.llm_num_predict = llm_num_predict
        self.top_k_results = top_k_results
        self.db = None
        self.qa_chain = None
        self.ollama_available = False
        self.doc_count = 0

    def initialize(self):
        """Initialiser le RAG"""
        # Charger ou créer la base vectorielle
        if self._db_exists_and_has_data():
            self._load_vector_db()
        else:
            documents = self._load_documents()
            self._create_vector_db(documents)

        # Configurer Ollama
        self._setup_ollama()

    def _load_documents(self):
        """Charger les PDFs"""
        documents = []
        pdf_files = list(Path(self.docs_folder).glob("*.pdf"))

        for pdf_file in pdf_files:
            loader = PyPDFLoader(str(pdf_file))
            docs = loader.load()
            documents.extend(docs)

        return documents

    def _create_vector_db(self, documents):
        """Créer la base vectorielle"""
        # Découpage en chunks
        text_splitter = CharacterTextSplitter(
            chunk_size=800,
            chunk_overlap=150,
            separator="\n"
        )
        chunks = text_splitter.split_documents(documents)

        # Embeddings HuggingFace
        embeddings = HuggingFaceEmbeddings(
            model_name="all-MiniLM-L6-v2",
            cache_folder="/app/data/huggingface_cache",
            model_kwargs={"device": "cpu"}
        )

        # Créer Chroma DB
        self.db = Chroma.from_documents(
            chunks,
            embeddings,
            persist_directory=self.db_folder
        )
        self.db.persist()

    def _setup_ollama(self):
        """Configurer le LLM Ollama"""
        llm = Ollama(
            base_url=self.ollama_host,
            model=self.ollama_model,
            temperature=0.7,
            num_predict=512
        )

        # Prompt générique (adaptable à tout domaine)
        prompt_template = f"""Tu es un assistant expert en {self.domain}.
Utilise les documents fournis pour répondre de manière précise et structurée.

Contexte des documents:
{{context}}

Question: {{question}}

Réponds de manière claire et bien structurée. Cite toujours les sources.
Si l'information n'est pas dans les documents, dis-le explicitement."""

        PROMPT = PromptTemplate(
            template=prompt_template,
            input_variables=["context", "question"]
        )

        # Chaîne RAG
        self.qa_chain = RetrievalQA.from_chain_type(
            llm=llm,
            chain_type="stuff",
            retriever=self.db.as_retriever(search_kwargs={"k": 3}),
            return_source_documents=True,
            chain_type_kwargs={"prompt": PROMPT}
        )

    def query(self, question, k=3):
        """Interroger le système"""
        # Recherche de similarité
        results = self.db.similarity_search(question, k=k)

        # Génération avec LLM
        result = self.qa_chain({"query": question})

        return {
            "answer": result["result"],
            "sources": [
                {
                    "file": Path(doc.metadata["source"]).name,
                    "content": doc.page_content[:300]
                }
                for doc in results
            ]
        }

API FastAPI (main.py)

Python - main.py
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from rag_system import MyRag
from config import config
import os

app = FastAPI(
    title="Système RAG - Retrieval-Augmented Generation",
    description="API RAG pour interroger des documents spécialisés",
    version="1.0.0"
)

# Configuration CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # À restreindre en production
    allow_methods=["*"],
    allow_headers=["*"]
)

# Instance globale du RAG
rag_system = None

@app.on_event("startup")
async def startup_event():
    """Initialisation au démarrage"""
    global rag_system
    print("[INFO] Starting RAG system initialization...")
    try:
        rag_system = MyRag(
            docs_folder=config.DOCS_FOLDER,
            db_folder=config.DB_FOLDER,
            ollama_host=config.OLLAMA_HOST,
            ollama_model=config.OLLAMA_MODEL,
            chunk_size=config.CHUNK_SIZE,
            chunk_overlap=config.CHUNK_OVERLAP,
            hf_model_name=config.HF_MODEL_NAME,
            hf_home=config.HF_HOME,
            hf_device=config.HF_DEVICE,
            llm_temperature=config.LLM_TEMPERATURE,
            llm_num_predict=config.LLM_NUM_PREDICT,
            top_k_results=config.TOP_K_RESULTS
        )
        rag_system.initialize()
        print("[OK] RAG system initialized successfully")
    except Exception as e:
        print(f"[ERROR] Failed to initialize RAG system: {e}")
        raise

class QueryRequest(BaseModel):
    question: str
    k: int = 3

@app.post("/api/query")
async def query(request: QueryRequest):
    """Interroger les documents"""
    global rag_system
    if rag_system is None or rag_system.db is None:
        raise HTTPException(status_code=503, detail="RAG non initialisé")

    try:
        result = rag_system.query(request.question, k=request.k)
        return {
            "question": request.question,
            "answer": result["answer"],
            "sources": result["sources"],
            "model": config.OLLAMA_MODEL
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/health")
async def health_check():
    """Vérifier l'état du système"""
    global rag_system
    if rag_system is None:
        return {
            "status": "initializing",
            "rag_initialized": False,
            "docs_count": 0,
            "ollama_connected": False
        }

    return {
        "status": "healthy",
        "rag_initialized": rag_system.db is not None,
        "docs_count": rag_system.doc_count,
        "ollama_connected": rag_system.ollama_available
    }

@app.post("/api/refresh")
async def refresh_documents(background_tasks: BackgroundTasks):
    """Rafraîchir la base vectorielle"""
    global rag_system
    if rag_system is None:
        raise HTTPException(status_code=503, detail="RAG non disponible")

    background_tasks.add_task(rag_system.refresh)
    return {"message": "Refresh en cours..."}

4. Bonnes pratiques

Chunking optimal

Le découpage des documents est crucial pour la qualité des résultats.

Stratégie de chunking
text_splitter = CharacterTextSplitter(
    chunk_size=800,      # Taille du chunk
    chunk_overlap=150,   # Overlap entre chunks (évite perte de contexte)
    separator="\n"       # Découpe sur les retours à la ligne
)

Pourquoi 800 caractères ?

  • Compatible avec all-MiniLM-L6-v2 (limite ~512 tokens)
  • Assez grand pour conserver le contexte sémantique
  • Assez petit pour des résultats précis

L'overlap de 150 permet de ne pas couper un concept en deux (ex: un article de loi).

Persistance des données

Les volumes Docker garantissent la persistance des données entre redémarrages :

Volumes persistants
volumes:
  - ./data/docs:/app/data/docs                          # PDFs sources
  - ./data/chroma_db:/app/data/chroma_db                # Base vectorielle
  - ./data/huggingface_cache:/root/.cache/huggingface  # Modèle embeddings

Après la première initialisation (15-20 minutes), les redémarrages sont instantanés car la base vectorielle est déjà construite.

Gestion des erreurs et fallbacks

Fallback si Ollama indisponible
if self.qa_chain and self.ollama_available:
    try:
        result = self.qa_chain({"query": question})
        answer = result.get("result", "")
    except Exception as e:
        answer = f"Erreur génération : {e}"
else:
    # Fallback : retourner les chunks pertinents
    answer = "Extraits pertinents :\n\n"
    answer += "\n\n---\n\n".join([doc.page_content[:500] for doc in results])

Si Ollama est inaccessible, le système retourne quand même les chunks pertinents trouvés par la recherche vectorielle.

5. Sécurité

Isolation réseau et données privées

Principe : Vos documents restent sur votre infrastructure. Aucune donnée n'est envoyée à OpenAI ou des services cloud.

Le système fonctionne 100% en local :

  • Embeddings : all-MiniLM-L6-v2 téléchargé une fois, puis en cache
  • Base vectorielle : Chroma DB locale
  • LLM : Ollama sur votre réseau local (ou même machine)

CORS et sécurité API

Attention : La configuration actuelle autorise allow_origins=["*"] pour le développement. En production, limitez aux origines autorisées.

CORS sécurisé (production)
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://votre-domaine.com",
        "https://app.votre-domaine.com"
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["*"]
)

Variables d'environnement sensibles

Ne jamais hardcoder les configurations dans le code :

.env (exemple)
OLLAMA_HOST=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
DOCS_FOLDER=/app/data/docs
DB_FOLDER=/app/data/chroma_db
HF_HOME=/root/.cache/huggingface
HF_MODEL_NAME=all-MiniLM-L6-v2
HF_DEVICE=cpu

Rate limiting et validation des entrées

Pour une API en production, ajoutez du rate limiting :

Rate limiting avec slowapi
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

@app.post("/api/query")
@limiter.limit("10/minute")  # Max 10 requêtes/minute
async def query(request: Request, query_request: QueryRequest):
    # ...

6. Cas d'usage réel

Voici l'interface en action avec un corpus réel de documents juridiques :

Capture d'écran du frontend RAG - Système RAG Législatif
Interface RAG interrogeant 41 188 documents chargés de droit français

Requêtes testées - Exemples variés

Voici des exemples de requêtes réelles dans différents domaines :

Exemple 1 : Domaine juridique
Question : "Quelles sont les obligations fiscales d'un auto-entrepreneur?"

Réponse :
Selon la documentation, les obligations fiscales incluent :

1. Déclaration du chiffre d'affaires mensuelle ou trimestrielle
2. Versement libératoire de l'impôt sur le revenu (optionnel)
3. CFE (Cotisation Foncière des Entreprises) dès la 2ème année
4. TVA non applicable sous certains seuils (franchise en base)
5. Tenue d'un livre de recettes

Sources : auto-entrepreneur-guide.pdf (pages 12-15)
Exemple 2 : Domaine médical/santé
Question : "Quels sont les protocoles de traitement pour une infection bactérienne?"

Réponse :
Selon les protocoles médicaux standards :

1. Diagnostic clinique et confirmation par culture
2. Sélection d'antibiotique approprié (selon antibiogramme)
3. Dosage optimal selon le site d'infection
4. Durée de traitement : généralement 7-14 jours
5. Suivi et test de sensibilité bactérienne

Sources : medical-protocols-2025.pdf (pages 34-41)
Exemple 3 : Domaine technique/IT
Question : "Comment configurer un cluster Kubernetes haute disponibilité?"

Réponse :
Les étapes principales pour configurer un cluster HA :

1. Déployer au moins 3 nœuds master (etcd + API server)
2. Configurer load balancer devant les master nodes
3. Mettre en place persistance etcd avec sauvegarde
4. Implémenter health checks et auto-recovery
5. Monitorer avec Prometheus + Grafana

Sources : kubernetes-production-guide.pdf (pages 56-72)

Performances observées

15-20 min Génération embeddings (CPU)
2-3 sec Temps de réponse API
~500 MB Taille Chroma DB
< 1 sec Redémarrage (DB existante)

7. Conclusion

Ce POC démontre qu'il est possible de créer un système RAG complet et fonctionnel pour des documents spécialisés (ici, le droit français) en combinant des outils open-source :

  • FastAPI pour une API REST performante
  • HuggingFace embeddings pour la vectorisation (sans GPU)
  • Chroma pour une base vectorielle légère et persistante
  • Ollama pour un LLM local et privé
  • Docker Compose pour un déploiement reproductible

Résultats clés

~193 PDFs indexés
~41k chunks vectorisés
Réponses multilingues
100% privé (local)

Évolutions possibles

  • GPU pour embeddings : Réduire le temps de génération (15-20 min → 2-3 min)
  • Hybrid search : Combiner recherche vectorielle + recherche par mots-clés (BM25)
  • Filtrage par métadonnées : Date, source, type de document
  • Historique des requêtes : PostgreSQL pour tracking et analytics
  • Interface avancée : React + streaming des réponses (SSE)
  • Multi-tenancy : Bases vectorielles séparées par client

Prochaine étape : Je prévois d'ajouter un système de feedback utilisateur pour améliorer le retrieval (re-ranking des résultats basé sur les retours).

Ressources

Note : Ce guide est entièrement générique et applicable à tout domaine spécialisé : juridique, médical, technique, commercial, financier, académique, etc. L'architecture et le code fournissent une base solide que vous pouvez adapter à votre corpus spécifique.

Gestion Incrémentale des Documents

Une des forces du système implémenté est sa capacité à gérer l'ajout de nouveaux PDFs de manière incrémentale, sans recalculer les embeddings des documents déjà indexés.

Procédure pour ajouter des PDFs

Approche simple : Copier les PDFs dans le dossier `/data/docs/` et redémarrer le conteneur. Le système détecte automatiquement les nouveaux fichiers au démarrage et les indexe.

Ajouter des PDFs - Procédure
// Étape 1 : Copier les PDFs
cp /chemin/vers/mes/documents.pdf /data/docs/

// Étape 2 : Redémarrer le conteneur
docker-compose restart backend

// Étape 3 : Vérifier l'état
curl http://localhost:8000/health

Optimisation : Indexation Incrémentale

Le système utilise une détection par hash MD5 pour identifier les nouveaux documents :

  • Au démarrage : Scan automatique des PDFs du disque, comparaison avec les fichiers indexés dans Chroma, ajout UNIQUEMENT des nouveaux
  • À la demande : Endpoint /api/refresh pour synchroniser sans redémarrage
  • Détection de modifications : Si un PDF est modifié (contenu change), le hash diffère et le fichier est ré-indexé

Performances

Comparaison avec un refresh complet (avant optimisation) :

~25min Indexation initiale (193 PDFs)
~7min Ancien refresh (recalcul complet)
~2min Nouveau sync (10 PDFs seulement)

Avec l'indexation incrémentale, l'ajout de 10 nouveaux PDFs à un corpus de 200 ne recalcule que les embeddings des 10 nouveaux, gagnant ainsi ~5 minutes de calcul.

API Endpoint

Pour déclencher la synchronisation sans redémarrage :

Endpoint Refresh
POST /api/refresh

# Réponse
{
  "message": "Refresh en cours..."
}

# Vérifier l'état
GET /health
{
  "status": "healthy",
  "rag_initialized": true,
  "docs_count": 220,
  "ollama_connected": true
}

L'interface dispose également d'un bouton "Actualiser" pour déclencher le refresh directement depuis le frontend, sans passer par des appels cURL.

Astuce : Pour un corpus volumineux, regroupez les ajouts de PDFs et lancez un seul refresh plutôt que plusieurs petits redémarrages. Cela amortit le coût de calcul.

Vous avez des questions ou un sujet technique à explorer ?

Ce guide couvre les fondations, mais chaque projet RAG est unique. Que ce soit pour optimiser les embeddings, améliorer la latence, ou adapter l'architecture à votre cas spécifique, je suis ouvert à discuter de vos défis techniques.

Partagez votre question ou sujet via le formulaire de contact et je répondrai avec des insights adaptés à votre contexte.

P.S. - Debug & Résolution des problèmes courants

Problème : Isolation réseau Docker (HuggingFace inaccessible)

Si votre conteneur ne peut pas télécharger le modèle HuggingFace :

Solution : Pré-téléchargement et montage du cache
Étape 1 : Télécharger sur la machine hôte
python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('sentence-transformers/all-MiniLM-L6-v2')"

Étape 2 : Monter le cache dans docker-compose.yml
volumes:
  - ~/.cache/huggingface:/root/.cache/huggingface

Étape 3 : Configuration HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(
    model_name="all-MiniLM-L6-v2",
    cache_folder="/app/data/huggingface_cache",
    model_kwargs={"device": "cpu"}
)

Autres problèmes fréquents

  • Limite de contexte : OllamaEmbeddings limité à 512 tokens. Solution : passer à HuggingFace (flexible)
  • Performance CPU lente : Chunking de 800 caractères optimal. Utilisez GPU si disponible
  • Chroma persistence : Créer les dossiers /data/chroma_db/ avant le démarrage
Thomas Bourimech

Thomas Bourimech

Développeur & Intégrateur IA freelance

thomasbourimech.com

↑