Accueil / Articles

Réclassement dans RAG : encodeurs croisés contre juges LLM et leur latence

Comment un réordonneur en deux étapes améliore le classement des candidats dans RAG, en comparant les encodeurs croisés avec des juges basés sur des LLM, les coûts de latence, les modes de défaillance et les moyens de les évaluer.

Éditeur : REACTAPP.TOP14 min de lecture3111 mots

Sources: Reranking for RAG: Cross-Encoders, LLM Rerankers, and Latency Tradeoffs

Le bon fragment se trouvait bien dans les résultats, mais trop bas

Imaginez un système RAG dont la recherche hybride commence enfin à fonctionner correctement. Le stockage vectoriel est opérationnel, l’index des mots-clés fonctionne, la logique de fusion a été écrite, et le fragment contenant la réponse apparaît désormais quelque part dans l’ensemble des candidats. Malgré cela, le modèle produit toujours une réponse médiocre.

Les journaux d’activité expliquent pourquoi. Un développeur a demandé comment déboguer une erreur PAYMENTS_API_TIMEOUT en environnement de staging. La recherche hybride a retourné vingt candidats : un aperçu général de l’authentification, une note d’incident obsolète datant de l’année précédente, une page générique sur la logique de tentative, et, en huitième position, le fragment du guide de fonctionnement pour le staging contenant la solution exacte. L’invite n’avait de place que pour trois fragments. Le bon fragment se trouvait trop bas, si bien que le modèle a construit sa réponse à partir du mauvais matériel, car celui-ci ne convenait pas.

La récupération a trouvé les preuves mais ne les a pas mises en premier. La récupération vise le rappel, tandis que le réclassement vise la précision dans le cadre d’un budget de ressources strict.

Un bon ensemble de candidats ne suffit pas à lui seul. Ce que le modèle lit réellement dépend de l’ordre de cet ensemble, et c’est cet ordre qui constitue le goulot d’étranglement.

Quel est le rôle d’une étape de réclassement

La première passe est conçue pour être rapide et exhaustive. Une requête adressée à une base de données vectorielle ou à un index de mots-clés doit couvrir des millions d’enregistrements en quelques millisecondes ; par conséquent, ces systèmes s’appuient sur des approximations et des index précalculés, et ils récupèrent délibérément plus d’informations que nécessaire.

Le réclassement ajoute une deuxième étape qui sélectionne un petit sous-ensemble de résultats et les examine plus en détail. Il est plus lent mais plus précis, et il ne touche jamais à la base de données. Ce que la première passe renvoie, généralement les vingt ou cinquante meilleurs résultats, est réévalué à l’aide d’un modèle plus puissant et plus exact.

La première passe produit des candidats, et le réclassement décide lesquels d’entre eux méritent une place dans la fenêtre de contexte limitée.

Pourquoi la première passe ne peut pas terminer le travail

Si le modèle d’incorporation est bon, le meilleur extrait ne devrait-il pas figurer en tête ? Pas de manière fiable, car la récupération lors de la première passe est par nature bruitée.

La notation des mots-clés telle que BM25 dépend de la superposition exacte des termes. Une page de référence extensive qui mentionne un code d’erreur cinquante fois l’emportera souvent sur un guide étape par étape court qui ne le mentionne que deux fois, car l’algorithme interprète la fréquence comme une mesure de pertinence.

La récupération vectorielle présente un autre type de bruit. Les embeddings mesurent la proximité sémantique en plaçant les concepts dans un espace à haute dimension. Pour une question de dépannage, ils mettent en évidence des extraits liés aux systèmes mentionnés. Les aperçus généraux ont tendance à se trouver très près de procédures spécifiques dans cet espace. Un extrait décrivant l’architecture de facturation peut obtenir un score plus élevé que la commande exacte permettant de redémarrer le processus de facturation. Le modèle sait que les deux concernent la facturation, mais il ne parvient pas à distinguer une instruction opérationnelle d’une description théorique.

