iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Python · Intermédiaire

Entraîner son IA en local

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.

OllamaUnslothHugging FaceChromaDBQLoRAGGUF
01Pré-entraînement, fine-tuning, RAG : ce qu'on peut vraiment faire chez soi02Matériel et environnement de travail03Choisir le LLM de départ04Préparer les données : sources, nettoyage et formats05Construire le RAG local06Fine-tuner le modèle en QLoRA07Évaluer : savoir si l'IA répond vraiment bien08Exporter, déployer avec Ollama et faire évoluer l'IA
Chapitre 4·50 min

Préparer les données : sources, nettoyage et formats

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.

Rassembler les sources

Pour le potager, les sources possibles :

  • guides et fiches techniques en PDF (chambres d'agriculture, associations de jardiniers)
  • pages web de référence
  • vos propres notes, tableaux de semis, calendriers
  • des questions réelles posées par des utilisateurs (forums, emails, FAQ)

Trois règles :

  1. La qualité avant la quantité. Une source fausse contamine tout. Préférez les sources reconnues et récentes.
  2. Les droits. Vérifiez que vous pouvez utiliser les contenus. Pour un usage strictement personnel c'est rarement un problème ; pour un produit, ça l'est.
  3. La couverture. Listez les grands thèmes du domaine (semis, arrosage, maladies, associations, calendrier, sol, outils…) et vérifiez que chacun est couvert. Une IA ne peut pas bien répondre sur un thème absent des données.

Rangez tout dans data/raw/.

Extraire le texte

rag/extraire.py
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).

Nettoyer

Ouvrez quelques fichiers de data/clean/ et regardez-les vraiment. Vous y trouverez :

  • des en-têtes et pieds de page répétés à chaque page (« Guide du potager — page 12 »)
  • des mots coupés en fin de ligne (« toma-\ntes »)
  • des lignes vides en rafale, des caractères parasites
  • des sommaires, mentions légales, pages de publicité
rag/nettoyer.py
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.

Découper en passages pour le RAG

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 passageEffet
trop petit (moins de 100 mots)contexte perdu, « Semer en mars » sans savoir de quel légume on parle
200 à 500 motsbon 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.

rag/decouper.py
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 format des données de fine-tuning

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 :

data/train.jsonl
{"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 :

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

Combien d'exemples ?

ObjectifNombre d'exemples indicatif
Imposer un format / un ton100 à 500
Spécialiser sur un domaine1 000 à 5 000
Apprendre une tâche nouvelle et complexe5 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.

Générer des questions-réponses à partir du corpus

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

rag/generer_qr.py
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.

Ajouter les exemples qui manquent toujours

La génération automatique produit presque uniquement des questions dont la réponse est dans le corpus. Un bon assistant doit aussi savoir :

  • refuser poliment les questions hors sujet : « Je suis spécialisé dans le potager, je ne peux pas t'aider pour réparer ta voiture. »
  • dire qu'il ne sait pas quand l'information manque, plutôt qu'inventer
  • demander une précision quand la question est ambiguë (« Quand planter ? » → « Quel légume, et dans quelle région ? »)

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

Contrôler la qualité

rag/filtrer_qr.py
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.

Séparer entraînement et évaluation

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 :

FichierRôleQui l'écrit
train.jsonlentraînementgénéré + exemples manuels
val.jsonlsurveiller le surapprentissage pendant l'entraînementgénéré
bench.jsonl / eval.jsonlmesure finalevous, 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.

Précédent
Choisir le LLM de départ
Suivant
Construire le RAG local

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main