02 — Retrieval avancé : HyDE, reranking et évaluation

← 01 — Hands-On Grounding · 03 — Embeddings from scratch → · Sommaire de la série

Objectifs d’apprentissage

À la fin de ce notebook, vous saurez :

  1. définir et calculer Recall@k et nDCG@k sans bibliothèque de métriques ;
  2. établir une baseline bi-encoder avant d’ajouter de la complexité ;
  3. appliquer HyDE (Hypothetical Document Embeddings) avec un modèle génératif local ;
  4. reranker les candidats avec un cross-encoder multilingue ;
  5. comparer quatre configurations sur le même corpus et diagnostiquer les questions hors corpus.

Prérequis : recherche vectorielle de 01, géométrie des embeddings de 03, Python/NumPy. Durée estimée : 60 minutes (premier téléchargement des modèles compris). Exécution : CPU, inférence locale uniquement ; aucune clé API. Les poids ouverts sont téléchargés depuis Hugging Face au premier passage puis lus depuis le cache local.

Installation reproductible avant le premier passage :

python -m pip install "sentence-transformers>=3.4,<4" "transformers>=4.48,<5" sentencepiece pandas matplotlib

Verdict SOTA : SOTA-OK local. Le bi-encoder, le générateur HyDE et le cross-encoder sont réellement invoqués. Aucun texte hypothétique ni score de reranking n’est fabriqué.

import math
import random
import re
from collections import defaultdict

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from sentence_transformers import CrossEncoder, SentenceTransformer
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer

SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.set_num_threads(min(4, torch.get_num_threads()))

BI_ENCODER_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
HYDE_MODEL_NAME = "google/flan-t5-small"
CROSS_ENCODER_NAME = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"

print(f"PyTorch : {torch.__version__} | device : CPU")
print(f"Seed : {SEED} | threads PyTorch : {torch.get_num_threads()}")
print("Modèles locaux : bi-encoder multilingue, FLAN-T5-small, cross-encoder mMARCO")
PyTorch : 2.11.0+cpu | device : CPU
Seed : 42 | threads PyTorch : 4
Modèles locaux : bi-encoder multilingue, FLAN-T5-small, cross-encoder mMARCO

Lecture : l’environnement déclaré — trois modèles locaux, zéro appel réseau

La sortie PyTorch : 2.11.0+cpu | device : CPU puis la liste des trois modèles locaux méritent d’être lues comme un choix de protocole, pas comme un journal d’installation. Trois décisions s’y trouvent. D’abord le CPU est explicite (device="cpu" sur les trois modèles) : le notebook ne dépend d’aucun GPU, donc il s’exécute partout avec le même résultat — le coût se paie en temps de calcul, jamais en matériel. Ensuite tout est local et rien ne sort de la machine : bi-encoder multilingue, google/flan-t5-small, cross-encoder mMARCO sont chargés depuis le cache local, sans clé d’API ni quota — contraste direct avec un notebook d’API distante où chaque score dépend d’un service tiers. Le prix de cette clôture : les scores sont reproductibles et comparables entre configurations, ce qui est précisément la condition d’un benchmark contrôlé comme celui de la section 5. Enfin, ces modèles sont petits par construction — flan-t5-small compte ~77 M de paramètres — et cette petitesse est une hypothèse à tester, pas une garantie de qualité : la lecture du document hypothétique (section 3) montrera ce qu’un générateur non affiné produit réellement. Le Seed : 42 et le plafonnement threads PyTorch : 4 complètent la démarche : on privilégie la prévisibilité à la vitesse, car un benchmark n’a de valeur que si deux runs donnent le même tableau.

1. Protocole d’évaluation avant les modèles

Un système de retrieval doit être jugé sur une vérité terrain explicite. Nous construisons donc d’abord :

  • 60 documents français : 20 thèmes techniques, chacun décrit sous trois angles ;
  • 20 questions in-corpus : une par thème, avec trois documents pertinents ;
  • 4 questions hors corpus : elles n’ont volontairement aucun document pertinent.

Cette séparation évite un faux succès : forcer un moteur à retourner un document ne signifie pas que le corpus contient la réponse.