La récupération hybride fusionne les listes lexicales et sémantiques en un ensemble complet de candidats contenant les éléments nécessaires. Cet ensemble inclut également des doublons, des documents obsolètes, des résumés généraux et des extraits seulement partiellement pertinents.

La récupération en première passe ne doit jamais être considérée comme la dernière couche de classement. Elle fonctionne comme un entonnoir, et le reclassement constitue le filtre situé en bas.

Deux méthodes pour réclasser

Lorsque des candidats existent, il faut les classer. Deux approches sont devenues standard. L’une utilise des cross-encoders, des modèles spécialisés entraînés pour évaluer le degré de pertinence d’un document par rapport à une requête. L’autre fait appel à des grands modèles de langage, où un modèle comme Claude ou GPT reçoit la requête et le document et est interrogé sur l’utilité de ce dernier.

Les deux améliorent considérablement la qualité du classement par rapport à la première tentative. Ils diffèrent en termes de latence, de coût, de déterminisme et des exigences techniques qu’ils imposent.

Cross-encoders

Un cross-encoder est un modèle transformer, bien qu’il fonctionne différemment des modèles d’incorporation utilisés dans une base de données vectorielle.

Un modèle d’incorporation standard fait passer la requête à travers le réseau pour obtenir un vecteur, puis fait passer le document pour en obtenir un autre, et compare les deux par similitude cosinus. C’est la conception du bi-encodeur, qui est rapide car chaque vecteur de document peut être calculé à l’avance.

Un cross-encodeur ne produit pas de vecteurs séparés. Il combine la requête et le document en une seule chaîne de caractères et fait passer l’ensemble à travers le réseau en même temps. L’auto-attention peut donc examiner les mots de la requête et ceux du document ensemble à chaque couche, afin de voir comment les termes se rapportent dans le contexte. La sortie est un seul score de pertinence.

Cette conception excelle pour des décisions de pertinence à faible échelle. Les résultats sont prévisibles et stables, avec une dispersion bien moindre que celle obtenue en sollicitant un modèle de langage, et elle préfère de manière fiable un fragment opérationnel spécifique à un résumé général.

Voici une fonction de réclassement basée sur une API d’encodeur croisé hébergée.

import cohere
import time

def rerank_cross_encoder(
    query: str,
    candidates: list[dict],
    top_n: int = 5,
    model: str = "rerank-v4.0-pro",
) -> list[dict]:
    """
    The practical default for second-stage ranking.
    Passes the query and candidate texts to a dedicated cross-encoder model.
    """
    co = cohere.ClientV2()

    # Extract just the text content for the API call
    documents = [c["content"] for c in candidates]

    resp = co.rerank(
        model=model,
        query=query,
        documents=documents,
        top_n=top_n,
    )

    # Reattach the original metadata and the new score
    reranked = []
    for r in resp.results:
        original_chunk = candidates[r.index]
        reranked.append({
            **original_chunk,
            "rerank_score": r.relevance_score
        })

    return reranked

Dans un environnement de production, un encodeur croisé convient aux dix à cinquante meilleurs candidats. Les équipes l’utilisent par défaut pour la recherche de documentation destinée aux ingénieurs, dans les bases de connaissances de support et les copilotes internes.

Le principal inconvénient réside dans la vitesse. Un encodeur croisé ne peut pas scanner toute une base de données. Il attend la première itération, reçoit les candidats via une API ou un modèle local, puis attend les scores, ce qui ajoute une étape de connexion réseau et une opération de calcul.

Réclassificateurs de LLM

L’alternative consiste à utiliser un modèle de langage polyvalent qui agit comme juge. Un prompt précis contient la requête de l’utilisateur ainsi qu’un extrait d’un candidat, et le modèle renvoie un score.

