Talki Academy
Tutoriel14 min de lecture

Construire un Assistant Juridique RAG avec LangChain, Ollama et ChromaDB

Guide pratique pas à pas pour construire un système RAG d'analyse de contrats juridiques en utilisant uniquement des outils open-source. Code Python complet, configuration Docker Compose et évaluation RAGAS inclus. Zéro coût API — 100% sur votre propre infrastructure.

Par Talki Academy·Publié le 5 mai 2026·English version →

Un cabinet d'avocats de taille intermédiaire gère 4 000 contrats — accords fournisseurs, NDA, SLA et baux commerciaux. Quand un client demande "notre contrat Azure plafonne-t-il la responsabilité à 2× les frais annuels ?", la réponse est enfouie dans la clause 14.3 d'un PDF de 90 pages. L'analyste passe 45 minutes à chercher. Avec un système RAG local, la même requête retourne une réponse citée en moins de 3 secondes — à coût marginal zéro, sans que les données contractuelles ne quittent les serveurs du cabinet.

Ce tutoriel construit exactement ce système : un assistant d'analyse contractuelle alimenté par LangChain, Ollama (inférence LLM locale) et ChromaDB (base vectorielle open-source). Tous les composants sont gratuits, auto-hébergés et conformes au RGPD.

Architecture : Trois Composants, Zéro Dépendance Propriétaire

Le système suit le schéma RAG classique en deux phases. L'indexation offline ingère les documents une fois (puis à chaque mise à jour). La récupération online répond aux requêtes en temps réel.

┌──────────────────────────────────────────────────┐ │ SYSTÈME RAG CONTRATS JURIDIQUES │ ├─────────────┬──────────────┬────────────────────┤ │ Ollama │ ChromaDB │ LangChain │ │ (LLM local) │ (base vect.) │ (orchestration) │ │ llama3.2:8b │ mode docker │ retriever + chain │ │ nomic-embed │ ~2 Go / 4k │ template de prompt │ └─────────────┴──────────────┴────────────────────┘ INDEXATION (une fois / à chaque mise à jour) : PDF → chunks → embeddings → ChromaDB REQUÊTE (~2–4 s par appel) : Question → embed → récupérer → LLM → réponse

Prérequis et Configuration de l'Environnement

  • Python 3.11+ et pip
  • Docker Desktop (mode serveur ChromaDB) — ou 8 Go de RAM pour le mode embarqué
  • Ollama installé depuis ollama.com — fonctionne sur macOS, Linux, Windows
  • 16 Go de RAM recommandés pour llama3.2:8b ; utilisez llama3.2:3b avec 8 Go
  • ~10 Go d'espace disque pour les modèles