topics = [
    ("python", "Python", "langage interprété, typage dynamique et écosystème de bibliothèques", "pip, environnements virtuels et tests pytest"),
    ("docker", "Docker", "conteneurs isolés construits à partir d'images reproductibles", "Dockerfile, volumes et réseaux de conteneurs"),
    ("git", "Git", "contrôle de version distribué fondé sur des commits", "branches, fusion et résolution de conflits"),
    ("kubernetes", "Kubernetes", "orchestration de conteneurs déclarative", "pods, deployments, services et autoscaling"),
    ("rest", "API REST", "interface HTTP organisée autour de ressources", "verbes HTTP, codes de statut et JSON"),
    ("sql", "SQL", "langage déclaratif pour les bases relationnelles", "jointures, index, transactions et contraintes"),
    ("nosql", "NoSQL", "famille de bases non relationnelles adaptées à certains accès", "documents, clés-valeurs, cohérence et partitionnement"),
    ("ml", "apprentissage automatique", "modèles ajustés à partir de données", "entraînement, validation, test et généralisation"),
    ("overfit", "surapprentissage", "écart entre performance d'entraînement et de validation", "régularisation, early stopping et augmentation de données"),
    ("transformer", "transformer", "architecture neuronale fondée sur l'attention", "requêtes, clés, valeurs et connexions résiduelles"),
    ("embedding", "embedding", "vecteur dense représentant un objet par sa proximité sémantique", "similarité cosinus, index vectoriel et retrieval"),
    ("rag", "RAG", "génération augmentée par des documents récupérés", "chunking, recherche, citations et grounding"),
    ("qdrant", "Qdrant", "base vectorielle dédiée à la recherche de voisins", "collections, payloads, HNSW et filtres"),
    ("lora", "LoRA", "adaptation bas-rang de matrices de poids gelées", "rang, facteur alpha, fusion et faible coût mémoire"),
    ("quant", "quantification", "réduction de précision numérique des poids", "formats 8 bits ou 4 bits, mémoire et erreur d'approximation"),
    ("dpo", "DPO", "optimisation directe de préférences sans modèle de récompense séparé", "paires choisi-rejeté et politique de référence"),
    ("grpo", "GRPO", "optimisation de politique par groupes de complétions", "récompenses vérifiables, avantage relatif et raisonnement"),
    ("observability", "observabilité", "compréhension d'un système par ses signaux", "logs, métriques, traces et corrélation"),
    ("testing", "tests logiciels", "vérification automatisée de comportements attendus", "tests unitaires, intégration, propriétés et non-régression"),
    ("security", "sécurité des secrets", "protection des clés et jetons d'accès", "variables d'environnement, rotation et moindre privilège"),
]

documents = []
for key, title, definition, practice in topics:
    variants = [
        f"{title} — définition. {title} désigne {definition}.",
        f"{title} — pratique. On le reconnaît notamment par {practice}.",
        f"{title} — décision. Le choix dépend du besoin ; il faut mesurer les compromis liés à {definition} et à {practice}.",
    ]
    for variant, text in enumerate(variants):
        documents.append({"id": f"{key}-{variant}", "topic": key, "text": text})

question_by_topic = {
    "python": "Quel langage utilise pip et pytest ?",
    "docker": "À quoi servent un Dockerfile et les volumes ?",
    "git": "Comment suivre des versions avec des branches et des commits ?",
    "kubernetes": "Quel orchestrateur manipule des pods et des deployments ?",
    "rest": "Pourquoi une API emploie-t-elle des verbes HTTP et des codes de statut ?",
    "sql": "Quel langage permet jointures et transactions relationnelles ?",
    "nosql": "Quand choisir une base documentaire ou clé-valeur ?",
    "ml": "Comment séparer entraînement, validation et test ?",
    "overfit": "Comment reconnaître et limiter le surapprentissage ?",
    "transformer": "Quelle architecture repose sur requêtes, clés et valeurs ?",
    "embedding": "Quel vecteur sert à mesurer une proximité sémantique ?",
    "rag": "Comment ancrer une génération dans des documents récupérés ?",
    "qdrant": "Quelle base vectorielle utilise collections et HNSW ?",
    "lora": "Comment adapter un modèle avec des matrices de faible rang ?",
    "quant": "Comment réduire la mémoire des poids avec quatre ou huit bits ?",
    "dpo": "Quelle méthode apprend directement avec des paires choisi-rejeté ?",
    "grpo": "Quelle méthode compare des groupes de complétions avec des récompenses ?",
    "observability": "Quels signaux combinent logs, métriques et traces ?",
    "testing": "Comment automatiser la non-régression d'un logiciel ?",
    "security": "Comment stocker et faire tourner une clé d'API sans la committer ?",
}

queries = []
for topic, question in question_by_topic.items():
    relevant = {doc["id"] for doc in documents if doc["topic"] == topic}
    queries.append({"id": f"q-{topic}", "question": question, "relevant": relevant, "in_corpus": True})