Les outils de rérankage basés sur des LLM gèrent bien les subtilités. Un encodeur croisé cherche une pertinence sémantique profonde, tandis qu’un LLM peut également vérifier si une procédure est complète, raisonner à partir de preuves contradictoires et distinguer les parties contenant des étapes concrètes de celles ne présentant que des informations de contexte. Le prompt peut lui indiquer de pénaliser la vagueness ou de privilégier les parties incluant des exemples de code.

Ce prompt de juge utilise le client d’Anthropic.

import anthropic

JUDGE_PROMPT = """\
You are a strict relevance judge. Given a user query and a candidate document chunk,
rate how well the chunk answers the query on a scale of 0 to 10.

Respond with ONLY a JSON object in this exact format:
{"score": <int>, "reason": "<one short sentence>"}

Query: {query}
Chunk: {chunk}"""

async def rerank_llm(
    query: str,
    candidates: list[dict],
    top_n: int = 5,
) -> list[dict]:
    """
    Expensive special forces. Uses an LLM to reason about nuance and completeness.
    """
    client = anthropic.AsyncAnthropic()
    scored = []

    for c in candidates:
        resp = await client.messages.create(
            model="claude-opus-4-6",
            max_tokens=128,
            messages=[{
                "role": "user",
                "content": JUDGE_PROMPT.format(query=query, chunk=c["content"]),
            }],
        )

        import json
        try:
            result = json.loads(resp.content[0].text)
            scored.append({
                **c,
                "rerank_score": result["score"],
                "reason": result.get("reason", "")
            })
        except (json.JSONDecodeError, KeyError):
            # Fallback if the model fails to follow JSON instructions
            scored.append({**c, "rerank_score": 0, "reason": "parse_error"})

    # Sort by the LLM-assigned score descending
    scored.sort(key=lambda x: x["rerank_score"], reverse=True)
    return scored[:top_n]

On demande au modèle de fournir une raison accompagnant la note. Écrire une justification en premier fonctionne comme une méthode de réflexion progressive et permet d’obtenir un chiffre plus fiable.

Ils gagnent leur vie grâce aux requêtes à haute valeur, dans les systèmes à faible volume où la précision de la réponse justifie un temps d’attente, ainsi que dans des corpus complexes où la pertinence dépend de subtilités dans les règles de sélection et non seulement d’une correspondance thématique.

Les coûts sont élevés. Une invocation complète du LLM est effectuée pour chaque fragment de texte considéré, et même lorsque ces invocations se déroulent en parallèle, c’est la réponse la plus lente qui conditionne le résultat final. Le coût est bien supérieur à celui d’un encodeur croisé, et la sortie est moins déterministe.

L’impact du réclassement sur la latence

Une étape supplémentaire modifie le fonctionnement du système. Elle entraîne davantage d’appels au modèle, une serialization supplémentaire des données textuelles et un temps de réseau accru.

Les utilisateurs ne se soucient pas de la manière dont s’effectue la récupération des informations et ne perçoivent pas les métriques de rappel. Ils remarquent plutôt la qualité des réponses et le temps de réponse. Un outil de réclassement théoriquement excellent est inutile s’il rend l’assistant trop lent pour être fiable.

Cet outil mesure le temps d’exécution d’une fonction de réclassement.

def rerank_with_timing(
    rerank_fn: callable,
    query: str,
    candidates: list[dict],
    top_n: int = 5,
) -> tuple[list[dict], float]:
    """
    Measure the exact cost of the reranking stage.
    """
    t0 = time.perf_counter()

    results = rerank_fn(query, candidates, top_n)

    latency_ms = (time.perf_counter() - t0) * 1000
    return results, latency_ms

Envoyer vingt candidats à une API d’encodeur croisé rapide peut ajouter de 150 à 300 millisecondes, ce qui représente généralement un prix raisonnable pour obtenir un meilleur contexte. Envoyer cinquante candidats à un modèle LLM peut ajouter de deux à quatre secondes. Avec trois secondes supplémentaires pour la génération, l’utilisateur attend sept secondes, ce qui dépasse ce qui est acceptable dans une interface de chat.