# Installer les dépendances Python pip install langchain langchain-community langchain-chroma \ chromadb ollama pypdf ragas datasets python-dotenv
# .env.example — copiez vers .env et adaptez OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_LLM_MODEL=llama3.2:8b OLLAMA_EMBED_MODEL=nomic-embed-text CHROMA_HOST=localhost CHROMA_PORT=8000 CHROMA_COLLECTION=contrats_juridiques CHUNK_SIZE=1000 CHUNK_OVERLAP=200 RETRIEVER_K=5
# docker-compose.yml — serveur ChromaDB version: "3.9" services: chromadb: image: chromadb/chroma:0.6.3 ports: - "8000:8000" volumes: - ./chroma_data:/chroma/chroma environment: - CHROMA_SERVER_AUTH_PROVIDER=none - ANONYMIZED_TELEMETRY=false restart: unless-stopped
Mode embarqué vs mode serveur : pour un développement en solo, ignorez Docker — ChromaDB fonctionne embarqué dans votre processus Python. Remplacez chromadb.HttpClient(...) par chromadb.PersistentClient(path="./chroma_data"). Passez en mode serveur quand plusieurs processus doivent accéder à la même collection (ex. : serveur API + job d'ingestion en arrière-plan).

Étape 1 : Télécharger les Modèles Ollama

Deux modèles sont nécessaires : un modèle d'embedding pour vectoriser documents et requêtes, et un LLM de chat pour générer les réponses. Les deux tournent en local après un téléchargement unique.

# Télécharger les deux modèles — mis en cache après le premier download ollama pull nomic-embed-text # 274 Mo, modèle d'embedding ollama pull llama3.2:8b # 4,7 Go (utilisez :3b si RAM < 12 Go) # Vérifier ollama list # Test rapide ollama run llama3.2:8b "Qu'est-ce qu'une clause de force majeure ? En une phrase." # → Une clause de force majeure exonère une partie de ses obligations contractuelles # en cas d'événements extraordinaires indépendants de sa volonté.

Étape 2 : Pipeline d'Ingestion de Documents

Le script d'ingestion charge les PDF contractuels, les découpe en chunks chevauchants, génère les embeddings et les stocke dans ChromaDB. Les IDs de document sont dérivés du chemin du fichier + l'index du chunk — relancer le script sur le même fichier ne crée jamais de doublons.

# ingest.py import os, hashlib from pathlib import Path from dotenv import load_dotenv from langchain_community.document_loaders import PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import OllamaEmbeddings from langchain_chroma import Chroma import chromadb load_dotenv() embeddings = OllamaEmbeddings( model=os.getenv("OLLAMA_EMBED_MODEL", "nomic-embed-text"), base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"), ) chroma_client = chromadb.HttpClient( host=os.getenv("CHROMA_HOST", "localhost"), port=int(os.getenv("CHROMA_PORT", "8000")), ) vector_store = Chroma( client=chroma_client, collection_name=os.getenv("CHROMA_COLLECTION", "contrats_juridiques"), embedding_function=embeddings, ) splitter = RecursiveCharacterTextSplitter( chunk_size=int(os.getenv("CHUNK_SIZE", "1000")), chunk_overlap=int(os.getenv("CHUNK_OVERLAP", "200")), separators=["\n\n", "\n", ". ", " "], # respecte la structure des paragraphes ) def id_stable(path: str, idx: int) -> str: return hashlib.sha256(f"{path}::chunk_{idx}".encode()).hexdigest()[:16] def ingerer(pdf_path: str) -> int: chunks = splitter.split_documents(PyPDFLoader(pdf_path).load()) for i, chunk in enumerate(chunks): chunk.metadata.update({ "fichier_source": Path(pdf_path).name, "index_chunk": i, "doc_id": id_stable(pdf_path, i), }) vector_store.add_documents(chunks, ids=[c.metadata["doc_id"] for c in chunks]) return len(chunks) if __name__ == "__main__": import sys pdfs = list(Path(sys.argv[1] if len(sys.argv) > 1 else "./contrats").glob("**/*.pdf")) total = sum(ingerer(str(p)) for p in pdfs) print(f"{total} chunks stockés depuis {len(pdfs)} contrat(s)") # Lancer avec : # docker compose up -d # python ingest.py ./contrats # → 110 chunks stockés depuis 2 contrat(s)

Étape 3 : Chaîne RAG avec LangChain

La chaîne encode la requête, récupère les top-k chunks les plus similaires, et les transmet avec un prompt imposant la citation au LLM local. temperature=0 est essentiel pour un usage juridique — il élimine la variation aléatoire dans les réponses.

# rag_chain.py import os from dotenv import load_dotenv from langchain_community.chat_models import ChatOllama from langchain_community.embeddings import OllamaEmbeddings from langchain_chroma import Chroma from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough import chromadb load_dotenv() OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") llm = ChatOllama( model=os.getenv("OLLAMA_LLM_MODEL", "llama3.2:8b"), base_url=OLLAMA_URL, temperature=0, # déterministe pour les réponses juridiques ) embeddings = OllamaEmbeddings( model=os.getenv("OLLAMA_EMBED_MODEL", "nomic-embed-text"), base_url=OLLAMA_URL, ) chroma_client = chromadb.HttpClient( host=os.getenv("CHROMA_HOST", "localhost"), port=int(os.getenv("CHROMA_PORT", "8000")), ) vector_store = Chroma( client=chroma_client, collection_name=os.getenv("CHROMA_COLLECTION", "contrats_juridiques"), embedding_function=embeddings, ) retriever = vector_store.as_retriever( search_kwargs={"k": int(os.getenv("RETRIEVER_K", "5"))} ) SYSTEM = """Vous êtes un analyste de contrats juridiques. Répondez en vous basant UNIQUEMENT sur les extraits fournis. Si la réponse ne figure pas dans les extraits, dites : "Je ne trouve pas cette information dans les contrats fournis." Citez toujours le fichier source et la clause lorsque c'est possible. Extraits contractuels : {context}""" prompt = ChatPromptTemplate.from_messages([ ("system", SYSTEM), ("human", "{question}"), ]) def formater_docs(docs): return "\n\n---\n\n".join( f"[Source : {d.metadata.get('fichier_source', 'inconnu')}]\n{d.page_content}" for d in docs ) chain = ( {"context": retriever | formater_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) if __name__ == "__main__": print("Assistant Juridique — tapez 'quitter' pour sortir\n") while True: q = input("Question : ").strip() if q.lower() in ("quitter", "exit"): break print("\nRéponse :", chain.invoke(q), "\n")

Cas Pratique : L'Assistant Juridique en Action

Un cabinet d'avocats utilise ce système avec 4 000 PDF contractuels (~2,1 Go total) sur un Mac Mini M4 (CPU uniquement). Ingestion : 42 minutes. Latence moyenne par requête : 2,8 s. Trois types de requêtes illustrent la valeur :

  • Recherche de clause : "Quel est le délai de préavis dans notre contrat AWS Enterprise ?" → réponse citée avec référence de clause en 2,1 s
  • Recherche croisée : "Quels accords fournisseurs autorisent des sous-traitants sans consentement écrit préalable ?" → 3 contrats pertinents récupérés, réponse synthétisée en 4,4 s
  • Détection de risque : "Y a-t-il des contrats avec une exposition illimitée en responsabilité ?" → 2 contrats signalés avec références de clauses exactes
Filtrez la récupération avec les métadonnées : retriever = vector_store.as_retriever(search_kwargs={"k": 5, "filter": {"fichier_source": {"$contains": "nda"}}}) Cela restreint la recherche à des catégories spécifiques de contrats et élimine les contaminations croisées entre types d'accords non liés.

Étape 4 : Évaluer avec RAGAS

Avant de déployer, mesurez la qualité avec trois métriques RAGAS : Faithfulness (la réponse reste-t-elle dans le contexte récupéré ?), Answer Relevancy (répond-elle à la question ?) et Context Precision (les chunks récupérés sont-ils pertinents ?).

# evaluate.py from datasets import Dataset from ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy, context_precision from rag_chain import chain, retriever jeu_eval = [ { "question": "Quel est le plafond de responsabilité dans l'accord Azure ?", "ground_truth": "Microsoft plafonne la responsabilité cumulée au montant payé dans les 12 mois précédents, maximum 500 000 USD.", }, { "question": "Quels contrats autorisent la cession à des filiales sans consentement ?", "ground_truth": "Les accords Stripe et Twilio autorisent la cession à des filiales sans consentement préalable.", }, ] lignes = [] for item in jeu_eval: docs = retriever.invoke(item["question"]) lignes.append({ "question": item["question"], "answer": chain.invoke(item["question"]), "contexts": [d.page_content for d in docs], "ground_truth": item["ground_truth"], }) scores = evaluate(Dataset.from_list(lignes), metrics=[faithfulness, answer_relevancy, context_precision]) print(scores) # {'faithfulness': 0.87, 'answer_relevancy': 0.83, 'context_precision': 0.79} # # faithfulness 0.87 → réponses bien ancrées dans le contexte (bon pour usage juridique) # context_precision 0.79 → quelques chunks hors sujet récupérés ; réduire chunk_size
RAGAS utilise un LLM comme juge — par défaut GPT-4. Pour garder l'évaluation gratuite, configurez-le sur votre modèle Ollama local : from ragas.llms import LangchainLLMWrapper; from rag_chain import llm; ragas_llm = LangchainLLMWrapper(llm). Les modèles locaux de petite taille sont moins fiables comme évaluateurs — utilisez llama3.3:70b si disponible.

Checklist de Production

  • Ingestion idempotente : IDs de chunk stables (hash chemin + index) — les relances ne créent jamais de doublons, sûr de scheduler en cron nocturne
  • Tags de métadonnées : ajoutez type_contrat, contrepartie, date_effet à chaque chunk pour une récupération filtrée sans scan du corpus entier
  • Contrôle d'accès : ChromaDB n'a pas d'authentification native — utilisez un proxy FastAPI + JWT pour que les utilisateurs n'accèdent qu'à leurs collections autorisées
  • Sauvegarde : montez ./chroma_data en volume et lancez une synchronisation S3 quotidienne — 4 000 contrats vectorisés tiennent en ~2 Go
  • Surveillance qualité : loguez les triplets question + chunks + réponse ; relancez RAGAS hebdomadairement sur un benchmark fixe pour détecter la dérive du corpus
  • Mise à jour des modèles : changez OLLAMA_LLM_MODEL dans .env pour changer de modèle — aucune modification de code

Et Après ?

Ce tutoriel vous donne une base fonctionnelle. Pour aller en production robuste, les étapes suivantes sont la recherche hybride BM25+vectorielle (améliore le Context Recall de ~15 % sur les documents techniques), le reranking par cross-encoder (meilleure précision sur les requêtes ambiguës) et le chunking parent-enfant (meilleure complétude des réponses sur les longs contrats structurés). Tout cela est couvert dans la formation RAG Avancé.

Questions Fréquentes

Ce système RAG peut-il traiter des contrats en plusieurs langues (FR + EN) ?

Oui. nomic-embed-text est multilingue et gère l'anglais, le français, l'allemand, l'espagnol et d'autres langues. Pour de meilleurs résultats sur les contrats francophones, testez multilingual-e5-large. Pour le LLM, mistral:7b a de meilleures capacités multilingues que llama3.2:8b. Divisez les corpus multilingues par langue si possible — les chunks mélangés réduisent la précision de récupération de 10 à 15 %.

Combien de contrats ChromaDB peut-il gérer avant que je doive migrer ?

ChromaDB gère confortablement jusqu'à ~1 million de vecteurs sur un serveur avec 8 Go de RAM. Un corpus de 500 PDF (100 pages chacun, ~25 chunks/page) produit ~1,25 million de chunks — il faut 16 Go de RAM ou passer à Qdrant (qui streame depuis le disque). Pour la plupart des cabinets gérant moins de 200 contrats, ChromaDB en mode embarqué suffit largement.

Ce setup est-il conforme au RGPD pour traiter des contrats clients ?

Oui — toutes les données restent sur votre infrastructure. Ollama fait l'inférence en local, ChromaDB stocke les vecteurs en local, aucune donnée n'est envoyée vers des API externes. Vous avez tout de même besoin d'une AIPD si les contrats contiennent des données personnelles (noms, signatures, adresses). L'architecture zéro-egress respecte directement les principes de minimisation des données et de limitation du stockage de l'Article 5 du RGPD.

Quelle latence attendre par rapport à OpenAI ?

Sur une machine avec GPU milieu de gamme (RTX 3080) : embedding ~50ms, récupération ChromaDB ~20ms, génération llama3.2:8b ~2–3s. Total : 2,5–3,5s par requête. Comparé à OpenAI + Pinecone : embedding ~80ms (réseau), récupération Pinecone ~60ms, génération GPT-4o ~1,5–2s. Total : 1,6–2,5s. La stack locale est 20–40 % plus lente mais à coût marginal zéro et sans sortie de données.

Quand passer de llama3.2:8b à un modèle plus grand ?

Quand le score RAGAS faithfulness descend sous 0,80, ou quand le modèle échoue à synthétiser plusieurs chunks récupérés (ex. : 'quels contrats autorisent une responsabilité non plafonnée ?'). llama3.3:70b (nécessite 40+ Go de VRAM) améliore significativement la synthèse multi-documents. Pour la plupart des tâches de Q&R sur un seul contrat, llama3.2:8b à temperature=0 est suffisant.

Aller plus loin : Formation RAG Avancé

Chunking sémantique, reranking cross-encoder, recherche hybride BM25+vectorielle et optimisation des coûts en production. Formation 2 jours avec code fonctionnel tout au long du parcours.

Articles liés