Intermédiaire 22 minRAG

RAG en local avec ChromaDB et Ollama : tutoriel Python

Faire du rag chromadb ollama python en local, c'est trois briques qui s'imbriquent : un vector store qui persiste sur disque (ChromaDB), un modèle d'embeddings qui transforme vos chunks en vecteurs (nomic-embed-text via Ollama), et un LLM de chat qui répond en s'appuyant sur les passages retrouvés. Pas de clé d'API, pas de fuite de données. Ce guide vous fait passer d'un PDF brut à un chatbot qui cite ses sources en 22 minutes.

Par Mohamed Meguedmi·Màj 2026-06-10·Testé sur Windows, macOS, Linux

#Pourquoi cette stack pour un RAG local

Beaucoup de tutos RAG commencent par LangChain ou LlamaIndex. Ces frameworks sont puissants mais cachent ce qui se passe sous le capot. Ici, on écrit le pipeline à la main avec trois dépendances seulement. Vous comprendrez chaque étape, et vous saurez quoi optimiser plus tard.

ChromaDB
Vector store open source, pur Python, mode persistant intégré (SQLite + index HNSW). Pas de serveur à lancer.
Ollama
Sert à la fois le modèle d'embeddings (nomic-embed-text) et le LLM de chat (Mistral, Qwen, Llama). Endpoint HTTP unique sur localhost:11434.
Python natif
Quelques fonctions, pas de framework. Vous pourrez brancher LangChain plus tard si besoin, mais ce n'est pas nécessaire pour démarrer.
i
Ce que vous obtenez
Un script Python d'environ 150 lignes qui ingère un dossier de PDF, les chunke, les indexe dans ChromaDB, et répond à des questions en français avec citations. Tout en local, zéro requête sortante.

#Prérequis

Python 3.10+
ChromaDB demande 3.10 minimum. Vérifiez avec python --version.
Ollama installé et lancé
Le daemon écoute par défaut sur http://localhost:11434. Si vous partez de zéro, suivez d'abord le guide d'installation Ollama.
8 Go de RAM
16 Go confortables. Le modèle de chat 7B en Q4 prend ~5 Go, le modèle d'embeddings ~300 Mo.
Un GPU n'est pas obligatoire
L'inférence CPU fonctionne, juste plus lente. Pour l'ingestion d'un gros corpus, un GPU 6 Go+ accélère beaucoup les embeddings.

#1. Installer ChromaDB et préparer Ollama

On crée un environnement virtuel propre, on installe les trois libs nécessaires, et on télécharge les modèles côté Ollama.

Environnement Python
python -m venv .venv
source .venv/bin/activate  # sous Windows : .venv\Scripts\activate
pip install chromadb ollama pypdf

Trois paquets : chromadb pour le vector store, ollama pour le client Python officiel, pypdf pour lire les PDF. C'est tout.

Modèles Ollama
ollama pull nomic-embed-text
ollama pull mistral

nomic-embed-text est un modèle d'embeddings de 137M paramètres, multilingue, qui produit des vecteurs de dimension 768. Léger, rapide, bon en français. Mistral 7B sert au chat final. Vous pouvez le remplacer par qwen2.5:7b ou llama3.1:8b sans rien changer au code.

Vérifier qu'Ollama répond
Un simple curl http://localhost:11434/api/tags doit lister vos modèles. Si rien ne sort, le daemon n'est pas lancé : ollama serve dans un autre terminal.

#2. Configurer le modèle d'embeddings

Un embedding, c'est un vecteur qui représente le sens d'un texte. Deux textes proches sémantiquement ont des vecteurs proches. C'est le moteur du RAG : on cherche les chunks dont l'embedding ressemble le plus à celui de la question.

embed.py — test rapide
import ollama

resp = ollama.embeddings(
    model="nomic-embed-text",
    prompt="Le contrat est résilié de plein droit en cas de manquement grave."
)

vec = resp["embedding"]
print(f"Dimension du vecteur : {len(vec)}")
print(f"5 premières valeurs : {vec[:5]}")

Vous devriez voir Dimension du vecteur : 768. Si ça plante avec model not found, c'est que ollama pull nomic-embed-text n'a pas été fait.