Le nombre de candidats réévalués doit être ajusté. Les dix meilleurs sont rapides, mais peuvent manquer le fragment pertinent lorsque la première tentative échoue. Les cinquante meilleurs couvrent presque tout, mais épuisent tout le budget de latence. Pour un encodeur croisé en production, les vingt meilleurs constituent généralement le bon équilibre.

Lorsque la réévaluation est utile

Ajoutez un outil de réévaluation lorsque la récupération en première tentative est bruitée, mais trouve généralement les éléments pertinents.

Si les traces montrent que le fragment d’élément de preuve exact apparaît fréquemment chez les candidats mais en position dix ou quinze, un réclassage corrigerait immédiatement le système. Cela est justifié lorsque des documents larges battent systématiquement des procédures précises, et lorsque la qualité de la réponse dépend fortement de la présence des trois meilleurs fragments dans la demande, de sorte que la précision l’emporte sur le simple rappel.

Les manuels opérationnels, les assistants de politique interne et les outils de dépannage ont presque toujours besoin d’un outil de réclassage. Choisir le bon commandement au lieu d’un choix proche peut déterminer si un incident est résolu ou se transforme en panne.

Lorsque le réclassage est excessif

Avec un corpus très petit, évitez-le. Une base de connaissances de cinquante pages peut être intégrée dans son intégralité et servie par une recherche vectorielle.

C’est également inutile lorsque la première étape est déjà presque parfaite. Dans un système où les recherches sont fréquentes et où BM25 place de manière fiable la bonne référence API en première position, un encodage croisé ne fait qu’ralentir les choses sans changer le résultat.

Un budget de latence très restreint, comme celui d’un assistant vocal ou d’une complétion de code en ligne, ne laisse aucune marge pour une étape supplémentaire. Ajustez la première étape et acceptez quelques imperfections. De plus, lorsque le bon fragment se trouve généralement déjà en première ou deuxième position, le classement n’est pas ce qui vous retient.

Modèles de défaillance à surveiller

Les outils de réclassement corrigent l’ordre des résultats mais introduisent leurs propres problèmes. Faites attention à ceux-ci après le déploiement.

Biais en faveur des résumés larges. Certains encodages croisés sont entraînés sur des données qui privilégient des réponses complètes. Un aperçu général peut obtenir de meilleurs résultats qu’un petit extrait de code précis, car cet aperçu partage davantage de vocabulaire avec la requête.

Surcharge due aux doublons. Si le corpus contient trois versions d’un guide de déploiement, la première passe récupérera probablement les trois. Le réclassificateur considère que chacune répond bien à la requête et leur attribue donc un score élevé. Les trois premières places dans la fenêtre de contexte contiennent alors les mêmes informations, éliminant ainsi le prochain élément d’information distinct. Un outil de ce type permet d’éliminer les doublons quasi identiques.

def dedupe_candidates(
    candidates: list[dict],
    similarity_threshold: float = 0.85,
) -> list[dict]:

    seen_tokens: list[set[str]] = []
    deduped = []

    for c in candidates:
        tokens = set(c["content"].lower().split())
        is_dup = False

        for s in seen_tokens:
            # Calculate simple Jaccard similarity
            overlap = len(tokens & s) / max(len(tokens | s), 1)
            if overlap >= similarity_threshold:
                is_dup = True
                break

        if not is_dup:
            deduped.append(c)
            seen_tokens.append(tokens)

    return deduped

Préférence pour les documents obsolètes. Un encodeur croisé ne lit que le texte et n’a aucune idée de l’année en cours. Un guide opérationnel soigneusement rédigé en 2022 peut obtenir de meilleurs résultats qu’un guide rédigé à la hâte en 2026, même si le document plus ancien est complètement déprécié. Les ajustements de métadonnées appliqués après un réclassement modifient les scores en fonction de la fraîcheur et du statut du document.

