Aller au contenu principal

Pipeline RAG multi-index

Mis à jour le 30 juillet 2026

Nous avons deux moteurs de recherche qui fonctionnent indépendamment — sémantique et BM25 — et une promesse : les combiner. L’étape suivante consiste donc à les fondre en un pipeline unifié, capable d’interroger les deux et de rendre un classement unique. Le nœud du problème n’est pas d’exécuter deux recherches, c’est de fusionner leurs résultats.

Reciprocal Rank Fusion (RRF)

Pourquoi une méthode dédiée ? Parce que les deux moteurs parlent des langues de score incompatibles : similarité cosinus d’un côté, scores de pertinence BM25 de l’autre — des échelles sans rapport, qu’on ne peut pas simplement additionner. Le Reciprocal Rank Fusion contourne l’obstacle avec une idée élégante : ignorer les scores bruts et se baser sur la position dans chaque classement. Être premier vaut beaucoup, deuxième un peu moins, et ainsi de suite — quelle que soit la magnitude du score qui a valu cette place.

Exemple

Recherche vectorielle retourne : sections 2, 7, 6 BM25 retourne : sections 6, 2, 7

Formule RRF : RRF_score(d) = sum(1 / (k + rank_i(d)))

Avec k=1 :

  • Section 2 : 1/(1+1) + 1/(1+2) = 0.833
  • Section 7 : 1/(1+2) + 1/(1+3) = 0.583
  • Section 6 : 1/(1+3) + 1/(1+1) = 0.75

Classement final : Section 2, Section 6, Section 7.

Lisez ce que la formule récompense : la section 2 gagne parce qu’elle est bien classée des deux côtés — première en vectoriel, deuxième en BM25. La section 6, première en BM25 mais dernière en vectoriel, passe devant la 7, moyenne partout. Le consensus entre moteurs pèse plus qu’un exploit isolé — exactement l’intuition qu’on attend d’une fusion. La constante k amortit l’écart entre les premiers rangs ; en pratique, on la fixe souvent à 60, une valeur devenue standard dans la littérature.

Implémentation du Retriever

Côté code, la fusion s’incarne dans une classe façade :

class Retriever:
    def __init__(self, *indexes):
        self._indexes = list(indexes)

    def add_document(self, document):
        for index in self._indexes:
            index.add_document(document)

    def search(self, query_text, k=1, k_rrf=60):
        all_results = [index.search(query_text, k) for index in self._indexes]
        # Logique de fusion RRF...

C’est ici que l’interface commune des leçons précédentes paie : le Retriever accepte n’importe quels index — vectoriel, BM25, les deux, d’autres encore — pourvu qu’ils exposent add_document() et search(). add_document propage chaque document vers tous les index, qui l’indexent chacun à leur manière ; search interroge tout le monde, puis fusionne les classements par RRF. Le reste de l’application ne voit qu’un moteur de recherche unique — elle ignore combien d’index travaillent derrière la façade.

Ce que cette architecture apporte

Ce petit design concentre plusieurs vertus. La modularité : chaque index reste indépendant, développé et testé isolément. L’extensibilité : ajouter une nouvelle méthode de recherche revient à implémenter la même interface et à la passer au constructeur — le Retriever n’y change pas une ligne, et le RRF fusionne trois classements aussi bien que deux. Et surtout la précision : la combinaison couvre les deux angles morts à la fois, compréhension sémantique pour les questions reformulées, correspondance de mots-clés pour les identifiants exacts. Notre pipeline RAG interroge désormais les documents avec les deux moteurs en même temps — et rend un seul classement, meilleur que chacun des deux.

Points clés à retenir

  • Les scores cosinus et BM25 sont incomparables : la fusion se fait sur les rangs, pas sur les scores
  • RRF : somme des 1/(k + rang) dans chaque classement — le consensus entre moteurs l’emporte
  • La constante k (souvent 60) amortit les écarts entre premiers rangs
  • Le Retriever façade propage l’indexation et fusionne les recherches — l’application ne voit qu’un moteur
  • Grâce à l’interface commune, ajouter un index = implémenter add_document/search, rien de plus