RAG avancé et graphes de connaissances#

Le chapitre précédent a introduit le paradigme RAG (Retrieval-Augmented Generation) : enrichir le contexte d’un LLM avec des documents retrouvés dans une base vectorielle. Cette approche de base — encoder les chunks, retrouver les \(k\) plus proches voisins, les injecter dans le prompt — fonctionne remarquablement bien pour des questions simples et des corpus homogènes. Cependant, elle atteint rapidement ses limites face à des requêtes complexes, des corpus volumineux ou des questions nécessitant un raisonnement multi-étapes.

Ce chapitre explore les techniques avancées qui dépassent le RAG naif. Nous étudierons le re-ranking avec des cross-encoders pour améliorer la précision de la récupération, la recherche hybride combinant approches dense et sparse, le Self-RAG qui donne au modèle le contrôle sur le processus de récupération, les graphes de connaissances pour le raisonnement structuré, et enfin l”indexation hiérarchique pour les requêtes complexes.

Ces techniques ne sont pas mutuellement exclusives : les systèmes RAG les plus performants en production combinent plusieurs de ces approches dans des pipelines sophistiqués. L’objectif est de construire une intuition solide sur les forces et faiblesses de chaque méthode, ainsi que sur les contextes où elles apportent un gain réel.

Limites du RAG naive#

Avant d’introduire des solutions avancées, il est essentiel de comprendre pourquoi le RAG naive échoue. Le premier problème est la récupération non pertinente : la similarité cosinus dans l’espace d’embeddings ne garantit pas la pertinence sémantique. Deux phrases proches vectoriellement peuvent ne pas répondre è la même question.

Le deuxième problème concerne les frontières de chunks. Le découpage en chunks de taille fixé peut séparer des informations qui doivent être lues ensemble — un tableau dont l’en-tête et les données se retrouvent dans des chunks différents devient inutilisable.

Le troisième problème est l”absence de raisonnement sur les documents récupérés. Le RAG naive injecte les chunks sans les analyser ni les croiser. Enfin, la stratégie uniforme applique le même mécanisme de recherche quelle que soit la complexité de la requête : une question factuelle et une question analytique reçoivent le même traitement.

Hide code cell source

# Import des librairies Python
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import networkx as nx
from collections import Counter
import math

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Hide code cell source

# Illustration des modes de défaillance du RAG naive
categories = [
    "Récupération\nnon pertinente",
    "Frontières\nde chunks",
    "Pas de\nraisonnement",
    "Stratégie\nuniforme"
]
impact = [0.72, 0.58, 0.81, 0.65]
colors = sns.color_palette("Reds_d", n_colors=4)

fig, ax = plt.subplots(figsize=(9, 5))
bars = ax.barh(categories, impact, color=colors, edgecolor="white", height=0.6)
ax.set_xlabel("Impact sur la qualité des réponses (estimé)")
ax.set_title("Modes de défaillance du RAG naive")
ax.set_xlim(0, 1.0)
for bar, val in zip(bars, impact):
    ax.text(val + 0.02, bar.get_y() + bar.get_height() / 2,
            f"{val:.0%}", va="center", fontweight="bold")
plt.show()
_images/e4bac2a9e04f1a25cab3580c2c760dd934507a83de8f383d9caed1f5f5208539.png

Re-ranking avec cross-encoders#

La solution la plus directe au problème de la récupération non pertinente est d’introduire une étape de re-ranking après la récupération initiale. L’idée est simple : utiliser un premier modèle rapide pour récupérer un ensemble large de candidats, puis un second modèle plus précis pour les réordonner.

Définition 57 (Re-ranking)

Le re-ranking (ou reordonnancement) est une étape de post-traitement dans un pipeline de récupération d’information. Etant donné une requête \(q\) et un ensemble de \(N\) documents candidats \(\{d_1, \ldots, d_N\}\) retrouvés par un premier modèle, un modèle de re-ranking attribue un score de pertinence \(s(q, d_i)\) à chaque paire \((q, d_i)\) et réordonne les documents selon ces scores. Seuls les \(k \ll N\) documents les mieux classés sont transmis au LLM.