from datetime import datetime, timezone

def apply_metadata_boost(
    candidates: list[dict],
    freshness_halflife_days: int = 90,
) -> list[dict]:

    now = datetime.now(timezone.utc)
    boosted = []

    for c in candidates:
        score = c.get("rerank_score", 0.0)

        # Hard penalty for superseded documentation
        if c.get("status") == "superseded":
            score *= 0.4

        # Gradual decay for older documents
        updated = c.get("updated_at")
        if updated:
            age_days = (now - updated).days
            decay_factor = max(0.5, 1 - age_days / (freshness_halflife_days * 2))
            score *= decay_factor

        boosted.append({**c, "rerank_score": score})

    # Sort again based on the adjusted scores
    boosted.sort(key=lambda x: x["rerank_score"], reverse=True)
    return boosted

Explosion de la latence. La qualité s’améliore après le déploiement, mais de temps en temps le système s’arrête pendant cinq secondes. Cela se produit généralement lorsque le nombre de candidats augmente brusquement ou que l’API externe de réclassement dégrade ses performances. Il faut toujours prévoir un mécanisme de fallback basé sur un délai d’attente qui permet d’éviter l’utilisation du réclasseur et de retourner les résultats obtenus en première passe lorsque la deuxième étape prend trop de temps.

Évaluer correctement un réclasseur

Les équipes mesurent souvent le mauvais indicateur, en vérifiant si le bon fragment apparaît quelque part parmi les vingt meilleurs résultats. La récupération en première passe couvre déjà ce cas.

La question plus pertinente est de savoir si le réclasseur a suffisamment hissé le bon fragment en haut de la liste pour qu’il ait de l’importance. Cela implique de mesurer la pertinence des premiers résultats (top-1 et top-3), la complétude des réponses et l’élimination des doublons, ainsi que de suivre la charge liée à la latence et le coût par requête.

Créez un outil d’évaluation qui compare la base de référence simple en une seule passe avec l’encodeur croisé et le réordonneur de LLM, en utilisant des ensembles de candidats identiques.

from dataclasses import dataclass

@dataclass
class RerankEvalCase:
    query: str
    expected_substring: str
    category: str  # e.g., "identifier", "procedure", "troubleshooting"

def eval_reranking(
    cases: list[RerankEvalCase],
    retrieve_fn: callable,
    rerank_fns: dict[str, callable | None],
    top_n: int = 3,
) -> dict:
    """
    Compare multiple reranking strategies against a baseline.
    Measures hit rate at top-N and tracks latency overhead.
    """
    results = {}

    for name, rerank_fn in rerank_fns.items():
        hits = 0
        total_latency = 0.0
        by_type: dict[str, dict] = {}

        for case in cases:
            # Get the exact same starting candidates for every strategy
            candidates = retrieve_fn(case.query)

            if rerank_fn is not None:
                reranked, lat = rerank_with_timing(
                    rerank_fn, case.query, candidates, top_n
                )
                total_latency += lat
            else:
                # Baseline: just take the top-N from first-pass retrieval
                reranked = candidates[:top_n]

            # Check if the expected evidence made it into the final prompt window
            top_contents = [r["content"] for r in reranked]
            found = any(case.expected_substring in c for c in top_contents)
            hits += int(found)

            # Track metrics by query category
            by_type.setdefault(case.category, {"hit": 0, "total": 0})
            by_type[case.category]["total"] += 1
            by_type[case.category]["hit"] += int(found)

        total = len(cases)
        results[name] = {
            "hit_rate": hits / total if total else 0,
            "avg_latency_ms": total_latency / total if total else 0,
            "by_type": {
                t: {**v, "rate": v["hit"] / v["total"]}
                for t, v in by_type.items()
            },
        }

    return results

