Apogée Consult
Retour au blog
Jules Ginhac
Jules GinhacCo-Founder & ingénieur IA

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 340g

Au lieu de :

Tension d'alimentation : 12V DC
Courant max admissible : 2.5A
Plage de température : -20°C à +85°C
Poids : 340g

Un 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 chunks

Ce 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.

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
RAG PDF : pipeline d'extraction pour fichiers complexes | Apogée Consult