La distinction clé est entre les bi-encoders et les cross-encoders.

Définition 58 (Cross-encoder)

Un cross-encoder est un modèle qui prend en entrée la concaténation de la requête et du document \([q ; d_i]\) et produit un score de pertinence \(s(q, d_i) \in [0, 1]\). Contrairement au bi-encoder qui encode \(q\) et \(d_i\) indépendamment, le cross-encoder permet une intéraction croisée (cross-attention) entre tous les tokens de \(q\) et de \(d_i\) à chaque couche du Transformer :

\[s(q, d_i) = \sigma\big(\mathbf{w}^\top \text{BERT}([q; d_i])_{\texttt{[CLS]}} + b\big)\]

\(\sigma\) est la fonction sigmoide.

Remarque 67 (Bi-encoder vs cross-encoder)

Le bi-encoder encode la requête et chaque document séparément en vecteurs \(\mathbf{e}_q\) et \(\mathbf{e}_d\), puis calcule leur similarité (cosinus, produit scalaire). Il est rapide car les embeddings des documents peuvent être pré-calculés. Le cross-encoder est plus précis car il modélise les intéractions fines entre requête et document, mais il est \(O(N)\) en temps d’inférence. En pratique, on combine les deux :

# Pipeline typique : bi-encoder (recall) + cross-encoder (précision)
# Etape 1 : bi-encoder retrouve top-100 candidats (rapide)
# Etape 2 : cross-encoder re-rank les 100 candidats (précis)
# Etape 3 : top-5 envoyés au LLM

Cette architecture à deux étages est parfois appelée retrieve-and-rerank.

Hide code cell source

# Simulation du re-ranking : bi-encoder vs cross-encoder
n_docs = 20
bi_scores = np.sort(np.random.beta(2, 5, size=n_docs))[::-1]

cross_scores = bi_scores.copy()
cross_scores[0], cross_scores[1] = 0.35, 0.28     # faux positifs du bi-encoder
cross_scores[7], cross_scores[12] = 0.92, 0.85     # pertinents mais mal classés
cross_scores = np.clip(cross_scores + np.random.normal(0, 0.05, n_docs), 0, 1)

fig, axes = plt.subplots(2, 1, figsize=(9, 9))
x = np.arange(n_docs)
axes[0].bar(x, bi_scores, color=sns.color_palette("muted")[0], edgecolor="white")
axes[0].set_title("Scores bi-encoder (ordre initial)")
axes[0].set_xlabel("Rang du document")
axes[0].set_ylabel("Score de similarité")
axes[0].set_ylim(0, 1)

reranked = np.argsort(cross_scores)[::-1]
axes[1].bar(x, cross_scores[reranked], color=sns.color_palette("muted")[1], edgecolor="white")
axes[1].set_title("Scores cross-encoder (après re-ranking)")
axes[1].set_xlabel("Rang du document (réordonné)")
axes[1].set_ylabel("Score de pertinence")
axes[1].set_ylim(0, 1)

plt.show()
_images/2dac4b3cb88725dfbb66cab78a12c384d8fc72eac73a1519de3ddf8c29f94fc7.png

Recherche hybride : dense + sparse#

La recherche vectorielle dense excelle pour capturer la similarité sémantique, mais elle peut manquer des correspondances lexicales exactes. Inversement, les méthodes sparse comme BM25 capturent les correspondances de mots-clés mais ignorent la sémantique. La recherche hybride combine les deux.

Définition 59 (BM25)

BM25 (Best Matching 25) est une fonction de pondération issue de la famille TF-IDF. Pour une requête \(q = (t_1, \ldots, t_m)\) et un document \(d\), le score est :

