Aller au contenu principal

Évaluer la qualité de recherche

Mis à jour le 29 juillet 2026

Objectifs

  • Comprendre les métriques standard d’évaluation de la recherche
  • Créer un dataset d’évaluation
  • Mesurer et améliorer la qualité de votre moteur

Pourquoi évaluer ?

Les leçons précédentes ont introduit une série de choix : modèle d’embedding, nombre de dimensions, taille des chunks, pondération hybride. Chacun peut améliorer ou dégrader votre système, et l’impression subjective tirée de trois requêtes essayées à la main ne suffit pas à trancher — d’autant qu’un changement améliore souvent une catégorie de requêtes en dégradant une autre. Sans métriques, vous naviguez à vue.

Les métriques clés

Le Recall@k répond à la question : parmi les documents pertinents, combien apparaissent dans les k premiers résultats ? C’est la métrique reine du RAG, car un document absent du contexte ne pourra jamais être cité par le modèle de génération, quelle que soit sa qualité.

def recall_at_k(pertinents: set[str], resultats: list[str], k: int) -> float:
    """Proportion de documents pertinents trouvés dans le top-k."""
    top_k = set(resultats[:k])
    trouves = pertinents & top_k
    return len(trouves) / len(pertinents) if pertinents else 0.0

La Precision@k regarde l’inverse : parmi les k résultats retournés, combien sont réellement pertinents ? Elle compte surtout quand le contexte envoyé au LLM est limité, puisque chaque document hors sujet occupe une place et dilue l’attention du modèle.

def precision_at_k(pertinents: set[str], resultats: list[str], k: int) -> float:
    """Proportion de résultats pertinents dans le top-k."""
    top_k = resultats[:k]
    trouves = sum(1 for r in top_k if r in pertinents)
    return trouves / k if k > 0 else 0.0

Le MRR (Mean Reciprocal Rank) mesure à quel rang se trouve le premier résultat pertinent. Un document en première position vaut 1, en deuxième 0,5, en cinquième 0,2. C’est la métrique à privilégier pour une barre de recherche destinée à des humains, qui regardent rarement au-delà des trois premières lignes.

def mrr(pertinents: set[str], resultats: list[str]) -> float:
    """Rang réciproque du premier résultat pertinent."""
    for i, r in enumerate(resultats):
        if r in pertinents:
            return 1.0 / (i + 1)
    return 0.0

Le NDCG@k (Normalized Discounted Cumulative Gain) va plus loin en abandonnant la pertinence binaire : il accepte des degrés de pertinence, ce qui permet de distinguer le document qui répond exactement à la question de celui qui l’effleure. Le logarithme au dénominateur applique une décote progressive selon le rang, et la normalisation par le classement idéal ramène le tout entre 0 et 1.

import numpy as np

def ndcg_at_k(relevances: list[float], k: int) -> float:
    """NDCG avec scores de pertinence gradués."""
    dcg = sum(
        rel / np.log2(i + 2) for i, rel in enumerate(relevances[:k])
    )
    ideal = sorted(relevances, reverse=True)[:k]
    idcg = sum(
        rel / np.log2(i + 2) for i, rel in enumerate(ideal)
    )
    return dcg / idcg if idcg > 0 else 0.0

Créer un dataset d’évaluation

Aucune de ces formules ne sert à rien sans vérité terrain. Un dataset d’évaluation associe à chaque requête la liste des documents qui devraient remonter, et éventuellement ceux qui ne le devraient surtout pas — ces derniers sont utiles pour surveiller les faux positifs connus.

import json

# Structure du dataset
dataset_eval = [
    {
        "requête": "comment installer Python sur Windows",
        "pertinents": ["doc_install_python", "doc_setup_env"],
        "non_pertinents": ["doc_cuisine"]
    },
    {
        "requête": "configurer nginx comme reverse proxy",
        "pertinents": ["doc_nginx_proxy", "doc_nginx_config"],
        "non_pertinents": ["doc_python_web"]
    },
]

# Sauvegarder
with open("eval_dataset.json", "w") as f:
    json.dump(dataset_eval, f, ensure_ascii=False, indent=2)

Une trentaine de paires bien choisies, couvrant les différents types de questions que reçoit réellement votre moteur, valent mieux que trois cents produites à la chaîne. Puisez en priorité dans vos logs de recherche : ce sont les vraies formulations de vos utilisateurs, pas celles que vous imaginez.

Générer des requêtes de test avec un LLM

