Module 9 — Système de questions-réponses local sur documents
Le cabinet a désormais un modèle de conversation généraliste calé pour ses usages. Reste la brique qui change tout : lui donner accès aux dossiers internes — jurisprudence de la maison, actes récents, notes de doctrine — pour qu'il réponde en citant les documents pertinents, plutôt qu'en inventant. C'est le rôle de la récupération augmentée de génération (RAG), déjà couverte au cours 18. Ce module montre comment monter la chaîne complète entièrement hors ligne avec Ollama, sans qu'un seul document ne quitte le serveur du cabinet.
L'anatomie d'un système RAG local
Deux moteurs, deux étapes. À l'indexation (une fois, puis chaque fois qu'un document nouveau arrive) : lire les fichiers, les découper en morceaux, transformer chaque morceau en un vecteur via un modèle de plongements, stocker ces vecteurs dans une base spécialisée. À la requête (à chaque question posée) : transformer la question en vecteur avec le même modèle de plongements, retrouver dans la base les morceaux les plus proches sémantiquement, glisser ces morceaux dans la consigne d'un modèle d'instruction qui rédige la réponse en s'appuyant dessus.
Trois choix techniques nous engagent. Le modèle de plongements sera nomic-embed-text (274 Mo, 768 dimensions) : léger, francophone, largement éprouvé. La base sera Chroma en mode persistant sur disque : elle tient dans une seule bibliothèque Python, ne réclame aucun service séparé, et sauvegarde son état dans un dossier local qu'on peut chiffrer et sauvegarder comme n'importe quel autre. Le modèle de rédaction sera cabinet-fr du module 5.
L'indexation
Le script d'indexation est court. Il parcourt les PDF déposés dans un dossier, les découpe en morceaux d'environ 500 mots avec un chevauchement de 50 mots (pour ne pas couper une idée en deux), et stocke chaque morceau accompagné de son plongement et de son chemin d'origine.
from pathlib import Path
from ollama import Client
from pypdf import PdfReader
import chromadb
client_ollama = Client(host="http://127.0.0.1:11434")
client_chroma = chromadb.PersistentClient(path="/srv/cabinet/index")
collection = client_chroma.get_or_create_collection("dossiers")
def decouper(texte: str, taille=2000, chevauche=200):
for i in range(0, len(texte), taille - chevauche):
yield texte[i:i + taille]
def indexer(chemin_pdf: Path):
texte = "\n".join(p.extract_text() or "" for p in PdfReader(chemin_pdf).pages)
morceaux = list(decouper(texte))
vecteurs = [
client_ollama.embeddings(model="nomic-embed-text", prompt=m)["embedding"]
for m in morceaux
]
collection.add(
ids=[f"{chemin_pdf.stem}-{i}" for i in range(len(morceaux))],
documents=morceaux,
embeddings=vecteurs,
metadatas=[{"source": chemin_pdf.name} for _ in morceaux],
)
for pdf in Path("/srv/cabinet/documents").glob("*.pdf"):
indexer(pdf)
Pour 500 actes moyens (environ 3 000 morceaux au total), l'indexation prend une demi-heure sur le serveur commun — sans envoyer un octet hors du réseau. Le dossier /srv/cabinet/index fait quelques centaines de mégaoctets et est sauvegardé chaque nuit avec le reste des données du cabinet.
La requête
Le script de question s'appuie sur la même collection Chroma. Il calcule le plongement de la question, récupère les cinq morceaux les plus proches, et les insère dans la consigne du modèle de rédaction :
def repondre(question: str, k: int = 5) -> str:
vecteur_q = client_ollama.embeddings(
model="nomic-embed-text", prompt=question
)["embedding"]
resultat = collection.query(query_embeddings=[vecteur_q], n_results=k)
passages = resultat["documents"][0]
sources = [m["source"] for m in resultat["metadatas"][0]]
contexte = "\n\n---\n\n".join(
f"[{s}]\n{p}" for s, p in zip(sources, passages)
)
reponse = client_ollama.chat(
model="cabinet-fr",
messages=[
{"role": "system",
"content": "Reponds a la question uniquement a partir des passages "
"fournis. Cite entre crochets le nom du document utilise. "
"Si les passages ne suffisent pas, dis-le explicitement."},
{"role": "user",
"content": f"Passages :\n\n{contexte}\n\nQuestion : {question}"},
],
options={"temperature": 0.1, "num_ctx": 8192, "num_predict": 500},
)
return reponse["message"]["content"]
La consigne insiste sur deux points, qui font toute la différence entre un RAG utile et un RAG dangereux. « Uniquement à partir des passages » empêche le modèle d'aller puiser dans sa mémoire d'entraînement — indispensable pour un contexte juridique où la jurisprudence hors du cabinet est majoritairement hors sujet. « Cite entre crochets le nom du document » rend chaque affirmation traçable : l'utilisateur peut retourner à la source et vérifier. Sans cette traçabilité, l'assistant n'est qu'un joli chatbot ; avec elle, c'est un outil que l'on peut faire tourner sur des enjeux professionnels.
La qualité, dans la langue du cabinet
Un point sous-estimé : un modèle de plongements donne d'excellents résultats sur les langues qu'il a vues en volume durant son entraînement. nomic-embed-text couvre convenablement le français ; mxbai-embed-large et surtout bge-m3 (celui-ci multilingue et taillé pour l'usage professionnel) le font encore mieux et méritent d'être testés. Le test se fait avec vingt questions typiques, dont on note manuellement si les documents effectivement pertinents figurent parmi les cinq récupérés. Un modèle qui atteint 15/20 est utilisable ; en dessous, on change de modèle avant d'optimiser quoi que ce soit d'autre — c'est la brique dont dépend toute la suite.
Ce que ne fait pas ce RAG minimal
Le squelette ci-dessus est volontairement simple. Il ignore le re-classement (module 5 du cours 18) qui pourrait remettre les cinq passages dans un meilleur ordre, la recherche hybride (dense + BM25) qui rattrape les acronymes, la synthèse des questions à plusieurs sauts. Ce sont autant d'améliorations qui rapportent, mais le squelette minimal fonctionne dès le premier jour et couvre correctement 70 à 80 % des questions du cabinet, ce qui suffit largement pour se convaincre de l'intérêt de la démarche.
Constituez vingt questions typiques posées par les collaborateurs, avec pour chacune le document précis qui contient la réponse. Après indexation, vérifiez que le bon document apparaît dans les cinq premiers résultats — c'est le seul chiffre qui compte réellement pour un RAG. Si ce score est bas, aucun réglage de température ni de consigne système ne le rattrapera : c'est le modèle de plongements ou le découpage qu'il faut changer. Cette heure de banc d'essai évite des semaines d'ajustements inutiles.
En résumé
- Un RAG local combine un modèle de plongements (
nomic-embed-text), une base vectorielle persistante (Chroma) et un modèle de rédaction (cabinet-frdu module 5), le tout via Ollama et sans réseau externe. - L'indexation découpe les documents en morceaux de 500 mots avec chevauchement, calcule un vecteur par morceau, et stocke sur disque un dossier sauvegardable comme tout autre.
- La consigne du modèle de rédaction doit imposer « à partir des passages uniquement » et « citer la source » : c'est ce qui distingue un RAG professionnel d'un chatbot.
- La qualité dépend d'abord du modèle de plongements et du découpage ; un mini-banc de vingt questions est le seul juge fiable pour la choisir.
Module suivant : les limites honnêtes de l'exécution locale, et quand il faut savoir en sortir.