i
Pourquoi nomic-embed-text
Sur des benchmarks FR (MTEB-fr), nomic-embed-text est dans le top 5 des modèles de moins de 200M paramètres. Pour du français pur, mxbai-embed-large fait souvent mieux mais pèse 670M. nomic est un excellent compromis qualité/vitesse pour démarrer.

#3. Ingestion de PDF en français

L'ingestion fait trois choses : lire les pages d'un PDF, découper le texte en chunks de taille raisonnable, et stocker chaque chunk avec son embedding dans ChromaDB en mode persistant.

ingest.py
import os
import chromadb
import ollama
from pypdf import PdfReader

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="docs")

def chunk_text(text, size=800, overlap=100):
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + size, len(text))
        chunks.append(text[start:end])
        start += size - overlap
    return chunks

def ingest_pdf(path):
    reader = PdfReader(path)
    name = os.path.basename(path)
    for page_num, page in enumerate(reader.pages):
        text = page.extract_text() or ""
        for i, chunk in enumerate(chunk_text(text)):
            emb = ollama.embeddings(
                model="nomic-embed-text",
                prompt=chunk
            )["embedding"]
            collection.add(
                ids=[f"{name}-p{page_num}-c{i}"],
                embeddings=[emb],
                documents=[chunk],
                metadatas=[{"source": name, "page": page_num + 1}],
            )
    print(f"OK : {name} ingéré ({len(reader.pages)} pages)")

if __name__ == "__main__":
    for f in os.listdir("./pdfs"):
        if f.endswith(".pdf"):
            ingest_pdf(f"./pdfs/{f}")

Le chunker découpe en blocs de 800 caractères avec 100 de chevauchement. C'est un point de départ : ni trop petit (manque de contexte) ni trop gros (dilue le signal). Pour du contenu juridique très dense, descendez à 500. Pour des manuels techniques aérés, montez à 1200.

Le mode persistant de ChromaDB
PersistentClient(path="./chroma_db") crée un dossier qui survit aux redémarrages. SQLite stocke les métadonnées, un index HNSW les vecteurs. Pas de serveur à lancer, pas de Docker. Pour passer en mode client/serveur plus tard, il suffit de remplacer par HttpClient.

Lancez l'ingestion sur un dossier ./pdfs/ contenant vos documents :

Lancer l'ingestion
mkdir -p pdfs
# placez vos PDF dans ./pdfs/
python ingest.py
!
PDF scannés = pas de texte
pypdf n'extrait que le texte natif. Si vos PDF sont des scans d'images, extract_text() renverra du vide. Il faut alors passer par un OCR (Tesseract, ou un modèle vision comme Qwen2.5-VL via Ollama) avant l'ingestion.

#4. Recherche top-k dans ChromaDB

Une fois les chunks indexés, la recherche consiste à embedder la question puis demander à Chroma les k vecteurs les plus proches en distance cosinus. C'est instantané, même sur 100 000 chunks.

search.py
import chromadb
import ollama

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection(name="docs")

def search(question, k=4):
    q_emb = ollama.embeddings(
        model="nomic-embed-text",
        prompt=question
    )["embedding"]
    results = collection.query(
        query_embeddings=[q_emb],
        n_results=k,
    )
    chunks = results["documents"][0]
    metas = results["metadatas"][0]
    return list(zip(chunks, metas))

if __name__ == "__main__":
    hits = search("Quelles sont les conditions de résiliation ?")
    for chunk, meta in hits:
        print(f"[{meta['source']} p.{meta['page']}]")
        print(chunk[:200], "...\n")

k=4 est un bon défaut. Trop petit, vous ratez du contexte pertinent ; trop grand, vous noyez le LLM dans du bruit et explosez la fenêtre de contexte. Pour des questions très précises, k=2 suffit. Pour des questions transversales, montez à 6.

#5. Boucle de chat avec citations

Maintenant on assemble : on cherche les chunks pertinents, on construit un prompt avec le contexte, on envoie à Mistral via Ollama, et on demande au modèle de citer ses sources.

chat.py
import ollama
from search import search

SYSTEM = """Tu es un assistant qui répond uniquement à partir du CONTEXTE fourni.
Si la réponse n'est pas dans le contexte, dis-le clairement.
Cite tes sources entre crochets sous la forme [source.pdf p.X]."""

