Créer un assistant capable de répondre à des questions à partir de vos propres documents est l’un des cas d’usage les plus utiles de l’intelligence artificielle aujourd’hui. Dans une entreprise, une école, une agence ou même un petit site e-commerce, on retrouve partout le même problème : l’information existe déjà dans des PDF, des guides, des procédures, des catalogues ou des contrats… mais elle reste difficile à retrouver rapidement.
Un collaborateur pose une question. Un client demande une précision. Un responsable cherche une règle interne. Et, bien souvent, quelqu’un doit ouvrir plusieurs documents, faire une recherche approximative, lire manuellement, puis reformuler la réponse. C’est lent, répétitif et source d’erreurs. C’est précisément là que le RAG entre en jeu.
Le RAG, pour Retrieval-Augmented Generation, consiste à retrouver les passages les plus pertinents dans vos documents, puis à les fournir à un modèle de langage pour qu’il génère une réponse plus précise et contextualisée. L’idée importante ici est la suivante : vous n’avez pas besoin d’entraîner un modèle.
Comprendre le principe du RAG sans jargon inutile
Avant de coder, il faut comprendre ce que nous allons construire.
Pourquoi ne pas entraîner un modèle ?
Quand on découvre l’IA générative, on imagine souvent qu’il faut « entraîner son propre modèle » pour lui faire connaître ses documents. En réalité, ce n’est pas nécessaire dans la majorité des cas. Entraîner ou affiner un modèle demande beaucoup de données, du temps, de la puissance de calcul, une vraie stratégie d’évaluation, et un coût non négligeable. Pour une FAQ basée sur des documents, le plus intelligent est souvent de laisser le modèle tel quel et de lui fournir, au moment de la question, les passages les plus utiles.
Le flux logique d’un assistant RAG
Voici le pipeline que nous allons implémenter :
- Lire les PDF et extraire leur texte
- Découper le texte en petits blocs (chunks)
- Transformer chaque bloc en vecteur numérique grâce à un modèle d’embeddings
- Indexer ces vecteurs dans FAISS
- Lors d’une question : transformer la question en vecteur, retrouver les blocs les plus proches, donner ces blocs au modèle, produire une réponse avec sources
Les composants que nous allons utiliser
Notre stack sera volontairement simple :
- PyMuPDF pour lire le contenu des PDF
- FAISS pour la recherche de similarité
- Ollama pour les embeddings et la génération locale
- Streamlit pour créer une petite interface de chat
💡 Conseil : pour un premier projet RAG, commencez avec une architecture locale et lisible. Beaucoup de débutants se perdent en ajoutant trop tôt une base vectorielle distante, plusieurs modèles et des microservices. D’abord, faites simple. Ensuite, améliorez.
Préparer l’environnement de travail
Structure du projet
rag-faq-pdf/
├── app.py
├── build_index.py
├── rag_engine.py
├── pdf_utils.py
├── requirements.txt
├── data/
│ └── docs/
│ ├── guide_produit.pdf
│ └── faq_interne.pdf
└── storage/
├── index.faiss
└── metadata.json
Installer les dépendances Python
pip install pymupdf faiss-cpu numpy requests streamlit
Installer et préparer Ollama
ollama pull gemma3
ollama pull embeddinggemma
Extraire proprement le texte de vos PDF
Le premier défi d’un assistant FAQ n’est pas le modèle. C’est souvent la qualité du texte extrait.
Créer le module d’extraction (pdf_utils.py)
import os
import fitz # PyMuPDF
def extract_text_from_pdf(pdf_path: str) -> list[dict]:
doc = fitz.open(pdf_path)
results = []
for page_index, page in enumerate(doc):
text = page.get_text("text", sort=True).strip()
if not text:
try:
text_page = page.get_textpage_ocr()
text = page.get_text(textpage=text_page).strip()
except Exception:
text = ""
if text:
results.append({
"document": os.path.basename(pdf_path),
"page": page_index + 1,
"text": text
})
return results
💡 Conseil : avant même de parler d’IA, ouvrez 10 extraits issus de vos PDF et lisez-les comme un humain. Si vous trouvez le texte confus, répétitif ou mal ordonné, le modèle le trouvera aussi.
Découper les documents en chunks utiles
Le chunking est une étape sous-estimée. Pourtant, c’est lui qui détermine la qualité de la récupération.
Implémenter un chunker simple
def chunk_text(text: str, chunk_size: int = 900, overlap: int = 150) -> list[str]:
chunks = []
start = 0
text_length = len(text)
while start < text_length:
end = start + chunk_size
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start += chunk_size - overlap
return chunks
Générer les embeddings et construire l'index vectoriel
Créer le script d'indexation (build_index.py)
import os, json, requests, numpy as np, faiss
from pdf_utils import extract_text_from_pdf, build_chunks_from_pages
OLLAMA_BASE_URL = "http://localhost:11434/api"
EMBED_MODEL = "embeddinggemma"
DOCS_DIR = "data/docs"
INDEX_PATH = "storage/index.faiss"
METADATA_PATH = "storage/metadata.json"
def embed_texts(texts: list[str]) -> np.ndarray:
response = requests.post(
f"{OLLAMA_BASE_URL}/embed",
json={"model": EMBED_MODEL, "input": texts},
timeout=120
)
response.raise_for_status()
vectors = np.array(response.json()["embeddings"], dtype="float32")
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / np.clip(norms, 1e-12, None)
def main():
os.makedirs("storage", exist_ok=True)
chunks = collect_all_chunks()
texts = [c["text"] for c in chunks]
vectors = embed_texts(texts)
index = faiss.IndexFlatIP(vectors.shape[1])
index.add(vectors)
faiss.write_index(index, INDEX_PATH)
with open(METADATA_PATH, "w", encoding="utf-8") as f:
json.dump(chunks, f, ensure_ascii=False, indent=2)
print(f"Index créé avec {len(chunks)} chunks.")
if __name__ == "__main__":
main()
Interroger l'index et générer une réponse fiable
Créer le moteur RAG (rag_engine.py)
import json, requests, numpy as np, faiss
OLLAMA_BASE_URL = "http://localhost:11434/api"
EMBED_MODEL = "embeddinggemma"
CHAT_MODEL = "gemma3"
INDEX_PATH = "storage/index.faiss"
METADATA_PATH = "storage/metadata.json"
with open(METADATA_PATH, "r", encoding="utf-8") as f:
METADATA = json.load(f)
INDEX = faiss.read_index(INDEX_PATH)
def retrieve(query: str, top_k: int = 4) -> list[dict]:
response = requests.post(
f"{OLLAMA_BASE_URL}/embed",
json={"model": EMBED_MODEL, "input": query},
timeout=120
)
vector = np.array(response.json()["embeddings"][0], dtype="float32")
vector = vector / max(np.linalg.norm(vector), 1e-12)
scores, indices = INDEX.search(vector.reshape(1, -1), top_k)
return [METADATA[idx] for idx in indices[0] if idx != -1]
def ask(question: str) -> dict:
contexts = retrieve(question)
context_text = "
---
".join(
f"[{c['document']} - page {c['page']}]
{c['text']}"
for c in contexts
)
prompt = f"""Tu es un assistant FAQ. Réponds uniquement à partir du contexte.
Cite les sources (document, page) à la fin.
Question : {question}
Contexte :
{context_text}"""
response = requests.post(
f"{OLLAMA_BASE_URL}/chat",
json={"model": CHAT_MODEL, "messages": [{"role": "user", "content": prompt}], "stream": False},
timeout=180
)
return {"answer": response.json()["message"]["content"], "contexts": contexts}
Créer une interface de chat avec Streamlit
import streamlit as st
from rag_engine import ask
st.set_page_config(page_title="Assistant FAQ PDF", page_icon="📄")
st.title("📄 Assistant FAQ basé sur vos PDF")
if "messages" not in st.session_state:
st.session_state.messages = []
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
question = st.chat_input("Posez votre question...")
if question:
st.session_state.messages.append({"role": "user", "content": question})
with st.chat_message("user"):
st.markdown(question)
with st.chat_message("assistant"):
result = ask(question)
st.markdown(result["answer"])
with st.expander("Passages récupérés"):
for ctx in result["contexts"]:
st.markdown(f"**{ctx['document']} — page {ctx['page']}**
{ctx['text']}")
st.session_state.messages.append({"role": "assistant", "content": result["answer"]})
Pour lancer l'interface :
streamlit run app.py
Améliorer la qualité des réponses en production
Ajouter du reranking
Quand votre base grandit, le top 4 récupéré par similarité vectorielle n'est pas toujours optimal. Une technique fréquente consiste à récupérer 10 à 20 chunks candidats, les reranker avec un Cross-Encoder, puis ne garder que les meilleurs.
Gérer les PDF scannés
Tous les PDF ne contiennent pas du texte exploitable. Certains sont en réalité des images. PyMuPDF propose un chemin OCR avec get_textpage_ocr(), mais dans un environnement de production, vous devrez vérifier la présence de Tesseract, la langue OCR et la qualité du scan.
Erreurs fréquentes à éviter
- Croire que le problème vient toujours du modèle : dans un système RAG, les erreurs viennent souvent de l'extraction de texte ou d'un mauvais chunking
- Indexer tout sans filtrer : n'indexez pas les pages vides, mentions légales, tables des matières ou pages OCR très bruitées
- Masquer les sources à l'utilisateur : toujours montrer d'où vient l'information pour maintenir la confiance
💡 Conseil : ne cherchez pas d'abord à rendre votre assistant "plus intelligent". Cherchez d'abord à le rendre plus fiable, plus explicable et plus testable.
Conclusion
Créer un assistant FAQ avec RAG à partir de vos PDF est l'un des meilleurs moyens de rendre l'IA utile immédiatement dans un contexte réel. Vous n'avez pas besoin d'entraîner un modèle. Vous avez surtout besoin de documents exploitables, d'une extraction propre, d'un bon découpage, d'un index vectoriel, d'un prompt discipliné, et d'une interface simple pour poser des questions.
Dans ce tutoriel, nous avons construit une chaîne complète avec PyMuPDF, FAISS, Ollama et Streamlit. Le résultat est une base solide pour créer un assistant support interne, une FAQ produit intelligente, un copilote RH ou un moteur de réponse documentaire alimenté par vos propres ressources.
Envie d'aller plus loin sur l'IA appliquée ? Explorez les autres tutoriels publiés sur ITSkillsCenter.io pour transformer vos connaissances en compétences durables et solutions concrètes sur le terrain.