for index, question in enumerate([
    "Comment tailler un bonsaï au printemps ?",
    "Quelle est la recette traditionnelle du kouign-amann ?",
    "Comment entretenir un violoncelle ancien ?",
    "Quels oiseaux migrent à travers la Camargue ?",
]):
    queries.append({"id": f"q-out-{index}", "question": question, "relevant": set(), "in_corpus": False})

print(f"Corpus : {len(documents)} documents | thèmes : {len(topics)}")
print(f"Gold QA : {len(queries)} questions ({sum(q['in_corpus'] for q in queries)} in-corpus, {sum(not q['in_corpus'] for q in queries)} hors corpus)")
print("Exemple :", documents[0])
Corpus : 60 documents | thèmes : 20
Gold QA : 24 questions (20 in-corpus, 4 hors corpus)
Exemple : {'id': 'python-0', 'topic': 'python', 'text': 'Python — définition. Python désigne langage interprété, typage dynamique et écosystème de bibliothèques.'}

Lecture : le corpus est un dispositif de mesure, pas un jeu de données

Corpus : 60 documents | thèmes : 20 : le corpus est synthétique et volontairement régulier — trois variantes par thème (définition, pratique, décision), toutes construites sur la même matrice de phrases. Ce n’est pas de la paresse : c’est ce qui rend le benchmark INTERPRÉTABLE. Sur un corpus réel, on ne sait jamais si une chute de nDCG vient du retrieval ou d’une vérité terrain bruitée ; ici, la perturbation est la seule variable. Le second nombre est plus subtil : Gold QA : 24 questions (20 in-corpus, 4 hors corpus). Les 4 questions hors corpus ne sont pas un oubli ni une erreur d’annotation — c’est le groupe de CONTRÔLE du notebook, celui qui mesure le comportement quand la réponse n’existe pas. La section 5 les interroge séparément, et l’exercice 3 en fait le cœur du sujet (règle d’abstention). Noter enfin ce que la vérité terrain contient : des identifiants de documents ({'id': 'python-0', ...}), c’est-à-dire une pertinence BINAIRE et non graduée. Conséquence directe sur la métrique choisie plus bas : nDCG dégénère ici en une mesure de position du bon document, sans degrés de pertinence à pondérer. Un tel corpus valide les deltas, pas la performance absolue.

Métriques from scratch

Pour une question q et une liste ordonnée de documents :

  • Recall@k vaut la fraction des documents pertinents retrouvés dans les k premiers ;
  • DCG@k réduit le poids d’un document pertinent quand son rang augmente ;
  • nDCG@k normalise DCG par le classement idéal et reste dans [0, 1].

Les questions hors corpus sont exclues des moyennes de Recall/nDCG, car leur ensemble pertinent est vide. Elles servent à mesurer un autre échec : la confiance indue d’un moteur obligé de répondre.

def recall_at_k(ranked_ids, relevant_ids, k):
    if not relevant_ids:
        return float("nan")
    found = len(set(ranked_ids[:k]) & set(relevant_ids))
    return found / len(relevant_ids)


def dcg_at_k(ranked_ids, relevant_ids, k):
    return sum(
        1.0 / math.log2(rank + 2)
        for rank, doc_id in enumerate(ranked_ids[:k])
        if doc_id in relevant_ids
    )


def ndcg_at_k(ranked_ids, relevant_ids, k):
    if not relevant_ids:
        return float("nan")
    ideal_hits = min(k, len(relevant_ids))
    ideal = sum(1.0 / math.log2(rank + 2) for rank in range(ideal_hits))
    return dcg_at_k(ranked_ids, relevant_ids, k) / ideal

perfect = ["a", "b", "c"]
relevant = {"a", "b"}
assert recall_at_k(perfect, relevant, 2) == 1.0
assert math.isclose(ndcg_at_k(perfect, relevant, 2), 1.0)
assert 0.0 <= ndcg_at_k(["x", "a", "b"], relevant, 3) < 1.0

print("Tests métriques : PASS")
print(f"Exemple dégradé — Recall@3={recall_at_k(['x', 'a', 'b'], relevant, 3):.2f}, nDCG@3={ndcg_at_k(['x', 'a', 'b'], relevant, 3):.3f}")
Tests métriques : PASS
Exemple dégradé — Recall@3=1.00, nDCG@3=0.693

Lecture : deux métriques, deux questions — et les nombres se recomptent

