Créer chez soi une IA experte d'un domaine : choisir le matériel et le LLM de base, préparer les données, construire un RAG, fine-tuner en QLoRA, évaluer et déployer avec Ollama.
Les données font la qualité d'une IA spécialisée, bien plus que le modèle ou les hyperparamètres. Un corpus propre de 200 pages bien choisies bat 5 000 pages aspirées au hasard. Ce chapitre construit les deux jeux de données dont on a besoin : les passages pour le RAG et les exemples question → réponse pour le fine-tuning.
Pour le potager, les sources possibles :
Trois règles :
Rangez tout dans data/raw/.
from pathlib import Path
from pypdf import PdfReader
RAW = Path("data/raw")
CLEAN = Path("data/clean")
CLEAN.mkdir(parents=True, exist_ok=True)
def extraire_pdf(chemin: Path) -> str:
lecteur = PdfReader(chemin)
return "\n\n".join(page.extract_text() or "" for page in lecteur.pages)
for fichier in RAW.rglob("*"):
if fichier.suffix.lower() == ".pdf":
texte = extraire_pdf(fichier)
elif fichier.suffix.lower() in {".md", ".txt"}:
texte = fichier.read_text(encoding="utf-8")
else:
continue
(CLEAN / f"{fichier.stem}.md").write_text(texte, encoding="utf-8")
print(f"{fichier.name}: {len(texte):,} caractères")pypdf suffit pour les PDF textuels simples. Pour des PDF complexes (colonnes, tableaux, scans), des outils comme Docling ou Marker produisent un Markdown bien plus propre, avec la structure des titres et des tableaux conservée. Un PDF scanné sans couche texte demande de l'OCR.
Pour les pages web, récupérez le contenu principal sans la navigation ni les publicités (bibliothèque trafilatura par exemple).
Ouvrez quelques fichiers de data/clean/ et regardez-les vraiment. Vous y trouverez :
import re
from collections import Counter
from pathlib import Path
CLEAN = Path("data/clean")
def lignes_repetees(textes: list[str], seuil: int = 5) -> set[str]:
"""Lignes présentes à l'identique de nombreuses fois : en-têtes, pieds de page."""
compteur = Counter(l.strip() for t in textes for l in t.splitlines() if l.strip())
return {l for l, n in compteur.items() if n >= seuil and len(l) < 120}
def nettoyer(texte: str, parasites: set[str]) -> str:
texte = re.sub(r"(\w)-\n(\w)", r"\1\2", texte) # mots coupés
lignes = [l for l in texte.splitlines() if l.strip() not in parasites]
texte = "\n".join(lignes)
texte = re.sub(r"^\s*(page\s*)?\d+\s*$", "", texte, flags=re.I | re.M) # numéros de page seuls
texte = re.sub(r"[ \t]+", " ", texte)
texte = re.sub(r"\n{3,}", "\n\n", texte)
return texte.strip()
fichiers = list(CLEAN.glob("*.md"))
textes = [f.read_text(encoding="utf-8") for f in fichiers]
parasites = lignes_repetees(textes)
print(f"{len(parasites)} lignes parasites détectées")
for f, t in zip(fichiers, textes):
f.write_text(nettoyer(t, parasites), encoding="utf-8")Aucun script ne nettoie parfaitement. Prévoyez une relecture manuelle rapide des documents les plus importants. C'est ennuyeux, et c'est l'étape qui fait la plus grande différence sur la qualité finale.
Le RAG ne donne pas un document entier au modèle, mais des passages (chunks) de quelques centaines de mots. La taille compte :
| Taille du passage | Effet |
|---|---|
| trop petit (moins de 100 mots) | contexte perdu, « Semer en mars » sans savoir de quel légume on parle |
| 200 à 500 mots | bon compromis pour une documentation |
| trop grand (plus de 1000 mots) | recherche moins précise, contexte du LLM encombré |
Le meilleur découpage suit la structure du document : un passage par section ou sous-section, redécoupée si elle est trop longue. On ajoute un chevauchement entre passages consécutifs pour ne pas couper une idée en deux, et on préfixe chaque passage par le titre de sa section.
import json
import re
from pathlib import Path
CLEAN = Path("data/clean")
SORTIE = Path("data/chunks.jsonl")
MAX_MOTS = 350
CHEVAUCHEMENT = 50
def sections(texte: str) -> list[tuple[str, str]]:
"""Découpe sur les titres Markdown ; renvoie (titre, contenu)."""
parties = re.split(r"^(#{1,3} .+)$", texte, flags=re.M)
resultat, titre = [], ""
for partie in parties:
if re.match(r"^#{1,3} ", partie):
titre = partie.lstrip("# ").strip()
elif partie.strip():
resultat.append((titre, partie.strip()))
return resultat
def fenetres(mots: list[str]) -> list[list[str]]:
if len(mots) <= MAX_MOTS:
return [mots]
pas = MAX_MOTS - CHEVAUCHEMENT
return [mots[i:i + MAX_MOTS] for i in range(0, len(mots) - CHEVAUCHEMENT, pas)]
with SORTIE.open("w", encoding="utf-8") as out:
n = 0
for fichier in sorted(CLEAN.glob("*.md")):
for titre, contenu in sections(fichier.read_text(encoding="utf-8")):
for morceau in fenetres(contenu.split()):
n += 1
out.write(json.dumps({
"id": f"{fichier.stem}-{n}",
"source": fichier.name,
"titre": titre,
"texte": f"{titre}\n\n{' '.join(morceau)}" if titre else " ".join(morceau),
}, ensure_ascii=False) + "\n")
print(f"{n} passages écrits dans {SORTIE}")Si vos documents n'ont pas de titres Markdown (cas fréquent avec pypdf), le découpage se fera uniquement par fenêtres de mots. Les outils comme Docling qui restituent les titres améliorent nettement le résultat.
Le fine-tuning d'un modèle instruct se fait sur des conversations. Le format standard est le JSONL (un objet JSON par ligne) avec une liste de messages, identique à l'API de chat d'OpenAI et d'Ollama :
{"messages": [{"role": "system", "content": "Tu es un assistant expert du potager."}, {"role": "user", "content": "À quelle distance planter les courgettes ?"}, {"role": "assistant", "content": "Comptez environ 1 m entre chaque pied et 1,2 m entre les rangs : la courgette forme une grosse touffe et a besoin d'air pour limiter l'oïdium."}]}D'autres formats existent et se convertissent facilement :
| Format | Structure | Origine |
|---|---|---|
| messages / ChatML | {"messages": [{"role", "content"}]} | OpenAI, standard actuel |
| ShareGPT | {"conversations": [{"from": "human", "value"}]} | anciens jeux de données |
| Alpaca | {"instruction", "input", "output"} | premiers fine-tunings Llama |
Utilisez le format messages. Il gère le multi-tour et c'est celui qu'attendent TRL et Unsloth.
Autres formats que vous croiserez : les jeux de préférences (prompt, chosen, rejected) servent à des méthodes comme DPO, qui apprennent au modèle à préférer une réponse à une autre. C'est une étape optionnelle qu'on n'abordera qu'en ouverture.
| Objectif | Nombre d'exemples indicatif |
|---|---|
| Imposer un format / un ton | 100 à 500 |
| Spécialiser sur un domaine | 1 000 à 5 000 |
| Apprendre une tâche nouvelle et complexe | 5 000 et plus |
La diversité compte plus que le volume. 1 000 exemples qui couvrent tous les thèmes avec des formulations variées valent mieux que 10 000 exemples qui répètent la même question sur la tomate.
Écrire 2 000 exemples à la main est irréaliste. La technique standard : utiliser un LLM pour générer des paires question-réponse à partir de chaque passage. On appelle ça des données synthétiques. Le passage garantit que la réponse est fondée sur votre source et non sur l'imagination du modèle.
On peut le faire avec le plus gros modèle qui tourne chez vous, ou un modèle via API si vos données ne sont pas sensibles et que la licence du modèle autorise cet usage. Un modèle générateur plus fort que le modèle élève donne de meilleures données.
import json
import random
from pathlib import Path
import ollama
from pydantic import BaseModel
GENERATEUR = "qwen3:14b" # le plus gros modèle disponible chez vous
PAR_PASSAGE = 4
SYSTEME_ASSISTANT = "Tu es un assistant expert du potager. Réponds en français, avec précision."
class PaireQR(BaseModel):
question: str
reponse: str
class Lot(BaseModel):
paires: list[PaireQR]
CONSIGNE = """Voici un extrait d'un guide de jardinage.
<extrait>
{texte}
</extrait>
Génère {n} paires question/réponse en français :
- des questions que poserait un jardinier amateur, formulées naturellement et variées (question directe, problème concret, demande de conseil) ;
- chaque réponse doit être entièrement justifiée par l'extrait, sans rien inventer ;
- réponses complètes mais concises, 2 à 5 phrases, ton chaleureux et pratique ;
- ne mentionne jamais « l'extrait » ou « le texte » dans les questions ni les réponses."""
passages = [json.loads(l) for l in Path("data/chunks.jsonl").read_text(encoding="utf-8").splitlines()]
random.seed(42)
with Path("data/qr_brut.jsonl").open("w", encoding="utf-8") as out:
for i, p in enumerate(passages, 1):
if len(p["texte"].split()) < 60: # passage trop pauvre
continue
rep = ollama.chat(
model=GENERATEUR,
messages=[{"role": "user", "content": CONSIGNE.format(texte=p["texte"], n=PAR_PASSAGE)}],
format=Lot.model_json_schema(),
options={"temperature": 0.7},
)
try:
lot = Lot.model_validate_json(rep["message"]["content"])
except ValueError:
continue
for qr in lot.paires:
out.write(json.dumps({
"source_id": p["id"],
"messages": [
{"role": "system", "content": SYSTEME_ASSISTANT},
{"role": "user", "content": qr.question},
{"role": "assistant", "content": qr.reponse},
],
}, ensure_ascii=False) + "\n")
print(f"{i}/{len(passages)}")Le paramètre format d'Ollama force une sortie JSON conforme au schéma Pydantic : plus besoin de parser un texte libre.
La génération automatique produit presque uniquement des questions dont la réponse est dans le corpus. Un bon assistant doit aussi savoir :
Écrivez à la main 50 à 100 exemples de chaque type et ajoutez-les au jeu. Sans eux, le modèle fine-tuné répondra avec assurance à absolument tout.
import json
import random
from pathlib import Path
vus, gardes = set(), []
for ligne in Path("data/qr_brut.jsonl").read_text(encoding="utf-8").splitlines():
ex = json.loads(ligne)
q = ex["messages"][1]["content"].strip().lower()
r = ex["messages"][2]["content"]
if q in vus: # doublon exact
continue
if len(r.split()) < 8 or len(r.split()) > 250:
continue
if "extrait" in r.lower() or "le texte" in r.lower():
continue
vus.add(q)
gardes.append(ex)
random.seed(0)
random.shuffle(gardes)
print(f"{len(gardes)} exemples conservés")
for ex in random.sample(gardes, 20): # relecture humaine d'un échantillon
print("Q:", ex["messages"][1]["content"])
print("R:", ex["messages"][2]["content"], "\n")Lisez au moins 50 exemples tirés au hasard. Si plus de 1 sur 10 est faux ou bancal, corrigez la consigne de génération plutôt que les exemples un par un.
coupure = int(len(gardes) * 0.9)
Path("data/train.jsonl").write_text("\n".join(json.dumps(e, ensure_ascii=False) for e in gardes[:coupure]), encoding="utf-8")
Path("data/val.jsonl").write_text("\n".join(json.dumps(e, ensure_ascii=False) for e in gardes[coupure:]), encoding="utf-8")On obtient trois jeux distincts :
| Fichier | Rôle | Qui l'écrit |
|---|---|---|
train.jsonl | entraînement | généré + exemples manuels |
val.jsonl | surveiller le surapprentissage pendant l'entraînement | généré |
bench.jsonl / eval.jsonl | mesure finale | vous, questions réelles, jamais vues |
Le jeu d'évaluation final doit rester écrit par un humain et indépendant des données générées. Sinon, vous mesurerez la capacité du modèle à répéter les formulations de votre générateur.
Des sources fiables, un nettoyage relu, des passages découpés selon la structure, des exemples de conversation variés qui incluent refus et incertitude : c'est ce travail peu glamour qui décide si votre assistant sera fiable. Le chapitre suivant met les passages au travail dans un RAG.