Sur un gros corpus, la rédaction manuelle devient impraticable. On renverse alors le problème : plutôt que de chercher les documents qui répondent à une question, on demande à un modèle quelles questions un document donné permet de traiter. Le document est par construction pertinent pour ces questions, et vous obtenez des paires en quelques minutes.

from openai import OpenAI

client = OpenAI()

def generer_requetes_test(document: str, n: int = 3) -> list[str]:
    """Génère n requêtes auxquelles ce document devrait répondre."""
    response = client.chat.completions.create(
        model="gpt-5.6-terra",
        messages=[{
            "role": "user",
            "content": (
                f"Voici un document :\n\n{document}\n\n"
                f"Génère {n} questions auxquelles ce document répond. "
                f"Retourne uniquement les questions, une par ligne."
            )
        }]
    )
    return response.choices[0].message.content.strip().split("\n")

Cette méthode a un angle mort qu’il faut connaître : les questions générées reprennent souvent le vocabulaire exact du document, ce qui les rend plus faciles que les vraies requêtes utilisateur et gonfle artificiellement vos scores. Utilisez-les pour comparer deux configurations entre elles, jamais pour annoncer un niveau de qualité absolu.

Pipeline d’évaluation complet

L’évaluateur exécute chaque requête du dataset sur le moteur, calcule toutes les métriques pour plusieurs valeurs de k, puis moyenne l’ensemble.

class EvaluateurRecherche:
    def __init__(self, moteur_recherche):
        self.moteur = moteur_recherche

    def evaluer(
        self, dataset: list[dict], k_values: list[int] = [1, 3, 5, 10]
    ) -> dict:
        """Évalue le moteur sur un dataset complet."""
        resultats = {f"recall@{k}": [] for k in k_values}
        resultats.update({f"precision@{k}": [] for k in k_values})
        resultats["mrr"] = []

        for item in dataset:
            # Exécuter la recherche
            res = self.moteur.rechercher(item["requête"], k=max(k_values))
            ids_resultats = [r["id"] for r in res]
            pertinents = set(item["pertinents"])

            # Calculer les métriques
            resultats["mrr"].append(mrr(pertinents, ids_resultats))
            for k in k_values:
                resultats[f"recall@{k}"].append(
                    recall_at_k(pertinents, ids_resultats, k)
                )
                resultats[f"precision@{k}"].append(
                    precision_at_k(pertinents, ids_resultats, k)
                )

        # Moyenner
        return {
            metrique: round(np.mean(scores), 4)
            for metrique, scores in resultats.items()
        }

# Utilisation
evaluateur = EvaluateurRecherche(moteur)
metriques = evaluateur.evaluer(dataset_eval)

print("Résultats d'évaluation :")
for metrique, score in metriques.items():
    print(f"  {metrique}: {score}")

Le suivi de plusieurs valeurs de k est instructif en soi : un recall@10 élevé accompagné d’un recall@1 médiocre signale que les bons documents sont bien retrouvés mais mal classés — un problème de reranking, pas d’indexation.

Comparer deux configurations

La fonction suivante met deux moteurs face au même dataset et affiche l’écart métrique par métrique. C’est le geste à répéter avant chaque mise en production, y compris pour un changement qui semble anodin.

def comparer_configs(moteur_a, moteur_b, dataset, noms=("A", "B")):
    """Compare deux moteurs de recherche."""
    eval_a = EvaluateurRecherche(moteur_a).evaluer(dataset)
    eval_b = EvaluateurRecherche(moteur_b).evaluer(dataset)

    print(f"{Métrique:<20} {noms[0]:<10} {noms[1]:<10} {Delta:<10}")
    print("-" * 50)
    for metrique in eval_a:
        delta = eval_b[metrique] - eval_a[metrique]
        signe = "+" if delta > 0 else ""
        print(f"{metrique:<20} {eval_a[metrique]:<10.4f} "
              f"{eval_b[metrique]:<10.4f} {signe}{delta:<10.4f}")

Lisez les deltas avec prudence sur un petit dataset : un écart de 0,02 sur trente requêtes tient probablement du bruit, alors qu’une baisse de 0,10 sur le recall@5 est un signal net qu’il ne faut pas déployer.

Résumé

  • Recall@k, Precision@k, MRR et NDCG sont les métriques standard
  • Créez un dataset d’évaluation avec des paires (requête, documents pertinents)
  • Automatisez la génération de requêtes avec un LLM
  • Comparez systématiquement les configurations avant de déployer