La sortie donne Tests métriques : PASS puis l’exemple dégradé Recall@3=1.00, nDCG@3=0.693. Le ranking ['x', 'a', 'b'] contre les documents pertinents {a, b} est précisément le cas qui sépare les deux métriques : le document non pertinent est en tête, mais les deux pertinents restent dans la fenêtre de 3. Résultat, Recall@3 = 2/2 = 1.00 — parfait — tandis que nDCG chute. Ce n’est pas une incohérence : les deux mesurent des choses différentes. Recall répond à « le bon document est-il DANS la fenêtre ? », nDCG à « À QUELLE HAUTEUR ? ». Le 0.693 se recompte à la main. Le DCG idéal place les deux pertinents aux rangs 1 et 2 : 1/log2(2) + 1/log2(3) = 1 + 0,6309 = 1,6309. Le DCG réel, avec x en tête, les place aux rangs 2 et 3 : 0 + 1/log2(3) + 1/log2(4) = 0,6309 + 0,5 = 1,1309. Le rapport vaut 1,1309 / 1,6309 = 0,693 — exactement la valeur affichée. La décote logarithmique est la clé : occuper le rang 1 rapporte 1,0, le rang 3 seulement 0,5. C’est ce qui rend nDCG sensible au reranking de la section 4 : déplacer un document pertinent du rang 4 au rang 3 améliore le score sans changer le Recall d’un iota.

Exercice 1 — Modifier la sensibilité au rang

Objectif : remplacer le facteur logarithmique de DCG par une pénalisation linéaire, puis comparer les deux nDCG.

Étapes : 1. écrire linear_dcg_at_k ; 2. tester un classement parfait et un classement dégradé ; 3. expliquer quelle métrique punit le plus fortement une réponse pertinente tardive.

def linear_dcg_at_k(ranked_ids, relevant_ids, k):
    # TODO étudiant : sommer 1 / (rang + 1) pour les documents pertinents.
    return None

resultat_exercice_1 = None  # TODO étudiant : comparer les deux métriques
print("Exercice 1 à compléter : nDCG logarithmique vs pénalisation linéaire.")
Exercice 1 à compléter : nDCG logarithmique vs pénalisation linéaire.

2. Baseline bi-encoder

Le bi-encoder encode indépendamment question et document. Cette factorisation permet de pré-calculer le corpus ; la recherche est rapide, mais aucune interaction token-à-token n’a lieu entre la question et chaque document.

Nous chargeons un modèle multilingue ouvert en local, encodons les 60 documents une seule fois et classons par similarité cosinus.

bi_encoder = SentenceTransformer(BI_ENCODER_NAME, device="cpu")
doc_texts = [doc["text"] for doc in documents]
doc_ids = [doc["id"] for doc in documents]
doc_embeddings = bi_encoder.encode(
    doc_texts, normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False
)


def retrieve_bi_encoder(question, top_k=10):
    query_embedding = bi_encoder.encode(
        [question], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False
    )[0]
    scores = doc_embeddings @ query_embedding
    order = np.argsort(-scores)[:top_k]
    return [(doc_ids[index], float(scores[index])) for index in order]

baseline_example = retrieve_bi_encoder(question_by_topic["rag"], top_k=5)
print(f"Bi-encoder chargé : {BI_ENCODER_NAME}")
print(f"Matrice corpus : {doc_embeddings.shape}")
print("Top-5 pour la question RAG :")
for doc_id, score in baseline_example:
    print(f"  {doc_id:<18} score={score:.3f}")
Bi-encoder chargé : sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
Matrice corpus : (60, 384)
Top-5 pour la question RAG :
  rag-0              score=0.517
  rag-2              score=0.467
  overfit-1          score=0.403
  rag-1              score=0.303
  transformer-1      score=0.299

Lecture : le bi-encoder factorise — et laisse un intrus au rang 3

Matrice corpus : (60, 384) dit l’essentiel de l’architecture : les 60 documents sont encodés une fois pour toutes en 384 dimensions (la taille de MiniLM-L12), et une requête se réduit à un vecteur unique, comparé par un simple produit matriciel (60, 384) · (384,) → 60 scores. C’est la factorisation qui fait la vitesse du bi-encoder : aucun des 60 documents n’est relu au moment de la question. Mais cette économie a un prix, et il est visible dans la sortie. Le top-5 affiche rag-0 score=0.517, rag-2 score=0.467, puis overfit-1 score=0.403 — un document d’un AUTRE thème s’insère au rang 3, devant rag-1 (0.303). Le diagnostic se lit en relisant les textes du corpus : le thème surapprentissage est défini par « l’écart entre performance d’entraînement et de validation », vocabulaire qui recoupe (validation, généralisation, jeu de données) celui du document RAG. La proximité est sémantique de surface — elle porte sur des mots partagés, pas sur le sujet — et le bi-encoder, qui encode question et document SÉPARÉMENT, n’a aucun moyen de la distinguer d’une vraie correspondance : c’est exactement la limite que le cross-encoder de la section 4 est conçu pour lever. Noter aussi l’amplitude : de 0,517 à 0,299, la queue est serrée, donc la marge entre rangs voisins est faible — de la matière pour un réordonnanceur, peu de certitude pour un seuil.

