
Je suis Jules Ginhac, Co-Founder & ingénieur IA chez Apogée Consult à Lyon. Je conçois et déploie des architectures IA génératives (RAG, agents, LLMOps) pour des PME, startups et organisations publiques.
Notre pipeline pour ingérer des PDF complexes dans un RAG sans perdre la structure
- rag
Tables, colonnes multiples, schémas, en-têtes répétés : comment on extrait et structure des PDF métier pour les indexer dans un RAG sans perdre ce qui compte.
Notre pipeline pour ingérer des PDF complexes dans un RAG sans perdre la structure
La plupart des pipelines RAG traitent les PDF comme du texte brut. Pour des PDF nés numériquement avec une mise en page simple, ça passe. Pour des rapports annuels, des datasheets industrielles, des documents réglementaires avec des tableaux de valeurs et des mises en page multi-colonnes, l'extraction naïve casse la structure et produit des chunks inutilisables.
Ce texte documente notre pipeline actuel, les choix qu'on a faits et pourquoi, avec le code des étapes clés.
Ce que l'extraction naïve rate
Prenons un PDF de fiche technique avec deux colonnes : spécifications à gauche, valeurs à droite. Un extracteur texte lit le PDF ligne par ligne dans l'ordre des objets PDF (pas dans l'ordre visuel). Il retourne souvent :
Tension d'alimentation Courant max admissible
12V DC 2.5A
Plage de température Poids
-20°C à +85°C 340gAu lieu de :
Tension d'alimentation : 12V DC
Courant max admissible : 2.5A
Plage de température : -20°C à +85°C
Poids : 340gUn embedding sur le premier extrait ne capture pas la relation propriété/valeur. Un LLM interrogé dessus peut les inverser.
L'architecture du pipeline
Notre pipeline comporte quatre étapes distinctes. Chacune est testable indépendamment.
PDF brut
→ [1] Détection de type et stratégie d'extraction
→ [2] Extraction structurée (texte + tables + métadonnées)
→ [3] Post-traitement et nettoyage
→ [4] Chunking sémantique adapté au type
→ Chunks indexablesÉtape 1 : détecter le type de PDF
Un PDF peut être natif (le texte est dans le PDF), scanné (le texte est une image), ou mixte. Extraire du texte depuis un scan sans OCR retourne des caractères vides ou du bruit.
import fitz # PyMuPDF
def classify_pdf(path: str) -> str:
"""
Retourne 'native', 'scanned', ou 'mixed'.
Heuristique : si < 100 caractères de texte sur les 3 premières pages,
on considère le document comme scanné.
"""
doc = fitz.open(path)
char_count = 0
pages_sampled = min(3, len(doc))
for i in range(pages_sampled):
text = doc[i].get_text("text")
char_count += len(text.strip())
doc.close()
avg_chars = char_count / pages_sampled
if avg_chars < 100:
return "scanned"
elif avg_chars < 500:
return "mixed"
return "native"Pour les PDFs scannés, on route vers un pipeline OCR (Tesseract ou AWS Textract selon le volume et le budget). On ne traite pas les scans dans le pipeline standard.
Étape 2 : extraction structurée avec Unstructured
Unstructured est la bibliothèque qu'on utilise comme couche principale d'extraction. Elle identifie les éléments (titre, paragraphe, table, image, liste) et les retourne avec leur type.
from unstructured.partition.pdf import partition_pdf
def extract_elements(path: str) -> list[dict]:
"""
Extrait les éléments structurés du PDF.
strategy="hi_res" active la détection de layout par vision.
C'est plus lent (5-15s par page) mais indispensable pour
les PDFs multi-colonnes et les tableaux complexes.
Pour des PDFs simples, strategy="fast" suffit et prend < 1s/page.
"""
elements = partition_pdf(
filename=path,
strategy="hi_res",
infer_table_structure=True, # extrait les tables en HTML
include_page_breaks=True,
languages=["fra", "eng"],
)
return [
{
"type": el.category,
"text": el.text,
"metadata": el.metadata.to_dict(),
# Pour les tables, Unstructured génère du HTML
"html": getattr(el, "metadata", {}).get("text_as_html", None),
}
for el in elements
]L'option infer_table_structure=True génère une représentation HTML des tableaux. On la convertit ensuite en Markdown pour l'embedding, car les LLMs interprètent mieux le Markdown que le HTML brut dans un contexte de RAG.
from markdownify import markdownify as md
def table_html_to_markdown(html: str) -> str:
return md(html, strip=["a", "img"]).strip()Étape 3 : post-traitement
Trois nettoyages systématiques :
Suppression des artefacts d'en-tête/pied de page. Unstructured les classe souvent comme Header ou Footer. On les filtre, sauf quand ils contiennent une date ou un numéro de version (information utile pour les métadonnées du chunk).
Reconstruction des listes fragmentées. Unstructured sépare parfois chaque item de liste en un élément ListItem distinct. On les regroupe avec leur titre de liste parent pour former un bloc cohérent.
Détection des éléments trop courts. Un élément de moins de 15 tokens sans contexte est écarté ou fusionné avec l'élément précédent.
def post_process_elements(elements: list[dict]) -> list[dict]:
processed = []
buffer_list_items = []
for el in elements:
# Ignorer les artefacts de pagination
if el["type"] in ("Header", "Footer", "PageBreak"):
continue
# Regrouper les items de liste
if el["type"] == "ListItem":
buffer_list_items.append(el["text"])
continue
else:
if buffer_list_items:
processed.append({
"type": "List",
"text": "\n".join(f"- {item}" for item in buffer_list_items),
"metadata": el["metadata"],
})
buffer_list_items = []
# Écarter les fragments trop courts
if len(el["text"].split()) < 15 and el["type"] == "NarrativeText":
continue
processed.append(el)
return processedÉtape 4 : chunking adapté au type d'élément
Tous les éléments ne se chunkent pas de la même façon :
- Tables : un chunk = une table entière, jamais découpée. Si la table dépasse 800 tokens, on la découpe par blocs de lignes en conservant les en-têtes de colonnes dans chaque chunk.
- Titres + paragraphes : on fusionne le titre avec le texte qui suit jusqu'à la limite de tokens.
- Listes : un chunk = la liste complète si elle tient en dessous de la limite. Sinon, découpage par items avec le titre répété.
def chunk_elements(
elements: list[dict],
max_tokens: int = 512,
) -> list[dict]:
chunks = []
for el in elements:
if el["type"] == "Table":
# Une table = un chunk, avec en-têtes répétées si scindée
table_text = el.get("html") and table_html_to_markdown(el["html"]) or el["text"]
chunks.append({
"content": table_text,
"type": "table",
"source_page": el["metadata"].get("page_number"),
})
else:
# Texte narratif : chunking par limite de tokens
# On utilise le tokenizer du modèle d'embedding cible
tokens = tokenize(el["text"])
if len(tokens) <= max_tokens:
chunks.append({"content": el["text"], "type": "text"})
else:
# Découpage récursif sur les séparateurs forts
for sub_chunk in split_on_separators(el["text"], max_tokens):
chunks.append({"content": sub_chunk, "type": "text"})
return chunksCe qu'on ne fait pas encore
L'extraction des figures et schémas reste un problème ouvert dans notre pipeline. Unstructured les identifie mais ne les décrit pas. On les ignore pour l'instant, ce qui signifie que les PDF très schématiques (plans techniques, organigrammes) produisent des RAGs partiels.
La solution en cours d'évaluation : passer les images via un modèle vision (GPT-4o ou Qwen-VL) pour générer une description textuelle qui devient un chunk comme un autre. Le coût par page est significatif (environ 0,01 USD par image avec GPT-4o), à réserver aux PDF où les schémas sont critiques.
La question non résolue : comment maintenir la cohérence de l'index quand le PDF source est mis à jour ? Re-extraire et rechunker entièrement est fiable mais coûteux. La mise à jour incrémentale par page nécessite de tracer la correspondance chunk → page source et de gérer les invalidations en cascade.