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.
C'est le moment où l'on modifie réellement le modèle. Avec QLoRA, un modèle de 8 milliards de paramètres se spécialise sur une carte de 12 Go en une à deux heures. Ce chapitre explique ce qui se passe pendant l'entraînement, prépare des données adaptées au RAG, lance l'entraînement avec Unsloth et apprend à lire les courbes pour savoir quand s'arrêter.
Un fine-tuning complet modifie tous les poids : pour un 8B, il faudrait plus de 60 Go de VRAM (poids, gradients, états de l'optimiseur). Impossible sur une carte grand public.
LoRA (Low-Rank Adaptation) gèle les poids d'origine et ajoute à certaines couches deux petites matrices entraînables. Leur produit représente la modification apportée au modèle. On entraîne ainsi 0,5 à 2 % des paramètres, et le résultat est un adaptateur de quelques dizaines de Mo.
QLoRA va plus loin : le modèle gelé est chargé en 4 bits, les adaptateurs restent en 16 bits. La mémoire nécessaire chute d'un facteur 3 à 4, pour une qualité très proche d'un LoRA classique.
Poids d'origine (gelés, 4 bits) W ─────────┐
├──► sortie = W·x + (B·A)·x
Adaptateur LoRA (entraîné, 16 bits) B·A ────┘
A : d × r B : r × d r = rang, typiquement 8 à 64| Paramètre | Rôle | Valeur de départ |
|---|---|---|
r (rang) | capacité de l'adaptateur | 16 ; 32-64 pour beaucoup de données |
lora_alpha | force de l'adaptation | égal à r ou 2 × r |
target_modules | couches modifiées | toutes les projections d'attention et du MLP |
learning_rate | taille des pas d'apprentissage | 2e-4 ; 1e-4 si le modèle se dégrade |
num_train_epochs | passages sur les données | 1 à 3 |
| batch effectif | exemples par mise à jour | 8 à 16 (batch_size × gradient_accumulation) |
max_seq_length | longueur maximale d'un exemple | 2048 ; 4096 avec des passages RAG |
Ne cherchez pas les réglages parfaits au premier essai. Ces valeurs donnent un bon résultat dans la grande majorité des cas ; ce sont les données qui feront la différence.
Si l'assistant final utilise le RAG, entraînez-le dans les mêmes conditions : chaque exemple contient des passages récupérés, et la réponse s'appuie dessus. Le modèle apprend alors précisément la compétence dont il aura besoin : lire des documents, extraire la bonne information, ignorer les passages hors sujet, et avouer quand la réponse n'y est pas.
Cette idée vient de la méthode RAFT (Retrieval-Augmented Fine-Tuning). On mélange :
import json
import random
from pathlib import Path
from recherche import rechercher
random.seed(42)
passages = {p["id"]: p for p in map(json.loads, Path("data/chunks.jsonl").read_text(encoding="utf-8").splitlines())}
SYSTEME = """Tu es un assistant expert du potager. Tu réponds en français, de façon précise, pratique et concise.
Appuie-toi sur les documents fournis. S'ils ne contiennent pas la réponse, dis-le clairement et n'invente rien."""
SANS_SOURCE = ("Mes documents ne couvrent pas précisément ce point, je préfère ne pas te donner "
"une information approximative. {piste}")
def construire(ex: dict) -> dict:
question = ex["messages"][1]["content"]
reponse = ex["messages"][2]["content"]
source = passages[ex["source_id"]]
distracteurs = [r for r in rechercher(question, k=6) if r["texte"] != source["texte"]][:4]
docs = [{"source": d["source"], "texte": d["texte"]} for d in distracteurs]
if random.random() < 0.8:
docs.insert(random.randint(0, len(docs)), {"source": source["source"], "texte": source["texte"]})
reponse_finale = f"{reponse}\n\nSource : [{source['source']}]"
else:
reponse_finale = SANS_SOURCE.format(
piste="Si tu me précises ton contexte (région, variété, période), je peux chercher plus précisément."
)
contexte = "\n\n".join(f"[{d['source']}]\n{d['texte']}" for d in docs)
return {"messages": [
{"role": "system", "content": SYSTEME},
{"role": "user", "content": f"<documents>\n{contexte}\n</documents>\n\nQuestion : {question}"},
{"role": "assistant", "content": reponse_finale},
]}
for nom in ["train", "val"]:
exemples = [json.loads(l) for l in Path(f"data/{nom}.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
sortie = [construire(e) if "source_id" in e else e for e in exemples] # exemples manuels gardés tels quels
Path(f"data/{nom}_rag.jsonl").write_text(
"\n".join(json.dumps(s, ensure_ascii=False) for s in sortie), encoding="utf-8"
)
print(nom, len(sortie))Les exemples écrits à la main (refus hors sujet, demandes de précision) n'ont pas de source_id et passent sans modification.
Utilisez exactement le même prompt système et le même format de documents que dans assistant.py. Un modèle entraîné sur un format et utilisé avec un autre perd une bonne partie du bénéfice.
Dans l'environnement train/ créé au chapitre 2 :
from datasets import load_dataset
from trl import SFTConfig, SFTTrainer
from unsloth import FastLanguageModel, is_bfloat16_supported
from unsloth.chat_templates import train_on_responses_only
MODELE = "unsloth/Qwen3-8B"
MAX_LEN = 4096
# 1. Charger le modèle en 4 bits
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODELE,
max_seq_length=MAX_LEN,
load_in_4bit=True,
)
# 2. Ajouter les adaptateurs LoRA
model = FastLanguageModel.get_peft_model(
model,
r=16,
lora_alpha=16,
lora_dropout=0,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
use_gradient_checkpointing="unsloth",
random_state=42,
)
# 3. Charger et formater les données avec le chat template du modèle
def formater(lot):
return {"text": [
tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
for msgs in lot["messages"]
]}
train = load_dataset("json", data_files="../data/train_rag.jsonl", split="train").map(formater, batched=True)
val = load_dataset("json", data_files="../data/val_rag.jsonl", split="train").map(formater, batched=True)
print(train[0]["text"]) # TOUJOURS vérifier un exemple formaté avant d'entraîner
# 4. Configurer l'entraînement
trainer = SFTTrainer(
model=model,
processing_class=tokenizer,
train_dataset=train,
eval_dataset=val,
args=SFTConfig(
dataset_text_field="text",
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
num_train_epochs=2,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_steps=10,
optim="adamw_8bit",
weight_decay=0.01,
bf16=is_bfloat16_supported(),
fp16=not is_bfloat16_supported(),
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=50,
save_total_limit=3,
output_dir="../models/checkpoints",
report_to="none",
seed=42,
),
)
# 5. N'apprendre que sur les réponses de l'assistant
trainer = train_on_responses_only(
trainer,
instruction_part="<|im_start|>user\n",
response_part="<|im_start|>assistant\n",
)
# 6. Entraîner et sauvegarder l'adaptateur
trainer.train()
model.save_pretrained("../models/potager-lora")
tokenizer.save_pretrained("../models/potager-lora")cd train
uv run entrainer.pyLes bibliothèques d'entraînement évoluent très vite et certains noms de paramètres changent d'une version à l'autre. Si un argument est refusé, comparez avec les notebooks officiels d'Unsloth pour votre modèle : ils sont tenus à jour à chaque nouvelle sortie.
train_on_responses_onlyPar défaut, le modèle apprend à prédire tout le texte, y compris les passages et les questions. Ce n'est pas ce qu'on veut : il doit apprendre à produire les réponses, pas à réciter les documents. train_on_responses_only masque tout sauf les réponses de l'assistant dans le calcul de l'erreur.
Les balises instruction_part et response_part dépendent du chat template. Celles ci-dessus sont celles de Qwen ; pour Llama 3, ce seraient <|start_header_id|>user<|end_header_id|>\n\n et <|start_header_id|>assistant<|end_header_id|>\n\n. Le print(train[0]["text"]) vous montre les balises exactes à utiliser.
Pendant l'entraînement, deux valeurs s'affichent :
Step Training Loss Validation Loss
50 1.284 1.201
100 0.962 1.018
150 0.871 0.975
200 0.803 0.969 ← la validation stagne
250 0.694 0.991 ← la validation remonte : surapprentissage
300 0.581 1.043Interprétation :
| Situation | Diagnostic | Action |
|---|---|---|
| les deux baissent | apprentissage sain | continuer |
| train baisse, validation stagne | on atteint la limite utile | s'arrêter, garder ce checkpoint |
| train baisse, validation remonte | surapprentissage : le modèle récite | revenir au meilleur checkpoint, moins d'époques |
| train ne baisse pas | taux d'apprentissage trop faible ou données mal formatées | vérifier le formatage, augmenter le learning rate |
| train tombe près de 0 | mémorisation pure | réduire époques, rang ou learning rate |
Les checkpoints sauvegardés tous les 50 pas permettent de revenir au meilleur point. Dans l'exemple, c'est le pas 200.
Avant toute conversion, un test rapide dans Python :
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained("../models/potager-lora", max_seq_length=4096, load_in_4bit=True)
FastLanguageModel.for_inference(model)
messages = [
{"role": "system", "content": "Tu es un assistant expert du potager. Tu réponds en français, de façon précise, pratique et concise.\nAppuie-toi sur les documents fournis. S'ils ne contiennent pas la réponse, dis-le clairement et n'invente rien."},
{"role": "user", "content": "<documents>\n</documents>\n\nQuestion : Comment réparer l'embrayage de ma voiture ?"},
]
entrees = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt").to("cuda")
sortie = model.generate(input_ids=entrees, max_new_tokens=300, temperature=0.2, do_sample=True)
print(tokenizer.decode(sortie[0][entrees.shape[1]:], skip_special_tokens=True))Testez une question du domaine, une question hors sujet et une question dont la réponse n'est pas dans les documents. Trois questions suffisent à repérer un entraînement raté (réponses en boucle, mauvaise langue, balises parasites).
L'oubli catastrophique. Un fine-tuning trop agressif fait perdre au modèle des capacités générales : il écrit moins bien, raisonne moins bien, ou ne sait plus parler d'autre chose. Signes : réponses répétitives, formulations figées. Remèdes : learning rate plus bas, moins d'époques, et mélanger 10 à 20 % d'exemples de conversation générale dans le jeu d'entraînement.
Le modèle qui répète toujours la même structure. Vos exemples synthétiques se ressemblent trop. Diversifiez la consigne de génération, ou utilisez plusieurs modèles générateurs.
CUDA out of memory. Dans l'ordre : réduire per_device_train_batch_size à 1 (en augmentant gradient_accumulation_steps pour garder le même batch effectif), réduire MAX_LEN, passer à un modèle plus petit.
Le template d'entraînement différent du template de service. Déjà évoqué au chapitre 3, et on le vérifiera au chapitre 8. C'est la première chose à regarder quand un modèle excellent dans Python devient mauvais dans Ollama.
Une fois le SFT (supervised fine-tuning) terminé, on peut affiner encore avec DPO (Direct Preference Optimization) : pour une même question, on montre au modèle une bonne et une mauvaise réponse. Il apprend à préférer la première. C'est très efficace pour corriger des défauts précis repérés à l'évaluation : trop verbeux, trop sûr de lui, mauvais format. TRL fournit un DPOTrainer qui s'utilise comme le SFTTrainer. Je ne le recommande qu'une fois le reste stabilisé.
QLoRA rend le fine-tuning accessible sur une carte de joueur, mais ce n'est pas l'entraînement qui fait la qualité : ce sont des données au bon format, identiques aux conditions réelles d'utilisation, et la discipline de s'arrêter quand la courbe de validation remonte. La seule façon de savoir si le modèle est meilleur, c'est de le mesurer : c'est l'objet du chapitre suivant.