\[\text{BM25}(q, d) = \sum_{i=1}^{m} \text{IDF}(t_i) \cdot \frac{f(t_i, d) \cdot (k_1 + 1)}{f(t_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}\]

\(f(t_i, d)\) est la fréquence du terme \(t_i\) dans \(d\), \(|d|\) la longueur du document, \(\text{avgdl}\) la longueur moyenne du corpus, \(k_1 \approx 1{,}5\) et \(b \approx 0{,}75\). Le facteur IDF est :

\[\text{IDF}(t_i) = \ln\left(\frac{N - n(t_i) + 0{,}5}{n(t_i) + 0{,}5} + 1\right)\]

Hide code cell source

# Implementation de BM25 from scratch
class BM25:
    """Implémentation minimale de BM25."""
    def __init__(self, corpus, k1=1.5, b=0.75):
        self.k1, self.b = k1, b
        self.corpus = [doc.lower().split() for doc in corpus]
        self.N = len(self.corpus)
        self.avgdl = sum(len(d) for d in self.corpus) / self.N
        self.df, self.idf = {}, {}
        for doc in self.corpus:
            for term in set(doc):
                self.df[term] = self.df.get(term, 0) + 1
        for term, freq in self.df.items():
            self.idf[term] = math.log((self.N - freq + 0.5) / (freq + 0.5) + 1)

    def score(self, query, doc_idx):
        doc = self.corpus[doc_idx]
        tf = Counter(doc)
        s = 0.0
        for t in query.lower().split():
            if t in tf:
                f = tf[t]
                num = f * (self.k1 + 1)
                den = f + self.k1 * (1 - self.b + self.b * len(doc) / self.avgdl)
                s += self.idf.get(t, 0) * num / den
        return s

    def search(self, query, top_k=5):
        scores = sorted([(i, self.score(query, i)) for i in range(self.N)],
                        key=lambda x: x[1], reverse=True)
        return scores[:top_k]


corpus = [
    "Le retrieval augmented generation combine recherche et génération",
    "Les embeddings vectoriels capturent la sémantique des phrases",
    "BM25 est une méthode de recherche basée sur les mots clés",
    "Les graphes de connaissances représentent des relations entre entités",
    "Le re-ranking améliore la précision de la récupération",
    "Les transformers utilisent le mécanisme d attention",
    "La recherche hybride combine dense et sparse",
    "Le fine-tuning adapte un modèle pré-entrainé à une tâche spécifique",
]

bm25 = BM25(corpus)
query = "recherche hybride dense sparse"
results = bm25.search(query, top_k=5)
print(f"Requête : '{query}'\n\nTop-5 BM25 :")
for rank, (idx, score) in enumerate(results, 1):
    print(f"  {rank}. [score={score:.3f}] {corpus[idx]}")
Requête : 'recherche hybride dense sparse'

Top-5 BM25 :
  1. [score=6.865] La recherche hybride combine dense et sparse
  2. [score=0.970] Le retrieval augmented generation combine recherche et génération
  3. [score=0.834] BM25 est une méthode de recherche basée sur les mots clés
  4. [score=0.000] Les embeddings vectoriels capturent la sémantique des phrases
  5. [score=0.000] Les graphes de connaissances représentent des relations entre entités

La méthode la plus populaire pour combiner les classements est la Reciprocal Rank Fusion (RRF).

Remarque 68 (Reciprocal Rank Fusion)

La Reciprocal Rank Fusion (RRF), proposée par Cormack et al. (2009), est une méthode de fusion qui ne nécessite aucune calibration des scores. Elle repose uniquement sur les rangs des documents dans chaque classement, ce qui la rend robuste aux différences d’échelles. La constante \(k\) (typiquement \(k = 60\)) amortit l’influence des documents très bien classés dans un seul système :

# Plus k est grand, plus les rangs élevés sont attenués
# k = 60 est la valeur standard de la littérature

RRF produit des résultats comparables aux méthodes de fusion apprise (learned fusion) tout en étant beaucoup plus simple à implémenter.

Propriété 15 (Formule RRF)

Soit \(\mathcal{R} = \{r_1, r_2, \ldots, r_R\}\) un ensemble de \(R\) classements et \(\text{rank}_r(d)\) le rang du document \(d\) dans le classement \(r\). Le score RRF du document \(d\) est :

\[\text{RRF}(d) = \sum_{r \in \mathcal{R}} \frac{1}{k + \text{rank}_r(d)}\]

ou \(k\) est une constante (typiquement \(k = 60\)). Le classement final est obtenu en triant les documents par score RRF décroissant. Si un document n’apparait pas dans un classement \(r\), on lui attribue un rang \(\infty\) (contribution nulle).

Hide code cell source

# Implémentation de Reciprocal Rank Fusion
def reciprocal_rank_fusion(rankings, k=60):
    """Fusionne plusieurs classements via RRF."""
    rrf_scores = {}
    for ranking in rankings:
        for rank, (doc_id, _) in enumerate(ranking, start=1):
            rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)