3. HyDE : chercher le document hypothétique

HyDE demande d’abord à un modèle génératif de produire un court passage susceptible de répondre à la question. Le passage, plus proche du style documentaire que la question, devient la requête du bi-encoder.

Ici FLAN-T5-small tourne réellement en local sur CPU. La génération est déterministe (do_sample=False) pour rendre le benchmark reproductible. HyDE n’est pas garanti meilleur : son texte peut introduire un concept absent ou déplacer le vocabulaire dans une mauvaise direction.

hyde_tokenizer = AutoTokenizer.from_pretrained(HYDE_MODEL_NAME)
hyde_model = AutoModelForSeq2SeqLM.from_pretrained(HYDE_MODEL_NAME).to("cpu").eval()


def generate_hypothetical_document(question):
    prompt = (
        "Rédige en français un passage technique factuel de deux phrases qui répondrait "
        f"à cette question. N'invente pas de référence : {question}"
    )
    inputs = hyde_tokenizer(prompt, return_tensors="pt", truncation=True, max_length=192)
    with torch.no_grad():
        generated = hyde_model.generate(
            **inputs, max_new_tokens=64, do_sample=False, num_beams=2
        )
    return hyde_tokenizer.decode(generated[0], skip_special_tokens=True).strip()


def retrieve_hyde(question, top_k=10):
    hypothetical = generate_hypothetical_document(question)
    query_embedding = bi_encoder.encode(
        [hypothetical], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False
    )[0]
    scores = doc_embeddings @ query_embedding
    order = np.argsort(-scores)[:top_k]
    return hypothetical, [(doc_ids[index], float(scores[index])) for index in order]

hyde_example_text, hyde_example = retrieve_hyde(question_by_topic["rag"], top_k=5)
print(f"Générateur HyDE chargé : {HYDE_MODEL_NAME}")
print("Document hypothétique :", hyde_example_text)
print("Top-5 HyDE :", [doc_id for doc_id, _ in hyde_example])
Générateur HyDE chargé : google/flan-t5-small
Document hypothétique : In French, a current technique of two phrases that responds to this question is not a reference to this question. N'invente pas de référence : Comment ancrear a generation in documents récupérés ?
Top-5 HyDE : ['rag-1', 'rag-2', 'rag-0', 'kubernetes-0', 'transformer-1']

Lecture : le document hypothétique est un texte DÉGÉNÉRÉ — et c’est l’expérience

La sortie doit être lue sans complaisance :

In French, a current technique of two phrases that responds to this question is not a reference to this question. N'invente pas de référence : Comment ancrear a generation in documents récupérés ?

Les trois symptômes de dégénérescence sont simultanés. (1) Le modèle recopie la consigne au lieu d’y répondre : « N’invente pas de référence » appartient au prompt, pas à une réponse — le modèle paraphrase l’instruction qu’il a reçue. (2) Il mélange deux langues (« In French… » en anglais, puis la suite en français) alors que la question est entièrement française. (3) Il fabrique un mot (ancrear, hybride français/espagnol de « ancrer »). Le mécanisme de HyDE reste pourtant intact : on n’encode plus la question, on encode le texte généré, quelle que soit sa qualité — le retrieve_hyde ne vérifie rien. Le classement qui en résulte (['rag-1', 'rag-2', 'rag-0', 'kubernetes-0', 'transformer-1']) est lu contre la baseline bi-encoder de la section précédente (['rag-0', 'rag-2', 'overfit-1', 'rag-1', 'transformer-1']) : deux mouvements opposés s’annulent presque. Gain — rag-1 remonte du rang 4 au rang 1 et l’intrus overfit-1 est éliminé. Perte — rag-0, le meilleur document, recule du rang 1 au rang 3, et un intrus NOUVEAU apparaît (kubernetes-0). Le solde est un déplacement de bruit, pas une amélioration : HyDE, tel que publié (Gao et al., 2022), suppose un modèle instruct capable de rédiger un passage plausible ; flan-t5-small sans affinage ne satisfait pas cette hypothèse, et le notebook l’expose au lieu de la masquer. C’est le point de méthode : une technique ne se juge pas sur son nom, mais sur la qualité de son entrée.

Exercice 2 — Tester la dérive de HyDE

Objectif : comparer la question originale et le document hypothétique sur une question ambiguë.

Indice : encodez les deux textes avec le bi-encoder, mesurez leur cosinus, puis inspectez les thèmes des cinq premiers documents.

