Le pipeline d'inférence RAG
Le RAG avancé à la requête : réécriture de requête, self-query, recherche hybride et reranking pour des réponses pertinentes et sourcées.
Au chapitre 4, le pipeline de features RAG (RAG feature pipeline) a fait le gros travail en amont : récupérer les données depuis l'entrepôt, les nettoyer, les découper en morceaux (chunking), les transformer en vecteurs (embedding) et les charger dans la base de données vectorielle (vector database). À ce stade, la base vectorielle est remplie et prête. Reste à l'exploiter au moment où l'utilisateur pose une question. C'est l'objet du pipeline d'inférence RAG (RAG inference pipeline), et c'est là que se joue toute la magie d'un système de génération augmentée par récupération (retrieval-augmented generation).
Iusztin et Labonne insistent sur un point qui surprend souvent : l'essentiel du code RAG ne se trouve pas dans l'appel au LLM, mais dans l'étape de récupération (retrieval). Appeler un modèle est trivial ; ramener de la base les bons morceaux de texte ne l'est pas. Tout le reste — augmenter le prompt, générer la réponse — découle de la qualité de cette récupération. Ce chapitre vulgarise donc le passage du RAG naïf au RAG avancé : on enrichit la requête avant de chercher, on cherche mieux, puis on filtre finement avant de générer une réponse sourcée et traçable.
Du RAG naïf au RAG avancé
Le RAG naïf tient en trois gestes : on transforme la question de l'utilisateur en un seul vecteur, on cherche les K morceaux les plus proches dans la base vectorielle, on les colle dans le prompt. Cela fonctionne sur des cas simples, mais souffre de plusieurs faiblesses structurelles. Un unique vecteur de requête ne couvre qu'une petite zone de l'espace d'embedding : si la formulation est étroite ou vague, on rate des documents pourtant pertinents. La recherche purement sémantique confond aussi des contenus voisins en surface mais hors sujet (chercher « Java » ramène le langage et l'île indonésienne). Enfin, le contexte ramené contient souvent du bruit qui dilue l'information et augmente le coût.
Le RAG avancé répond à chacun de ces problèmes en insérant des optimisations à trois moments du pipeline. C'est l'architecture que le livre organise autour de son projet fil rouge, le LLM Twin, mais les techniques se transposent à n'importe quel système RAG.
PIPELINE D'INFERENCE RAG
requete utilisateur
|
v
+-----------------------------------------------------+
| PRE-RECUPERATION |
| - expansion de requete (1 requete -> N requetes) |
| - self-query (extraire des filtres) |
+-----------------------------------------------------+
|
v
+-----------------------------------------------------+
| RECUPERATION |
| - recherche vectorielle filtree (xN, en //) |
| -> N x K morceaux candidats, dedupliques |
+-----------------------------------------------------+
|
v
+-----------------------------------------------------+
| POST-RECUPERATION |
| - reranking (cross-encoder) -> top K |
+-----------------------------------------------------+
|
v
GENERATION : prompt augmente -> LLM -> reponse sourcee
+-----------------------------------------------------+
| AMELIORATIONS POSSIBLES (non implementees) |
| - recherche hybride (dense + BM25), fusion RRF |
| - routeur, memoire de conversation, multi-index |
+-----------------------------------------------------+ | Étape ajoutée | Catégorie | Problème résolu | Bénéfice |
|---|---|---|---|
| Expansion de requête | Pré-récupération | Un seul vecteur couvre trop peu d'espace | Plus de rappel : on capte des angles variés de la question |
| Self-query | Pré-récupération | Le signal des métadonnées se dilue dans l'embedding | Filtres explicites (auteur, tag) plus précis et plus rapides |
| Recherche vectorielle filtrée | Récupération | Trop de candidats sémantiquement proches mais hors contexte | Espace de recherche réduit, latence et précision améliorées |
| Reranking (cross-encoder) | Post-récupération | L'ordre par similarité cosinus est grossier | Tri fin sur la vraie pertinence question/morceau |
Note
L'approche du livre est volontairement modulaire : on n'écrit pas un module dédié à l'augmentation du prompt, ce serait de la sur-ingénierie (overengineering). On distingue seulement deux modules Python : un module de récupération (où va presque toute la logique avancée) et un service d'inférence qui assemble le prompt et appelle le LLM. Recollés, ils forment le flux RAG de bout en bout.
Pré-récupération : reformuler la requête
La pré-récupération transforme la question brute avant de toucher à la base. Deux techniques s'y combinent : l'expansion de requête, qui élargit la recherche, et le self-query, qui la cible. Le livre les implémente derrière une interface commune RAGStep, dotée d'un attribut mock qui court-circuite les appels au LLM pendant le développement, pour réduire coûts et temps de débogage.
Expansion de requête
L'expansion de requête (query expansion), aussi appelée multi-query, consiste à demander à un LLM de générer N variantes de la question initiale, chacune éclairant une facette différente. Une fois transformées en vecteurs, ces variantes ciblent d'autres zones de l'espace d'embedding tout en restant pertinentes. On passe d'une recherche à N recherches, ce qui augmente nettement la probabilité de ramener les bons documents.
class QueryExpansion(RAGStep):
def generate(self, query: Query, expand_to_n: int) -> list[Query]:
assert expand_to_n > 0, "'expand_to_n' doit etre > 0."
if self._mock:
return [query for _ in range(expand_to_n)]
template = QueryExpansionTemplate()
# On genere expand_to_n - 1 variantes : l'originale est gardee.
prompt = template.create_template(expand_to_n - 1)
model = ChatOpenAI(
model=settings.OPENAI_MODEL_ID,
api_key=settings.OPENAI_API_KEY,
temperature=0, # deterministe
)
chain = prompt | model
response = chain.invoke({"question": query})
# Le prompt impose un separateur pour decouper les variantes.
parts = response.content.strip().split(template.separator)
queries = [query]
queries += [
query.replace_content(stripped)
for part in parts
if (stripped := part.strip())
]
return queries Le prompt sous-jacent est un simple zero-shot qui demande au modèle « génère N versions différentes de cette question » et impose un séparateur unique (#next-question#) pour découper proprement le résultat. La température à 0 rend la sortie déterministe. Pour la question « Write an article about the best types of advanced RAG methods », on obtient par exemple deux reformulations : « What are the most effective advanced RAG methods, and how can they be applied? » et « Can you provide an overview of the top advanced retrieval-augmented generation techniques? ».
Attention
Multiplier les requêtes multiplie les recherches, donc la latence. Il faut expérimenter le nombre de variantes pour rester dans les contraintes de l'application. La parade du livre : paralléliser les N recherches dans un pool de threads (vu plus loin dans ContextRetriever), ce qui réduit drastiquement le temps total.
Décomposition d'une question complexe
L'expansion produit des reformulations parallèles de la même intention. Une variante voisine — technique connexe, non traitée dans le livre — consiste à décomposer une question composite en sous-questions indépendantes : « Compare le RAG et le fine-tuning sur le coût et la fraîcheur des données » devient « Quel est le coût du RAG ? », « Quel est le coût du fine-tuning ? », « Le RAG gère-t-il la fraîcheur ? ». Chaque sous-question est récupérée séparément, puis les contextes sont agrégés. Le mécanisme est identique — un LLM en pré-récupération, N recherches en parallèle — mais l'intention diffère : couvrir des aspects distincts plutôt que des reformulations équivalentes.
Self-query : extraire des filtres de métadonnées
Quand vous transformez une question en vecteur, rien ne garantit que toutes les contraintes utiles y soient présentes avec assez de signal. Si l'utilisateur dit « je suis Paul Iusztin », vous voulez être certain à 100 % que la récupération se restreigne à ses contenus — or l'embedding seul ne le garantit pas. Le même problème vaut pour tout identifiant, tag, catégorie ou compteur.
Le self-query (self-querying) résout cela en demandant à un LLM d'extraire ces métadonnées critiques de la requête en langage naturel. Une fois extraites, vous avez un contrôle total sur leur usage : elles deviendront des filtres explicites pour la recherche vectorielle. Dans le LLM Twin, on extrait le nom de l'auteur pour ne chercher que dans ses contenus.
class SelfQuery(RAGStep):
def generate(self, query: Query) -> Query:
if self._mock:
return query
prompt = SelfQueryTemplate().create_template()
model = ChatOpenAI(
model=settings.OPENAI_MODEL_ID,
api_key=settings.OPENAI_API_KEY,
temperature=0,
)
chain = prompt | model
response = chain.invoke({"question": query})
user_full_name = response.content.strip("\n ")
# Le prompt few-shot renvoie "none" si rien n'est trouve.
if user_full_name == "none":
return query
first, last = utils.split_user_full_name(user_full_name)
user = UserDocument.get_or_create(
first_name=first, last_name=last
)
query.author_id = user.id
query.author_full_name = user.full_name
return query Le prompt de self-query est un few-shot : il montre au modèle des exemples (« My name is Paul Iusztin and I want a post about… » → « Paul Iusztin ») et lui demande de renvoyer uniquement le nom, l'identifiant, ou le jeton none si rien n'est présent. Le résultat enrichit l'objet Query avec author_id et author_full_name, prêts à servir de filtres.
Astuce
Le découpage pré-récupération / récupération est subtil mais utile. Le self-query traite la requête pour en extraire l'auteur : c'est de la pré-récupération. L'usage effectif de ce filtre dans la recherche relève de la récupération. La même métadonnée traverse donc deux étapes conceptuelles distinctes.
Récupération : chercher mieux
Recherche vectorielle filtrée
La recherche vectorielle pure compare la proximité numérique des embeddings sans tenir compte du contexte catégoriel. Deux écueils en découlent : des documents sémantiquement proches mais hors sujet, et une montée en latence quand la base grossit (on calcule des similarités sur tout l'espace). La recherche vectorielle filtrée (filtered vector search) applique d'abord un filtre sur les métadonnées — l'author_id extrait par le self-query — pour réduire l'espace avant de calculer les similarités. On gagne sur les deux tableaux : plus de précision (on élimine les faux positifs) et moins de latence (moins de comparaisons).
from qdrant_client.models import FieldCondition, Filter, MatchValue
records = qdrant_connection.search(
collection_name="articles",
query_vector=query_embedding,
limit=3,
with_payload=True,
query_filter=Filter(
must=[
FieldCondition(
key="author_id",
match=MatchValue(value=str("1234")),
)
]
),
) Point d'ingénierie crucial : on embarque la requête avec le même modèle d'embedding qu'à l'ingestion (le livre réutilise la classe EmbeddingDispatcher du pipeline de features). Embarquer documents et requêtes avec des modèles différents fausserait la comparaison de distances et ruinerait la récupération.
Recherche hybride et fusion des résultats
La recherche sémantique excelle pour reconnaître synonymes et concepts liés, mais elle rate parfois les termes techniques exacts — précisément ceux qui comptent dans un domaine pointu (« RAG », « SageMaker », un nom de fonction). Avant les embeddings, on cherchait par mots-clés avec des algorithmes lexicaux comme BM25. La recherche hybride (hybrid search) combine les deux : on lance en parallèle une recherche dense (vectorielle) et une recherche lexicale (BM25), puis on fusionne. Iusztin et Labonne ne l'implémentent pas dans le LLM Twin : ils la citent comme une amélioration possible (au même titre que le routeur, la mémoire de conversation et le multi-index) et en décrivent la mécanique en trois temps.
| Étape | Ce qui se passe | Pourquoi c'est nécessaire |
|---|---|---|
| Traitement parallèle | La requête passe simultanément par la recherche vectorielle et par BM25 | Chacune ramène un ensemble pertinent selon son critère propre |
| Normalisation des scores | Les scores des deux moteurs sont ramenés à une échelle comparable | Les scores BM25 et cosinus vivent sur des échelles différentes, incomparables sans cela |
| Fusion des résultats | Les scores normalisés sont combinés (souvent somme pondérée) pour un classement final | Un poids ajustable arbitre entre sémantique et mots-clés |
Au-delà du livre, une méthode de fusion classique est la fusion par rang réciproque (Reciprocal Rank Fusion, RRF) : plutôt que de mélanger des scores hétérogènes, on combine les rangs de chaque document dans chaque liste, ce qui contourne le problème de normalisation. Chaque document reçoit un score proportionnel à la somme des 1 / (k + rang) sur les listes où il apparaît.
def reciprocal_rank_fusion(
ranked_lists: list[list[str]], k: int = 60
) -> list[str]:
"""Fusionne plusieurs classements via le rang reciproque."""
scores: dict[str, float] = {}
for ranking in ranked_lists: # ex. [liste_dense, liste_bm25]
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True) Collecter et dédupliquer
Avec N requêtes (après expansion) ramenant chacune jusqu'à K morceaux, on agrège jusqu'à N × K candidats. Dans le LLM Twin, chaque recherche interroge trois catégories de données (articles, posts, dépôts de code), d'où une limite de K / 3 par catégorie. Comme les recherches se recouvrent, il faut dédupliquer la liste agrégée pour ne garder que des morceaux uniques avant de passer au tri fin.
class ContextRetriever:
def __init__(self, mock: bool = False) -> None:
self._query_expander = QueryExpansion(mock=mock)
self._metadata_extractor = SelfQuery(mock=mock)
self._reranker = Reranker(mock=mock)
def search(self, query: str, k: int = 3,
expand_to_n_queries: int = 3) -> list:
query_model = Query.from_str(query)
# 1) self-query : extrait l'auteur comme filtre
query_model = self._metadata_extractor.generate(query_model)
# 2) expansion : 1 -> N requetes
queries = self._query_expander.generate(
query_model, expand_to_n=expand_to_n_queries
)
# 3) N recherches filtrees, en parallele
with concurrent.futures.ThreadPoolExecutor() as executor:
tasks = [executor.submit(self._search, q, k) for q in queries]
n_k = [t.result()
for t in concurrent.futures.as_completed(tasks)]
n_k = list(set(utils.misc.flatten(n_k))) # aplatir + dedupliquer
# 4) reranking -> top K
if n_k:
return self.rerank(query, chunks=n_k, keep_top_k=k)
return [] Post-récupération : reranking et compression
Pourquoi reranker
Le contexte ramené contient encore des morceaux faiblement pertinents, ce qui pose trois problèmes. Du bruit : du texte hors sujet encombre l'information et peut désorienter le modèle. Un prompt plus gros : chaque morceau inutile gonfle le prompt, donc le coût ; et les LLM ont tendance à privilégier le début et la fin du contexte, si bien qu'une masse de texte risque de noyer l'essentiel. Un désalignement : les morceaux sont sélectionnés sur la similarité d'embeddings, et le modèle d'embedding n'est pas forcément accordé à votre question précise.
Le reranking réordonne les N × K candidats selon leur pertinence réelle vis-à-vis de la question initiale, puis ne garde que le top K. La différence clé avec la récupération initiale tient au type de modèle. Un bi-encodeur (bi-encoder) — celui des embeddings — encode question et document séparément puis compare leurs vecteurs : rapide, mais grossier. Un cross-encodeur (cross-encoder) passe la paire (question, morceau) ensemble dans le modèle et produit un score de pertinence entre 0 et 1, bien plus précis car il voit les deux textes en interaction. Trop coûteux pour scanner toute la base, le cross-encodeur est en revanche idéal pour affiner un petit pool de candidats déjà présélectionnés.
| Critère | Bi-encodeur (récupération) | Cross-encodeur (reranking) |
|---|---|---|
| Entrée | Question et document encodés séparément | Paire (question, document) encodée ensemble |
| Coût | Faible : embeddings précalculables | Élevé : un passage modèle par paire |
| Précision | Approximative (distance cosinus) | Fine (interaction question/document) |
| Usage | Filtrer toute la base vers N × K | Réordonner N × K vers le top K |
Implémenter le reranker
from sentence_transformers.cross_encoder import CrossEncoder
class Reranker(RAGStep):
def __init__(self, mock: bool = False) -> None:
super().__init__(mock=mock)
self._model = CrossEncoderModelSingleton()
def generate(self, query: Query, chunks: list[EmbeddedChunk],
keep_top_k: int) -> list[EmbeddedChunk]:
if self._mock:
return chunks
# 1) paires (question, morceau)
pairs = [(query.content, c.content) for c in chunks]
# 2) score de pertinence par le cross-encodeur
scores = self._model(pairs)
# 3) trier par score decroissant, garder le top K
scored = sorted(
zip(scores, chunks, strict=False),
key=lambda x: x[0], reverse=True,
)
return [chunk for _, chunk in scored[:keep_top_k]] Le modèle est enveloppé dans un singleton (CrossEncoderModelSingleton) pour deux raisons. D'abord ne charger le modèle qu'une fois en mémoire et le réutiliser partout. Ensuite — plus subtil — définir une interface stable pour le reranking : si demain vous voulez reranker via une API tierce plutôt qu'un modèle local, vous n'écrivez qu'un nouveau wrapper respectant la même interface et l'échangez, sans toucher au reste du code. Le reranking devient un LEGO interchangeable.
À retenir
Le reranking se marie particulièrement bien avec l'expansion de requête. Sans expansion, on cherche « plus de K morceaux » au même endroit puis on garde le top K. Avec expansion, on collecte un pool venu de plusieurs zones de l'espace (N × K morceaux), ce qui donne au cross-encodeur un éventail bien plus riche à trier. Récupérer large puis reranker serré : c'est le cœur de la stratégie RAG avancée.
Compression et filtrage du contexte
Au-delà du simple top K, on peut compresser le contexte retenu : ne garder que les phrases d'un morceau réellement utiles, résumer les morceaux verbeux, ou écarter ceux dont le score de reranking passe sous un seuil. L'objectif est double — réduire le coût en tokens et limiter le bruit qui détourne le modèle. Cette étape reste optionnelle et n'est pas traitée dans le livre, mais elle prolonge naturellement la logique du reranking : moins de contexte, mais du meilleur contexte.
Génération sourcée
Une fois le top K obtenu, l'augmentation du prompt tient en quelques lignes. On transforme les morceaux en une chaîne de contexte, on l'injecte dans un gabarit avec la requête, et on appelle le LLM.
def call_llm_service(query: str, context: str | None) -> str:
prompt = f"""
You are a content creator. Write what the user asked you to while using
the provided context as the primary source of information for the content.
User query: {query}
Context: {context}
"""
llm = LLMInferenceSagemakerEndpoint(
endpoint_name=settings.SAGEMAKER_ENDPOINT_INFERENCE,
inference_component_name=None,
)
return InferenceExecutor(llm, query, context).execute()
def rag(query: str) -> str:
retriever = ContextRetriever(mock=False)
documents = retriever.search(query, k=3)
context = EmbeddedChunk.to_context(documents)
return call_llm_service(query, context) Comme toute la complexité a été modularisée en classes indépendantes, la fonction de haut niveau rag() se réduit à cinq lignes — exactement ce que proposent LangChain, LlamaIndex ou Haystack, à ceci près qu'ici on a construit le moteur soi-même et qu'on en comprend chaque rouage.
Un atout déterminant émerge ici : la génération sourcée (sourced generation). Chaque morceau récupéré transporte ses métadonnées — l'auteur, la plateforme, le lien d'origine. On peut donc joindre à la réponse une liste de références citant les sources exactes. Cette traçabilité fait deux choses : elle augmente la confiance de l'utilisateur, qui peut vérifier, et elle réduit les hallucinations, puisque le modèle est explicitement contraint de s'appuyer sur le contexte fourni plutôt que d'inventer. Le RAG avancé n'améliore pas seulement la pertinence brute ; il rend la réponse vérifiable.
Astuce
Le livre rappelle l'éthique de l'expérimentation : le RAG est un domaine empirique. Construisez d'abord une chaîne de bout en bout qui fonctionne, pas forcément optimale, puis itérez technique par technique (expansion, hybride, reranking, multi-index, routeur, mémoire de conversation). On délivre ainsi de la valeur vite tout en récoltant du feedback réel.
À retenir
- Le RAG naïf (un vecteur, top K, prompt) échoue sur les requêtes vagues, les termes techniques exacts et le bruit ; le RAG avancé insère des optimisations en pré-récupération, récupération et post-récupération.
- En pré-récupération, l'expansion de requête (1 → N reformulations) élargit la couverture de l'espace d'embedding, et le self-query extrait des métadonnées (auteur, tag) pour en faire des filtres explicites.
- En récupération, la recherche vectorielle filtrée réduit l'espace avant de calculer les similarités ; parmi les améliorations possibles (non implémentées dans le livre), la recherche hybride (dense + BM25) capterait à la fois les synonymes et les mots-clés précis.
- En post-récupération, le reranking par cross-encodeur réordonne finement le pool N × K vers le top K — récupérer large puis trier serré est le cœur de la stratégie.
- L'essentiel du code RAG vit dans le module de récupération, pas dans l'appel au LLM ; en parallélisant les N recherches on contient la latence induite par l'expansion.
- La génération sourcée exploite les métadonnées des morceaux pour citer les sources, ce qui renforce la confiance et réduit les hallucinations en ancrant la réponse dans un contexte vérifiable.