dense_ranking = [(2, 0.95), (0, 0.88), (6, 0.82), (4, 0.75), (1, 0.71),
                 (3, 0.60), (5, 0.45), (7, 0.30)]
sparse_ranking = [(6, 12.5), (2, 10.1), (3, 8.7), (0, 7.2), (7, 5.1),
                  (4, 3.8), (1, 2.1), (5, 1.0)]
fused = reciprocal_rank_fusion([dense_ranking, sparse_ranking], k=60)

print("Dense :", [d for d, _ in dense_ranking[:5]])
print("Sparse :", [d for d, _ in sparse_ranking[:5]])
print("Fusionné (RRF) :", [d for d, _ in fused[:5]])
for doc_id, score in fused[:5]:
    print(f"  Doc {doc_id} : RRF = {score:.5f}")
Dense : [2, 0, 6, 4, 1]
Sparse : [6, 2, 3, 0, 7]
Fusionné (RRF) : [2, 6, 0, 3, 4]
  Doc 2 : RRF = 0.03252
  Doc 6 : RRF = 0.03227
  Doc 0 : RRF = 0.03175
  Doc 3 : RRF = 0.03102
  Doc 4 : RRF = 0.03078

Exemple 44 (Pipeline de recherche hybride)

Un pipeline de recherche hybride typique suit les étapes suivantes :

  1. Indexation : chaque document est indexé dans un index vectoriel (FAISS, Pinecone) et un index inverse (Elasticsearch, BM25).

  2. Récupération parallèle : les deux systèmes retournent leurs top-\(N\) candidats (\(N \approx 100\)).

  3. Fusion : les classements sont fusionnés via RRF.

  4. Re-ranking (optionnel) : un cross-encoder réordonne les top-\(M\) documents fusionnés.

  5. Génération : les top-\(k\) documents sont injectés dans le prompt du LLM.

def hybrid_rag(query, top_n=100, top_k=5):
    dense_results = vector_store.search(query, top_n)
    sparse_results = bm25_index.search(query, top_n)
    fused = reciprocal_rank_fusion([dense_results, sparse_results])
    reranked = cross_encoder.rerank(query, fused[:20])
    context = "\n".join(doc.text for doc, _ in reranked[:top_k])
    return llm.generate(query, context)

Hide code cell source

# Comparaison des approches de recuperation (donnees simulees)
methods = ["Dense\n(bi-encoder)", "Sparse\n(BM25)", "Hybride\n(RRF)", "Hybride +\nre-ranking"]
precision_at_5 = [0.62, 0.55, 0.74, 0.83]
recall_at_20 = [0.78, 0.71, 0.89, 0.89]

x = np.arange(len(methods))
width = 0.35

fig, ax = plt.subplots(figsize=(10, 5))
bars1 = ax.bar(x - width / 2, precision_at_5, width,
               label="Precision@5", color=sns.color_palette("muted")[0], edgecolor="white")
bars2 = ax.bar(x + width / 2, recall_at_20, width,
               label="Recall@20", color=sns.color_palette("muted")[1], edgecolor="white")

ax.set_ylabel("Score")
ax.set_title("Comparaison des stratégies de récupération (simulé)")
ax.set_xticks(x)
ax.set_xticklabels(methods)
ax.set_ylim(0, 1.0)
ax.legend()

for bar in bars1:
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.02,
            f"{bar.get_height():.2f}", ha="center", fontsize=9)
for bar in bars2:
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.02,
            f"{bar.get_height():.2f}", ha="center", fontsize=9)

plt.show()
_images/e03a6fc64519fbbb3a3f958b8c748a5c3dee9cadc46b1c4016c21d83df185b4c.png

Self-RAG et RAG adaptatif#

