Un système RAG minimaliste transforme un PDF en réponses sourcées et annotées, sans base de données vectorielle ni framework complexe. Voici la recette.
UN SYSTÈME RAG MINIMALISTE POUR LES PDF
Imaginez un système qui lit un PDF, comprend une question, et répond en citant exactement les lignes qui justifient sa réponse. Sans vecteur database, sans framework, sans agents. Juste du code Python simple et efficace. C'est ce que propose ce tutoriel, basé sur l'article Attention Is All You Need de 2017.
LES QUATRE ÉTAPES D'UN PIPELINE RAG
Un pipeline RAG (Retrieval-Augmented Generation) se décompose en quatre étapes clés :
- 1. Parsing du document : transformer le PDF en données structurées
- 2. Parsing de la question : extraire les mots-clés pour la Recherche
- 3. Recherche : trouver les pages les plus pertinentes
- 4. Génération : produire une réponse sourcée et justifiée
Une cinquième étape, optionnelle, permet d'annoter le PDF original avec les lignes citées.
ÉTAPE 1 : TRANSFORMER UN PDF EN LIGNES DE TEXTE
La première étape consiste à extraire le texte du PDF ligne par ligne, en conservant la position de chaque ligne sur la page. Voici comment faire avec la bibliothèque PyMuPDF (aussi appelée fitz).
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(
apikey=os.getenv("APIKEY"),
baseurl=os.getenv("BASEURL"),
)
modelchat = os.getenv("MODELCHAT", "gpt-4.1")
modelembed = os.getenv("MODELEMBED", "text-embedding-3-small")
La fonction fitzpdftolinedf parcourt chaque page du PDF et extrait chaque ligne de texte avec ses coordonnées sur la page.
def fitzpdftolinedf(file_path):
doc = fitz.open(file_path)
data = []
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("dict").get("blocks", [])
line_num = 0
for block in blocks:
if block.get("type") .= 0:
continue
for line in block.get("lines", []):
spans = line.get("spans", [])
if not spans: continue
text = "".join(s["text"] for s in spans)
rect = fitz.Rect(spans[0]["bbox"])
for span in spans[1:]:
rect |= fitz.Rect(span["bbox"])
data.append({
"pagenum": pagenum + 1,
"linenum": linenum + 1,
"text": text,
"x0": float(rect.x0), "y0": float(rect.y0),
"x1": float(rect.x1), "y1": float(rect.y1),
})
line_num += 1
return pd.DataFrame(data)
Le résultat est un tableau de données (DataFrame) où chaque ligne contient : le numéro de page, le numéro de ligne, le texte, et les coordonnées (x0, y0, x1, y1) de la ligne sur la page.
ÉTAPE 2 : EXTRAIRE LES MOTS-CLÉS DE LA QUESTION
Avant de chercher dans le PDF, il faut transformer la question en mots-clés exploitables. Par exemple, la question "Quelles sont les options mentionnées pour l'encodage positionnel ?" devient ["encodage positionnel", "options", "mentionné"].
parsedquestion = getkeywordsfromquestion(question, client=client)
parsed_question.keywords = ['positional encoding', 'options', 'mentioned']
Le système peut adapter le type de mots-clés extraits selon le contexte. Par exemple, pour un contrat d'assurance, il privilégiera des termes comme "clauses", "exclusions" ou "franchises".
contract_prompt = (
"Extract 1 to 3 short keywords from the user question for searching an "
"insurance contract or legal policy. Prefer literal terms the contract is "
"likely to use: clauses, exclusions, named perils, deductibles, caps. Drop "
"question framing words. Output 1 to 3 keywords."
)
parsedquestioncontract = getkeywordsfrom_question(
demoquestion, systemprompt=contract_prompt, client=client,
)
Pour la question "Les séismes sont-ils exclus de la couverture ?", les mots-clés extraits sont : ["séismes", "exclusions", "couverture"] (contre ["séismes", "couverture"] pour un article de recherche classique).
ÉTAPE 3 : TROUVER LES PAGES LES PLUS PERTINENTES
Avec les mots-clés en main, le système recherche les pages du PDF les plus pertinentes. Il utilise des embeddings (représentations vectorielles du texte) pour comparer la question et les pages du document.
La fonction retrieve_pages calcule la similarité entre la question et chaque page, puis sélectionne les top_k pages les plus proches (par défaut, 3 pages).
def cosinesimmatrix(queryvec, docmatrix):
q = queryvec / (np.linalg.norm(queryvec) + 1e-12)
d = docmatrix / np.linalg.norm(docmatrix, axis=1, keepdims=True)
return d @ q
def retrievepages(pagedf, linedf, question, topk=3):
qvec = np.asarray(getembedding(question), dtype=np.float32)
docmatrix = np.vstack(pagedf["embedding"].values)
sims = cosinesimmatrix(qvec, docmatrix)
scored = page_df.copy()
scored["similarity"] = sims
retrievedpagesdf = scored.nlargest(top_k, "similarity")
keptpages = retrievedpagesdf["pagenum"].tolist()
filteredlinedf = linedf[linedf["pagenum"].isin(keptpages)]
return retrievedpagesdf, filteredlinedf
ÉTAPE 4 : GÉNÉRER UNE RÉPONSE SOURCÉE
Une fois les pages pertinentes identifiées, le système demande à un modèle de langage (comme GPT-4.1) de produire une réponse en se basant uniquement sur ces extraits. La réponse est structurée en JSON pour inclure :
- La réponse elle-même
- Les numéros de page et de ligne où se trouvent les citations
- Un score de confiance (entre 0 et 1)
- Les citations exactes
- Les limites ou mises en garde éventuelles
class AnswerWithEvidence(BaseModel):
answer: str = Field(.)
startpagenum: int | None
startlinenum: int | None
endpagenum: int | None
endlinenum: int | None
confidence: float = Field(., ge=0.0, le=1.0)
justification: str = Field(.)
quotes: list[str] = Field(default_factory=list)
caveats: list[str] = Field(default_factory=list)
Exemple de réponse pour la question "Quelles sont les options mentionnées pour l'encodage positionnel ?" :
{
"answer": "Les options mentionnées pour l'encodage positionnel sont les embeddings positionnels appris et les encodages positionnels fixes (notamment l'utilisation de fonctions sinus et cosinus de différentes fréquences).",
"startpagenum": 6,
"startlinenum": 31,
"endpagenum": 6,
"endlinenum": 32,
"confidence": 0.98,
"justification": "Les lignes 31–32 indiquent explicitement : 'Il existe de nombreuses options d'encodage positionnel, appris et fixes [9].' De plus, d'autres lignes détaillent l'encodage sinusoïdal comme choix fixe, et le tableau 3 ligne (E) discute de l'utilisation d'embeddings appris à la place.",
"quotes": [
"Il existe de nombreuses options d'encodage positionnel, appris et fixes [9]."
],
"caveats": [
"Les détails sur l'implémentation spécifique des embeddings appris ne sont abordés que brièvement ailleurs, mais les deux options sont mentionnées ici."
],
"completeanswerfound": true,
"context_structured": true,
"llmdiscoveredkeywords": [
"embeddings positionnels appris",
"encodages positionnels fixes",
"encodage positionnel sinusoïdal"
]
}
ÉTAPE 5 (OPTIONNELLE) : ANNOTER LE PDF AVEC LES LIGNES CITÉES
Pour visualiser les sources de la réponse, le système peut dessiner des rectangles autour des lignes citées directement sur le PDF original. Voici comment faire :
def passagelinesdffromanswer(linedf, answerjson):
a = json.loads(answer_json)
sp, sl = a["startpagenum"], a["startlinenum"]
ep, el = a["endpagenum"], a["endlinenum"]
if sp is None: return line_df.iloc[0:0]
mask = (
linedf["pagenum"].between(sp, ep)
& ((linedf["pagenum"] .= sp) | (linedf["linenum"] >= sl))
& ((linedf["pagenum"] .= ep) | (linedf["linenum"] <= el))
)
return line_df.loc[mask].copy()
def passage_bbox_by_page(passage_df):
return passage_df.groupby("page_num", as_index=False).agg(
x0=("x0", "min"), y0=("y0", "min"),
x1=("x1", "max"), y1=("y1", "max"))
def draw_passage_rectangles(pdf_path, bboxes_df, out_path):
doc = fitz.open(pdf_path)
for _, r in bboxes_df.iterrows():
page = doc[int(r["page_num"]) - 1]
page.add_rect_annot(fitz.Rect(r["x0"], r["y0"], r["x1"], r["y1"]))
doc.save(out_path)
LE PIPELINE COMPLET EN UNE SEULE FONCTION
Toutes ces étapes peuvent être regroupées dans une seule fonction, pdfqabaseline, qui prend en entrée un PDF et une question, et retourne une réponse sourcée.
def pdfqabaseline(
pdf_path: str,
question: str,
top_k: int = 3,
annotate_pdf: str | None = None,
):
# 1. Parsing
linedf = fitzpdftolinedf(pdfpath)
# 2. Retrieval
pagedf = embedpagedf(buildpagedf(linedf))
, filtered = retrievepages(pagedf, linedf, question, top_k)
# 3. Generation
answer = llmanswerwith_evidence(question, filtered)
# 4. Optional highlighting on the source PDF
if annotate_pdf is not None:
passage = passagelinesdffromanswer(line_df, answer)
bboxes = passagebboxby_page(passage)
drawpassagerectangles(pdfpath, bboxes, annotatepdf)
return answer
EXEMPLES CONCRETS DE RÉPONSES
Voici deux exemples de réponses générées par le système, avec leurs sources et leurs scores de confiance.
Exemple 1 : Question simple sur l'aluminium
{
"answer": "Les prix de l'aluminium devraient augmenter d'environ 22 % en 2026 (par rapport à 2025) pour atteindre un niveau record, soit environ 21 % de plus que les projections de janvier 2026. Cette hausse est soutenue par des conditions d'offre tendues et une demande solide. Les prix devraient ensuite baisser d'environ 6 % en 2027 à mesure que les conditions d'offre s'améliorent.",
"startpagenum": 45,
"startlinenum": 32,
"endpagenum": 45,
"endlinenum": 43,
"confidence": 0.98,
"justification": "Le passage sélectionné fournit explicitement le pourcentage de hausse projeté pour les prix de l'aluminium en 2026, le contexte de ces mouvements, et les perspectives pour 2027. Il mentionne également le niveau record prévu et les facteurs qui poussent les prix à la hausse.",
"quotes": [
"Les prix de l'aluminium devraient augmenter d'environ 22 % en 2026 (par rapport à 2025) pour atteindre un niveau record, soit environ 21 % de plus que les projections de janvier 2026, soutenus par des conditions d'offre tendues et une demande solide (tableau 1).",
"Les prix devraient ensuite baisser d'environ 6 % en 2027 à mesure que les conditions d'offre s'améliorent."
],
"caveats": [],
"completeanswerfound": true,
"context_structured": true,
"llmdiscoveredkeywords": [
"niveau record",
"conditions d'offre tendues",
"demande solide"
]
}
Exemple 2 : Question complexe sur les data centers et l'électricité
{
"answer": "Les lignes fournies mentionnent que l'expansion plus rapide que prévu des data centers liés à l'IA pourrait stimuler la demande pour certains métaux comme l'aluminium et le cuivre, mais ne quantifient pas la contribution de ces data centers à la croissance de la demande mondiale d'électricité.",
"startpagenum": 47,
"startlinenum": 39,
"endpagenum": 47,
"endlinenum": 40,
"confidence": 0.8,
"justification": "La seule mention des data centers liés à l'IA concerne la demande de métaux, et non la demande d'électricité. Aucun chiffre ou pourcentage n'est donné concernant leur impact sur la croissance de la demande mondiale d'électricité.",
"quotes": [
"De plus, l'expansion plus rapide que prévu des data centers liés à l'IA pourrait stimuler la demande d'aluminium et de cuivre, faisant monter les prix."
],
"caveats": [
"Aucun chiffre spécifique ou déclaration directe sur la croissance de la demande mondiale d'électricité causée par les data centers liés à l'IA n'a été trouvé dans les lignes fournies."
],
"completeanswerfound": false,
"context_structured": true,
"llmdiscoveredkeywords": [
"data centers liés à l'IA",
"demande d'électricité",
"stimuler la demande d'aluminium et de cuivre"
]
}
POURQUOI UTILISER UN SYSTÈME RAG MINIMALISTE ?
Un système RAG minimaliste comme celui-ci a plusieurs avantages par rapport à des solutions plus complexes :
Voici pourquoi ce système est utile, même s'il semble basique :
- Économique : Envoyer un PDF entier à un modèle de langage coûte cher. Avec la recherche, on ne lui envoie que les parties pertinentes.
- Précis : Le modèle se concentre sur les passages utiles, ce qui améliore la qualité des réponses.
- Transparente : Les citations sont directement visibles dans le PDF, ce qui permet de vérifier les sources.
- Adaptable : On peut facilement ajouter des étapes ou des fonctionnalités plus tard, si besoin.
Ce système est conçu pour apprendre les bases du RAG. Les prochains articles de cette série ajouteront des fonctionnalités pour gérer des corpus plus grands, des documents plus complexes, ou des questions plus techniques.
QUAND LE RAG MINIMALISTE NE SUFFIT PLUS
Un système RAG minimaliste fonctionne bien pour un seul PDF de 15 pages. Mais que se passe-t-il quand on passe à l'échelle ?
Voici deux situations où ce système atteint ses limites :
- Un seul document long : Si le PDF fait 500 pages, envoyer tout le texte au modèle coûtera très cher en tokens.
- Plusieurs documents : Si la même question doit être posée sur 1 000 contrats différents, il faut un système capable de chercher dans tous ces documents en même temps.
Dans ces cas, des outils comme les bases de données vectorielles ou les frameworks d'orchestration deviennent utiles. Mais ils ne remplacent pas la compréhension des bases du RAG.
LE CODE COMPLET POUR TESTER LE SYSTÈME
Voici un exemple de code complet pour tester le système sur un PDF réel. Ce code utilise les fonctions définies précédemment et peut être exécuté directement.
def runpipelinetest(
question: str,
linedfin: pd.DataFrame,
pagedfin: pd.DataFrame,
pagedfemb_in: pd.DataFrame,
top_k: int = 3,
client=client,
) -> dict:
"""Exécute les étapes de recherche et de génération sur une question ; retourne un résumé."""
parsedq = getkeywordsfromquestion(question, client=client)
retrievedembdf, = retrievepagesbysimilarity(
pagedfembin, linedfin, question, topk=top_k, client=client,
)
retrievedkwdf, filteredlineskw = retrieve_pages(
pagedfin, linedfin, parsedq.keywords, topk=top_k,
)
linesforgeneration = (
filteredlineskw if len(filteredlineskw) > 0 else linedfin
)
answer = llmanswerwith_evidence(
question, linesforgeneration, client=client,
)
return {
"question": question,
"keywords": parsed_q.keywords,
"embtop3": retrievedembdf["pagenum"].tolist(),
"kw_top3": (
retrievedkwdf["page_num"].tolist()
if len(retrievedkwdf) > 0 else "(pas de correspondance)"
),
"answer_excerpt": (answer.answer[:80] + ("." if len(answer.answer) > 80 else "")),
"citepage": answer.startpage_num,
}
CE QUE CE SYSTÈME RAG APPORTE VRAIMENT
Ce système RAG minimaliste n'est pas une solution miracle. Mais il permet de comprendre comment fonctionne un pipeline RAG, de voir où les choses peuvent mal tourner, et de construire des systèmes plus robustes par la suite.
Voici ce que ce système vous apprend :
- Le RAG n'est pas magique : Il faut bien structurer les données, bien poser les questions, et bien choisir les passages à envoyer au modèle.
- La transparence est clé : Les citations permettent de vérifier que la réponse est bien sourcée.
- L'adaptabilité est essentielle : Un système minimaliste peut évoluer vers des solutions plus complexes quand c'est nécessaire.
Si vous voulez aller plus loin, les prochains articles de cette série aborderont :
- Le parsing avancé des documents
- L'extraction plus fine des mots-clés
- La recherche avec des embeddings plus performants
- La génération avec des modèles plus puissants
- Towards Data Science
L'indépendance de CLODCO est votre garantie.
Pour que l'actualité de l'IA reste sans filtre et sans concession, votre soutien est indispensable. Votre contribution est le seul moteur de notre liberté éditoriale.
Soutenir CLODCO