question_ambigue = "Comment faire tourner un modèle ?"
# TODO étudiant : générer le document HyDE et comparer les deux classements.
comparaison_hyde = None
print("Exercice 2 à compléter : mesurer si HyDE désambiguïse ou dérive.")
Exercice 2 à compléter : mesurer si HyDE désambiguïse ou dérive.

4. Reranking cross-encoder

Un cross-encoder reçoit la paire (question, document) dans une seule séquence. Il peut donc modéliser des interactions fines, mais il faut l’appeler pour chaque candidat. Le compromis courant est :

  1. bi-encoder rapide pour récupérer 10 candidats ;
  2. cross-encoder plus coûteux pour reranker seulement ces 10 candidats.

Le reranker multilingue mMARCO ci-dessous est exécuté localement. Les scores ne sont comparables qu’à l’intérieur d’une même liste candidate.

cross_encoder = CrossEncoder(CROSS_ENCODER_NAME, device="cpu")


def rerank(question, candidates):
    candidate_docs = [documents[doc_ids.index(doc_id)]["text"] for doc_id, _ in candidates]
    pairs = list(zip([question] * len(candidate_docs), candidate_docs))
    scores = cross_encoder.predict(pairs, show_progress_bar=False)
    ranked = sorted(
        [(candidates[index][0], float(scores[index])) for index in range(len(candidates))],
        key=lambda item: item[1],
        reverse=True,
    )
    return ranked

reranked_example = rerank(question_by_topic["rag"], baseline_example)
print(f"Cross-encoder chargé : {CROSS_ENCODER_NAME}")
print("Avant reranking :", [doc_id for doc_id, _ in baseline_example])
print("Après reranking :", [doc_id for doc_id, _ in reranked_example])
Cross-encoder chargé : cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
Avant reranking : ['rag-0', 'rag-2', 'overfit-1', 'rag-1', 'transformer-1']
Après reranking : ['rag-2', 'rag-0', 'rag-1', 'overfit-1', 'transformer-1']

Lecture : le reranker corrige l’intrus — mais ne récupère rien

Le tableau avant/après est le verdict d’un réordonnanceur qui a bien fonctionné :

  • avant — ['rag-0', 'rag-2', 'overfit-1', 'rag-1', 'transformer-1']
  • après — ['rag-2', 'rag-0', 'rag-1', 'overfit-1', 'transformer-1']

Trois mouvements, tous conformes à la limite diagnostiquée sur le bi-encoder. rag-1 remonte du rang 4 au rang 3 en passant devant overfit-1 : l’intrus lexical qui s’était glissé au rang 3 est repoussé au rang 4, et les trois documents du thème rag occupent désormais les rangs 1 à 3. rag-2 et rag-0 permutent en tête. C’est la démonstration que le cross-encoder fait ce que le bi-encoder ne pouvait pas : il voit la paire (question, document) dans une seule séquence et peut donc modéliser les interactions entre les termes, là où deux encodages séparés ne comparent que des directions de vecteurs. Mais la composition du top-5 est identique : aucun document n’entre, aucun ne sort. C’est structurel, pas accidentel — le rerank ne reçoit que la fenêtre baseline_example et ne peut choisir que parmi ses candidats ; un document absent de la liste du bi-encoder est définitivement hors de portée. D’où le coût, qui explique l’ordre d’application : le cross-encoder exécute un passage avant par PAIRE (question, document), soit 5 ici mais 50 à 100 en production — on rappelle donc large et pas cher avec le bi-encoder, puis on reranke étroit et cher. Dernier détail de lecture : les scores du cross-encoder sont des logits non bornés, non comparables aux cosinus du bi-encoder ; seuls les RANGS se comparent entre colonnes, ce que le notebook respecte en ne joignant jamais les deux échelles dans un même tableau.

5. Benchmark contrôlé des quatre configurations

Nous comparons strictement :

  • Baseline : question → bi-encoder ;
  • HyDE : document hypothétique → bi-encoder ;
  • Rerank : baseline top-10 → cross-encoder ;
  • HyDE + rerank : HyDE top-10 → cross-encoder.

Chaque configuration voit le même corpus, les mêmes golds et les mêmes budgets top_k. Les documents HyDE sont générés une fois et réutilisés afin de ne pas confondre variation générative et qualité du retrieval.

hyde_cache = {}
results_by_query = defaultdict(dict)