Les approches précédentes améliorent la qualité de la récupération, mais elles ne remettent pas en question le schéma : pour toute requête, on récupère des documents et on les injecte. Or, certaines questions ne nécessitent aucune récupération, tandis que d’autres en necessitent plusieurs étapes. Le Self-RAG et le RAG adaptatif donnent au modèle le contrôle sur ce processus.

Définition 61 (Self-RAG)

Le Self-RAG (Self-Reflective Retrieval-Augmented Generation), propose par Asai et al. (2023), est un framework dans lequel le LLM génère des tokens de réflexion (reflection tokens) contrôlant la récupération et la génération :

  1. Décider s’il faut récupérer des documents (token [Retrieve] : oui / non).

  2. Evaluer la pertinence de chaque document récupéré (token [IsRel] : pertinent / non pertinent).

  3. Vérifier si la génération est soutenue par le document (token [IsSup] : totalement / partiellement / pas du tout).

  4. Juger l’utilité globale de la reponse (token [IsUse] : utile / pas utile).

Le modèle est entrainé par distillation à partir d’un modèle critique qui annote les données avec ces tokens de réflexion.

Exemple 45 (Boucle Self-RAG)

Déroulement d’une requête dans un système Self-RAG :

  1. Requête : « Quelle est la population de Lyon ? »

  2. Le modèle génère [Retrieve] = oui (connaissance paramétrique potentiellement obsolète).

  3. Le système récupère 5 documents.

  4. Pour chaque document, [IsRel] est évalué. Seuls 3 sont jugés pertinents.

  5. Le modèle génère une réponse candidate par document pertinent.

  6. Pour chaque réponse, [IsSup] et [IsUse] sont evalués.

  7. La réponse avec les meilleurs scores est sélectionnée.

def self_rag(query):
    need_retrieval = model.predict_retrieve(query)
    if not need_retrieval:
        return model.generate(query)
    docs = retriever.search(query, top_k=5)
    relevant_docs = [d for d in docs if model.predict_relevance(query, d)]
    candidates = []
    for doc in relevant_docs:
        response = model.generate(query, context=doc)
        support = model.predict_support(response, doc)
        utility = model.predict_utility(response, query)
        candidates.append((response, support, utility))
    return max(candidates, key=lambda x: x[1] + x[2])[0]

Le RAG adaptatif généralise cette idée en introduisant un routeur qui sélectionne la stratégie de récupération selon la complexité de la requête. Une question factuelle simple peut être traitée par une recherche directe, tandis qu’une question complexe est routée vers un pipeline agentic avec décomposition.

Hide code cell source

