Création d’un système de questions-réponses citées à partir de PDFs de banques centrales grâce à RAG
Une mise en œuvre pratique de RAG à partir de rapports trimestriels des banques centrales : ingestion des données, récupération en tenant compte de la date de publication, réponses citées, ainsi que les bugs rencontrés au cours du processus.
La plupart des tutoriels sur RAG s’appuient sur des données organisées et synthétisées. Les documents réels se comportent différemment, et le fossé entre la compréhension de la génération augmentée par la récupération d’informations et sa mise en œuvre concrète ne se révèle que lorsque l’on développe tout le système à partir d’un corpus désordonné. C’est la motivation qui a conduit à bspQA, un système de réponse à des questions basé sur les rapports trimestriels de politique monétaire de la Bangko Sentral ng Pilipinas, la banque centrale des Philippines.
Le corpus constitue un excellent objet d’apprentissage. Chaque rapport compte de 60 à 70 pages d’analyses économiques approfondies, et dix-sept d’entre eux couvrent plusieurs années, tous étant accessibles au public. Le langage utilisé est spécifique au domaine, il existe de nombreux fichiers, et le problème de récupération est réel, puisque personne ne peut coller dix-sept rapports dans une fenêtre de contexte. L’objectif était modeste : poser une question du type « Quelles étaient les perspectives d’inflation pour 2026 ? » et recevoir une réponse fondée et citée en quelques secondes, au lieu de devoir utiliser Ctrl+F sur une pile de PDFs.
Le schéma en un paragraphe
RAG lie les réponses d’un LLM à un ensemble de documents choisis, plutôt qu’à ce qu’il a absorbé pendant son entraînement. Les documents sont transformés en embeddings vectoriels à l’avance. Lorsqu’une question arrive, les fragments les plus proches sont récupérés, transmis au modèle en tant que contexte, et le modèle est invité à répondre uniquement à partir de ce contexte. L’avantage est un système qui refuse d’inventer des informations absentes des documents.
Deux processus, séparés
La conception sépare délibérément deux tâches.
- L’ingestion s’effectue hors ligne, une fois par nouveau document. Elle télécharge les PDF, les analyse pour en extraire du texte brut, divise ce texte en fragments, crée des embeddings et stocke tout dans Qdrant.
- La consultation s’effectue en temps réel à chaque demande. Elle encode la question, récupère les fragments pertinents et génère la réponse. Les PDF bruts ne sont jamais modifiés à ce stade.
Conception de l’ingestion
Étape de téléchargement
Un fichier manifest.json constitue la source de vérité. Chaque rapport dispose d’une entrée indiquant son URL, une étiquette avec un point et sa date de publication. Les URLs ont été collées manuellement, ce qui est aussi peu glamour que cela en a l’air.
{
"doc_id": "bsp-mpr-2023-q3",
"title": "Monetary Policy Report Q3 2023",
"period_label": "Q3 2023",
"publication_date": "2023-08-17",
"url": "https://www.bsp.gov.ph/..."
}
Le téléchargeur passe à côté des fichiers déjà présents sur le disque, donc le relancer ne pose aucun problème, et ajouter un rapport signifie simplement ajouter un élément de plus à la liste des documents.
Étape de parsing
MarkItDown de Microsoft convertit les PDF en texte. Pour les rapports de politique riches en texte, il fait un travail correct : les en-têtes sont conservés, les sauts de paragraphe apparaissent proprement, et les tableaux le sont à peine.
MarkItDown est également une dépendance lourde qui entraîne l’installation de nombreux paquets. Comme le parsing ne se produit qu’au moment de l’ingestion et jamais lors de la réponse aux requêtes, il fait partie d’un groupe de dépendances optionnelles, de sorte que le backend hébergé par Render n’est pas alourdi par cela. Il est judicieux d’adopter tôt cette pratique : déterminer quels paquets appartiennent à l’environnement d’ingestion et quels autres à l’environnement de diffusion, car ces deux environnements n’ont pas besoin d’être identiques. Une étape de nettoyage se situe également entre le parsing et le découpage en blocs, et sa raison sera expliquée ci-dessous.
Chunking, embedding et upserting
C’est ici que se prennent les décisions importantes. Les chunks proviennent d’une fenêtre glissante de 512 tokens avec une superposition de 64 tokens, comptés à l’aide de tiktoken.
def chunk_text_by_tokens(text, enc, chunk_size=512, overlap=64):
tokens = enc.encode(text)
stride = chunk_size - overlap
chunks = []
start = 0
while start < len(tokens):
window = tokens[start : start + chunk_size]
chunks.append(enc.decode(window))
if start + chunk_size >= len(tokens):
break
start += stride
return chunks
La superposition garantit qu’une phrase coupée à la frontière d’un bloc apparaît toujours complète dans au moins un autre bloc.
Les embeddings proviennent de OpenAI’s text-embedding-3-small, un modèle économique et fiable pour du texte de politique dense. Le projet dans son intégralité n’a coûté que dix dollars en crédits, bien que le montant minimum de rechargement à l’époque était de cinq, ce qui suffit amplement pour un petit projet.
Chaque vecteur reçoit un identifiant UUID5 dérivé du nom du fichier source et de l’index du bloc. Cela rend la réingestion sûre, car une seconde exécution écrase les enregistrements existants au lieu d’accumuler des doublons.
Chaque charge utile stockée contient le fichier source, l’index du chunk, le texte, le modèle d’incorporation, l’étiquette de période, une clé de période normalisée telle que q3_2023, ainsi que la date de publication selon le format ISO 8601. Qdrant conserve des index de mots-clés pour le fichier source et la clé de période, ainsi qu’un index de date et heure pour la date de publication, ce qui permet un filtrage rapide des métadonnées au moment de la requête.
Récupération et le piège de la proximité temporelle
Une configuration naïve renvoie les K résultats les plus pertinents en fonction de la similarité cosinus. Cela fonctionne tant que le corpus couvre quelques années seulement, mais cela pose problème lorsque l’on cherche des informations sur la situation actuelle, comme les perspectives d’inflation. Si la banque a utilisé des formulations presque identiques pour parler de l’inflation au premier trimestre 2022 et au troisième trimestre 2024, le chunk de 2022 pourrait obtenir un score plus élevé en raison d’une formulation légèrement meilleure, même si le lecteur souhaite presque certainement avoir les informations les plus récentes.
La solution consiste à récupérer un nombre excessif de candidats puis à les réclasser. Le système sélectionne trois fois le nombre de candidats demandé, puis réévalue chacun en combinant la pertinence cosinus et une valeur de fraîcheur normalisée.
RECENCY_WEIGHT = 0.85
def combined_score(hit):
relevance = hit.score / max_score
recency = (pub_date - min_date).days / date_span_days # 0.0 to 1.0
return (1 - RECENCY_WEIGHT) * relevance + RECENCY_WEIGHT * recency
Un poids de récenteté de 0,85 signifie que les documents plus récents sont fortement privilégiés, à moins qu’un document plus ancien ne soit bien plus pertinent. Dans un corpus de politiques où chaque rapport remplace effectivement le précédent, cet équilibre semble approprié.
Des filtres optionnels permettent à l’utilisateur de limiter la recherche à une période donnée ou à une plage de dates :
# scope to Q1 2023 only
retrieve_chunks(query, period_label_key="q1_2023")
# scope to a date range
retrieve_chunks(query, publication_date_from="2023-01-01", publication_date_to="2023-12-31")
Sans filtres, le système fonctionne comme un assistant de « dernière vue ». Avec eux, il devient un outil de recherche historique.
Génération
Le générateur est délibérément simple. Les extraits obtenus sont formatés en sources numérotées et transmis à gpt-4o-mini via un prompt système qui, paraphrasé, stipule : utiliser uniquement les sources fournies, ne pas recourir à de connaissances extérieures, citer chaque affirmation factuelle directement dans le texte, et l’indiquer lorsque les sources ne contiennent pas suffisamment d’informations.
Cette dernière clause mérite une attention particulière. Un prompt strictement encadré constitue la meilleure défense contre les hallucinations. Si la recherche ne renvoie rien de pertinent, le système répond par un message de secours prédéfini plutôt que d’improviser.
Les réponses API contiennent le texte de la réponse ainsi qu’une liste structurée des sources, chacune indiquant la période à laquelle elle appartient, la date de publication et l’emplacement du PDF source. Sur l’interface utilisateur, celles-ci se transforment en citations cliquables, ce qui permet de tracer chaque affirmation.
Problèmes rencontrés en pratique
Les éléments de mise en page qui polluent les blocs de texte
Chaque page de chaque rapport présente un en-tête indiquant « Classification : GÉNÉRAL » ainsi qu’un pied de page de la forme « Rapport sur la politique monétaire - T3 2023 | 47 ». Après le parsing, ces lignes apparaissent comme du texte ordinaire et sont intégrées aux blocs de texte, gaspillant ainsi des ressources inutilement. Le numéro de page figurant dans ce pied de page pourrait en réalité permettre des citations plus précises, une idée qui n’a été envisagée que tardivement.
La solution consiste à effectuer un nettoyage avant la création des blocs de texte :
def _clean_parsed_text(text):
text = text.replace("\x0c", "\n\n")
text = re.sub(r"Classification:\s*GENERAL\s*\n", "", text)
text = re.sub(r"Monetary Policy Report\s*[--][^\n]+\|\s*\d+\s*\n", "", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
En y repensant, c’est évident, mais cela peut facilement passer inaperçu si le texte analysé n’est jamais examiné avant son intégration. Le symptôme était étrange : lors des tests des réponses, le système semblait s’appuyer sur d’autres fragments plutôt que sur celui classé en tête. Les pieds de page divisaient les paragraphes au milieu d’une pensée, modifiant ainsi discrètement le contexte représenté par chaque fragment.
Un effacement accidentel
Qdrant propose un drapeau « recreate-collection » qui supprime et reconstruit toute la collection. Utile en phase de développement, mais catastrophique en production. Une protection ignore désormais ce drapeau dès que l’environnement ressemble à celui de production :
def _should_recreate_qdrant_collection():
if _is_production_environment():
return False
explicit = os.environ.get("QDRANT_RECREATE_COLLECTION", "").lower()
if explicit in ("1", "true", "yes"):
return True
app_env = (os.environ.get("ENVIRONMENT") or "").lower()
return app_env in ("development", "dev", "local")
Pour un projet avec un seul utilisateur, cette protection est sans doute surdimensionnée, mais elle ne coûte presque rien.
Dupliques après réingestion
Après avoir modifié une entrée du manifeste et réexécuté l’ingestion, deux ensembles de fragments existaient pour le même PDF. La solution consiste à supprimer les fragments existants d’un fichier source avant d’insérer les nouveaux :
client.delete(
collection_name=collection,
points_selector=models.Filter(must=[
models.FieldCondition(
key="source_file",
match=models.MatchValue(value=source_file)
)
]),
)
Avec les identifiants UUID5 stables, la réingestion est désormais idempotente.
Démarrage en froid sur le plan gratuit
Le backend fonctionne sur le plan gratuit de Render, qui est mis en veille lorsqu’il n’y a pas d’activité. La première requête après un démarrage en froid peut prendre de 30 à 60 secondes, un délai suffisant pour que l’utilisateur pense que l’application ne fonctionne pas. Un endpoint /health simple résout en grande partie ce problème : le frontend envoie une requête à ce dernier au chargement de la page, ce qui active le serveur avant même que la première question ne soit soumise. Cela représente une requête HTTP supplémentaire par session. L’interface utilisateur indique également brièvement que la première requête peut prendre de trente secondes à une minute tant que le backend s’active.
Choix de la taille du chunk
La valeur de 512 tokens a été déterminée par expérience. Des blocs de 128 à 256 tokens offraient plus de précision, mais perdaient souvent le contexte nécessaire pour être interprétés seuls. Des blocs de 1024 tokens ou plus entraînaient l’inclusion de texte environnant non pertinent. La superposition de 64 tokens atténue les coupes nettes aux frontières et justifie l’augmentation de l’espace de stockage.
Que modifier la prochaine fois
- Créer d’abord un ensemble d’évaluation. Un répertoire evals existe, mais aucun ensemble de questions de référence n’a été préparé avant d’ajuster le poids de la récente utilisation ou la taille des blocs, ce qui fait que ces choix restent des suppositions fondées sur des données limitées. Un véritable ensemble d’évaluation permettrait de les fonder sur des critères objectifs.
- Ajouter un outil de réclassement. Intégrer le critère de récente utilisation dans le score n’est qu’une approche heuristique. Un encodeur croisé comme Cohere Rerank, ou un modèle local, permettrait d’évaluer conjointement la requête et les blocs afin de classer leur pertinence de manière plus fiable.
- Essayez la recherche hybride. Les vecteurs denses peuvent manquer des termes exacts. Une phrase comme un taux de rachat inverse de 7,00 % est difficile à retrouver si les mots de la question sont formulés différemment. Associer la correspondance de mots-clés BM25 à la recherche vectorielle comblerait cette lacune, et Qdrant offre cette fonctionnalité directement.
- Diffusez les réponses. gpt-4o-mini met deux à cinq secondes pour répondre. En diffusant les tokens au fur et à mesure de leur arrivée, l’interface semble plus rapide même si le temps total reste identique.
Reutilisation du design
L’architecture est générique. Remplacez le manifeste par un autre corpus de documents, mettez à jour la description du domaine dans le prompt du système, et vous obtiendrez un système de Q&R fonctionnel sur n’importe quel ensemble de PDF. Les parties les plus complexes, comme le découpage propre des données, la récupération tenant compte de la fraîcheur, le filtrage des métadonnées et la réingestion sûre en production, sont déjà gérées.
Lectures complémentaires
- Notes pratiques : Création d’un pipeline RAG local de niveau production — 100 % gratuit, sans — Guide pas à pas des Notes pratiques : Création d’un pipeline RAG local de niveau production — 100 % gratuit, sans : contrats, vérifications et emplacements de code prêts à l’emploi pour les équipes qui mettent en œuvre ce modèle.
- Création de systèmes RAG fiables en production : de l’ingestion à l’observabilité — Du découpage des données et de la recherche hybride au réclassement, au contrôle d’accès, aux ensembles d’évaluation et au traçage : les éléments qui distinguent une démonstration RAG d’un système de production.