DEV-AI
RAG Ollama LangChain ChromaDB Python

Publié le · Lecture : 14 min

RAG avec Ollama en Python : connectez vos documents à un LLM local

Ce que vous allez construire : un pipeline RAG complet — ingestion de documents, chunking, embeddings vectoriels, stockage ChromaDB, retrieval sémantique et génération avec Ollama — 100% local, zéro donnée envoyée en dehors de votre machine.
nomic-embed-text · ChromaDB · LangChain PDF · TXT · Markdown · HTML Réponses ancrées dans les sources

1. Pourquoi RAG ? Le problème que ça résout

Les LLM ont deux limitations fondamentales que le RAG résout directement :

Problème 1 — Connaissance gelée

Le modèle ne connaît que ce qui était dans ses données d'entraînement. Il ignore vos documents internes, vos contrats, votre base de connaissances, et tout ce qui est apparu après sa date de coupure.

Problème 2 — Hallucinations

Quand le modèle ne sait pas, il invente. Sur des faits précis (chiffres, noms, procédures internes), les hallucinations sont fréquentes — elles persistent même avec RAG, mais le fait d'ancrer la génération dans des sources réelles les réduit significativement.

Le RAG résout les deux : avant de générer une réponse, le système récupère les passages les plus pertinents de vos documents, puis les donne au LLM comme contexte. Le modèle répond en s'appuyant sur des preuves réelles, pas sur ses paramètres.

Ce que le RAG apporte concrètement
↓ Halluc.
Ancrage dans
des sources réelles
0€
Coût d'inférence
(avec Ollama local)
Mise à jour
sans réentraînement

2. Architecture d'un pipeline RAG — les deux phases

Un pipeline RAG se décompose en deux phases distinctes : l'ingestion (offline, une seule fois) et la requête (runtime, à chaque question).

PHASE 1 — INGESTION (offline) PHASE 2 — REQUÊTE (runtime) Documents PDF, TXT, MD, HTML Chunking 512 tokens, 10% overlap Embedding nomic-embed-text (768d) Base vectorielle ChromaDB (persistant sur disque) Chunks chunk_1 : "Politique RH..." chunk_2 : "Article 3.2..." chunk_n : "..." Question "Quelle est la politique..." Embed Query même modèle d'embedding Similarity Search Top-K chunks (cosine) query LLM Ollama Prompt + contexte récupéré Réponse ancrée dans les sources

Pipeline RAG complet — phase d'ingestion (gauche) construite une fois, phase de requête (droite) exécutée à chaque question

3. Prérequis et installation

Avant de commencer, assurez-vous qu'Ollama est installé et tourne en arrière-plan (voir notre guide Ollama). Puis installez les dépendances Python :

pip install langchain langchain-community langchain-ollama langchain-chroma langchain-text-splitters chromadb pypdf

Téléchargez les deux modèles Ollama nécessaires — un LLM pour la génération, un modèle d'embedding pour la vectorisation :

# Modèle de génération (LLM)
ollama pull mistral

# Modèle d'embedding — open source, 768 dimensions, surpasse ada-002 d'OpenAI
ollama pull nomic-embed-text
Pourquoi nomic-embed-text ? C'est le modèle d'embedding open source recommandé pour Ollama : 768 dimensions, surpasse text-embedding-ada-002 d'OpenAI sur les benchmarks court et long format. Note : la fenêtre de contexte dans Ollama est de 2 048 tokens — suffisant pour des chunks bien dimensionnés. Disponible directement depuis la bibliothèque Ollama, aucun compte externe requis.

4. Phase 1 — Ingestion : charger et chunker vos documents

L'ingestion transforme vos documents bruts en chunks (morceaux de texte) prêts à être vectorisés. C'est l'étape qui détermine le plus la qualité finale du système.

4.1 Charger des documents

from langchain_community.document_loaders import PyPDFLoader, TextLoader, DirectoryLoader

# Charger un PDF
loader = PyPDFLoader("contrat.pdf")
documents = loader.load()

# Charger tous les fichiers .txt d'un dossier
loader = DirectoryLoader("./docs/", glob="**/*.txt", loader_cls=TextLoader)
documents = loader.load()

# Charger tous les PDF d'un dossier
loader = DirectoryLoader("./docs/", glob="**/*.pdf", loader_cls=PyPDFLoader)
documents = loader.load()

print(f"{len(documents)} documents chargés")

4.2 Chunker les documents

Le chunking découpe chaque document en morceaux superposables. La taille des chunks est un paramètre très impactant — le chunking influence fortement la qualité de récupération, et il faut le mesurer sur ses propres documents plutôt que d'appliquer une recette universelle.

from langchain_text_splitters import RecursiveCharacterTextSplitter