# Diagramme de decision Self-RAG
fig, ax = plt.subplots(figsize=(11, 6))
ax.set_xlim(0, 10)
ax.set_ylim(0, 7.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Processus de décision Self-RAG", fontsize=14, fontweight="bold", pad=15)

palette = sns.color_palette("muted")
boxes = [
    (5, 7, "Requête utilisateur", palette[0]),
    (5, 5.8, "[Retrieve] ?", palette[4]),
    (2, 4.2, "Génération directe", palette[2]),
    (7, 4.2, "Récupération", palette[1]),
    (7, 2.8, "[IsRel] filtrage", palette[4]),
    (7, 1.4, "Génération + [IsSup]", palette[3]),
    (5, 0.2, "Réponse [IsUse]", palette[5]),
]
for bx, by, text, color in boxes:
    bbox = dict(boxstyle="round,pad=0.35", facecolor=color, alpha=0.3, edgecolor=color)
    ax.text(bx, by, text, ha="center", va="center", fontsize=10,
            bbox=bbox, fontweight="bold")

arrow_kw = dict(arrowstyle="->", color="gray", lw=1.5)
ax.annotate("", xy=(5, 6.15), xytext=(5, 6.65), arrowprops=arrow_kw)
ax.annotate("", xy=(2, 4.7), xytext=(3.8, 5.55), arrowprops=arrow_kw)
ax.annotate("", xy=(7, 4.7), xytext=(6.2, 5.55), arrowprops=arrow_kw)
ax.annotate("", xy=(7, 3.25), xytext=(7, 3.85), arrowprops=arrow_kw)
ax.annotate("", xy=(7, 1.85), xytext=(7, 2.45), arrowprops=arrow_kw)
ax.annotate("", xy=(5, 0.6), xytext=(6.3, 1.15), arrowprops=arrow_kw)
ax.annotate("", xy=(5, 0.6), xytext=(2.5, 3.85), arrowprops=arrow_kw)
ax.text(2.8, 5.5, "Non", fontsize=9, fontstyle="italic", color="gray")
ax.text(6.5, 5.5, "Oui", fontsize=9, fontstyle="italic", color="gray")

plt.show()
_images/52bc2a87fd3acd152d2fe42594af407dcbff5094019ab8b8c44db3de7b3a52ff.png

Graphes de connaissances#

Les approches précédentes traitent les documents comme des blocs de texte plat. Les graphes de connaissances (knowledge graphs) introduisent une représentation structurée de l’information qui permet un raisonnement explicite sur les relations entre entités.

Définition 62 (Graphe de connaissances)

Un graphe de connaissances (knowledge graph, KG) est un graphe orienté \(G = (V, E)\) où :

  • \(V\) est l’ensemble des entités (noeuds) : concepts, personnes, lieux, etc.

  • \(E \subseteq V \times \mathcal{R} \times V\) est l’ensemble des triplets \((s, r, o)\), où \(s\) est le sujet, \(r \in \mathcal{R}\) la relation, et \(o\) l’objet.

Chaque triplet encode un fait : \((Paris, \text{est\_capitale\_de}, France)\). Les grands KG incluent Wikidata (\(\sim\)100M entités), Freebase et DBpedia.

Exemple 46 (Triplets d’un graphe de connaissances)

Exemples de triplets :

Sujet

Relation

Objet

BERT

est_un

Modèle de langage

BERT

cree_par

Google

GPT-4

est_un

Modèle de langage

GPT-4

cree_par

OpenAI

Modèle de langage

utilise

Transformer

A partir de ce graphe, on peut répondre à des questions multi-sauts comme « Quelles entreprises ont créé des modèles de langage ? » en traversant : Modèle de langage <-- est_un -- {BERT, GPT-4} -- cree_par --> {Google, OpenAI}.

Remarque 69 (GraphRAG)

GraphRAG (Microsoft, 2024) construit automatiquement un KG à partir d’un corpus, puis l’utilise pour améliorer la récupération et le raisonnement :

  1. Extraction : un LLM extrait les triplets \((s, r, o)\) de chaque chunk.

  2. Résolution d’entités : les mentions différentes d’une même entité sont fusionnées.

  3. Détection de communautés : l’algorithme de Leiden identifie des groupes d’entités fortement connectées.

  4. Résumés : un LLM génère un résumé par communauté.

triples = llm.extract_triples(chunks)
graph = build_knowledge_graph(triples)
communities = leiden_algorithm(graph)
summaries = [llm.summarize(c) for c in communities]

GraphRAG excelle pour les questions globales (« Quels sont les thèmes principaux du corpus ? ») la ou le RAG naive, qui ne récupère que quelques chunks locaux, échoue.

Hide code cell source

# Construction et visualisation d'un graphe de connaissances
G = nx.DiGraph()
triples = [
    ("BERT", "est_un", "Modèle de langage"), ("GPT-4", "est_un", "Modèle de langage"),
    ("Claude", "est_un", "Modèle de langage"), ("BERT", "cree_par", "Google"),
    ("GPT-4", "cree_par", "OpenAI"), ("Claude", "cree_par", "Anthropic"),
    ("Google", "est_une", "Entreprise"), ("OpenAI", "est_une", "Entreprise"),
    ("Anthropic", "est_une", "Entreprise"), ("Modèle de langage", "utilise", "Transformer"),
    ("Transformer", "propose_par", "Vaswani et al."),
    ("Modèle de langage", "application", "RAG"),
    ("RAG", "utilise", "Recherche vectorielle"),
]
for s, r, o in triples:
    G.add_edge(s, o, relation=r)

palette = sns.color_palette("muted")
models = {"BERT", "GPT-4", "Claude"}
orgs = {"Google", "OpenAI", "Anthropic"}
node_colors = [palette[1] if n in models else palette[2] if n in orgs
               else palette[3] if n == "Vaswani et al." else palette[0] for n in G.nodes()]

fig, ax = plt.subplots(figsize=(13, 7))
pos = nx.spring_layout(G, seed=42, k=2.5)
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1800, alpha=0.8, ax=ax)
nx.draw_networkx_labels(G, pos, font_size=8, font_weight="bold", ax=ax)
nx.draw_networkx_edges(G, pos, edge_color="gray", arrows=True,
                       arrowsize=15, alpha=0.6, ax=ax, connectionstyle="arc3,rad=0.1")
