Aller au contenu principal

Extraction d'entités structurées

Mis à jour le 28 juillet 2026

Extraction d’entités structurées

L’extraction d’entités structurées est l’un des cas d’usage les plus demandés en production : transformer du texte libre (emails, documents, pages web) en données exploitables par votre application. En combinant le Structured Output avec des prompts spécialisés, vous pouvez construire des pipelines d’extraction fiables sans entraîner de modèle NER dédié.

Pipeline d’extraction de base

Toute la spécificité de l’extraction tient dans le system prompt de la fonction ci-dessous, et il mérite qu’on s’y arrête. Trois consignes s’y superposent : n’extraire que ce qui est présent, utiliser null pour ce qui manque, et ne jamais déduire une information non mentionnée.

Cette dernière phrase est la plus importante en pratique. Un modèle laissé libre complétera volontiers un numéro de SIRET vraisemblable ou devinera une ville à partir d’un indicatif téléphonique, avec la meilleure intention du monde. Sur une extraction destinée à un système métier, cette obligeance est un défaut grave : une fois la donnée en base, rien ne distingue plus ce qui a été lu de ce qui a été supposé. La temperature à 0.0 va dans le même sens — sur deux exécutions du même document, on veut la même sortie, ne serait-ce que pour reproduire un incident.

import json
from openai import OpenAI

client = OpenAI()

def extraire_entites(texte: str, schema: dict,
                     instructions: str = "") -> dict:
    """Extrait des entités structurées d'un texte libre."""
    system = (
        "Tu es un extracteur d'entités spécialisé. "
        "Extrais UNIQUEMENT les informations présentes dans le texte. "
        "Si une information est absente, utilise null. "
        "Ne déduis JAMAIS d'informations non explicitement mentionnées."
    )
    if instructions:
        system += f"\n\nInstructions supplémentaires : {instructions}"

    response = client.responses.create(
        model="gpt-5.6-terra",
        instructions=system,
        input=f"Texte à analyser :\n\n{texte}",
        text={
            "format": {
                "type": "json_schema",
                "name": "extraction",
                "schema": schema,
                "strict": True
            }
        },
        temperature=0.0
    )
    return json.loads(response.output_text)

Exemple 1 : extraction de CV

Le CV est un excellent terrain d’exercice parce qu’il combine des rubriques stables et une mise en page totalement libre. Le schéma qui suit regroupe les coordonnées dans un objet identite dont presque tous les champs sont nullable — un candidat peut ne pas indiquer de téléphone, et forcer ce champ reviendrait à inviter le modèle à en inventer un.

Les compétences ne sont pas de simples chaînes mais des objets à trois attributs, ce qui permet de conserver le niveau annoncé et de ranger chaque compétence dans une famille. Sans ces enums, « Scrum » et « Python » finiraient côte à côte dans la même liste plate, inexploitable pour un filtre qui voudrait isoler les langages. Regardez aussi le champ date_fin des expériences, nullable là où date_debut ne l’est pas : c’est ainsi qu’on encode le poste en cours sans inventer de convention maison du type « présent » ou « 9999 ». Sur le CV de test, cette subtilité se joue sur la ligne TechCorp.

cv_schema = {
    "type": "object",
    "properties": {
        "identite": {
            "type": "object",
            "properties": {
                "nom_complet": {"type": "string"},
                "email": {"type": ["string", "null"]},
                "telephone": {"type": ["string", "null"]},
                "localisation": {"type": ["string", "null"]},
                "linkedin": {"type": ["string", "null"]}
            },
            "required": ["nom_complet", "email", "telephone",
                          "localisation", "linkedin"],
            "additionalProperties": False
        },
        "titre_actuel": {"type": ["string", "null"]},
        "annees_experience": {"type": ["integer", "null"]},
        "competences": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "nom": {"type": "string"},
                    "niveau": {
                        "type": "string",
                        "enum": ["debutant", "intermediaire",
                                 "avance", "expert"]
                    },
                    "categorie": {
                        "type": "string",
                        "enum": ["langage", "framework", "outil",
                                 "methodologie", "soft_skill"]
                    }
                },
                "required": ["nom", "niveau", "categorie"],
                "additionalProperties": False
            }
        },
        "experiences": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "entreprise": {"type": "string"},
                    "poste": {"type": "string"},
                    "date_debut": {"type": "string"},
                    "date_fin": {"type": ["string", "null"]},
                    "description": {"type": "string"}
                },
                "required": ["entreprise", "poste", "date_debut",
                              "date_fin", "description"],
                "additionalProperties": False
            }
        },
        "formations": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "etablissement": {"type": "string"},
                    "diplome": {"type": "string"},
                    "annee": {"type": ["string", "null"]}
                },
                "required": ["etablissement", "diplome", "annee"],
                "additionalProperties": False
            }
        },
        "langues": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "langue": {"type": "string"},
                    "niveau": {
                        "type": "string",
                        "enum": ["a1", "a2", "b1", "b2",
                                 "c1", "c2", "natif"]
                    }
                },
                "required": ["langue", "niveau"],
                "additionalProperties": False
            }
        }
    },
    "required": ["identite", "titre_actuel", "annees_experience",
                  "competences", "experiences", "formations", "langues"],
    "additionalProperties": False
}