def ask(question):
    hits = search(question, k=4)
    context = "\n\n".join(
        f"[{m['source']} p.{m['page']}]\n{c}" for c, m in hits
    )
    prompt = f"CONTEXTE :\n{context}\n\nQUESTION : {question}"
    resp = ollama.chat(
        model="mistral",
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": prompt},
        ],
        options={"temperature": 0.2, "num_ctx": 8192},
    )
    return resp["message"]["content"]

if __name__ == "__main__":
    while True:
        q = input("\nQuestion (vide pour quitter) > ").strip()
        if not q:
            break
        print("\n" + ask(q))

Trois détails qui comptent. Premièrement, temperature=0.2 : on veut une réponse factuelle, pas créative. Deuxièmement, num_ctx=8192 : la fenêtre par défaut d'Ollama (2048) est trop courte une fois qu'on injecte 4 chunks de 800 caractères. Troisièmement, le system prompt force le modèle à dire 'je ne sais pas' plutôt qu'hallucinier — c'est le principal garde-fou anti-hallucination du RAG.

Streaming pour une meilleure UX
Remplacez ollama.chat par ollama.chat(..., stream=True) et itérez sur la réponse pour afficher les tokens au fur et à mesure. C'est crucial dès qu'on intègre ce code dans une vraie interface (FastAPI + WebSocket, ou Streamlit).

#6. Exemple concret : chatbot juridique sur des contrats

Imaginons un cabinet qui veut interroger 200 contrats de prestation en PDF. Avec la stack ci-dessus, en moins d'une heure on a un assistant qui répond à des questions du type :

Question typique
« Quels contrats incluent une clause de non-concurrence post-rupture supérieure à 12 mois ? »
Ce qui se passe
L'embedding de la question retrouve les chunks qui contiennent les mots-clés sémantiquement proches (non-concurrence, post-rupture, durée). Mistral lit ces 4 passages et répond avec les noms de fichiers concernés.
Garantie de confidentialité
Aucune donnée ne sort du poste. Pas de clé d'API. Aucune télémétrie. C'est ce qui distingue un RAG local d'un wrapper OpenAI.
!
Limites à connaître
Un RAG basique répond bien aux questions ciblées (« quelle est la clause X »), mal aux questions agrégatives (« combien de contrats ont X »). Pour ces dernières, il faut soit un agent qui interroge la base en plusieurs étapes, soit un GraphRAG. C'est une autre histoire.

#Dépannage

ChromaDB lent à l'ingestion
Le goulot est presque toujours l'appel embeddings vers Ollama. Vérifiez que nomic-embed-text tourne sur GPU avec ollama ps. Sur CPU, comptez ~50 chunks/seconde ; sur GPU, ~500.
« model not found »
Ollama ne trouve pas nomic-embed-text. Relancez ollama pull nomic-embed-text et vérifiez avec ollama list.
Réponses qui inventent des sources
Mistral 7B hallucine encore parfois. Passez à qwen2.5:14b ou mistral-small:24b si vous avez la VRAM. Ou ajoutez un reranker (cross-encoder) après ChromaDB pour filtrer les faux positifs.
Embeddings de mauvaise qualité en FR
nomic-embed-text est multilingue mais pas optimal en français pur. Pour du contenu juridique ou médical, testez Solon-embeddings-large-0.1 ou bge-m3 (à charger via sentence-transformers, hors Ollama).
ChromaDB grossit sans limite
Chaque réindexation ajoute des doublons. Avant de réingérer un PDF, faites collection.delete(where={"source": name}) pour purger les anciens chunks.

#Pour aller plus loin

Vous avez un RAG fonctionnel. Voici les chantiers naturels pour le pousser plus loin :

Comparer les modèles d'embeddings FR
Notre guide « Les meilleurs modèles d'embeddings FR » compare BGE, E5, Solon et nomic sur du contenu francophone.
Améliorer le chunking
« Stratégies de chunking » détaille le chunking sémantique, par titres markdown, ou par paragraphes — souvent ce qui débloque le plus de précision.
Ajouter un reranker
« Ajouter un reranker à son pipeline » : +15% de pertinence en plaçant un cross-encoder après Chroma. Le pas suivant logique.
Recherche hybride
« Recherche hybride BM25 + vectoriel » combine lexical et sémantique, indispensable dès qu'il y a beaucoup de jargon ou de noms propres.
Ce guide vous a aidé ?

Un retour, une erreur, une précision ? Faites-nous signe, ça améliore le guide pour tout le monde.