nx.draw_networkx_edge_labels(G, pos, nx.get_edge_attributes(G, "relation"),
                             font_size=7, font_color="darkred", ax=ax)
ax.set_title("Graphe de connaissances (LLM)", fontsize=13, fontweight="bold")
ax.axis("off")
plt.show()
_images/3a078ba103b6cf81371497c905e0172d97b84d97f5ec80f4ff4b860f09b6de78.png

Le raisonnement multi-sauts (multi-hop) sur un graphe de connaissances permet de répondre à des questions nécessitant de traverser plusieurs arêtes. Par exemple, pour « Qui a proposé l’architecture utilisée par le modèle créé par Anthropic ? », le système doit identifier que Claude utilise le Transformer (via est_un \(\to\) utilise), puis que le Transformer a été proposé par Vaswani et al. Ce type de raisonnement est extrêmement difficile pour un RAG naive.

Indexation hiérarchique et multi-étapes#

Les documents complexes possèdent une structure naturelle — chapitres, sections, paragraphes — que le RAG naive ignore. L”indexation hiérarchique exploite cette structure en construisant un index à deux niveaux : des résumés au premier niveau pour la récupération grossière, et des chunks au second pour la récupération fine.

Les résumés capturent le thème global d’un document (utile pour les requêtes exploratoires), tandis que les chunks capturent les détails spécifiques (utile pour les questions précises). En combinant les deux, le système traite une gamme plus large de requêtes.

Hide code cell source

# Schéma d'indexation hiérarchique
fig, ax = plt.subplots(figsize=(12, 5.5))
ax.set_xlim(0, 13)
ax.set_ylim(0, 7)
ax.axis("off")
ax.set_title("Indexation hiérarchique : documents -> résumés -> chunks",
             fontsize=13, fontweight="bold", pad=15)

palette = sns.color_palette("muted")
arrow_kw = dict(arrowstyle="->", color="gray", lw=1.2)
levels = {
    "docs":     ([3, 7, 11], 6, palette[0], "Document {}"),
    "summaries":([3, 7, 11], 4, palette[1], "Resume {}"),
}
for key, (xs, y, col, fmt) in levels.items():
    for i, x in enumerate(xs):
        bbox = dict(boxstyle="round,pad=0.3", facecolor=col, alpha=0.3, edgecolor=col)
        ax.text(x, y, fmt.format(i+1), ha="center", va="center",
                fontsize=9, fontweight="bold", bbox=bbox)

chunk_xs = [[1.5, 3, 4.5], [5.5, 7, 8.5], [9.5, 11, 12.5]]
for gi, group in enumerate(chunk_xs):
    for ci, cx in enumerate(group):
        bbox = dict(boxstyle="round,pad=0.2", facecolor=palette[2], alpha=0.3, edgecolor=palette[2])
        ax.text(cx, 2, f"C{gi+1}.{ci+1}", ha="center", va="center", fontsize=8, bbox=bbox)
    ax.annotate("", xy=(levels["summaries"][0][gi], 4.35),
                xytext=(levels["docs"][0][gi], 5.65), arrowprops=arrow_kw)
    for cx in group:
        ax.annotate("", xy=(cx, 2.3),
                    xytext=(levels["summaries"][0][gi], 3.65), arrowprops=arrow_kw)

ax.text(6.5, 0.8, "Etape 1 : requête vs résumés | Etape 2 : chunks des documents pertinents",
        ha="center", fontsize=9, fontstyle="italic", color="gray")