Le jeu de test doit couvrir plusieurs types de requêtes : celles axées sur les identifiants où comptent les chaînes de caractères exactes, celles basées sur la procédure où compte l’ordre des étapes, et celles de dépannage où les symptômes indiquent les causes profondes.

Les chiffres montrent alors précisément ce que le réordonneur apporte. Si un encodage croisé permet d’augmenter le taux de pertinence des 3 meilleurs résultats de 60 % à 85 % en 200 millisecondes, il doit être adopté. Si le réordonneur basé sur un LLM atteint 88 % en trois secondes, l’encodage croisé reste probablement préférable. Le réordonnement doit être justifié par des réponses finales de meilleure qualité dans un délai réaliste.

Une pile par défaut sensée

Une base de référence fiable pour un système de production suit une séquence précise. Divisez les documents par segmentation tenant compte de leur structure. Utilisez une recherche hybride pour recueillir vingt à trente candidats. Éliminez les doublons afin d’éliminer les textes presque identiques. Évaluez le reste à l’aide d’un encodeur croisé pour déterminer la pertinence sémantique. Appliquez un renforcement des métadonnées qui pénalise les documents obsolètes. Ensuite, prenez les trois à cinq meilleurs segments de la liste finale triée pour créer le prompt.

def two_stage_retrieve(
    query: str,
    retrieve_fn: callable,
    top_k: int = 20,
    top_n: int = 5,
) -> tuple[list[dict], dict]:
    """
    The complete production pipeline for second-stage ranking.
    """
    t0 = time.perf_counter()

    # Stage 1: Fast hybrid retrieval
    candidates = retrieve_fn(query)[:top_k]

    # Clean up the candidate pool
    candidates = dedupe_candidates(candidates)

    # Stage 2: Cross-encoder rerank
    # We score slightly more than top_n to allow metadata boosts to reorder the edges
    score_limit = min(top_n * 2, len(candidates))
    reranked = rerank_cross_encoder(query, candidates, top_n=score_limit)

    # Apply business logic for freshness and status
    final = apply_metadata_boost(reranked)[:top_n]

    latency = (time.perf_counter() - t0) * 1000

    trace = {
        "query": query,
        "first_pass_count": len(candidates),
        "post_rerank_count": len(reranked),
        "final_count": len(final),
        "latency_ms": latency,
    }

    return final, trace

La configuration par défaut fonctionne car chaque outil s’acquitte de la tâche pour laquelle il est conçu. La récupération gère le rappel des candidats, le réclassement s’occupe de l’ordre des candidats, et les encodeurs croisés offrent le meilleur rapport qualité/délai pour cette deuxième étape.

Un bémol : un outil de réclassement ne doit pas être ajouté à la hâte pour corriger des données défectueuses. En cas de segmentation incorrecte, il se contentera de trier correctement les segments défectueux. En l’absence de métadonnées, il ne peut pas deviner quel document est obsolète. Corrigez d’abord l’ingestion et l’extraction des données.

Ce que le réclassement ne résout pas encore

Un outil de réclassement s’assure que les preuves les plus solides parmi les résultats obtenus parviennent au modèle. Ce qu’il ne peut pas vous dire, c’est si une recherche vectorielle était vraiment la bonne approche. Prenons l’exemple d’une question sur le nombre d’utilisateurs actifs dans une région donnée : diriger cette question vers un index de documentation est erroné, car ce chiffre se trouve dans une base de données SQL.

La qualité de la récupération des informations comporte plusieurs niveaux. La première étape consiste à localiser le texte, la deuxième à le trier, mais une décision est prise avant ces deux étapes concernant l’endroit où chercher. Diriger une requête vers une recherche vectorielle, des requêtes SQL ou une recherche web en temps réel, en fonction de l’intention de l’utilisateur, constitue le niveau qui précède tous les autres.