Vous avez 200 PDF de procédures internes, de contrats OHADA, de fiches produits, et chaque jour vos employés ou vos clients posent des questions dont la réponse est cachée quelque part dans ces fichiers. Apprendre à un modèle d’IA à répondre à ces questions ne demande pas de l’entraîner (long et coûteux) : il suffit de lui fournir, à chaque question, les passages pertinents de vos documents pour qu’il rédige une réponse fondée. Cette technique s’appelle RAG (Retrieval-Augmented Generation, ou « génération augmentée par récupération »). Cet article vous explique chaque brique, puis construit un assistant RAG fonctionnel en Python avec pgvector et l’API Anthropic.
1 — Comprendre le RAG en deux minutes
Imaginez un consultant qui doit répondre à des questions de droit OHADA. Il a deux options :
- Mémoriser tout le code OHADA par cœur (équivalent à fine-tuner un modèle).
- Garder un classeur bien indexé et, à chaque question, retrouver les bons articles avant de rédiger la réponse (équivalent au RAG).
L’option 2 est presque toujours préférable : moins coûteuse, plus rapide à mettre en place, plus simple à mettre à jour quand un texte change. Le RAG fait exactement cela en automatique : il transforme la question de l’utilisateur en empreinte numérique, retrouve les empreintes les plus proches dans la base de documents, puis envoie ces extraits à un LLM (Claude, GPT, Llama) qui synthétise la réponse.
Trois briques essentielles :
- Embeddings — un vecteur de 768 ou 1536 nombres qui représente la « signification » d’un texte. Deux textes proches sémantiquement ont des vecteurs proches géométriquement (cosine similarity élevée).
- Vector store — une base de données qui stocke ces vecteurs et les retrouve rapidement par proximité.
- LLM — modèle de langage qui rédige la réponse à partir des extraits récupérés.
2 — La stack 2026 minimale
Plusieurs combinaisons fonctionnent. La plus pragmatique en 2026 pour démarrer rapidement, sans dépendre d’une grosse plateforme :
| Brique | Choix recommandé | Alternative |
|---|---|---|
| Extraction PDF | pypdf (texte natif), pdfplumber (avec tableaux) | Apache Tika, MuPDF |
| Chunking | Code maison ou LangChain RecursiveCharacterTextSplitter | Unstructured.io |
| Embeddings | OpenAI text-embedding-3-small (cloud) ou nomic-embed-text via Ollama (local) |
Voyage AI voyage-3, Mistral Embed |
| Vector store | pgvector (PostgreSQL extension) ou Qdrant | Pinecone, Weaviate, Chroma |
| LLM | Claude Sonnet 4.6 ou Haiku 4.5 (API), Llama 3.3 (Ollama) | GPT-4.1 mini, Gemini 1.5 Flash |
| Orchestration | Code Python direct (asyncio + httpx) | LlamaIndex pour démarrer plus vite |
| Interface | Streamlit (proto) ou Next.js (prod) | Gradio, FastAPI + React |
Pour un assistant interne PME ouest-africaine traitant 50 à 500 questions/jour, la combinaison pgvector + nomic-embed-text + Claude Haiku 4.5 tourne sur un VPS Hetzner CX22 (8 EUR/mois) avec un coût d’inférence Anthropic d’environ 0,01 USD par question.
3 — Préparer l’environnement
# Sur Ubuntu 24.04
sudo apt update
sudo apt install -y python3.12 python3-pip postgresql-16 postgresql-16-pgvector
# Activer pgvector dans la base
sudo -u postgres psql -c "CREATE DATABASE rag_demo;"
sudo -u postgres psql -d rag_demo -c "CREATE EXTENSION vector;"
# Créer l'environnement Python
python3.12 -m venv .venv
source .venv/bin/activate
pip install pypdf pdfplumber psycopg[binary] anthropic httpx tiktoken streamlit
Si vous préférez les embeddings locaux (pas de coût API, pas de fuite de données), installer Ollama :
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text # 274 Mo, embeddings 768 dim
ollama pull llama3.3:8b # 4.7 Go, modèle de réponse
Le modèle nomic-embed-text est libre (Apache 2.0), tourne sur CPU, et produit des embeddings de qualité comparable à OpenAI ada-002 sur les benchmarks MTEB français.
4 — Schéma de la base vectorielle
Avec PostgreSQL + pgvector, on stocke les chunks et leurs embeddings dans une seule table :
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
source_file TEXT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding VECTOR(768),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Index pour recherche rapide par similarité cosinus
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
L’index HNSW (Hierarchical Navigable Small World) accélère la recherche des plus proches voisins de quelques secondes à quelques millisecondes pour des bases de plusieurs millions de vecteurs.
5 — Extraire et chunker les PDFs
Un PDF de 200 pages ne peut pas être envoyé tel quel au LLM (limite de contexte) ni au modèle d’embeddings (qui prend 512 tokens en moyenne). On le découpe en « chunks » — morceaux de 500 à 1 000 tokens — qui se chevauchent légèrement pour ne pas couper une phrase au milieu.
import pypdf
def extract_text(pdf_path: str) -> str:
reader = pypdf.PdfReader(pdf_path)
text = "\n".join(page.extract_text() or "" for page in reader.pages)
# Nettoyer les sauts de ligne abusifs
return "\n".join(line.strip() for line in text.splitlines() if line.strip())
def chunk_text(text: str, max_tokens: int = 800, overlap: int = 100) -> list[str]:
# Approximation : 1 token ≈ 4 caractères en français
max_chars = max_tokens * 4
overlap_chars = overlap * 4
chunks = []
start = 0
while start < len(text):
end = min(start + max_chars, len(text))
# Couper à la fin d'une phrase si possible
if end < len(text):
last_dot = text.rfind('.', start, end)
if last_dot > start + max_chars // 2:
end = last_dot + 1
chunks.append(text[start:end].strip())
start = end - overlap_chars
return [c for c in chunks if len(c) > 50]
Le chevauchement (overlap) sert à conserver le contexte aux frontières : si une question concerne un terme qui apparaît juste à la coupure, l’un des deux chunks contiendra le passage entier.
6 — Indexer les chunks dans pgvector
Pour chaque chunk, on calcule son embedding via Ollama (local) ou un fournisseur cloud, puis on l’insère en base.
import httpx
import psycopg
import json
OLLAMA_URL = "http://localhost:11434"
DB_URL = "postgresql://localhost/rag_demo"
async def embed_text(text: str) -> list[float]:
async with httpx.AsyncClient() as c:
r = await c.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": "nomic-embed-text", "prompt": text},
timeout=60,
)
return r.json()["embedding"]
async def index_pdf(pdf_path: str):
text = extract_text(pdf_path)
chunks = chunk_text(text)
with psycopg.connect(DB_URL) as conn:
with conn.cursor() as cur:
for i, chunk in enumerate(chunks):
emb = await embed_text(chunk)
cur.execute(
"INSERT INTO documents (source_file, chunk_index, content, embedding) "
"VALUES (%s, %s, %s, %s)",
(pdf_path, i, chunk, json.dumps(emb)),
)
conn.commit()
print(f"{len(chunks)} chunks indexés depuis {pdf_path}")
Lancer l’indexation sur un dossier de PDFs :
import asyncio, glob
async def main():
for pdf in glob.glob("documents/*.pdf"):
await index_pdf(pdf)
asyncio.run(main())
Sur un VPS modeste, comptez 2 à 5 secondes par chunk en local (CPU), 200 ms par chunk via API OpenAI. Pour 100 PDFs de 50 pages, soit environ 2 000 chunks, l’indexation prend 1 à 2 heures en local ou 10 minutes en cloud.
7 — Le moteur de question-réponse
Au moment d’une question, trois étapes : embedder la question, retrouver les chunks proches, demander la synthèse à Claude.
import anthropic
CLAUDE = anthropic.Anthropic() # ANTHROPIC_API_KEY en variable d'env
async def answer(question: str, k: int = 5) -> dict:
q_emb = await embed_text(question)
with psycopg.connect(DB_URL) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT source_file, content, "
"1 - (embedding <=> %s::vector) AS score "
"FROM documents "
"ORDER BY embedding <=> %s::vector "
"LIMIT %s",
(json.dumps(q_emb), json.dumps(q_emb), k),
)
chunks = cur.fetchall()
context = "\n\n---\n\n".join(
f"[Source: {fp}]\n{c}" for fp, c, _ in chunks
)
response = CLAUDE.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=800,
system=(
"Tu réponds aux questions en t'appuyant uniquement sur les extraits "
"fournis. Si la réponse n'y figure pas, dis-le clairement. Cite la source "
"à la fin de chaque affirmation entre crochets."
),
messages=[{
"role": "user",
"content": f"Question: {question}\n\nExtraits:\n{context}"
}]
)
return {
"answer": response.content[0].text,
"sources": [{"file": fp, "score": float(s)} for fp, _, s in chunks],
}
L’opérateur <=> de pgvector calcule la distance cosinus entre deux vecteurs ; 1 - distance donne la similarité (entre 0 et 1, où 1 = identique). On récupère les 5 chunks les plus pertinents.
Le system prompt est crucial : il instruit le modèle à refuser de répondre si l’information ne figure pas dans les extraits. Sans cette consigne, le modèle « complète » avec ses connaissances générales et invente des références — ce qu’on appelle une hallucination.
8 — Interface Streamlit pour tester
# app.py
import streamlit as st
import asyncio
st.title("Assistant FAQ — vos documents")
question = st.text_input("Votre question")
if question:
with st.spinner("Recherche..."):
result = asyncio.run(answer(question))
st.markdown(result["answer"])
with st.expander("Sources consultées"):
for src in result["sources"]:
st.caption(f"{src['file']} (score {src['score']:.2f})")
Lancer avec streamlit run app.py. Page accessible sur http://localhost:8501. Pour la production, basculer vers FastAPI + Next.js, mais Streamlit est largement suffisant pour valider le fonctionnement avec les premiers utilisateurs.
9 — Améliorations qui changent vraiment la qualité
Reranking
Les 5 chunks ramenés par similarité ne sont pas toujours dans le bon ordre de pertinence. Un modèle de reranking (par exemple cohere-rerank-3 ou jina-rerank-v2) re-classe ces 5 chunks en lisant question et chunk ensemble. Gain typique sur les benchmarks : +15 à 25 % de précision.
OCR pour les PDFs scannés
Si pypdf renvoie une chaîne vide, le PDF est probablement un scan d’image. Utiliser Tesseract OCR ou Claude Vision (qui voit l’image et extrait le texte) :
# Avec Tesseract
sudo apt install tesseract-ocr tesseract-ocr-fra
pip install pytesseract pdf2image
import pytesseract
from pdf2image import convert_from_path
def ocr_pdf(path):
images = convert_from_path(path, dpi=200)
return "\n".join(pytesseract.image_to_string(img, lang='fra') for img in images)
Filtrage par métadonnées
Stocker des métadonnées (date, catégorie, auteur) à côté de l’embedding permet de filtrer la recherche : « uniquement les contrats OHADA postérieurs à 2023 ». Ajouter une colonne JSONB dans la table documents et filtrer dans la requête SQL avec WHERE metadata->>'category' = 'ohada'.
10 — Pièges fréquents
- Chunks trop grands — au-delà de 1 200 tokens, les embeddings perdent en précision. Garder 500-800.
- Chunks trop petits — sous 200 tokens, le contexte sémantique est insuffisant et la recherche devient bruitée.
- Embeddings de modèles différents mélangés — un index ne peut contenir que des vecteurs du même modèle d’embeddings. Si vous changez de modèle, ré-indexer tout.
- Pas de citation des sources — sans source, l’utilisateur ne peut pas vérifier la réponse. Le modèle perd toute crédibilité au premier doute.
- Modèle qui invente quand il ne sait pas — relancer avec un system prompt plus strict ou abaisser
temperatureà 0,1.
Références
- pgvector — extension PostgreSQL
- nomic-embed-text sur Ollama
- Anthropic — API Messages
- Claude — outils et agents
- Serveur MCP sur PostgreSQL en Python — agent IA sécurisé