cv_texte = """
Marie Dupont - Développeuse Full Stack Senior
Email: [email protected] | Tel: +33 6 12 34 56 78
Paris, France | linkedin.com/in/mariedupont

8 ans d'expérience en développement web.

Compétences : Python (expert), TypeScript (avancé), React (avancé),
Django (expert), PostgreSQL (avancé), Docker (intermédiaire),
Scrum (avancé)

Expérience :
- TechCorp (2022-présent) : Lead Developer Full Stack
  Architecte principal d'une plateforme SaaS B2B, équipe de 6 dev.
- StartupAI (2019-2022) : Développeuse Senior
  Développement d'APIs ML en Python/FastAPI.

Formation :
- EPITECH Paris, Master Informatique (2017)

Langues : Français (natif), Anglais (C1), Espagnol (B1)
"""

result = extraire_entites(cv_texte, cv_schema)

Exemple 2 : extraction d’emails commerciaux

Le second exemple change de nature : on ne se contente plus de recopier des informations, on demande au modèle une lecture. intention et urgence sont des jugements — aucune phrase de l’email ne dit « ceci est une demande de devis, priorité haute ». C’est précisément ce qu’un pipeline d’extraction classique, fondé sur des règles ou des expressions régulières, ne sait pas faire, et c’est ce qui justifie l’usage d’un LLM ici plutôt qu’ailleurs.

Le reste du schéma revient à des faits vérifiables : les produits cités, les montants avec leur contexte, les dates et leur signification, les actions à mener. Le champ resume clôt l’objet et sert à l’humain qui relira la file, pas au code. Un point de vigilance sur ce schéma mixte : une erreur de jugement sur urgence n’invalide pas les montants, mais l’inverse peut se produire, car un modèle qui a mal compris l’intention lit parfois le reste à travers ce contresens. Si la classification est critique pour votre routage, séparez-la en un appel dédié.

email_schema = {
    "type": "object",
    "properties": {
        "intention": {
            "type": "string",
            "enum": ["demande_devis", "reclamation", "information",
                     "commande", "partenariat", "autre"]
        },
        "urgence": {
            "type": "string",
            "enum": ["critique", "haute", "normale", "basse"]
        },
        "expediteur": {
            "type": "object",
            "properties": {
                "nom": {"type": ["string", "null"]},
                "entreprise": {"type": ["string", "null"]},
                "email": {"type": ["string", "null"]}
            },
            "required": ["nom", "entreprise", "email"],
            "additionalProperties": False
        },
        "produits_mentionnes": {
            "type": "array",
            "items": {"type": "string"}
        },
        "montants_mentionnes": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "valeur": {"type": "number"},
                    "devise": {"type": "string"},
                    "contexte": {"type": "string"}
                },
                "required": ["valeur", "devise", "contexte"],
                "additionalProperties": False
            }
        },
        "dates_cles": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "date": {"type": "string"},
                    "signification": {"type": "string"}
                },
                "required": ["date", "signification"],
                "additionalProperties": False
            }
        },
        "actions_requises": {
            "type": "array",
            "items": {"type": "string"}
        },
        "resume": {"type": "string"}
    },
    "required": ["intention", "urgence", "expediteur",
                  "produits_mentionnes", "montants_mentionnes",
                  "dates_cles", "actions_requises", "resume"],
    "additionalProperties": False
}

Extraction en batch

