Le pipeline de features RAG
Transformer les données en connaissances interrogeables : chunking, embeddings, base vectorielle et indexation — le cœur du RAG.
Un grand modèle de langage (large language model, LLM) ne connaît que les données sur lesquelles il a été entraîné — sa connaissance paramétrée (parameterized knowledge). GPT-4o, par exemple, est entraîné sur des données qui s'arrêtent à octobre 2023 : interrogé sur le championnat d'Europe de football 2024, il répondra à côté, ou pire, il hallucinera une réponse confiante mais fausse. Le réentraîner en continu pour intégrer chaque nouveauté serait prohibitif. C'est là qu'intervient la génération augmentée par récupération (retrieval-augmented generation, RAG) : plutôt que de graver le savoir dans les poids du modèle, on lui injecte au moment de la requête les informations pertinentes, puisées dans une source externe à jour.
Ce chapitre se concentre sur la moitié « amont » du RAG : le pipeline de features qui transforme des documents bruts en connaissances interrogeables. Nous suivrons le trajet d'un document depuis l'entrepôt jusqu'à la base vectorielle — nettoyage, découpage, vectorisation, chargement — en expliquant chaque compromis. Le projet fil rouge du livre, le LLM Twin (un clone numérique qui écrit comme vous à partir de vos articles, posts et dépôts de code), sert d'exemple concret, mais les patrons présentés se réutilisent pour n'importe quelle application RAG.
Ce que RAG résout, et comment
L'acronyme décrit exactement le déroulé : Retrieval (chercher les données pertinentes), Augmented (les ajouter au prompt comme contexte), Generation (passer ce prompt enrichi au LLM). Le modèle reste le moteur de raisonnement, mais sa réponse s'appuie désormais sur une source de vérité unique fournie au moment de l'inférence.
RAG répond à deux problèmes fondamentaux des LLM.
- Les hallucinations. En forçant le modèle à répondre uniquement à partir du contexte injecté, on rend la réponse vérifiable : on peut contrôler si elle découle bien des données fournies.
- Les informations anciennes ou privées. Un LLM est toujours entraîné sur un sous-ensemble du savoir mondial, pour trois raisons : les données privées qu'on n'a pas le droit d'utiliser pour l'entraînement, les données nouvelles générées en permanence, et le coût d'un entraînement ou d'un fine-tuning. RAG contourne tout cela en injectant directement la donnée nécessaire dans le prompt.
Astuce
La règle de décision est simple : implémentez RAG dès que votre application a besoin d'accéder à de l'information externe. Un assistant financier a besoin des derniers cours et rapports ; un recommandeur de voyages doit parcourir une liste d'attractions et de restaurants à jour. Au moment de l'entraînement, le LLM n'a pas accès à vos données spécifiques — c'est presque toujours le cas.
Un système RAG se décompose en trois modules indépendants :
[ingestion] [retrieval] [generation]
raw → clean → chunk → user query → embed → prompt template +
embed → vector DB search vector DB query + contexte → LLM
(batch/stream) → top-K chunks → réponse - Le pipeline d'ingestion (ingestion pipeline) tourne en arrière-plan, à intervalle régulier ou en continu, pour peupler la base vectorielle avec des données externes.
- Le pipeline de récupération (retrieval pipeline) prétraite la question de l'utilisateur et interroge la base vectorielle.
- Le pipeline de génération (generation pipeline) assemble un prompt à partir d'un gabarit, de la question et du contexte récupéré, puis le passe au LLM.
Ce chapitre traite le premier module. Le détail de la récupération et de la génération viendra plus tard ; il suffit ici de comprendre une contrainte clé : la requête de l'utilisateur et les documents indexés doivent vivre dans le même espace vectoriel. On doit donc prétraiter la requête (nettoyage, vectorisation) avec exactement les mêmes fonctions et le même modèle que lors de l'ingestion. Sinon on obtient une incohérence d'inférence — un biais entraînement-service (training-serving skew).
Les embeddings : représenter le sens par des nombres
Tout le RAG repose sur les plongements vectoriels (embeddings). Imaginez un traducteur particulier qui transforme un mot, une image ou un son en un code numérique — non aléatoire, car des objets de sens proche reçoivent des codes proches. C'est une carte où les mots de signification voisine se regroupent.
Plus formellement : un embedding est une représentation numérique dense d'un objet, encodée comme un vecteur dans un espace continu, qui capture le sens sémantique et les relations entre objets. En traitement du langage naturel (natural language processing, NLP), les mots sémantiquement proches sont positionnés près les uns des autres. Ces vecteurs ont typiquement entre 64 et 2048 dimensions ; pour les visualiser, on les projette en 2D ou 3D avec une réduction de dimension comme UMAP ou t-SNE.
Pourquoi pas un encodage plus simple ? Parce que les méthodes classiques échouent sur le vocabulaire d'une langue. Le tableau suivant résume le compromis.
| Méthode | Principe | Limite principale |
|---|---|---|
| One-hot encoding | Un vecteur binaire par catégorie, dimension = nombre de catégories | Explosion dimensionnelle : 10 000 tokens → vecteurs de 10 000, inutilisable sur des séquences |
| Feature hashing | Fonction de hachage vers un nombre fixe de cases | Dimension bornée, mais collisions et perte des relations sémantiques |
| Embeddings | Réseau profond projetant l'objet dans un espace dense de dimension contrôlée | Demande un modèle entraîné, mais condense le sens dans peu de dimensions |
Les embeddings se calculent par des modèles d'apprentissage profond qui comprennent le contexte. Pour le texte, on trouve les méthodes historiques (Word2Vec, GloVe) et surtout les transformeurs encodeurs (BERT, RoBERTa). En Python, le paquet sentence-transformers offre une interface haut niveau. La similarité entre deux vecteurs se mesure le plus souvent par la similarité cosinus (cosine similarity), qui vaut 1 quand les vecteurs pointent dans la même direction, 0 s'ils sont orthogonaux, -1 s'ils sont opposés.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
sentences = [
"The dog sits outside waiting for a treat.",
"I am going swimming.",
"The dog is swimming.",
]
embeddings = model.encode(sentences)
print(embeddings.shape) # [3, 384]
similarities = model.similarity(embeddings, embeddings)
print(similarities)
# tensor([[ 1.0000, -0.0389, 0.2692],
# [-0.0389, 1.0000, 0.3837],
# [ 0.2692, 0.3837, 1.0000]]) La similarité d'une phrase avec elle-même vaut 1. La première et la deuxième phrase n'ont rien en commun : leur score est proche de 0. La première et la troisième partagent le thème du chien : leur score est plus élevé. Tout le RAG tient dans cette mécanique.
Note
Le meilleur modèle d'embedding dépend de votre cas et change avec le temps. Le Massive Text Embedding Benchmark (MTEB) sur Hugging Face permet de comparer précision et empreinte mémoire. sentence-transformers rend le changement de modèle trivial — expérimentez. On peut même projeter texte et image dans le même espace avec un modèle multimodal comme CLIP, pour rechercher une image à partir d'une phrase.
Le découpage : pourquoi et comment chunker
Avant de vectoriser, il faut découper (chunk) les documents en morceaux plus petits. Deux raisons à cela. D'abord, la contrainte technique : le contenu passé au modèle d'embedding ne doit pas dépasser sa taille d'entrée maximale. Ensuite, et c'est plus subtil, la granularité de la récupération : on veut grouper les passages sémantiquement liés pour n'injecter, au moment de la récupération, que l'essentiel dans le prompt.
L'intuition centrale : si l'on vectorise un texte trop long couvrant plusieurs sujets, on introduit du bruit et l'embedding devient une moyenne floue qui ne représente bien aucun thème. Un chunk plus court donne un vecteur plus net, donc une recherche plus précise. Mais un chunk trop court perd le contexte. C'est tout l'art du découpage.
| Stratégie | Principe | Quand l'utiliser |
|---|---|---|
| Fenêtre fixe (fixed-size) | Couper tous les N caractères ou tokens | Données peu structurées ; simple, rapide, point de départ |
| Par structure | Couper sur les frontières naturelles (chapitre, section, paragraphe, phrase) | Documents bien structurés (articles, livres) |
| Chevauchement (sliding window / overlap) | Réintroduire la fin du chunk précédent au début du suivant | Contexte critique entre les frontières (juridique, médical, support) |
| Small-to-big | Vectoriser un petit extrait, mais stocker une fenêtre plus large en métadonnée | Maximiser la précision de recherche et le contexte fourni au LLM |
Le chevauchement mérite une attention particulière : en faisant déborder chaque chunk sur le suivant, on garantit qu'une information à cheval sur une frontière reste capturée par au moins un embedding. Le small-to-big, lui, découple le morceau utilisé pour la recherche de celui injecté dans le prompt : un petit extrait précis pour l'indexation, une fenêtre élargie en métadonnée pour la génération.
En pratique, le LLM Twin combine deux niveaux de découpage. Pour les posts et dépôts, la fonction chunk_text() enchaîne un découpage par paragraphes puis un découpage par tokens qui respecte la limite du modèle d'embedding.
from langchain.text_splitter import (
RecursiveCharacterTextSplitter,
SentenceTransformersTokenTextSplitter,
)
def chunk_text(text, chunk_size=500, chunk_overlap=50):
# 1) D'abord par paragraphes (separateur "\n\n").
char_splitter = RecursiveCharacterTextSplitter(
separators=["\n\n"], chunk_size=chunk_size, chunk_overlap=0
)
sections = char_splitter.split_text(text)
# 2) Puis par tokens, en respectant la limite du modele
# et en appliquant le chevauchement ici seulement.
token_splitter = SentenceTransformersTokenTextSplitter(
chunk_overlap=chunk_overlap,
tokens_per_chunk=embedding_model.max_input_length,
model_name=embedding_model.model_id,
)
chunks = []
for section in sections:
chunks.extend(token_splitter.split_text(section))
return chunks L'ordre est délibéré : on coupe d'abord sur la structure, puis on garantit que chaque morceau tient dans le modèle. Le chevauchement n'est appliqué qu'à la seconde étape, une fois la taille validée. La stratégie varie selon le type de données : pour les dépôts de code on veut des chunks larges, pour les articles plutôt étroits, au niveau du paragraphe.
La base vectorielle : indexer et chercher des voisins
Une base de données vectorielle (vector database, ex. Qdrant) est une base spécialisée pour stocker, indexer et récupérer efficacement des embeddings. Une base scalaire classique peine sur ce type de données. Et un simple index de similarité comme FAISS, s'il suffit à la recherche, n'offre pas les capacités d'une vraie base : opérations CRUD, filtrage par métadonnées, scalabilité, mises à jour temps réel, sauvegardes, sécurité — autant de garanties indispensables en production.
La différence fondamentale avec une base traditionnelle : on ne cherche pas une correspondance exacte, mais les voisins les plus proches d'un vecteur de requête. Pour cela, la base utilise des algorithmes de voisins approximatifs (approximate nearest neighbor, ANN). Pourquoi « approximatifs » ? Parce que l'algorithme exact est trop lent en pratique, et qu'empiriquement, une bonne approximation des meilleures correspondances suffit largement. Le compromis précision/latence penche nettement en faveur de l'ANN.
Le flux typique se déroule en trois temps : indexer les vecteurs dans une structure optimisée pour les hautes dimensions, interroger pour trouver les plus similaires selon une métrique (cosinus, euclidienne, produit scalaire), puis post-traiter pour affiner la pertinence. Plusieurs algorithmes d'indexation coexistent.
| Algorithme | Principe | Atout |
|---|---|---|
| HNSW (hierarchical navigable small world) | Graphe multi-couches reliant les nœuds proches | Navigation rapide, très répandu |
| Projection aléatoire (random projection) | Réduit la dimension via une matrice aléatoire | Préserve les distances relatives |
| PQ (product quantization) | Découpe les vecteurs en sous-vecteurs quantifiés | Économie mémoire |
| LSH (locality-sensitive hashing) | Range les vecteurs proches dans les mêmes cases | Réduit l'espace de recherche |
La base peut aussi filtrer par métadonnées, avant ou après la recherche vectorielle — chaque approche a ses compromis de performance. Et comme toute base de production, une base vectorielle assure partitionnement (sharding) et réplication, supervision, contrôle d'accès et sauvegardes.
À retenir
Un point souvent négligé : l'index de métadonnées d'une base vectorielle se comporte comme une base NoSQL. On peut donc y stocker des documents sans vecteur attaché. Le LLM Twin en tire parti pour faire de Qdrant la source de vérité unique : il y pousse à la fois les chunks vectorisés (pour le RAG) et les documents nettoyés non découpés (pour le futur fine-tuning).
Du naïf à l'avancé : optimiser les trois étapes
Le RAG « vanilla » qu'on vient de décrire laisse de côté des questions cruciales : les documents récupérés sont-ils pertinents ? Le contexte suffit-il ? Y a-t-il du bruit redondant ? La latence est-elle acceptable ? On en tire deux conclusions : il faut un module d'évaluation robuste (traité plus loin dans le livre), et il faut améliorer l'algorithme lui-même. Ces améliorations, le RAG avancé (advanced RAG), se répartissent sur trois étapes.
- Pré-récupération (pre-retrieval) : mieux structurer et indexer les données, et optimiser la requête. Côté indexation : fenêtre glissante, métadonnées riches, small-to-big. Côté requête : routage (query routing, choisir la bonne source ou le bon gabarit comme un
if/elseen langage naturel), réécriture (query rewriting, paraphrase et sous-questions), expansion (query expansion, ajout de synonymes), ou HyDE (faire produire au LLM une réponse hypothétique qui sert de requête). - Récupération (retrieval) : améliorer le modèle d'embedding — par fine-tuning ou via un modèle instructor guidé par une instruction — et exploiter les fonctions de la base. La recherche hybride (hybrid search) mêle recherche vectorielle (similarité sémantique) et recherche par mots-clés (correspondance exacte), pondérées par un paramètre
alpha. La recherche vectorielle filtrée réduit l'espace de recherche via les métadonnées. - Post-récupération (post-retrieval) : nettoyer le contexte récupéré avant le LLM. La compression de prompt élimine le superflu. Le re-classement (re-ranking) emploie un cross-encoder qui note finement chaque chunk face à la requête, puis ne garde que les meilleurs.
Astuce
Le re-ranking illustre un compromis élégant. Un cross-encoder capte des relations plus complexes qu'une simple similarité d'embeddings, mais il est trop coûteux pour la première passe. La stratégie gagnante : récupérer un large lot par similarité vectorielle (rapide), puis affiner avec le cross-encoder sur ce petit lot. Côté récupération, on commence par la recherche filtrée ou hybride — vite implémentées — avant d'envisager le fine-tuning du modèle d'embedding.
Aucune de ces techniques n'est universelle : tout dépend du type, de la structure et de la source de vos données. L'optimisation du RAG est expérimentale — essayez plusieurs méthodes, itérez, mesurez.
Le pipeline de features : batch ou streaming ?
Le RAG du LLM Twin lit des données sociales brutes (articles, dépôts, posts) depuis un entrepôt (data warehouse) MongoDB, les nettoie, les découpe, les vectorise, et les charge dans un magasin de features (feature store). Pourquoi parler de « feature store » et non de simple ingestion RAG ? Parce que ce magasin est le point d'accès central à toutes les features : le pipeline d'inférence y récupère les chunks pour le RAG, le pipeline d'entraînement y lit des jeux de données versionnés pour le fine-tuning. Dans ce projet, le feature store est logique : la base Qdrant pour le service en ligne, plus des artefacts ZenML versionnés pour l'entraînement hors ligne.
Reste une décision d'architecture majeure : peupler ce magasin en batch ou en streaming ?
| Aspect | Pipeline batch | Pipeline streaming |
|---|---|---|
| Cadence | Intervalles réguliers (minute, heure, jour) | Continu, latence minimale |
| Efficacité | Gros volumes, traitement parallèle optimisé | Points individuels, mises à jour immédiates |
| Complexité de traitement | Transformations et agrégations lourdes | Flux à haute vélocité, faible latence |
| Cas d'usage | Entreposage, reporting, ETL, feature pipelines | Analytique temps réel, monitoring, événementiel |
| Complexité système | Plus simple à implémenter et maintenir | Plus complexe : tolérance aux pannes, scalabilité, outillage avancé |
Un pipeline de streaming repose sur une plateforme d'événements distribuée (Apache Kafka, Redpanda — ou une file comme RabbitMQ pour simplifier) et un moteur de traitement (Apache Flink, Bytewax). Il brille là où la fraîcheur est vitale : recommandation TikTok captant les changements d'humeur en temps réel, détection de fraude chez Stripe ou PayPal, trading haute fréquence. Mais on peut lui consacrer un chapitre entier, ce qui en dit long sur sa complexité.
Le LLM Twin choisit le batch, pour deux raisons. Le traitement immédiat n'est pas requis — un délai de quelques minutes entre l'entrepôt et le magasin est acceptable, d'autant que le volume est petit (des milliers d'enregistrements, pas des milliards). Et la simplicité : un streaming est environ deux fois plus complexe, or un système simple est plus facile à comprendre, déboguer et maintenir, pour un coût moindre.
Note
La bonne stratégie d'évolution : commencer en batch, plus rapide à mettre en place, puis basculer progressivement vers le streaming une fois le produit en place, pour réduire les coûts (le batch fait beaucoup de prédictions redondantes : recalculer chaque nuit les recommandations d'utilisateurs qui ne se connecteront pas) et améliorer l'expérience.
Les cinq étapes du pipeline
Quasiment tous les pipelines de features RAG enchaînent les mêmes cinq étapes. Voici celles du LLM Twin, orchestrées par ZenML — chaque appel est un step dont la sortie est automatiquement versionnée comme artefact.
from zenml import pipeline
from llm_engineering.interfaces.orchestrator.steps import (
feature_engineering as fe_steps,
)
@pipeline
def feature_engineering(author_full_names: list[str]) -> None:
raw = fe_steps.query_data_warehouse(author_full_names) # 1. extraction
cleaned = fe_steps.clean_documents(raw) # 2. nettoyage
s1 = fe_steps.load_to_vector_db(cleaned) # snapshot "nettoye" → Qdrant
embedded = fe_steps.chunk_and_embed(cleaned) # 3+4. chunk + embed
s2 = fe_steps.load_to_vector_db(embedded) # 5. chargement
return [s1.invocation_id, s2.invocation_id] - Extraction : récupérer les derniers documents de l'entrepôt. Les requêtes — articles, posts et dépôts vivent dans des collections distinctes — sont parallélisées par un thread pool — judicieux car elles sont bornées par l'I/O réseau, et les threads échappent au verrou global de Python (Global Interpreter Lock, GIL). On réserve les processus aux tâches bornées CPU.
- Nettoyage : standardiser le texte pour le modèle d'embedding — supprimer les caractères non-ASCII, remplacer les URL par des marqueurs, retirer les émojis, dédupliquer. Plus art que science : on itère après avoir mis en place l'évaluation.
- Découpage : appliquer la stratégie adaptée à chaque catégorie, sans dépasser la taille d'entrée du modèle.
- Vectorisation : passer chaque chunk au modèle d'embedding (ici
all-mpnet-base-v2, petit et tournant sur la plupart des machines, mais configurable). Implémentation simple grâce àsentence-transformers. - Chargement : combiner le vecteur et ses métadonnées (auteur, ID, contenu, URL, plateforme, date), emballer le tout au format Qdrant et le pousser.
Noter que deux instantanés sont stockés : les documents nettoyés (pour le fine-tuning) et les chunks vectorisés (pour le RAG). Pourquoi ne pas garder les données nettoyées dans l'entrepôt ? Parce que l'entrepôt est partagé dans toute l'entreprise : le spécialiser pour un cas d'usage créerait un entrepôt « spaghetti ». Les données y restent génériques et ne sont modelées qu'en aval, dans le feature store.
Garder les deux bases synchronisées
L'approche batch du LLM Twin est naïve : à chaque exécution, on relit tout l'entrepôt et on insère ou met à jour les enregistrements dans Qdrant. Cela marche pour des milliers d'enregistrements, mais soulève des questions dès que les données croissent : que faire de millions d'enregistrements ? d'une suppression dans l'entrepôt ? comment ne traiter que les éléments nouveaux ou modifiés ?
La réponse est la capture des changements de données (change data capture, CDC) : capturer chaque opération CRUD sur la base source et la répliquer sur la cible, sans surcoût d'I/O. Les approches se classent en push (la source pousse activement les changements, quasi instantané, avec une file comme tampon) ou pull (la cible interroge périodiquement, plus léger pour la source mais avec délai). Quant à la détection des changements : par horodatage (simple, mais ne capte pas les suppressions et scanne toute la table), par trigger (complet, mais alourdit chaque écriture) ou par log de transactions.
Piège courant
La méthode CDC la plus performante en production est celle fondée sur le log : zéro surcoût I/O sur la source, faible latence, support complet des opérations CRUD, sans modification de schéma. Son défaut est la complexité de développement — elle exige une file pour capturer les événements CRUD et un pipeline de streaming pour les traiter. C'est précisément le moment où l'on bascule du batch vers le streaming.
Modéliser le domaine : entités Pydantic et OVM
Une application robuste ne mélange pas la logique métier avec des if/else partout. Le LLM Twin suit la conception pilotée par le domaine (domain-driven design, DDD) : les entités du domaine sont au cœur de l'application, modélisées avec Pydantic pour la validation de type à l'exécution. Le domaine se croise sur deux dimensions — la catégorie (post, article, dépôt) et l'état (nettoyé, découpé, vectorisé) — soit neuf entités. On crée une classe de base abstraite par état, dont héritent les catégories : ainsi, ajouter une catégorie ne touche qu'un point du code.
Toutes ces classes héritent de VectorBaseDocument, une implémentation maison de mapping objet-vecteur (object-vector mapping, OVM) — l'équivalent d'un ORM, mais pour les embeddings et la base vectorielle. Deux méthodes pivots font le pont avec Qdrant : to_point() convertit une instance vers le format Qdrant, from_record() fait l'inverse. Au-dessus, des méthodes CRUD encapsulent les opérations.
class VectorBaseDocument(BaseModel, Generic[T], ABC):
id: UUID4 = Field(default_factory=uuid.uuid4)
@classmethod
def bulk_insert(cls, documents: list["VectorBaseDocument"]) -> bool:
try:
cls._bulk_insert(documents)
except exceptions.UnexpectedResponse:
# La collection n'existe pas : on la cree et on reessaie.
cls.create_collection()
try:
cls._bulk_insert(documents)
except exceptions.UnexpectedResponse:
return False
return True
@classmethod
def _bulk_insert(cls, documents) -> None:
points = [doc.to_point() for doc in documents]
connection.upsert(
collection_name=cls.get_collection_name(), points=points
)
@classmethod
def search(cls, query_vector: list, limit: int = 10, **kwargs):
# Recherche par similarite : c'est ici que se joue le RAG.
records = connection.search(
collection_name=cls.get_collection_name(),
query_vector=query_vector,
limit=limit,
**kwargs,
)
return [cls.from_record(r) for r in records] Deux bonnes pratiques apparaissent ici. D'abord, on sépare une fonction publique qui gère erreurs et reprises (bulk_insert) d'une fonction privée qui porte la logique (_bulk_insert). Ensuite, les génériques (Generic[T]) font que from_record() d'une sous-classe Chunk renvoie bien le type Chunk, ce qui aide les vérificateurs de types comme mypy. La méthode search() est le point d'entrée de la récupération : on lui passe le vecteur de la requête utilisateur, elle renvoie les chunks les plus similaires.
La couche dispatcher : un patron extensible
Comment appliquer le bon nettoyage, le bon découpage et la bonne vectorisation à chaque catégorie sans noyer le code sous les conditions ? Le LLM Twin combine deux patrons de conception. Un dispatcher reçoit un document, lit sa catégorie, et délègue à un handler dédié. Le choix du handler passe par une fabrique abstraite (abstract factory), et les handlers eux-mêmes implémentent une même interface — c'est le patron stratégie (strategy).
class CleaningDispatcher:
cleaning_factory = CleaningHandlerFactory()
@classmethod
def dispatch(cls, data_model) -> VectorBaseDocument:
category = DataCategory(data_model.get_collection_name())
handler = cls.cleaning_factory.create_handler(category)
return handler.clean(data_model)
class CleaningHandlerFactory:
@staticmethod
def create_handler(category: DataCategory):
if category == DataCategory.POSTS:
return PostCleaningHandler()
elif category == DataCategory.ARTICLES:
return ArticleCleaningHandler()
elif category == DataCategory.REPOSITORIES:
return RepositoryCleaningHandler()
raise ValueError("Unsupported data type") L'intuition : on sait dès le départ qu'on veut nettoyer, mais la catégorie n'est connue qu'à l'exécution — impossible de figer la stratégie à l'avance. On abstrait donc la logique derrière une interface Handler, la fabrique crée le bon handler au vol, et le dispatcher exécute la bonne stratégie. Les bénéfices : on isole la logique par catégorie, on évite des centaines de if/else par polymorphisme, et on rend le code extensible — une nouvelle catégorie ne demande qu'un nouveau handler et une ligne dans la fabrique. Le découpage et la vectorisation suivent exactement le même patron.
Un détail malin au passage : le chunk_id est calculé comme le hash MD5 du contenu du chunk. Deux chunks identiques reçoivent ainsi le même ID, ce qui permet de les dédupliquer naturellement. Et la vectorisation traite les chunks par lots (embed_batch) : sur GPU, les échantillons d'un lot sont traités en parallèle, ce qui accélère l'inférence d'un facteur 10 ou plus selon la taille du lot.
À retenir
- Le RAG injecte au moment de la requête des connaissances externes et à jour dans le prompt, résolvant deux problèmes des LLM : les hallucinations (en imposant une source de vérité vérifiable) et l'information ancienne ou privée (sans coût de réentraînement).
- Les embeddings transforment le sens en vecteurs denses où la proximité (mesurée par la similarité cosinus) reflète la proximité sémantique ; ils battent one-hot et hashing en contrôlant la dimension tout en préservant les relations. Requête et documents doivent vivre dans le même espace vectoriel.
- Le découpage arbitre entre la limite d'entrée du modèle et la précision de récupération : un chunk trop long dilue le sens, trop court perd le contexte. Chevauchement et small-to-big sont les leviers clés ; on coupe d'abord sur la structure, puis sur les tokens.
- La base vectorielle cherche les voisins approximatifs (ANN, ex. HNSW) plutôt que des correspondances exactes — un compromis précision/latence assumé — et offre filtrage par métadonnées, CRUD et scalabilité qu'un simple index n'a pas.
- Le pipeline de features enchaîne cinq étapes (extraction → nettoyage → découpage → vectorisation → chargement). On commence en batch pour la simplicité, puis on passe au streaming via la capture des changements de données (CDC) quand la fraîcheur et le volume l'exigent.
- Une architecture de production sépare la logique métier (dispatcher, fabrique, stratégie) du mapping de persistance (OVM type ORM) et modélise rigoureusement le domaine (entités Pydantic, DDD) — c'est ce qui distingue une démo jetable d'un système durable.