for query in queries:
    question = query["question"]
    baseline_candidates = retrieve_bi_encoder(question, top_k=10)
    hypothetical, hyde_candidates = retrieve_hyde(question, top_k=10)
    hyde_cache[query["id"]] = hypothetical

    configurations = {
        "Baseline": baseline_candidates,
        "HyDE": hyde_candidates,
        "Rerank": rerank(question, baseline_candidates),
        "HyDE + rerank": rerank(question, hyde_candidates),
    }
    for name, ranking in configurations.items():
        ranked_ids = [doc_id for doc_id, _ in ranking]
        results_by_query[query["id"]][name] = {
            "ranking": ranking,
            "recall@5": recall_at_k(ranked_ids, query["relevant"], 5),
            "recall@10": recall_at_k(ranked_ids, query["relevant"], 10),
            "ndcg@10": ndcg_at_k(ranked_ids, query["relevant"], 10),
        }

rows = []
for name in ["Baseline", "HyDE", "Rerank", "HyDE + rerank"]:
    in_corpus = [
        results_by_query[q["id"]][name]
        for q in queries if q["in_corpus"]
    ]
    rows.append({
        "configuration": name,
        "Recall@5": np.mean([item["recall@5"] for item in in_corpus]),
        "Recall@10": np.mean([item["recall@10"] for item in in_corpus]),
        "nDCG@10": np.mean([item["ndcg@10"] for item in in_corpus]),
    })

summary = pd.DataFrame(rows).set_index("configuration")
print("TABLEAU COMPARATIF — 20 questions in-corpus")
print(summary.round(3).to_string())

best_configuration = summary["nDCG@10"].idxmax()
print(f"\nMeilleure nDCG@10 observée : {best_configuration} ({summary.loc[best_configuration, 'nDCG@10']:.3f})")
TABLEAU COMPARATIF — 20 questions in-corpus
               Recall@5  Recall@10  nDCG@10
configuration                              
Baseline          0.767      0.883    0.805
HyDE              0.350      0.383    0.341
Rerank            0.733      0.883    0.828
HyDE + rerank     0.317      0.383    0.377

Meilleure nDCG@10 observée : Rerank (0.828)

Lecture du résultat — le gain n’est pas un dogme

Le tableau précédent est le verdict du run, pas une promesse écrite à l’avance. Un reranker peut améliorer l’ordre sans changer Recall@10, puisqu’il ne voit que les candidats du bi-encoder. HyDE peut améliorer certaines formulations et en dégrader d’autres.

La cellule suivante rend les régressions visibles question par question et traite séparément les demandes hors corpus.

comparison_rows = []
for query in queries:
    if not query["in_corpus"]:
        continue
    base = results_by_query[query["id"]]["Baseline"]["ndcg@10"]
    for name in ["HyDE", "Rerank", "HyDE + rerank"]:
        score = results_by_query[query["id"]][name]["ndcg@10"]
        comparison_rows.append({
            "question": query["id"],
            "variante": name,
            "delta_nDCG": score - base,
        })

deltas = pd.DataFrame(comparison_rows)
worst = deltas.sort_values("delta_nDCG").head(5)
print("Cinq régressions les plus fortes face à la baseline :")
print(worst.to_string(index=False, formatters={"delta_nDCG": "{:+.3f}".format}))

print("\nQuestions hors corpus — le moteur retourne malgré tout un top-1 :")
for query in queries:
    if query["in_corpus"]:
        continue
    for name in ["Baseline", "HyDE + rerank"]:
        doc_id, score = results_by_query[query["id"]][name]["ranking"][0]
        print(f"  {query['id']} | {name:<14} -> {doc_id:<18} score={score:.3f}")

fig, ax = plt.subplots(figsize=(8, 4))
summary.plot(kind="bar", ylim=(0, 1.05), ax=ax)
ax.set_ylabel("Score moyen")
ax.set_title("Retrieval français — même corpus, quatre configurations")
ax.grid(axis="y", alpha=0.25)
plt.xticks(rotation=15, ha="right")
plt.tight_layout()
plt.show()
Cinq régressions les plus fortes face à la baseline :
 question      variante delta_nDCG
 q-python          HyDE     -1.000
 q-python HyDE + rerank     -1.000
   q-rest          HyDE     -1.000
   q-rest HyDE + rerank     -1.000
q-overfit          HyDE     -1.000

Questions hors corpus — le moteur retourne malgré tout un top-1 :
  q-out-0 | Baseline       -> docker-0           score=0.254
  q-out-0 | HyDE + rerank  -> rag-1              score=-6.658
  q-out-1 | Baseline       -> kubernetes-0       score=0.307
  q-out-1 | HyDE + rerank  -> kubernetes-1       score=-4.874
  q-out-2 | Baseline       -> kubernetes-0       score=0.233
  q-out-2 | HyDE + rerank  -> rag-1              score=-4.719
  q-out-3 | Baseline       -> docker-0           score=0.278
  q-out-3 | HyDE + rerank  -> docker-1           score=-4.043