Pour traiter de gros volumes, parallélisez les extractions. Une boucle séquentielle sur mille documents passe l’essentiel de son temps à attendre le réseau ; avec asyncio, ces attentes se recouvrent et le lot se traite en une fraction du temps.

Le sémaphore n’est pas un détail décoratif. Sans lui, mille tâches lancées ensemble saturent votre quota et déclenchent des erreurs de limite de débit qui coûtent plus cher en reprises qu’elles ne font gagner en vitesse. La valeur de max_concurrent se règle en observant vos propres limites, dix étant un point de départ prudent. Notez enfin le return_exceptions=True : il évite qu’un document malformé fasse tomber tout le lot, et vous laisse trier les échecs après coup.

import asyncio
from openai import AsyncOpenAI

async_client = AsyncOpenAI()

async def extraire_batch(textes: list[str], schema: dict,
                          max_concurrent: int = 10) -> list[dict]:
    """Extraction parallèle avec contrôle de concurrence."""
    semaphore = asyncio.Semaphore(max_concurrent)

    async def extract_one(texte: str) -> dict:
        async with semaphore:
            response = await async_client.responses.create(
                model="gpt-5.6-terra",
                instructions="Extrais les entités du texte fourni. "
                             "Utilise null pour les champs absents.",
                input=texte,
                text={
                    "format": {
                        "type": "json_schema",
                        "name": "extraction",
                        "schema": schema,
                        "strict": True
                    }
                },
                temperature=0.0
            )
            return json.loads(response.output_text)

    tasks = [extract_one(t) for t in textes]
    return await asyncio.gather(*tasks, return_exceptions=True)

Évaluer la qualité de l’extraction

Un pipeline d’extraction sans jeu de test annoté est un pipeline dont personne ne connaît la fiabilité, et l’impression laissée par trois exemples lus à la main se révèle presque toujours trop optimiste. La fonction ci-dessous compare champ par champ une extraction obtenue à une référence établie manuellement, et renvoie une précision globale accompagnée du détail des écarts.

Ce détail est la partie utile : les erreurs ne sont presque jamais réparties uniformément. Un champ concentre les problèmes — souvent une date ambiguë, un niveau de compétence non explicite ou une catégorie mal délimitée — et une phrase ajoutée au system prompt le corrige. Sans cette mesure, vous auriez retouché le prompt au hasard, en aggravant peut-être un champ qui fonctionnait déjà.

def evaluer_extraction(attendu: dict, obtenu: dict,
                       champs: list[str]) -> dict:
    """Compare l'extraction obtenue avec la référence."""
    correct = 0
    total = len(champs)
    erreurs = []

    for champ in champs:
        val_attendue = attendu.get(champ)
        val_obtenue = obtenu.get(champ)

        if val_attendue == val_obtenue:
            correct += 1
        else:
            erreurs.append({
                "champ": champ,
                "attendu": val_attendue,
                "obtenu": val_obtenue
            })

    return {
        "precision": correct / total if total > 0 else 0,
        "correct": correct,
        "total": total,
        "erreurs": erreurs
    }

Construire votre référence avant d’optimiser

Choisissez un type de document de votre domaine — contrats, factures, rapports — et définissez un schéma d’extraction d’au moins dix champs, en mêlant délibérément des champs faciles et des champs que vous savez ambigus. Constituez cinq documents d’exemple avec leurs extractions attendues, écrites à la main : cette étape est fastidieuse, elle prend une heure, et c’est elle qui donne toute sa valeur à la suite.

Faites ensuite tourner le pipeline, mesurez la précision, puis lisez la liste des erreurs champ par champ. Le champ le plus fautif vous indiquera quoi corriger dans le prompt ; une seconde mesure vous dira si la correction a effectivement servi — ou si elle a dégradé un autre champ au passage, ce qui arrive plus souvent qu’on ne le croit dès qu’une consigne supplémentaire déplace l’attention du modèle.

Points clés à retenir

  • L’extraction structurée remplace les modèles NER dédiés pour la plupart des cas
  • Utilisez temperature=0.0 pour maximiser la cohérence
  • Rendez les champs facultatifs nullable plutôt qu’optionnels
  • Parallélisez avec asyncio pour les gros volumes
  • Évaluez systématiquement la qualité avec un jeu de test annoté
  • Les instructions du system prompt guident le contenu, le schéma contraint la structure