# Configuration recommandée (point de départ — ajustez selon vos docs)
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,        # 512 caractères (≈ 80-120 mots selon la densité du texte)
    chunk_overlap=50,      # ~10% de chevauchement pour maintenir le contexte
    separators=["\n\n", "\n", ".", " ", ""]  # Priorité : paragraphe > phrase > mot
)

chunks = splitter.split_documents(documents)
print(f"{len(chunks)} chunks créés")

# Inspecter un chunk
print(chunks[0].page_content[:200])
print(chunks[0].metadata)  # {"source": "contrat.pdf", "page": 0}
Attention : chunk_size est en caractères, pas en tokens. RecursiveCharacterTextSplitter(chunk_size=512) découpe en segments de 512 caractères (≈ 80-120 mots). Pour un chunking vraiment basé sur le nombre de tokens (utile si vous voulez coller précisément aux limites de contexte), utilisez RecursiveCharacterTextSplitter.from_tiktoken_encoder(chunk_size=512) qui tokenise avec tiktoken avant de couper.

5. Phase 2 — Embeddings : vectoriser les chunks

Chaque chunk est converti en un vecteur de nombres (768 dimensions avec nomic-embed-text) qui encode sa signification sémantique. Deux chunks proches dans cet espace vectoriel ont un sens similaire — c'est ce qui permet la recherche par sens et non par mots-clés.

from langchain_ollama import OllamaEmbeddings

# Initialiser le modèle d'embedding (tourne via Ollama local)
embeddings = OllamaEmbeddings(model="nomic-embed-text")

# Tester sur un texte
vector = embeddings.embed_query("Quelle est la politique de congés ?")
print(f"Dimensions : {len(vector)}")  # 768
Important : le modèle d'embedding utilisé à l'ingestion doit être exactement le même à la requête. Mélanger les modèles produit des espaces vectoriels incompatibles et des résultats incohérents.

6. Phase 3 — ChromaDB : stocker et indexer

ChromaDB est une base de données vectorielle open source, légère et sans serveur — elle persiste directement sur disque. Elle indexe vos embeddings et permet une recherche par similarité cosinus rapide, même sur des milliers de documents, sans infrastructure externe à gérer.

from langchain_chroma import Chroma

# Créer la base et indexer les chunks (première fois)
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",   # Stockage persistant sur disque
    collection_name="mes_documents"
)

# Recharger une base existante (appels suivants)
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings,
    collection_name="mes_documents"
)

# Vérifier le nombre de documents indexés
print(f"{vectorstore._collection.count()} chunks indexés")
Persistance : le répertoire ./chroma_db contient toute la base vectorielle. L'ingestion (coûteuse en temps) se fait une seule fois. Les requêtes suivantes chargent simplement la base existante.

7. Phase 4 — Retrieval : trouver les passages pertinents

À chaque question, le système vectorise la requête avec le même modèle d'embedding, puis cherche les K chunks les plus similaires dans ChromaDB par distance cosinus.

# Créer un retriever — récupère les 3 chunks les plus proches
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 3}
)

# Tester le retrieval directement
question = "Quelle est la politique de télétravail ?"
docs_retrieved = retriever.invoke(question)

for i, doc in enumerate(docs_retrieved):
    print(f"--- Chunk {i+1} (source: {doc.metadata.get('source', '?')}) ---")
    print(doc.page_content[:300])
    print()

Le paramètre k=3 est un compromis : trop petit et vous ratez des informations pertinentes, trop grand et vous polluez le contexte du LLM avec des passages hors-sujet. Commencez avec k=3 ou k=4, ajustez selon vos résultats.

8. Phase 5 — Génération : répondre avec Ollama

Les chunks récupérés sont assemblés dans un prompt structuré envoyé au LLM. La clé est un prompt qui force le modèle à s'appuyer sur le contexte fourni plutôt que sur ses paramètres.

from langchain_ollama import OllamaLLM
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain

# LLM local
llm = OllamaLLM(model="mistral", temperature=0.1)  # temperature basse = réponses plus factuelles

# Prompt template — forcer l'ancrage dans le contexte
prompt = ChatPromptTemplate.from_template("""Tu es un assistant expert qui répond uniquement à partir des documents fournis.
Si la réponse n'est pas dans le contexte, dis-le explicitement — ne devine pas.

Contexte :
{context}

Question : {input}

Réponse (basée exclusivement sur le contexte ci-dessus) :""")

# Chaîne RAG — API LCEL (recommandée depuis LangChain 0.1+)
combine_docs_chain = create_stuff_documents_chain(llm, prompt)
rag_chain = create_retrieval_chain(retriever, combine_docs_chain)

# Poser une question
result = rag_chain.invoke({"input": "Quelle est la politique de télétravail ?"})
print(result["answer"])
print("\nSources :")
for doc in result["context"]:
    print(f"  - {doc.metadata.get('source')} (page {doc.metadata.get('page', '?')})")

