Guide 7 sur 12
Interroger ses propres documents hors ligne
Indexer un corpus — cours, archives, documentation d'une collectivité — dans un simple fichier SQLite, et le rendre interrogeable en langage naturel sans qu'un octet ne sorte de la machine.
Démarrer le pas à pas 6 étapes · 2 heures · une étape à l'écran, avec Précédent / Suivant
- Ollama fonctionnel avec un modèle de génération
- Python 3.10 ou plus récent
- Un dossier de documents en texte, Markdown ou PDF converti
Ce que fait réellement un RAG
Le modèle ne connaît pas vos documents et on ne va pas le réentraîner. On procède autrement : on découpe le corpus en passages, on les indexe, et à chaque question on retrouve les passages pertinents pour les coller dans le prompt. Le modèle répond alors sur pièces.
- Découper les documents en passages de quelques centaines de mots.
- Vectoriser chaque passage avec un modèle d'embedding.
- Stocker les vecteurs dans une base interrogeable.
- Chercher les passages les plus proches de la question.
- Générer la réponse à partir de ces passages, en citant les sources.
Préparer l'environnement
python3 -m venv .venv && source .venv/bin/activate
pip install sqlite-vec requests
# Modèle d'embedding adapté au français
ollama pull zylonai/multilingual-e5-small
# Modèle de génération
ollama pull qwen3:4b
Découper le corpus
Le découpage détermine la qualité de tout le reste. Trop court, le passage perd son contexte ; trop long, il noie l'information utile. Entre 500 et 1 000 caractères avec un recouvrement de 15 %, on se trompe rarement.
# decouper.py
from pathlib import Path
TAILLE, RECOUVREMENT = 800, 120
def passages(texte):
debut = 0
while debut < len(texte):
yield texte[debut:debut + TAILLE]
debut += TAILLE - RECOUVREMENT
docs = []
for f in Path("corpus").rglob("*.txt"):
contenu = f.read_text(encoding="utf-8", errors="ignore")
for i, p in enumerate(passages(contenu)):
docs.append({"source": f.name, "n": i, "texte": p})
print(f"{len(docs)} passages")
Pour des PDF, convertissez-les d'abord : pdftotext -layout doc.pdf doc.txt (paquet poppler-utils).
Indexer
# indexer.py
import sqlite3, sqlite_vec, requests, struct
OLLAMA = "http://localhost:11434"
MODELE = "zylonai/multilingual-e5-small"
DIMENSION = 384
def vecteur(texte):
r = requests.post(f"{OLLAMA}/api/embeddings",
json={"model": MODELE, "prompt": texte}, timeout=120)
return r.json()["embedding"]
def encoder(v):
return struct.pack(f"{len(v)}f", *v)
base = sqlite3.connect("corpus.db")
base.enable_load_extension(True)
sqlite_vec.load(base)
base.execute("CREATE TABLE IF NOT EXISTS passages(id INTEGER PRIMARY KEY, source TEXT, texte TEXT)")
base.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS vecteurs USING vec0(id INTEGER PRIMARY KEY, v FLOAT[{DIMENSION}])")
for doc in docs: # docs vient de decouper.py
cur = base.execute("INSERT INTO passages(source, texte) VALUES (?, ?)",
(doc["source"], doc["texte"]))
base.execute("INSERT INTO vecteurs(id, v) VALUES (?, ?)",
(cur.lastrowid, encoder(vecteur(doc["texte"]))))
base.commit()
print("index terminé")
Le résultat tient dans un seul fichier corpus.db. Il se copie sur une clé USB, se sauvegarde, se duplique sur toutes les bornes du parc.
Chercher et répondre
# demander.py
import sqlite3, sqlite_vec, requests, struct, sys
OLLAMA, DIMENSION = "http://localhost:11434", 384
question = " ".join(sys.argv[1:])
def vecteur(t):
return requests.post(f"{OLLAMA}/api/embeddings",
json={"model": "zylonai/multilingual-e5-small", "prompt": t}).json()["embedding"]
base = sqlite3.connect("corpus.db")
base.enable_load_extension(True)
sqlite_vec.load(base)
q = struct.pack(f"{DIMENSION}f", *vecteur(question))
lignes = base.execute("""
SELECT p.source, p.texte FROM vecteurs v
JOIN passages p ON p.id = v.id
WHERE v.v MATCH ? AND k = 4
ORDER BY distance
""", (q,)).fetchall()
extraits = "\n\n".join(f"[{s}] {t}" for s, t in lignes)
prompt = f"""Réponds uniquement à partir des extraits ci-dessous.
Si la réponse ne s'y trouve pas, dis-le clairement.
Cite la source entre crochets.
Extraits :
{extraits}
Question : {question}"""
r = requests.post(f"{OLLAMA}/api/generate",
json={"model": "qwen3:4b", "prompt": prompt, "stream": False})
print(r.json()["response"])
python demander.py "Quelles sont les conditions d'inscription ?"
Ce qui rate en pratique
- Le modèle invente malgré tout. Renforcez la consigne : « si l'information ne figure pas dans les extraits, réponds que tu ne sais pas ». Un petit modèle a besoin qu'on insiste.
- Les extraits sont hors sujet. Le modèle d'embedding ne convient pas à la langue du corpus, ou le découpage est trop fin.
- La réponse est incomplète. Quatre extraits ne suffisent pas ; passez à six ou huit, en surveillant la taille du contexte.
- L'indexation est interminable sur SBC. Indexez sur un poste plus puissant et copiez le fichier
.db: le résultat est identique.
← Guide précédent
Un serveur d'IA pour toute une classe
Une machine, trente navigateurs, aucune connexion internet : monter le service partagé, gérer les comptes et dimensionner la file d'attente sans acheter de matériel.
Guide suivant →
Dictée et lecture à voix haute, sans connexion
La chaîne vocale complète — micro, transcription, modèle, synthèse — sur une machine à quelques watts. C'est ce qui rend l'outil accessible à ceux qui ne lisent pas encore, ou plus.