plt.show()
_images/7d88b208659f1790e0af169660aad52e828a3e28a741e0ec13071ee9e821c5b1.png

La récupération multi-étapes va plus loin en décomposant une requête complexe en sous-requêtes. Chaque sous-requête récupère des documents spécifiques, et les résultats sont synthétisés pour construire la réponse finale. Cette approche est particulièrement utile pour les questions nécessitant un raisonnement conditionnel ou un croisement d’informations.

Hide code cell source

# Précision de la récupération selon le type de requête
query_types = ["Factuelle\nsimple", "Thematique\nlarge", "Multi-\ndocuments", "Analytique\ncomplexe"]
flat_chunking = [0.82, 0.45, 0.38, 0.30]
hierarchical = [0.80, 0.72, 0.68, 0.55]
hierarchical_multi = [0.83, 0.75, 0.78, 0.71]

x = np.arange(len(query_types))
width = 0.25

fig, ax = plt.subplots(figsize=(10, 5))
ax.bar(x - width, flat_chunking, width, label="Chunks plats",
       color=sns.color_palette("muted")[0], edgecolor="white")
ax.bar(x, hierarchical, width, label="Hiérarchique",
       color=sns.color_palette("muted")[1], edgecolor="white")
ax.bar(x + width, hierarchical_multi, width, label="Hiérarchique + multi-étapes",
       color=sns.color_palette("muted")[2], edgecolor="white")

ax.set_ylabel("Precision@5 (simulée)")
ax.set_title("Impact de l'indexation hiérarchique selon le type de requête")
ax.set_xticks(x)
ax.set_xticklabels(query_types)
ax.set_ylim(0, 1.0)
ax.legend(loc="upper right")
plt.show()
_images/f751cfc32d688e4a32d70f4b8d9f13a274cfce8815f38810b905e1fe2c79ca36.png

La récupération multi-étapes s’intègre naturellement avec les systèmes agentiques (chapitres 12–15). Un agent LLM peut décomposer une requête complexe, formuler des sous-requêtes, évaluer les résultats intermédiaires, et itérer jusqu’à obtenir une réponse satisfaisante. Cette convergence entre RAG avancé et agents est l’une des tendances les plus prometteuses du domaine.

Résumé#

Ce chapitre a présenté les techniques avancées qui dépassent le RAG naive pour construire des systèmes de récuperation et de génération plus performants.

  1. Le RAG naive souffre de plusieurs modes de défaillance : récupération non pertinente, frontières de chunks, absence de raisonnement sur les documents récupérés, et stratégie uniforme indépendante de la complexité de la requête.

  2. Le re-ranking avec des cross-encoders introduit une étape de précision après la récupération initiale par bi-encoder. Le cross-encoder modélise les intéractions croisées entre requête et document, offrant une précision supérieure au coût d’une latence accrue.

  3. La recherche hybride combine récupération dense (similarité sémantique) et sparse (BM25, correspondance lexicale). La Reciprocal Rank Fusion (RRF), avec \(\text{RRF}(d) = \sum_r \frac{1}{k + \text{rank}_r(d)}\), fusionne les classements sans calibration.

  4. Le Self-RAG (Asai et al., 2023) donné au modèle le contrôle sur le processus de récupération via des tokens de réflexion ([Retrieve], [IsRel], [IsSup], [IsUse]). Le RAG adaptatif route les requêtes vers des stratégies différentes selon leur complexité.

  5. Les graphes de connaissances représentent l’information sous forme de triplets \((s, r, o)\) et permettent un raisonnement multi-sauts. GraphRAG (Microsoft) automatise la construction de KG à partir de documents et utilise la détection de communautés pour générer des résumés thématiques.

  6. L”indexation hiérarchique exploite la structure naturelle des documents (résumés pour la récupération grossière, chunks pour la récupération fine). La récupération multi-étapes décompose les requêtes complexes en sous-requêtes, convergeant avec les approches agentiques.

  7. Ces techniques sont complémentaires et les systèmes les plus performants en production les combinent dans des pipelines sophistiqués. Le choix de la bonne combinaison dépend du corpus, du type de requêtes, des contraintes de latence et du budget computationnel.