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