9. Pipeline complet en 80 lignes

Voici le pipeline RAG complet, de l'ingestion à la réponse, dans un seul fichier réutilisable :

"""
Pipeline RAG local — Ollama + LangChain + ChromaDB
Usage : python rag_pipeline.py
"""
import os
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings, OllamaLLM
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain

# --- Configuration ---
DOCS_DIR = "./docs"          # Dossier contenant vos documents
CHROMA_DIR = "./chroma_db"   # Stockage persistant ChromaDB
EMBED_MODEL = "nomic-embed-text"
LLM_MODEL = "mistral"
CHUNK_SIZE = 512
CHUNK_OVERLAP = 50
TOP_K = 3

# --- Initialisation ---
embeddings = OllamaEmbeddings(model=EMBED_MODEL)
llm = OllamaLLM(model=LLM_MODEL, temperature=0.1)

def ingest_documents() -> Chroma:
    """Charge, chunke et indexe tous les documents du dossier DOCS_DIR."""
    loaders = [
        DirectoryLoader(DOCS_DIR, glob="**/*.pdf", loader_cls=PyPDFLoader),
        DirectoryLoader(DOCS_DIR, glob="**/*.txt", loader_cls=TextLoader),
    ]
    documents = []
    for loader in loaders:
        try:
            documents.extend(loader.load())
        except Exception:
            pass

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP
    )
    chunks = splitter.split_documents(documents)
    print(f"Ingestion : {len(documents)} documents → {len(chunks)} chunks")

    return Chroma.from_documents(
        documents=chunks, embedding=embeddings,
        persist_directory=CHROMA_DIR, collection_name="rag_docs"
    )

def load_vectorstore() -> Chroma:
    """Recharge une base existante sans ré-ingérer."""
    return Chroma(
        persist_directory=CHROMA_DIR,
        embedding_function=embeddings,
        collection_name="rag_docs"
    )

def build_rag_chain(vectorstore: Chroma):
    prompt = ChatPromptTemplate.from_template(
        """Tu es un assistant qui répond uniquement à partir des documents fournis.
Si la réponse est absente du contexte, indique-le clairement.

Contexte :
{context}

Question : {input}

Réponse :"""
    )
    combine_docs_chain = create_stuff_documents_chain(llm, prompt)
    return create_retrieval_chain(
        vectorstore.as_retriever(search_kwargs={"k": TOP_K}),
        combine_docs_chain
    )

if __name__ == "__main__":
    # Ingestion si la base n'existe pas encore, sinon rechargement
    if not Path(CHROMA_DIR).exists():
        vectorstore = ingest_documents()
    else:
        vectorstore = load_vectorstore()
        print(f"Base chargée : {vectorstore._collection.count()} chunks")

    chain = build_rag_chain(vectorstore)

    # Boucle interactive
    print("\nRAG prêt. Posez vos questions (Ctrl+C pour quitter)\n")
    while True:
        question = input("Question : ")
        result = chain.invoke({"input": question})
        print(f"\nRéponse : {result['answer']}")
        print("Sources :", [d.metadata.get('source') for d in result['context']])
        print()

10. Chunking avancé : stratégies et benchmarks

Le choix de la stratégie de chunking est souvent sous-estimé. Voici les principales approches avec leurs cas d'usage :

Stratégie Taille indicative Idéal pour Limite
RecursiveCharacter (défaut) 512 car. (défaut) ou 512 tokens via from_tiktoken_encoder Textes généraux, contrats, docs internes Peut couper au milieu d'une idée complexe
Fixed size 256–1024 car. (ou tokens avec splitter dédié) Datasets homogènes, code, logs Ignore la structure sémantique
Par page (PDF) 1 page = 1 chunk PDFs avec structure de pages significative Pages longues = embeddings trop génériques
Parent-child Child : 100t / Parent : 512t Recherche précise + contexte riche Plus complexe à implémenter
Markdown / HTML headers Par section Documentation technique structurée Sections trop longues non gérées automatiquement
Le paradoxe du chunking : pour la recherche, de petits chunks (100-256 tokens) donnent une meilleure précision sémantique. Pour la génération, le LLM a besoin de contexte large (512+ tokens). La technique parent-child chunking résout ce paradoxe : on indexe de petits chunks, mais on récupère leurs parents plus larges pour la génération.

11. RAG vs fine-tuning — quand choisir quoi ?

Critère RAG Fine-tuning
Données qui changent souvent Idéal Réentraînement requis
Réduction des hallucinations Réduit (ancrage sources) Amélioration limitée
Style/format de réponse imposé Prompt engineering Encodé dans les poids
Données privées / sensibles Restent hors du modèle Encodées dans les poids
Coût de mise en place Faible (quelques heures) Élevé (GPU + données)
Comportement spécialisé (ton, domaine) Limité au prompt Profond et stable
En pratique (2026) : la plupart des systèmes de production combinent les deux. Fine-tuning pour la consistance de format et le ton de marque, RAG pour la connaissance dynamique et la réduction des hallucinations. Commencez toujours par le RAG — c'est plus rapide à déployer et couvre 80% des cas d'usage.

