Dans beaucoup d’entreprises, l’information existe déjà. Elle se trouve dans des guides RH, des procédures qualité, des documents techniques, des PDF commerciaux, des notes de service, des comptes rendus, des FAQ internes ou des documentations produit. Le vrai problème n’est donc pas toujours l’absence de connaissance. Le problème, c’est l’accès rapide à cette connaissance.
Un chatbot interne capable de citer ses sources change fondamentalement la manière dont les équipes interagissent avec l’information. Au lieu de chercher manuellement dans plusieurs dossiers partagés, l’utilisateur pose une question en langage naturel et reçoit une réponse précise, accompagnée des passages qui la justifient.
Ce tutoriel vous guide, pas à pas, dans la construction d’un chatbot documentaire basé sur RAG (Retrieval-Augmented Generation), capable de citer ses sources et déployable localement sans infrastructure complexe.
Comprendre pourquoi la citation des sources est cruciale
Un chatbot qui répond sans dire d’où vient l’information crée un problème de confiance. Dans un contexte professionnel, un collaborateur ou un client ne peut pas vérifier si la réponse est correcte, récente ou bien interprétée. La confiance dans l’outil s’érode rapidement si des erreurs apparaissent sans traçabilité.
Les trois piliers d’un chatbot documentaire fiable
- La précision : le chatbot ne doit répondre qu’à partir des documents fournis
- La traçabilité : chaque réponse doit mentionner le ou les documents sources et leur page
- L’honnêteté : si l’information n’est pas dans les documents, le chatbot doit le dire explicitement
Ces trois piliers sont non négociables dans un déploiement sérieux.
Ce qui distingue un chatbot documentaire d’un chatbot généraliste
Un chatbot généraliste (comme ChatGPT sans contexte) répond à partir de sa mémoire d’entraînement. Il peut être fluide et convaincant, mais il ne connaît pas vos procédures internes, vos tarifs, vos politiques RH ou vos spécifications techniques. Un chatbot documentaire ancré sur vos fichiers répond uniquement à partir de vos données. Il est moins impressionnant dans l’absolu, mais beaucoup plus utile et fiable dans votre contexte précis.
Architecture technique du projet
Notre chatbot interne reposera sur la stack suivante :
- PyMuPDF : extraction du texte des PDF
- FAISS : index vectoriel pour la recherche sémantique
- Ollama : modèles locaux pour les embeddings et le chat
- Streamlit : interface conversationnelle dans le navigateur
Structure du projet
chatbot-interne/
├── app.py # Interface Streamlit
├── indexer.py # Extraction et indexation des documents
├── retriever.py # Recherche de passages pertinents
├── generator.py # Génération de réponse avec citations
├── requirements.txt
└── docs/ # Vos documents PDF
Installation des dépendances
pip install pymupdf faiss-cpu numpy requests streamlit
# Installer Ollama et télécharger les modèles
ollama pull gemma3
ollama pull embeddinggemma
💡 Conseil : pour un chatbot documentaire, séparez toujours la fonction « retrouver le bon passage » de la fonction « rédiger la réponse ». Cette séparation évite beaucoup de confusion lors du débogage.
Extraire et préparer les documents
Module d’extraction (indexer.py)
import os, json, fitz, requests, numpy as np, faiss
def extract_chunks_from_pdf(pdf_path: str, chunk_size=800, overlap=100) -> list[dict]:
doc = fitz.open(pdf_path)
chunks = []
for page_num, page in enumerate(doc):
text = page.get_text("text", sort=True).strip()
if not text:
continue
# Découper en chunks avec chevauchement
for i in range(0, len(text), chunk_size - overlap):
chunk = text[i:i + chunk_size].strip()
if len(chunk) > 100: # ignorer les chunks trop petits
chunks.append({
"text": chunk,
"source": os.path.basename(pdf_path),
"page": page_num + 1
})
return chunks
def build_index(docs_dir: str):
all_chunks = []
for filename in os.listdir(docs_dir):
if filename.endswith(".pdf"):
path = os.path.join(docs_dir, filename)
all_chunks.extend(extract_chunks_from_pdf(path))
# Générer les embeddings
texts = [c["text"] for c in all_chunks]
resp = requests.post("http://localhost:11434/api/embed",
json={"model": "embeddinggemma", "input": texts}, timeout=300)
vectors = np.array(resp.json()["embeddings"], dtype="float32")
# Normaliser et indexer
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / np.clip(norms, 1e-12, None)
index = faiss.IndexFlatIP(vectors.shape[1])
index.add(vectors)
faiss.write_index(index, "storage/index.faiss")
with open("storage/metadata.json", "w", encoding="utf-8") as f:
json.dump(all_chunks, f, ensure_ascii=False, indent=2)
print(f"✅ {len(all_chunks)} chunks indexés depuis {docs_dir}")
Construire le moteur de récupération avec citations
Module de retrieval (retriever.py)
import json, requests, numpy as np, faiss
INDEX = faiss.read_index("storage/index.faiss")
with open("storage/metadata.json", encoding="utf-8") as f:
METADATA = json.load(f)
def retrieve_with_citations(query: str, top_k=4) -> list[dict]:
"""Retrouve les passages les plus pertinents et retourne leurs métadonnées."""
resp = requests.post("http://localhost:11434/api/embed",
json={"model": "embeddinggemma", "input": query}, timeout=60)
vector = np.array(resp.json()["embeddings"][0], dtype="float32")
vector = vector / max(np.linalg.norm(vector), 1e-12)
scores, indices = INDEX.search(vector.reshape(1, -1), top_k)
results = []
for score, idx in zip(scores[0], indices[0]):
if idx >= 0:
item = METADATA[idx].copy()
item["relevance_score"] = float(score)
results.append(item)
return results
Générer des réponses avec citations explicites
Module de génération (generator.py)
import requests
from retriever import retrieve_with_citations
SYSTEM_PROMPT = """Tu es un assistant documentaire interne.
Règles absolues :
- Tu réponds UNIQUEMENT à partir des extraits fournis
- Tu DOIS citer les sources à la fin de chaque réponse sous la forme : [Source: nom_fichier.pdf, page X]
- Si l'information n'est pas dans les extraits, tu réponds : "Je n'ai pas trouvé cette information dans les documents disponibles."
- Tu n'inventes jamais, tu ne complètes jamais avec des connaissances générales"""
def generate_answer_with_citations(question: str) -> dict:
contexts = retrieve_with_citations(question)
# Construire le contexte avec indicateurs de source
context_parts = []
for ctx in contexts:
context_parts.append(
f"[{ctx['source']} - Page {ctx['page']}]
{ctx['text']}"
)
context_block = "
---
".join(context_parts)
prompt = f"""{SYSTEM_PROMPT}
Question : {question}
Extraits disponibles :
{context_block}
Réponse (avec citations obligatoires) :"""
resp = requests.post("http://localhost:11434/api/chat", json={
"model": "gemma3",
"messages": [{"role": "user", "content": prompt}],
"stream": False
}, timeout=120)
answer = resp.json()["message"]["content"]
return {
"answer": answer,
"sources": [{"file": c["source"], "page": c["page"], "score": c["relevance_score"]} for c in contexts]
}
💡 Conseil : le fait d’inclure les citations dans le prompt système comme une « règle absolue » réduit significativement les cas où le modèle oublie de citer ses sources. Testez systématiquement avec des questions auxquelles vous connaissez la réponse.
Construire l’interface conversationnelle Streamlit
Application principale (app.py)
import streamlit as st
from indexer import build_index
from generator import generate_answer_with_citations
import os
st.set_page_config(page_title="Chatbot Documentaire Interne", page_icon="💬", layout="wide")
st.title("💬 Chatbot Interne — Réponses avec sources")
# Sidebar pour l'indexation
with st.sidebar:
st.header("📂 Documents")
if st.button("🔄 Réindexer les documents"):
with st.spinner("Indexation en cours..."):
os.makedirs("storage", exist_ok=True)
build_index("docs")
st.success("Indexation terminée !")
st.markdown("---")
st.caption("Déposez vos PDF dans le dossier 'docs/' puis réindexez.")
# Zone de conversation
if "messages" not in st.session_state:
st.session_state.messages = []
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if msg.get("sources"):
with st.expander("📎 Sources utilisées"):
for s in msg["sources"]:
st.markdown(f"- **{s['file']}** — Page {s['page']} (pertinence: {s['score']:.3f})")
question = st.chat_input("Posez votre question sur les documents internes...")
if question:
st.session_state.messages.append({"role": "user", "content": question})
with st.chat_message("user"):
st.markdown(question)
with st.chat_message("assistant"):
with st.spinner("Recherche dans les documents..."):
result = generate_answer_with_citations(question)
st.markdown(result["answer"])
with st.expander("📎 Sources utilisées"):
for s in result["sources"]:
st.markdown(f"- **{s['file']}** — Page {s['page']} (pertinence: {s['score']:.3f})")
st.session_state.messages.append({
"role": "assistant",
"content": result["answer"],
"sources": result["sources"]
})
# Lancer l'application
streamlit run app.py
Améliorer la fiabilité des citations
Technique 1 : validation automatique des citations
Après génération, vous pouvez vérifier programmatiquement que la réponse contient bien une référence de source :
import re
def validate_citations(answer: str, expected_sources: list) -> bool:
"""Vérifie que la réponse contient au moins une citation valide."""
citation_pattern = r'[Source:.*?]|[.*?.pdf.*?]'
citations_found = re.findall(citation_pattern, answer)
return len(citations_found) > 0
Technique 2 : mode strict vs mode flexible
Proposez deux modes à l’utilisateur :
- Mode strict : répond uniquement si un passage très pertinent est trouvé (score > 0.7)
- Mode flexible : répond même avec des passages moyennement pertinents en indiquant l’incertitude
Technique 3 : historique des questions sans réponse
Journalisez les questions pour lesquelles le chatbot répond « Je n’ai pas trouvé ». Ces questions révèlent les lacunes de votre base documentaire et permettent de prioriser les documents à ajouter.
Déployer dans un environnement professionnel
Points de vigilance en production
- Confidentialité : assurez-vous que votre modèle Ollama tourne en local et que rien n’est envoyé vers l’extérieur
- Mise à jour des documents : planifiez une réindexation régulière quand les documents changent
- Gestion des accès : si différents utilisateurs ne doivent pas voir tous les documents, gérez la segmentation au niveau de l’index
💡 Conseil : en entreprise, la valeur d’un chatbot documentaire ne se mesure pas à sa sophistication technique, mais à la qualité et à l’actualité des documents qu’il consulte. Un bon contenu vaut mieux qu’un bon algorithme.
Conclusion
Mettre en place un chatbot interne capable de citer ses sources est aujourd’hui à la portée de n’importe quelle équipe technique, sans infrastructure cloud coûteuse. En combinant PyMuPDF pour l’extraction, FAISS pour la recherche vectorielle, Ollama pour les modèles locaux et Streamlit pour l’interface, vous obtenez un outil professionnel, traçable et maintenu en interne.
La force de cette approche réside dans sa rigueur : chaque réponse est ancrée dans vos documents, chaque source est citée, et le système refuse d’inventer ce qu’il ne sait pas. C’est exactement ce qu’on attend d’un assistant documentaire en contexte professionnel.
Prêt à aller plus loin ? Consultez les autres tutoriels sur ITSkillsCenter.io pour approfondir vos compétences en IA appliquée, automatisation et développement d’outils concrets pour les professionnels francophones.