Publié le · Lecture : 14 min
RAG avec Ollama en Python : connectez vos documents à un LLM local
1. Pourquoi RAG ? Le problème que ça résout
Les LLM ont deux limitations fondamentales que le RAG résout directement :
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.
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.
des sources réelles
(avec Ollama local)
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).
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
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}
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
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")
./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 |
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 |
12. Limites et pièges à éviter
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.
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.
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.
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.
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.
./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.