Lecture : -1.000 n’est pas une baisse, c’est une disparition — et le signe compte

delta_nDCG -1.000 répété cinq fois (q-python, q-rest, q-overfit sous HyDE et HyDE + rerank) demande une lecture précise : le nDCG@10 ne baisse pas de 1, il tombe à zéro. Traduit en langage de retrieval : sur ces questions, le document pertinent n’est plus dans les 10 premiers du tout. L’échelle d’aide à mesurer la gravité — le corpus ne compte que 60 documents et la fenêtre en couvre 10, soit un sixième ; perdre le bon document dans ces conditions signale une dérive complète de la représentation de requête, pas un réordonnancement malheureux. C’est la conséquence directe du texte dégénéré de la section 3 : quand la requête encodée ne parle plus du sujet, le classement n’a plus de raison de contenir la réponse. Le résultat le plus instructif est ailleurs : HyDE + rerank est aussi à -1.000 sur q-python et q-rest. Le cross-encoder, dont on vient de vérifier qu’il corrige proprement les intrus quand le bon document est dans la fenêtre, est ici impuissant — non par faiblesse, mais parce qu’il ne reçoit pas le document pertinent. Un réordonnanceur ne peut pas réparer un rappel manquant ; c’est la démonstration expérimentale de la limite énoncée après la cellule du cross-encoder, et la raison pour laquelle l’ordre rappel-large → rerank-étroit est une contrainte d’architecture, pas une préférence. Le second tableau apporte la pièce manquante sur les questions hors corpus, et c’est l’observation la plus utile du notebook. La baseline retourne un top-1 avec des scores faibles mais POSITIFS (docker-0 0.254, kubernetes-0 0.307, 0.233, 0.278) ; HyDE + rerank retourne des scores négatifs (-6.658, -4.874, -4.719, -4.043). Le signe est une information exploitable : le cross-encoder affirme explicitement la NON-pertinence de la paire, alors qu’un cosinus — borné par le bas à -1 et structurellement incapable de dire « aucun rapport » — laisse 0,25 ressembler à un score acceptable. Il n’y a pas de document pertinent à trouver dans ces quatre cas ; un système qui répond quand même avec aplomb est exactement le défaut que l’exercice 3 (should_abstain) est chargé de corriger : le reranker fournit le signal (un logit négatif), le seuil reste la décision à prendre.

Exercice 3 — Ajouter une règle d’abstention

Objectif : empêcher une réponse sûre d’elle-même sur une question hors corpus.

Étapes : 1. choisir un seuil sur le score du top-1 ou sur l’écart top-1/top-2 ; 2. calibrer le seuil sur les 20 questions in-corpus ; 3. mesurer taux d’abstention correct et faux rejets sur les 4 questions hors corpus.

Un seuil n’est pas universel : les scores du bi-encoder et du cross-encoder n’ont pas la même échelle.

def should_abstain(ranking, threshold):
    # TODO étudiant : exploiter score top-1 et/ou marge top-1 moins top-2.
    return None

resultat_exercice_3 = None  # TODO étudiant : matrice de confusion abstention/réponse
print("Exercice 3 à compléter : calibrer l'abstention sur les questions hors corpus.")
Exercice 3 à compléter : calibrer l'abstention sur les questions hors corpus.

6. Guide de choix et coût conceptuel

Technique Coût principal Gain possible Limite structurante
Bi-encoder encodage du corpus une fois rappel rapide à grande échelle interaction question-document faible
HyDE une génération par question rapproche vocabulaire requête/document hallucination ou dérive du document hypothétique
Cross-encoder un forward par candidat meilleur ordre dans le top-k ne récupère pas un document absent des candidats
HyDE + rerank cumule les deux coûts combine rappel et précision d’ordre latence maximale, gain non garanti

Décision outillée : commencez par mesurer la baseline. Ajoutez HyDE si le rappel souffre d’un écart de formulation ; ajoutez le reranker si les bons documents sont présents mais mal ordonnés. Si les questions hors corpus sont fréquentes, l’abstention et la couverture documentaire passent avant l’optimisation du ranking.

Ponts avec le dépôt

Conclusion

Le retrieval avancé n’est pas « HyDE + reranker » par réflexe. C’est une séquence expérimentale : gold → baseline → variante → métriques → analyse des échecs. Le tableau exécuté ci-dessus indique ce qui aide réellement ce corpus français ; les questions hors corpus rappellent qu’un meilleur classement ne remplace jamais une politique d’abstention.

Retour au sommet