12. Limites et pièges à éviter

Chunking trop grand ou trop petit

Des chunks >1024 tokens donnent des embeddings trop génériques — la recherche sémantique perd en précision. Des chunks <100 tokens perdent le contexte nécessaire à la génération. Testez sur un échantillon représentatif avant de tout indexer.

Modèles d'embedding différents entre ingestion et requête

Si vous changez de modèle d'embedding, vous devez re-ingérer toute la base. Les espaces vectoriels de deux modèles différents sont incompatibles — la similarité cosinus entre eux est sans signification.

RAG ne corrige pas les documents incorrects

Le modèle répond en s'appuyant sur vos documents. Si vos documents contiennent des erreurs, le RAG va les propager avec confiance. La qualité des sources détermine la qualité des réponses.

Top-K trop élevé = contexte pollué

Récupérer k=10 chunks pour remplir le contexte semble intuitif, mais les chunks les moins pertinents introduisent du bruit. Le LLM peut alors s'appuyer sur ces passages hors-sujet. Commencez avec k=3, montez à k=5 si vous manquez d'informations.

Scalabilité ChromaDB

ChromaDB convient très bien aux projets locaux et à de nombreux volumes intermédiaires. Pour des volumes très importants ou des besoins d'indexation avancée, des bases spécialisées comme Qdrant, Weaviate ou Milvus peuvent être plus adaptées.

FAQ

Peut-on utiliser autre chose que ChromaDB ?

Oui. LangChain supporte de nombreuses bases vectorielles : FAISS (en mémoire, idéal pour les petits projets), Qdrant (performances et scalabilité), Weaviate (graphe + vecteur), Milvus (enterprise). ChromaDB est recommandé pour débuter car il ne requiert aucune infrastructure externe et persiste automatiquement sur disque.

Comment améliorer la qualité de récupération ?

Plusieurs techniques avancées : Hybrid Search (combinaison similarité cosinus + BM25 lexical via Reciprocal Rank Fusion), re-ranking (passer les top-100 résultats dans un cross-encoder pour ne garder que les top-5), parent-child chunking (embed petits chunks, récupère les parents). Ces techniques sont importantes en production mais complexifient le pipeline — maîtrisez d'abord la version simple.

Le RAG fonctionne-t-il avec des documents en français ?

Oui pour le français, avec nuances. nomic-embed-text (v1) est principalement entraîné sur de l'anglais — il fonctionne en français mais sans garantie de qualité benchmark. Pour un usage multilingue sérieux, préférez nomic-embed-text-v2-moe (disponible sur Ollama) : architecture MoE, ~100 langues, dimensions flexibles 256-768. Le LLM de génération (Mistral 7B, Llama 3.1) gère le français nativement.

Combien de temps prend l'ingestion ?

Sur un GPU RTX 4070 avec nomic-embed-text : environ 100-200 chunks/seconde. Un document de 50 pages (~500 chunks) s'indexe en 3-5 secondes. 1 000 documents PDF = 15-30 minutes selon la densité. L'ingestion se fait une seule fois — les requêtes suivantes chargent simplement la base existante en <1 seconde.

Conclusion

Le RAG est aujourd'hui l'une des techniques les plus pratiques et efficaces pour connecter un LLM à des données privées ou évolutives sans réentraînement. En combinant Ollama (inférence locale), nomic-embed-text (embeddings), LangChain (orchestration) et ChromaDB (stockage vectoriel), vous disposez d'un pipeline complet, 100% local, sans dépendance cloud, opérationnel en quelques dizaines de lignes de code.

Un bon point de départ pratique : chunks de taille moyenne avec léger overlap (ex. 512 car., ~50 car. de chevauchement), k=3. Mais mesurez sur vos vraies questions avant d'optimiser — la configuration de chunking a plus d'impact que le choix du LLM, et le "bon" réglage dépend de vos documents.

Par où commencer : créez un dossier ./docs, copiez-y 3-4 documents PDF, lancez le pipeline complet ci-dessus. Posez des questions sur le contenu — si le retrieval retourne les bons passages, votre pipeline fonctionne. Optimisez ensuite le chunking et le prompt selon vos cas d'usage spécifiques.

Articles liés

LLM LOCAL
Ollama : héberger un LLM localement
Installation, modèles, VRAM, API Python — le guide complet.
TRANSCRIPTION
Whisper Python pour le français
Transcription audio locale avec OpenAI Whisper.
AGENTS IA
MCP : Model Context Protocol
Connecter des outils externes à un LLM via MCP.