LLM Engineer's Handbook
Chapitre 3 / 11 · 15 min de lecture

L'ingénierie des données

Le socle de tout système LLM : collecter, nettoyer et stocker les données via un pipeline ETL et un entrepôt, base du RAG et du fine-tuning.

Avant d'entraîner ou d'interroger le moindre modèle, il faut des données. Dans un projet jouet ou en recherche, on part d'un jeu de données statique livré clé en main. Mais un système LLM réel ne dispose pas de ce luxe : il doit collecter et curer sa propre matière première. C'est tout l'enjeu de ce chapitre du LLM Engineer's Handbook, qui pose la première brique du projet fil rouge des auteurs, le LLM Twin (un assistant qui imite votre style d'écriture). Iusztin et Labonne y construisent un pipeline qui parcourt plusieurs plateformes — Medium, Substack, GitHub — et agrège les contenus dans un entrepôt de données.

L'adage qui gouverne tout ce qui suit est connu : « garbage in, garbage out ». La qualité des données conditionne aussi bien le RAG que le fine-tuning. Un modèle nourri de texte bruité, dupliqué ou mal structuré produira des réponses bruitées, peu importe le raffinement de l'architecture en aval. Ce chapitre vulgarise donc le patron fondamental de l'ingénierie des données pour les LLM : un pipeline d'extraction, transformation, chargement (ETL, Extract-Transform-Load) qui transforme un fouillis de sources hétérogènes en documents propres et requêtables.

Pourquoi un pipeline ETL plutôt qu'un jeu de données figé

Un pipeline ETL repose sur trois étapes fondamentales, qu'il faut bien distinguer car chacune répond à un problème différent.

ÉtapeQuestion résolueDans le LLM Twin
Extraction (extract)D'où viennent les données et comment y accéder ?Crawl de Medium, Substack, GitHub, LinkedIn
Transformation (transform)Comment uniformiser des sources hétérogènes ?Extraction du texte depuis le HTML, nettoyage, normalisation
Chargement (load)Où stocker la donnée brute durablement ?Insertion dans l'entrepôt MongoDB

L'intérêt de construire ce pipeline plutôt que d'acheter un dataset tout fait est double. D'abord, il reflète un scénario réel : en production, vos données vivent sur le web, dans des dépôts de code, dans des bases internes, et changent en permanence. Ensuite, il relie tous les maillons d'un projet ML de bout en bout — la collecte alimente le fine-tuning comme l'inférence.

La signature du pipeline est volontairement simple. En entrée, une liste de liens et l'auteur qui leur est associé. En sortie, une liste de documents bruts stockés dans l'entrepôt NoSQL. Le pipeline détecte le domaine de chaque lien, appelle le crawler spécialisé adéquat, standardise le contenu récupéré et le sauvegarde sous cet auteur.

Note

Les auteurs emploient « utilisateur » (user) et « auteur » (author) de façon interchangeable : dans la plupart des cas, l'utilisateur est l'auteur du contenu extrait. L'entrepôt ne contient toutefois qu'une seule collection users.

Réduire la diversité : trois catégories de données

La clé de voûte de l'architecture est une décision de modélisation simple mais puissante. Peu importe d'où provient un contenu, tout document se ramène à l'une de trois catégories :

  • un article (article) — un billet de blog, un post Medium, une page Substack ;
  • un dépôt (repository ou code) — l'arborescence d'un projet GitHub ;
  • un post (post) — une publication courte de réseau social, façon LinkedIn ou X.

Pourquoi ce choix ? Parce que ce qui compte, c'est le format du document, pas sa provenance. On ne crée pas une catégorie par source, on crée une catégorie par format. Chaque catégorie devient une entité de domaine avec sa propre classe et sa propre collection MongoDB ; l'URL source est conservée dans les métadonnées, donc on n'oublie jamais l'origine.

Le bénéfice est l'extensibilité à moindre coût. Pour collecter X (ex-Twitter), il suffit d'écrire un nouveau crawler qui produit un document post — le reste du code ne bouge pas. Si au contraire on avait introduit la dimension « source » dans la structure des classes, chaque nouvelle plateforme aurait imposé une nouvelle classe de document et des adaptations dans toutes les couches en aval, jusqu'au pipeline de features.

       liens fournis


   ┌─────────────────┐
   │ CrawlerDispatcher│  détecte le domaine de chaque lien
   └────────┬────────┘
            │ instancie le bon crawler
   ┌────────┼────────┬──────────────┐
   ▼        ▼        ▼              ▼
 Medium   GitHub  LinkedIn   CustomArticle (filet de sécurité)
   │        │        │              │
 article  repo     post         article
   └────────┴────────┴──────────────┘
                     │ extract() → standardise → save()

            entrepôt MongoDB (source de vérité brute)

Astuce

Concevoir d'abord une version end-to-end imparfaite, puis itérer, est une excellente stratégie. Pour une preuve de concept, quelques centaines de documents suffisent. Mais les LLM sont voraces en données : un produit réel en exigera des milliers. L'architecture par catégories permet justement d'ajouter des sources au fil des itérations sans tout réécrire.

Le dispatcher : instancier le bon crawler

Le point d'entrée de la logique de collecte est le dispatcher (CrawlerDispatcher), une couche intermédiaire entre les liens et les crawlers. Il sait extraire le domaine d'une URL et instancier le crawler approprié. Il s'appuie sur le patron monteur (builder pattern) : on enchaîne des appels register_*() pour configurer les domaines supportés, chaque méthode renvoyant self.

import re
from urllib.parse import urlparse

from .custom_article import CustomArticleCrawler
from .github import GithubCrawler
from .medium import MediumCrawler


class CrawlerDispatcher:
    def __init__(self) -> None:
        self._crawlers = {}

    @classmethod
    def build(cls) -> "CrawlerDispatcher":
        return cls()

    def register_medium(self) -> "CrawlerDispatcher":
        self.register("https://medium.com", MediumCrawler)
        return self  # chaînage façon builder

    def register(self, domain: str, crawler) -> None:
        # On normalise le domaine : c'est la clé de correspondance
        parsed = urlparse(domain).netloc
        pattern = r"https://(www.)?{}/*".format(re.escape(parsed))
        self._crawlers[pattern] = crawler

    def get_crawler(self, url: str):
        for pattern, crawler in self._crawlers.items():
            if re.match(pattern, url):
                return crawler()
        # Aucun crawler dédié : filet de sécurité générique
        return CustomArticleCrawler()

Le détail important est le comportement par défaut : si aucun domaine enregistré ne correspond, le dispatcher retombe sur le CustomArticleCrawler. Ce dernier ne connaît les particularités d'aucune plateforme ; il avale aveuglément le HTML d'un lien et en extrait le texte. C'est exactement ce qu'il faut pour un blog personnel ou un Substack librement accessible. Ainsi un lien Substack utilise le crawler générique, tandis qu'un lien Medium déclenche le crawler dédié.

Cette élégance repose sur le polymorphisme (polymorphism). Tous les crawlers partagent la même interface extract(link, **kwargs). Du coup, le code appelant manipule un objet abstrait sans se soucier de sa sous-classe concrète :

crawler = dispatcher.get_crawler(link)
crawler.extract(link=link, user=user)  # peu importe le type réel

Les crawlers : trois techniques d'extraction

Tous les crawlers héritent d'une classe de base abstraite et exposent extract(), mais leurs moyens d'accès diffèrent radicalement selon la plateforme. Le livre en illustre trois, qui couvrent l'essentiel des situations rencontrées sur le web.

CrawlerTechniqueQuand l'utiliser
GithubCrawlergit clone dans un sous-processusDonnées accessibles sans login navigateur
CustomArticleCrawlerUtilitaires LangChain (chargement HTML)Page publique, chargeable d'un bloc
MediumCrawlerSelenium (navigateur piloté)Login requis, scroll pour charger le contenu

Cloner plutôt que scraper : le crawler GitHub

Pour GitHub, inutile de simuler un navigateur : on exploite directement git. Le crawler vérifie d'abord que le dépôt n'a pas déjà été collecté (déduplication), clone dans un répertoire temporaire, parcourt l'arborescence en ignorant le bruit (.git, .toml, .lock, .png), lit chaque fichier pertinent, puis nettoie le répertoire dans un finally.

class GithubCrawler(BaseCrawler):
    model = RepositoryDocument

    def __init__(self, ignore=(".git", ".toml", ".lock", ".png")):
        super().__init__()
        self._ignore = ignore

    def extract(self, link: str, **kwargs) -> None:
        if self.model.find(link=link) is not None:
            return  # déjà en base : on évite les doublons

        local_temp = tempfile.mkdtemp()
        try:
            os.chdir(local_temp)
            subprocess.run(["git", "clone", link])

            repo_path = os.path.join(
                local_temp, os.listdir(local_temp)[0])
            tree = {}
            for root, _, files in os.walk(repo_path):
                rel = root.replace(repo_path, "").lstrip("/")
                if rel.startswith(self._ignore):
                    continue
                for file in files:
                    if file.endswith(self._ignore):
                        continue
                    path = os.path.join(rel, file)
                    with open(os.path.join(root, file),
                              "r", errors="ignore") as f:
                        # Normalisation : on retire les espaces
                        tree[path] = f.read().replace(" ", "")

            user = kwargs["user"]
            self.model(content=tree, name=..., link=link,
                       platform="github",
                       author_id=user.id,
                       author_full_name=user.full_name).save()
        finally:
            shutil.rmtree(local_temp)  # nettoyage systématique

Le filet de sécurité : LangChain pour le HTML générique

Le CustomArticleCrawler délègue tout le travail à deux classes de langchain_community : AsyncHtmlLoader charge le HTML, Html2TextTransformer en extrait le texte. C'est rapide à écrire mais on ne contrôle ni l'extraction ni le parsing.

from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_community.document_transformers.html2text import (
    Html2TextTransformer)


class CustomArticleCrawler(BaseCrawler):
    model = ArticleDocument

    def extract(self, link: str, **kwargs) -> None:
        if self.model.find(link=link) is not None:
            return

        docs = AsyncHtmlLoader([link]).load()
        doc = Html2TextTransformer().transform_documents(docs)[0]

        content = {
            "Title": doc.metadata.get("title"),
            "Subtitle": doc.metadata.get("description"),
            "Content": doc.page_content,
            "language": doc.metadata.get("language"),
        }
        user = kwargs["user"]
        self.model(content=content, link=link,
                   platform=urlparse(link).netloc,
                   author_id=user.id,
                   author_full_name=user.full_name).save()

Attention

Les utilitaires LangChain offrent une fonctionnalité de haut niveau qui marche correctement dans la plupart des cas, mais qui est rapide à implémenter et difficile à personnaliser. C'est précisément pour cela que de nombreux développeurs évitent LangChain en production : dès qu'il faut un comportement fin, l'abstraction devient un carcan. Réservez ce crawler au rôle de filet de sécurité.

Piloter un navigateur : Selenium pour Medium et LinkedIn

Certaines plateformes exigent de se connecter et de défiler la page (scroll) pour charger le contenu paresseux. Le MediumCrawler étend une base Selenium qui ouvre un Chrome sans interface (headless), avec une batterie d'options de performance et de sécurité (--headless=new, --no-sandbox, --disable-extensions…). Une fois la page entièrement chargée, BeautifulSoup parse le HTML pour en extraire titre, sous-titre et corps.

from bs4 import BeautifulSoup
from .base import BaseSeleniumCrawler


class MediumCrawler(BaseSeleniumCrawler):
    model = ArticleDocument

    def extract(self, link: str, **kwargs) -> None:
        if self.model.find(link=link) is not None:
            return

        self.driver.get(link)
        self.scroll_page()  # charge le contenu paresseux

        soup = BeautifulSoup(self.driver.page_source, "html.parser")
        title = soup.find_all("h1", class_="pw-post-title")
        subtitle = soup.find_all("h2", class_="pw-subtitle-paragraph")
        data = {
            "Title": title[0].string if title else None,
            "Subtitle": subtitle[0].string if subtitle else None,
            "Content": soup.get_text(),
        }
        self.driver.close()  # libère les ressources

        user = kwargs["user"]
        self.model(platform="medium", content=data, link=link,
                   author_id=user.id,
                   author_full_name=user.full_name).save()

Selenium est puissant mais fragile : il dépend d'un navigateur installé et d'un pilote (ChromeDriver) dont la version doit correspondre. Le livre signale d'ailleurs que c'est la principale source de pannes du pipeline, au point de prévoir un jeu de données de secours importable si les crawlers échouent.

Astuce

Au-delà de ces trois techniques artisanales, l'écosystème Python s'est enrichi d'outils spécialisés. Scrapy crawle des sites et en extrait des données structurées à grande échelle ; Crawl4AI est taillé pour la collecte destinée aux applications LLM. Pour un produit réel, ils valent souvent mieux qu'un assemblage de scripts maison.

Nettoyer et normaliser : transformer le bruit en texte exploitable

L'étape de transformation est discrète dans le code mais cruciale pour la qualité. Quelle que soit la source, chaque crawler suit le même enchaînement : accéder à la page, extraire le HTML, en tirer le texte, le nettoyer et le normaliser pour qu'il entre dans l'entrepôt sous une interface commune. Concrètement, on observe trois gestes récurrents :

  • la déduplication — chaque crawler vérifie via find(link=link) que le document n'existe pas déjà avant de l'insérer, évitant les doublons coûteux en aval ;
  • la suppression du bruit — exclure les fichiers et dossiers non pertinents d'un dépôt, ne retenir que titre, sous-titre et corps d'un article ;
  • la normalisation — uniformiser le texte (le crawler GitHub retire par exemple les espaces), pour que tous les documents partagent le même format.

Ce nettoyage à la collecte reste léger. Le gros du travail de transformation — découpage en morceaux (chunking), vectorisation (embedding) — interviendra plus tard, dans le pipeline de features. La règle est de ne faire ici que le strict nécessaire pour obtenir une donnée brute propre et homogène.

Stocker : l'entrepôt comme source de vérité

Le pipeline charge ses documents dans MongoDB, qui joue le rôle d'entrepôt de données (data warehouse). C'est un choix inhabituel — une base transactionnelle n'est pas un entrepôt au sens classique — que les auteurs justifient soigneusement.

CritèrePourquoi MongoDB pour le LLM Twin
Nature des donnéesDu texte non structuré crawlé sur le web
SchémaNoSQL : pas de schéma imposé, développement plus rapide
VolumeQuelques centaines de documents : largement dans ses cordes
OutillageSDK Python intuitif, image Docker locale, palier cloud gratuit

Le raisonnement tient à la distinction structuré / non structuré. Travailler avec du texte brut sans contrainte de schéma rend le développement plus simple et plus rapide ; une base relationnelle aurait imposé une rigidité inutile à ce stade. À l'échelle du projet — centaines de documents — MongoDB encaisse même le calcul de statistiques sans broncher.

À retenir

Ce choix vaut pour cette échelle. Dès qu'on manipule du big data (des millions de documents et au-delà), un entrepôt dédié comme Snowflake ou BigQuery devient le bon outil. L'idée à retenir n'est pas « toujours MongoDB » mais « choisir le stockage en fonction du volume et de la nature des données ».

Donnée brute contre features dérivées

Une séparation architecturale est ici essentielle. L'entrepôt MongoDB détient la donnée brute, source de vérité immuable. Le pipeline de features (chapitre suivant) lit cette donnée, la nettoie davantage, la transforme en features (chunking, embedding) et les range dans une base de données vectorielle (vector database, Qdrant).

  donnée BRUTE                     FEATURES dérivées
 ┌──────────────┐    lecture     ┌──────────────────┐
 │   MongoDB    │ ─────────────▶ │ pipeline features│
 │ (warehouse)  │                │  chunk + embed   │
 │ source vérité│                └────────┬─────────┘
 └──────▲───────┘                         ▼
        │ écriture                ┌──────────────────┐
 ┌──────┴───────┐                 │  Qdrant (vector) │
 │ ETL collecte │                 │ training/inférence│
 └──────────────┘                 └──────────────────┘

Les deux pipelines sont indépendants : ils ne communiquent qu'à travers MongoDB. La collecte écrit, le pipeline de features lit, sur des planifications différentes. Ce découplage est précieux : on peut recollecter sans retraiter, ou retraiter sans recollecter.

Modéliser les documents : le patron ODM

Comment manipuler ces documents en Python sans noyer le code dans des appels MongoDB ? Le livre invoque le mapping objet-document (ODM, Object-Document Mapping), cousin du mapping objet-relationnel (ORM, Object-Relational Mapping). L'ORM permet de requêter une base SQL avec un paradigme objet, en encapsulant les opérations CRUD ; l'ODM fait de même pour une base NoSQL orientée documents.

L'intérêt de structurer la donnée en classes plutôt qu'en dictionnaires est la sûreté. Avec un dictionnaire, on n'est jamais certain qu'une clé existe ni de son type. En enveloppant chaque document dans une classe Pydantic, on hérite d'une validation de types prête à l'emploi : créer un ArticleDocument avec un link nul ou non textuel lève immédiatement une erreur.

Les auteurs implémentent leur propre ODM léger, NoSQLBaseDocument, dont héritent tous les documents. Toute la logique CRUD y est centralisée une fois pour toutes.

import uuid
from abc import ABC
from typing import Generic, Type, TypeVar

from pydantic import UUID4, BaseModel, Field

T = TypeVar("T", bound="NoSQLBaseDocument")


class NoSQLBaseDocument(BaseModel, Generic[T], ABC):
    id: UUID4 = Field(default_factory=uuid.uuid4)

    def save(self: T, **kwargs) -> "T | None":
        collection = _database[self.get_collection_name()]
        try:
            collection.insert_one(self.to_mongo(**kwargs))
            return self
        except errors.WriteError:
            logger.exception("Échec de l'insertion.")
            return None

    @classmethod
    def get_or_create(cls: Type[T], **filters) -> T:
        collection = _database[cls.get_collection_name()]
        instance = collection.find_one(filters)
        if instance:
            return cls.from_mongo(instance)
        return cls(**filters).save()

    @classmethod
    def find(cls: Type[T], **filters) -> "T | None":
        collection = _database[cls.get_collection_name()]
        instance = collection.find_one(filters)
        return cls.from_mongo(instance) if instance else None

    @classmethod
    def get_collection_name(cls: Type[T]) -> str:
        if not hasattr(cls, "Settings") 
                or not hasattr(cls.Settings, "name"):
            raise ImproperlyConfigured(
                "Définissez une classe Settings avec 'name'.")
        return cls.Settings.name

Les sous-classes n'ont plus qu'à déclarer leurs champs propres et une classe imbriquée Settings qui nomme la collection. Tout le CRUD est délégué au parent. On retrouve nos trois catégories, plus l'utilisateur :

class Document(NoSQLBaseDocument, ABC):
    content: dict
    platform: str
    author_id: UUID4 = Field(alias="author_id")
    author_full_name: str = Field(alias="author_full_name")


class ArticleDocument(Document):
    link: str

    class Settings:
        name = DataCategory.ARTICLES  # nom de la collection


class RepositoryDocument(Document):
    name: str
    link: str

    class Settings:
        name = DataCategory.REPOSITORIES

Note

Il existe un ODM MongoDB prêt à l'emploi en Python, mongoengine, plus complet et suivant un patron similaire. Les auteurs réimplémentent le leur comme exercice de code modulaire et générique — utile pour comprendre le mécanisme, mais en production on s'épargne souvent cette roue en réutilisant une bibliothèque éprouvée.

Orchestrer et exécuter avec ZenML

Le pipeline est orchestré par ZenML : on le configure au runtime via des fichiers YAML et on le lance dans l'écosystème ZenML. Le pipeline digital_data_etl prend le nom complet d'un auteur et la liste de ses liens, puis enchaîne deux étapes (steps) — récupérer ou créer l'utilisateur, puis crawler les liens.

from zenml import pipeline
from steps.etl import crawl_links, get_or_create_user


@pipeline
def digital_data_etl(user_full_name: str,
                     links: list[str]) -> str:
    user = get_or_create_user(user_full_name)
    last_step = crawl_links(user=user, links=links)
    return last_step.invocation_id

L'étape crawl_links construit le dispatcher, itère sur les liens, et accumule des métadonnées (succès par domaine, total tenté) attachées à l'artefact de sortie. Cette traçabilité est un atout pour surveiller et déboguer chaque exécution.

@step
def crawl_links(user, links: list[str]):
    dispatcher = (CrawlerDispatcher.build()
                  .register_linkedin()
                  .register_medium()
                  .register_github())
    metadata, ok = {}, 0
    for link in tqdm(links):
        success, domain = _crawl_link(dispatcher, link, user)
        ok += success
        metadata = _add_to_metadata(metadata, domain, success)
    # ... attache metadata à l'artefact de sortie
    return links

En pratique, on fournit une configuration YAML par auteur et on lance via une commande poe :

parameters:
  user_full_name: Maxime Labonne
  links:
    - https://mlabonne.github.io/blog/posts/...html  # blog perso
    - https://maximelabonne.substack.com/p/...        # Substack
    # ... d'autres liens

Une fois les données collectées, les requêter via les classes ODM est trivial — deux lignes suffisent pour filtrer par auteur :

user = UserDocument.get_or_create(first_name="Paul",
                                  last_name="Iusztin")
articles = ArticleDocument.bulk_find(author_id=str(user.id))
print(f"Nombre d'articles : {len(articles)}")

Un mot sur l'éthique et la légalité

Collecter des données sur le web n'est jamais neutre. Le livre construit son exemple sur le contenu public de ses propres auteurs (Iusztin et Labonne), un cadre où la légitimité est claire. Dès qu'on étend la collecte à des tiers, plusieurs garde-fous s'imposent en pratique : respecter les conditions d'utilisation des plateformes, le robots.txt et les limites de débit ; éviter de récupérer des données personnelles ou protégées par le droit d'auteur sans base légale ; et tracer la provenance de chaque document (l'URL conservée en métadonnées y aide). La facilité technique de scraper ne dispense pas du devoir de vérifier qu'on en a le droit.

À retenir

  • « Garbage in, garbage out » : la qualité des données conditionne autant le RAG que le fine-tuning ; le pipeline ETL (extraction, transformation, chargement) est le socle de tout le reste.
  • Réduire toute source à trois catégories — article, dépôt, post — rend l'architecture extensible : ajouter une plateforme ne demande qu'un nouveau crawler, pas une refonte des couches en aval.
  • Le dispatcher et le polymorphisme des crawlers permettent d'extraire de sources hétérogènes via trois techniques — git clone, utilitaires LangChain, Selenium — derrière une interface extract() commune.
  • L'entrepôt (MongoDB ici) est la source de vérité brute, strictement séparée des features dérivées rangées dans la base vectorielle ; les deux pipelines ne communiquent qu'à travers lui, sur des planifications indépendantes.
  • Le patron ODM (sur base Pydantic) délègue tout le CRUD à une classe parente et offre une validation de types native, bien plus sûre que des dictionnaires bruts.
  • Le choix de stockage dépend de l'échelle : MongoDB convient à une preuve de concept de quelques centaines de documents, mais Snowflake ou BigQuery s'imposent au big data — et la collecte doit toujours rester légale et éthique.