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.
Le RAG est la première version de votre assistant, et souvent la plus rentable. En une soirée, le modèle de base passe de « répond à peu près » à « répond avec les informations exactes de vos sources, et les cite ». Ce chapitre construit un RAG entièrement local : embeddings, base vectorielle, recherche, reranking et génération.
Une recherche par mots-clés échoue dès que l'utilisateur ne dit pas les mêmes mots que le document. « Mes plants de tomates ont des taches noires » ne contient pas le mot « mildiou ».
Un modèle d'embeddings transforme un texte en vecteur de quelques centaines de nombres. Deux textes de sens proche donnent des vecteurs proches. On compare la question aux passages par similarité cosinus, et on récupère les plus proches.
"taches noires sur mes tomates" → [0.12, -0.54, 0.33, …] ┐
├─ proches
"Le mildiou de la tomate provoque des taches brunes…" → [0.10, -0.49, 0.37, …] ┘| Modèle | Langues | Taille | Via |
|---|---|---|---|
bge-m3 | multilingue, bon en français | ~570M | Ollama, Hugging Face |
nomic-embed-text | surtout anglais | ~140M | Ollama |
multilingual-e5-large | multilingue | ~560M | Hugging Face |
Pour du contenu en français, prenez un modèle multilingue. J'utilise bge-m3, disponible directement dans Ollama :
ollama pull bge-m3Règle absolue : le même modèle d'embeddings pour indexer les documents et pour encoder les questions. Changer de modèle impose de tout réindexer.
Une base vectorielle stocke les vecteurs et retrouve rapidement les plus proches d'une requête. Options locales :
| Base | Pour qui |
|---|---|
| ChromaDB | prototypage, embarquée dans le script Python, zéro serveur |
| Qdrant | production, serveur Docker, filtres puissants |
| pgvector | si vous avez déjà PostgreSQL : vecteurs dans une table SQL |
On utilise Chroma pour sa simplicité. Si vos données vivent déjà dans PostgreSQL, pgvector évite un service de plus ; les bases de PostgreSQL sont couvertes dans PostgreSQL pour débuter.
import json
from pathlib import Path
import chromadb
import ollama
EMBED = "bge-m3"
LOT = 32
client = chromadb.PersistentClient(path="data/chroma")
collection = client.get_or_create_collection("potager", metadata={"hnsw:space": "cosine"})
passages = [json.loads(l) for l in Path("data/chunks.jsonl").read_text(encoding="utf-8").splitlines()]
for i in range(0, len(passages), LOT):
lot = passages[i:i + LOT]
vecteurs = ollama.embed(model=EMBED, input=[p["texte"] for p in lot])["embeddings"]
collection.upsert(
ids=[p["id"] for p in lot],
documents=[p["texte"] for p in lot],
embeddings=vecteurs,
metadatas=[{"source": p["source"], "titre": p["titre"]} for p in lot],
)
print(f"{min(i + LOT, len(passages))}/{len(passages)}")
print(f"{collection.count()} passages indexés")upsert permet de relancer le script sans créer de doublons après une mise à jour du corpus.
import chromadb
import ollama
EMBED = "bge-m3"
collection = chromadb.PersistentClient(path="data/chroma").get_collection("potager")
def rechercher(question: str, k: int = 5) -> list[dict]:
vecteur = ollama.embed(model=EMBED, input=[question])["embeddings"][0]
res = collection.query(query_embeddings=[vecteur], n_results=k)
return [
{"texte": doc, "source": meta["source"], "score": round(1 - dist, 3)}
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0])
]
if __name__ == "__main__":
for r in rechercher("mes tomates ont des taches noires sur les feuilles"):
print(r["score"], r["source"], r["texte"][:100].replace("\n", " "))Testez la recherche seule avant de brancher le LLM. Si les bons passages ne remontent pas, aucun modèle ne pourra bien répondre. C'est l'étape que tout le monde saute et qui explique la majorité des RAG décevants.
Les embeddings sont bons sur le sens mais parfois faibles sur les termes exacts : noms de variétés (« Cœur de bœuf »), références, sigles. Combiner une recherche par mots-clés (BM25) et une recherche vectorielle corrige ce défaut. Qdrant et pgvector (avec la recherche plein texte de PostgreSQL) le permettent nativement ; avec Chroma, la bibliothèque rank-bm25 fait l'affaire côté Python.
La recherche vectorielle est rapide mais approximative. Un reranker (cross-encoder) lit la question et chaque passage ensemble et produit un score de pertinence beaucoup plus fin. On récupère 20 candidats, on en garde les 5 meilleurs après reranking.
from sentence_transformers import CrossEncoder
from recherche import rechercher
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def rechercher_precis(question: str, k: int = 5, candidats: int = 20) -> list[dict]:
resultats = rechercher(question, k=candidats)
scores = reranker.predict([(question, r["texte"]) for r in resultats])
for r, s in zip(resultats, scores):
r["rerank"] = float(s)
return sorted(resultats, key=lambda r: r["rerank"], reverse=True)[:k]Sur mes tests, le reranking est l'amélioration qui apporte le plus pour le moins d'effort, dès que le corpus dépasse quelques centaines de passages.
import ollama
from rerank import rechercher_precis
MODELE = "qwen3:8b"
SYSTEME = """Tu es un assistant expert du potager. Tu réponds en français, de façon précise, pratique et concise.
Règles :
- Appuie-toi en priorité sur les documents fournis entre balises <documents>.
- Si les documents ne contiennent pas la réponse, dis-le clairement ; tu peux alors donner une indication générale en précisant qu'elle ne vient pas des sources.
- N'invente jamais de chiffres, de dates ou de noms de produits.
- Termine par la liste des sources utilisées, sous la forme [source].
- Si la question ne concerne pas le jardinage potager, explique poliment que ce n'est pas ton domaine."""
def repondre(question: str, historique: list[dict] | None = None) -> str:
passages = rechercher_precis(question)
contexte = "\n\n".join(f"[{p['source']}]\n{p['texte']}" for p in passages)
messages = [
{"role": "system", "content": SYSTEME},
*(historique or []),
{"role": "user", "content": f"<documents>\n{contexte}\n</documents>\n\nQuestion : {question} /no_think"},
]
rep = ollama.chat(model=MODELE, messages=messages, options={"temperature": 0.2, "num_ctx": 8192})
return rep["message"]["content"]
if __name__ == "__main__":
historique: list[dict] = []
while (q := input("\n🌱 > ")).strip():
r = repondre(q, historique)
print(r)
historique += [{"role": "user", "content": q}, {"role": "assistant", "content": r}]
historique = historique[-6:] # garder les 3 derniers échangesPoints importants :
temperature basse (0.1-0.3) pour un assistant factuel : moins de créativité, moins d'inventionsnum_ctx : Ollama utilise par défaut un contexte réduit qui peut tronquer silencieusement vos passages ; fixez-le explicitement/no_think désactive la phase de réflexion de Qwen3 ; retirez-le pour un autre modèle<documents> séparent clairement les sources de la question« Et pour les poivrons ? » ne veut rien dire seul. Pour que la recherche fonctionne dans une conversation, on reformule la question avec l'historique avant de chercher :
def reformuler(question: str, historique: list[dict]) -> str:
if not historique:
return question
echanges = "\n".join(f"{m['role']}: {m['content'][:300]}" for m in historique[-4:])
rep = ollama.chat(model=MODELE, messages=[{
"role": "user",
"content": f"Conversation :\n{echanges}\n\nNouvelle question : {question}\n\n"
"Réécris la nouvelle question pour qu'elle soit compréhensible seule. "
"Réponds uniquement par la question réécrite. /no_think",
}], options={"temperature": 0})
return rep["message"]["content"].strip()Utilisez la question reformulée pour rechercher_precis, et la question d'origine dans la conversation.
Avant de juger les réponses, mesurez la recherche seule. Pour chaque question de votre banc d'essai, notez un mot-clé que doit contenir le bon passage, puis vérifiez qu'un passage le contenant remonte dans les 5 premiers résultats.
import json
from pathlib import Path
from rerank import rechercher_precis
# chaque ligne : {"question": "...", "mot_cle": "mildiou"}
cas = [json.loads(l) for l in Path("data/recherche_attendue.jsonl").read_text(encoding="utf-8").splitlines()]
trouves = 0
for c in cas:
textes = [r["texte"] for r in rechercher_precis(c["question"], k=5)]
ok = any(c["mot_cle"].lower() in t.lower() for t in textes)
trouves += ok
if not ok:
print("MANQUÉ :", c["question"])
print(f"Rappel@5 : {trouves}/{len(cas)} = {trouves / len(cas):.0%}")Le mot-clé (« mildiou », « 1 m ») est une approximation pratique : plus rapide à annoter que l'identifiant exact du passage, et suffisante pour repérer les ratés. Visez un rappel@5 au-dessus de 90 %. En dessous, travaillez le découpage, les embeddings, la recherche hybride ou le reranking avant toute autre chose.
Pour tester confortablement, Open WebUI se branche sur Ollama et propose une interface de type ChatGPT, avec son propre système de RAG sur documents :
docker run -d -p 3000:8080 \
--add-host=host.docker.internal:host-gateway \
-v open-webui:/app/backend/data \
--name open-webui ghcr.io/open-webui/open-webui:mainC'est pratique pour une démonstration, mais votre propre pipeline reste plus facile à mesurer et à régler finement.
Un RAG, c'est d'abord une bonne recherche : découpage propre, embeddings multilingues, reranking. Le LLM ne fait que rédiger à partir de ce qu'on lui donne. Si votre assistant répond déjà correctement à la grande majorité de votre banc d'essai, vous pourriez vous arrêter là. Le fine-tuning du chapitre suivant sert à corriger ce qui reste : le ton, le format, les refus, et la façon d'exploiter les passages.