Leçon 7 — Évaluer et passer à l'échelle

Évaluer la recherche : le rappel@k

5 min de lecture

Évaluer la recherche : le rappel@k

Sans mesure, pas d'amélioration

« Ça a l'air de marcher » ne suffit pas : changer un préfixe, une dimension ou un formatage peut améliorer trois requêtes et en casser cinq. On fixe donc un jeu de référence : des requêtes réalistes, et pour chacune la liste des produits qu'un humain juge pertinents.

files/requetes_eval.json (extrait)
{"requete": "faire le ménage sans y penser", "langue": "fr", "pertinents": ["P055"]},
{"requete": "noise cancelling headphones for long flights", "langue": "en", "pertinents": ["P019", "P059"]},
{"requete": "idée de cadeau pour de jeunes parents", "langue": "fr", "pertinents": ["P031", "P032", "P033", "P034", "P035", "P036"]},

Règles suivies pour ce jeu : des reformulations (pas les mots des fiches), plusieurs langues, des requêtes à plusieurs bonnes réponses, et quelques requêtes difficiles (« boire un café le matin en camping » : cafetière ET réchaud).

Le rappel@k

Le rappel@k (recall at k) répond à : « parmi les produits pertinents, quelle part apparaît dans les k premiers résultats ? »

rappel@k=∣pertinents∩topk∣∣pertinents∣\text{rappel@}k = \frac{|\text{pertinents} \cap \text{top}_k|}{|\text{pertinents}|}

files/evaluer.py (extrait)
def rappel_at_k(trouves: list, pertinents: list, k: int) -> float:
    """Part des produits pertinents présents dans les k premiers résultats."""
    return len(set(trouves[:k]) & set(pertinents)) / len(pertinents)

On fait la moyenne sur toutes les requêtes. Pour une requête à 6 produits pertinents, le rappel@5 ne peut pas dépasser 5/6 ≈ 0,83 : c'est normal, le jeu le prévoit.

flowchart LR
  J["31 requêtes + pertinents"] --> E1["Embeddings : top 5"]
  J --> E2["BM25 : top 5"]
  E1 --> R["rappel@5 par requête"]
  E2 --> R
  R --> A["Moyenne par langue"]
  R --> D["Liste des requêtes imparfaites"]
MétriqueQuestionQuand la regarder
Rappel@kai-je trouvé les bons produits dans les k premiers ?catalogue, RAG (le bon passage doit être dans le contexte)
Précision@kquelle part des k résultats est pertinente ?page de résultats affichée à l'utilisateur
MRRà quel rang arrive le premier bon résultat ?quand une seule bonne réponse compte

La comparaison avec BM25

evaluer.py calcule le même rappel@5 avec BM25 (bibliothèque rank-bm25) sur les mêmes textes. C'est la référence (baseline) : si les embeddings ne la battent pas, ils ne valent pas leur coût.

files/evaluer.py (extrait)
embed = moteur.embedder_gemini()
requetes = embed([moteur.texte_requete(q["requete"]) for q in jeu])   # 2 appels (lots de 20)
bm25 = BM25Okapi([mots(moteur.texte_document(p)) for p in produits])
for q, vq in zip(jeu, requetes):
    sem = [ids[i] for i, _ in moteur.top_k(vecteurs, vq, k)]
    mc = [ids[i] for i in np.argsort(-bm25.get_scores(mots(q["requete"])))[:k]]

Une requête a 2 produits pertinents ; un seul apparaît dans le top 5. Quel est son rappel@5 ?

Les embeddings obtiennent 0,99 et une variante 0,98 sur 31 requêtes. Que conclure ?

Exercice : ajouter le MRR

Ajoutez à evaluer.py le calcul du MRR (Mean Reciprocal Rank) : pour chaque requête, 1 / rang du premier produit pertinent (0 s'il n'y en a aucun dans le top k), puis la moyenne.

Voir la solution
def rang_reciproque(trouves, pertinents, k):
    for rang, pid in enumerate(trouves[:k], 1):
        if pid in pertinents:
            return 1 / rang
    return 0.0

# dans main(), à côté du rappel :
mrr = np.mean([rang_reciproque(sem, q["pertinents"], k) for q, _, _, sem in lignes])
print(f"MRR@{k} embeddings : {mrr:.2f}")

Le MRR est sensible à l'ordre : un bon produit en 1re place vaut 1, en 5e place 0,2. Le rappel@5 ne voit pas la différence.

Résultat obtenu

python evaluer.py exécuté le 2026-10-15 (gemini-embedding-2, 768 dimensions, avec préfixes de tâche) :

langue   n   rappel@5 embeddings   rappel@5 BM25
fr      24                0.98            0.65
en       3                1.00            0.00
es       2                1.00            0.00
de       2                1.00            0.50
toutes  31                0.99            0.54
dimension  128 : rappel@5 = 0.93
dimension  256 : rappel@5 = 0.97
dimension  768 : rappel@5 = 0.99

Requêtes imparfaites (2) :
  0.75  « équiper mon bivouac pour la nuit »  attendu ['P037', 'P038', 'P041', 'P060']  obtenu ['P038', 'P041', 'P040', 'P042', 'P037']
  0.83  « idée de cadeau pour de jeunes parents »  attendu ['P031', 'P032', 'P033', 'P034', 'P035', 'P036']  obtenu ['P032', 'P035', 'P036', 'P034', 'P033']

Lecture : BM25 ne trouve rien en anglais et en espagnol (aucun mot commun), les embeddings tout. Passer de 768 à 128 dimensions fait perdre 6 points. La 2e requête imparfaite est à son maximum possible (5 places pour 6 pertinents). Le même script sans les préfixes de tâche donnait 0,98 : écart trop faible pour conclure ici.

Je sais faire

  • Construire un jeu de requêtes de référence avec leurs produits pertinents
  • Calculer un rappel@k et le comparer à une baseline BM25
  • Ventiler une métrique par langue et par dimension
  • Refuser de conclure sur un écart plus petit que le bruit du jeu
Suivant