RAG local : interroger ses PDF avec Ollama et Chroma, sans quitter la boucle locale

pdftotext -layout écrit un fichier de 0 octet, sans message d’erreur, quand le PDF est un scan sans couche texte : l’index se remplit de fragments vides et le modèle répond à côté. Sur Debian 13 (trixie) et Ubuntu 24.04 LTS, la chaîne complète tient dans cinq outils : poppler-utils pour extraire le texte, nomic-embed-text (274 Mo, 768 dimensions) pour calculer les vecteurs, Chroma 1.5.9 pour ranger l’index, le client ollama-python 0.6.2 pour parler au démon, et llama3.2:3b (2,0 Go) pour rédiger la réponse.
Sommaire
- 1. ollama.service écoute sur 127.0.0.1:11434, jamais sur le réseau
- 2. nomic-embed-text pèse 274 Mo et sort 768 dimensions
- 3. pdftotext -layout écrit 0 octet sur un scan, sans code d’erreur
- 4. pip install chromadb hors venv affiche error: externally-managed-environment
- 5. L’indexation ajoute le préfixe search_document: à chaque fragment
- 6. La question part avec search_query:, et la réponse cite le fichier
- Changer de modèle d’embedding casse l’index : InvalidDimensionException
- Questions fréquentes
Un RAG local découpe chaque document en fragments, calcule un vecteur par fragment avec un modèle d’embedding, range le tout dans une base vectorielle, puis n’envoie au modèle de langue que les trois fragments les plus proches de la question. Ollama 0.34.2, publiée le 15 septembre 2026, écoute par défaut sur 127.0.0.1:11434 : tant que OLLAMA_HOST n’est pas modifié, aucune requête ne quitte la machine. Le choix du moteur lui-même est traité dans choisir une IA locale, dans la rubrique IA.
1. ollama.service écoute sur 127.0.0.1:11434, jamais sur le réseau
Le script d’installation officiel pose le binaire et enregistre le service systemd ollama, qui démarre avec la machine. La procédure détaillée figure dans installer Ollama.
curl -fsSL https://ollama.com/install.sh | sh systemctl status ollama curl -s http://localhost:11434/api/version
Ce qu’on doit voir : systemctl status ollama affiche Active: active (running), et le journal du service contient la ligne Listening on 127.0.0.1:11434. La requête curl renvoie un objet JSON du type {"version":"0.34.2"}. OLLAMA_HOST vaut 127.0.0.1:11434 par défaut ; la passer à 0.0.0.0:11434 ouvrirait l’API à tout le réseau local, ce qu’un index de documents privés ne veut pas.
2. nomic-embed-text pèse 274 Mo et sort 768 dimensions
Deux modèles suffisent : un modèle d’embedding, qui ne sait que produire des vecteurs et ne peut pas discuter, et un modèle de conversation. ollama pull télécharge par blocs et termine par success.
ollama pull nomic-embed-text ollama pull llama3.2:3b ollama list
Ce qu’on doit voir : les lignes pulling manifest, puis pulling suivi d’une empreinte à 100 %, verifying sha256 digest, writing manifest et success. La commande ollama list affiche les colonnes NAME, ID, SIZE et MODIFIED, avec 274 MB pour nomic-embed-text et 2.0 GB pour llama3.2:3b. La fiche Ollama du premier annonce 2K de contexte là où le modèle natif en supporte 8 192 ; avec des fragments de 1 000 caractères, l’écart ne change rien.
3. pdftotext -layout écrit 0 octet sur un scan, sans code d’erreur
pdftotext, fourni par poppler-utils, sort en code 0 même quand le PDF ne contient que des images : la page de manuel définit 0 comme « aucune erreur ». Le résultat se mesure donc avant d’indexer, jamais à l’œil.
sudo apt install poppler-utils mkdir -p ~/rag/documents ~/rag/textes pdftotext -layout ~/rag/documents/rapport.pdf ~/rag/textes/rapport.txt wc -c ~/rag/textes/rapport.txt
Ce qu’on doit voir : wc -c renvoie un nombre, par exemple 184233, suivi du nom du fichier. Un 0 signifie que le PDF est un scan. Le remède est OCRmyPDF, qui ajoute une couche de texte au fichier ; tesseract-ocr-fra fournit le français, -l fra choisit la langue et --sidecar écrit directement le texte reconnu.
sudo apt install ocrmypdf tesseract-ocr-fra ocrmypdf -l fra --sidecar ~/rag/textes/rapport.txt ~/rag/documents/rapport.pdf ~/rag/documents/rapport-ocr.pdf
Le fichier sidecar ne contient que le texte produit par l’OCR : sur un PDF mixte, les pages qui avaient déjà du texte n’y figurent pas. Mieux vaut relancer pdftotext sur le PDF océrisé, en lot sur tout le dossier :
for f in ~/rag/documents/*.pdf; do pdftotext -layout "$f" ~/rag/textes/"$(basename "$f" .pdf).txt"; done
Pour découper ou fusionner les PDF avant extraction, Stirling-PDF travaille en local, sans envoi de fichier.
4. pip install chromadb hors venv affiche error: externally-managed-environment
Debian 12 et suivants, comme Ubuntu 23.04 et suivants, marquent l’interpréteur système comme géré par apt. Un pip install lancé sans environnement virtuel s’arrête avec ce message, reproduit tel quel :
error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.
L’option --break-system-packages existe, mais elle mélange les paquets apt et pip dans le même interpréteur. Le venv est la sortie propre. Sur une Debian fraîche, python3 -m venv échoue d’abord avec sa propre erreur, car le paquet python3-venv n’est pas installé de base : « The virtual environment was not created successfully because ensurepip is not available. On Debian/Ubuntu systems, you need to install the python3-venv package ».
sudo apt install python3-venv python3 -m venv ~/rag/venv source ~/rag/venv/bin/activate pip install ollama chromadb pip show chromadb
Ce qu’on doit voir : pip énumère les paquets téléchargés puis affiche Successfully installed, et pip show chromadb répond Name: chromadb et Version: 1.5.9, la version publiée sur PyPI le 5 mai 2026 (le client ollama-python 0.6.2 date du 29 avril 2026). Le venv se réactive dans chaque nouveau terminal avec source ~/rag/venv/bin/activate. Chroma embarque une fonction d’embedding par défaut qui télécharge un modèle ONNX ; elle n’est pas utilisée ici, puisque les vecteurs sont fournis par Ollama.
5. L’indexation ajoute le préfixe search_document: à chaque fragment
Le découpage retenu fait 1 000 caractères avec 200 de chevauchement, ce qui garde les phrases à cheval sur deux fragments. La fiche Hugging Face de nomic-embed-text v1.5 impose un préfixe de tâche : search_document: pour les documents, search_query: pour les questions. Sans ces préfixes, le texte sort de la distribution du modèle et la recherche perd en précision ; la fiche Ollama du même modèle ne le mentionne pas. Le script à enregistrer dans ~/rag/indexer.py :
import pathlib
import chromadb
import ollama
MODELE = "nomic-embed-text"
RACINE = pathlib.Path.home() / "rag"
TAILLE, CHEVAUCHEMENT = 1000, 200
def fragments(texte):
sortie = []
for debut in range(0, len(texte), TAILLE - CHEVAUCHEMENT):
morceau = texte[debut:debut + TAILLE].strip()
if morceau:
sortie.append(morceau)
return sortie
client = chromadb.PersistentClient(path=str(RACINE / "index"))
try:
client.delete_collection("documents")
except Exception:
pass
collection = client.get_or_create_collection("documents")
for fichier in sorted((RACINE / "textes").glob("*.txt")):
morceaux = fragments(fichier.read_text(encoding="utf-8"))
if not morceaux:
print(fichier.name, ": ignoré, aucun texte")
continue
vecteurs = ollama.embed(
model=MODELE,
input=["search_document: " + m for m in morceaux],
)["embeddings"]
collection.add(
ids=[f"{fichier.stem}-{i}" for i in range(len(morceaux))],
documents=morceaux,
embeddings=vecteurs,
metadatas=[{"source": fichier.name}] * len(morceaux),
)
print(fichier.name, ":", len(morceaux), "fragments")
print("total :", collection.count())
python ~/rag/indexer.py
Ce qu’on doit voir : une ligne par PDF, du type rapport.txt : 312 fragments, puis total : 399, le nombre de vecteurs réellement rangés dans la collection. Les vecteurs d’Ollama sont normalisés, et Chroma compare en distance L2 par défaut : sur des vecteurs de norme 1, le classement L2 équivaut au classement par similarité cosinus. Tout l’index reste dans ~/rag/index.
6. La question part avec search_query:, et la réponse cite le fichier
La question subit exactement le même traitement que les documents, avec l’autre préfixe, puis les trois fragments les plus proches sont collés dans le message envoyé au modèle. Le script à enregistrer dans ~/rag/question.py :
import pathlib
import sys
import chromadb
import ollama
RACINE = pathlib.Path.home() / "rag"
question = " ".join(sys.argv[1:])
client = chromadb.PersistentClient(path=str(RACINE / "index"))
collection = client.get_collection("documents")
vecteur = ollama.embed(model="nomic-embed-text", input="search_query: " + question)["embeddings"][0]
resultats = collection.query(query_embeddings=[vecteur], n_results=3)
for extrait, meta, distance in zip(resultats["documents"][0], resultats["metadatas"][0], resultats["distances"][0]):
print(f"[{meta['source']} distance={distance:.3f}]", extrait[:100])
contexte = "\n\n".join(resultats["documents"][0])
reponse = ollama.chat(
model="llama3.2:3b",
messages=[
{"role": "system", "content": "Réponds uniquement à partir du contexte fourni et cite le fichier source."},
{"role": "user", "content": "Contexte :\n" + contexte + "\n\nQuestion : " + question},
],
)
print(reponse["message"]["content"])
python ~/rag/question.py "quelle est la duree de garantie prevue au contrat ?"
Ce qu’on doit voir : trois lignes commençant par le nom du fichier et une distance, la plus petite d’abord, puis la réponse du modèle qui reprend les termes du document. Reste à vérifier que rien ne sort, avec les sockets en écoute :
sudo ss -tlnp | grep 11434
La ligne attendue ressemble à LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:* users:(("ollama",pid=...,fd=...)) : le service n’écoute que sur la boucle locale. Coupez le Wi-Fi ou débranchez la prise réseau, relancez la question : la réponse doit arriver identique, sans attente supplémentaire.
Changer de modèle d’embedding casse l’index : InvalidDimensionException
La dimension d’une collection Chroma est fixée par le premier vecteur inséré et ne se modifie plus ensuite. Passer de nomic-embed-text (768 dimensions) à mxbai-embed-large (1 024 dimensions) produit cette erreur, citée telle quelle dans la FAQ Chroma :
chromadb.errors.InvalidDimensionException: Embedding dimension 1024 does not match collection dimensionality 768
La cause n’est pas un bogue : la collection a été verrouillée à 768 lors de la première insertion, et toute lecture ou écriture ultérieure doit utiliser la même largeur. Le remède tient en une ligne, supprimer la collection puis relancer l’indexation complète ; le script de l’étape 5 le fait déjà à chaque exécution. Les largeurs diffèrent d’un modèle à l’autre : all-minilm sort 384 dimensions pour 46 Mo, bge-m3 et qwen3-embedding:0.6b en sortent 1 024. Et changer de modèle à dimension égale ne suffit pas : deux modèles différents produisent des vecteurs incomparables, il faut réindexer tout le corpus. C’est la règle qu’applique Open WebUI, dont le bouton Reindex supprime la collection avant de tout recalculer.
Questions fréquentes
Faut-il une carte graphique ?
Non. nomic-embed-text pèse 274 Mo et tourne sur processeur, llama3.2:3b 2,0 Go également, plus lentement. Ollama embarque des bibliothèques de calcul dédiées au processeur (cpu, cpu_avx, cpu_avx2) et fonctionne sans GPU ; pour un modèle 8B en Q4_K_M, comptez environ 5 Go de mémoire. Un modèle d’embedding de 1 Go occupe donc souvent plus de place que l’index lui-même.
nomic-embed-text est-il bon en français ?
C’est un modèle anglophone, crédité de 62,28 sur la piste MTEB English v1. Pour un corpus français, bge-m3 (1 024 dimensions, 8 192 de contexte, plus de 100 langues, environ 1,2 Go) ou qwen3-embedding:0.6b (1 024 dimensions, 32K de contexte, 639 Mo, 64,33 sur la piste MTEB multilingue) sont mieux placés. Passer de l’un à l’autre change la dimension de la collection : l’index doit être reconstruit, et le script d’indexation s’en charge puisque sa première instruction supprime la collection.
Peut-on se passer de la ligne de commande ?
Oui, Open WebUI fait la même chose dans un navigateur : les fichiers déposés sont découpés et indexés automatiquement, et la documentation recommande pour un modèle local à contexte réduit 1 000 caractères par fragment, 100 de chevauchement et un Top K de 3 à 5. Deux pièges y sont documentés : changer de modèle d’embedding exige de lancer Reindex, sinon les anciens vecteurs restent en place ; et Ollama choisit 4 096 jetons de contexte sur les machines à moins de 24 GiB de mémoire vidéo, ce qui tronque les questions longues. L’installation est décrite dans installer Ollama.
Combien de fragments pour un PDF de 300 pages ?
Un PDF de 300 pages contient couramment 700 000 à 900 000 caractères, soit environ 1 100 fragments de 1 000 caractères au chevauchement près. Chroma stocke les vecteurs et le texte dans ~/rag/index, et collection.count() renvoie le total exact après indexation. Un vecteur de 768 dimensions en float32 occupe 3 072 octets, donc un index de 1 100 fragments tient largement dans les quelques mégaoctets de la base.
Comment savoir si Ollama est exposé au réseau ?
Une seule commande, sudo ss -tlnp | grep 11434 : si la ligne affiche 127.0.0.1:11434, seul le poste peut appeler le service. Une variable OLLAMA_HOST réglée sur 0.0.0.0:11434 dans l’unité systemd transforme cette ligne en 0.0.0.0:11434, visible depuis toute la machine d’à côté.