Accueil / Articles

Mémoire d’agent hybride : fusion de BM25 et de recherche vectorielle avec RRF en Python

Découvrez pourquoi la recherche vectorielle pure échoue en tant que mémoire d’agent, comment Reciprocal Rank Fusion fusionne BM25 et les résultats denses en Python, et dans quels cas les résumés GraphRAG sont utiles.

Éditeur : REACTAPP.TOP7 min de lecture1651 mots

Sources: State Checkpointing & Resiliency: Fault-Tolerant ReAct Engines

Un agent qui exécute des workflows prolongés sur plusieurs jours ou semaines a besoin d’une mémoire qu’il peut interroger de manière fiable, et le choix par défaut, un stockage vectoriel recherché via la similarité des embeddings, échoue rapidement dans les domaines techniques. Les codes d’erreur et les noms de fonctions disparaissent, les questions跨 documents restent sans réponse, et les prompts se remplissent de fragments peu liés entre eux. Ce guide montre comment combiner la recherche par mots-clés et la recherche dense avec Reciprocal Rank Fusion (RRF), présente une implémentation concise en Python ainsi que ses limites, et explique dans quels cas une couche GraphRAG est utile.

Pourquoi la recherche vectorielle seule ne suffit pas

La recherche dense pure, souvent appelée RAG naïf, présente trois faiblesses récurrentes en tant que mémoire à long terme pour un agent :

  • Les identifiants exacts sont floutés. Les embeddings capturent le sens, mais ils gèrent mal les tokens précis tels que les IDs d’entités, les numéros de modèle, les IDs d’utilisateurs et les noms de fonctions. Une requête concernant un code d’erreur peut renvoyer des extraits correspondant à un code similaire.
  • Aucune vue globale. La récupération fournit des extraits isolés proches de la requête et ne parvient pas à reconstituer les relations présentes dans l’ensemble du corpus.
  • Encombrement du contexte. Coller des résultats bruts dans la demande consomme des tokens sans aucune garantie de pertinence.

La réponse habituelle des systèmes de production est structurée en plusieurs étapes : la recherche hybride exécute BM25 en parallèle des vecteurs denses, les fusionne à l’aide de RRF, et ajoute un graphe de connaissances GraphRAG pour les questions plus larges. Pour un rappel sur l’aspect vectoriel, consultez comment fonctionnent réellement les bases de données vectorielles, des embeddings à la recherche hybride.

Trois méthodes de récupération et leurs forces respectives

  • Recherche de vecteurs denses (HNSW ou index plats) transforme le texte en un espace d’embeddings et permet de détecter la similarité conceptuelle et l’intention, même en cas de différences de formulation.
  • Recherche par mots-clés épars (BM25 ou TF-IDF) évalue les correspondances exactes de tokens, ce qui est indispensable pour les codes d’erreur, les identifiants uniques, les signatures de fonctions et les noms propres.
  • GraphRAG extrait des entités et des relations pour les intégrer dans un graphe de connaissances, ce qui permet de synthétiser des questions telles que "Comment tous les services du Cluster B gèrent-ils les délais d’attente de la base de données ?" De tels graphes sont des graphes dirigés généraux, pas nécessairement acycliques.
  • La forme d’une requête de mémoire hybride

    Un agent qui consulte sa mémoire envoie une seule requête qui paramétrise les deux moteurs :

    {
      "query_id": "mem_req_99214",
      "search_text": "HTTP 504 gateway timeout postgres connection pool",
      "top_k": 5,
      "hybrid_params": {
        "dense_weight": 0.6,
        "sparse_weight": 0.4,
        "rrf_k_constant": 60
      },
      "metadata_filter": {
        "environment": "production",
        "service": "billing-service"
      }
    }
    

    En plus du texte de la requête et de top_k, le chargement transporte des poids pour chaque moteur, la constante RRF k, ainsi qu’un filtre de métadonnées restreignant les résultats à un environnement et un service donnés. Le filtrage est important : une incident en production ne doit pas être répondu avec des notes relatives à l’environnement de test.

    La réponse fusionnée renvoie des fragments notés accompagnés de leur source :

    {
      "query_id": "mem_req_99214",
      "total_retrieved": 2,
      "results": [
        {
          "chunk_id": "doc_8832_p4",
          "rrf_score": 0.0322,
          "text": "PostgreSQL connection pool exhaustion causes HTTP 504 timeouts. Increase max_connections to 200.",
          "source": "wiki/infrastructure_troubleshooting.md"
        }
      ]
    }
    

    Le rrf_score est conçu pour être faible. Avec k = 60, un élément classé premier dans une liste et troisième dans l’autre obtient la note 1/61 + 1/63, soit environ 0,032. Ces scores n’ont de sens que par rapport aux autres au sein d’une même requête, il convient donc d’éviter les seuils absolus. (L’exemple indique total_retrieved comme étant 2 mais ne montre qu’un seul résultat ; considérez-le à titre illustratif.)

    Mise en œuvre de la recherche hybride et de RRF en Python

    L’implémentation ci-dessous utilise Pydantic et asyncio avec des backends simulés, afin de démontrer la logique de fusion plutôt que d’offrir un composant directement utilisable.

    Modèles de données pour les éléments et les résultats classés

    SearchResult correspond à ce que chaque moteur renvoie, avec sa position (rank) et son score brut ; MemoryChunk est la sortie fusionnée transmise à l’agent. Conserver raw_score facilite le débogage, même si la fusion l’ignore.

    import math
    from typing import List, Dict, Any, Optional
    from pydantic import BaseModel, Field
    
    
    class MemoryChunk(BaseModel):
        chunk_id: str
        content: str
        metadata: Dict[str, Any] = Field(default_factory=dict)
        score: float = 0.0
    
    
    class SearchResult(BaseModel):
        chunk_id: str
        content: str
        rank: int
        raw_score: float
    

    Le moteur de fusion

    RRF évalue chaque document comme la somme de 1 / (k + rank) pour toutes les listes dans lesquelles il apparaît. La classe applique cette formule aux listes vectorielles et BM25, mémorise le contenu de chaque chunk, classe les résultats selon le score combiné et renvoie ceux qui se classent en tête :

    class ReciprocalRankFusionEngine:
        """
        Combines ranked result lists from Dense Vector and BM25 Sparse Search
        using the RRF algorithm: RRF_Score = sum(1 / (k + rank))
        """
        def __init__(self, k_constant: int = 60):
            self.k_constant = k_constant
    
        def fuse_rankings(
            self,
            vector_results: List[SearchResult],
            bm25_results: List[SearchResult],
            top_k: int = 5
        ) -> List[MemoryChunk]:
    
            rrf_scores: Dict[str, float] = {}
            content_map: Dict[str, str] = {}
    
            # Process Vector Search Ranks
            for item in vector_results:
                rrf_scores[item.chunk_id] = rrf_scores.get(item.chunk_id, 0.0) + (
                    1.0 / (self.k_constant + item.rank)
                )
                content_map[item.chunk_id] = item.content
    
            # Process BM25 Search Ranks
            for item in bm25_results:
                rrf_scores[item.chunk_id] = rrf_scores.get(item.chunk_id, 0.0) + (
                    1.0 / (self.k_constant + item.rank)
                )
                content_map[item.chunk_id] = item.content
    
            # Sort by combined RRF Score descending
            sorted_chunks = sorted(
                rrf_scores.items(), key=lambda x: x[1], reverse=True
            )
    
            output: List[MemoryChunk] = []
            for chunk_id, score in sorted_chunks[:top_k]:
                output.append(
                    MemoryChunk(
                        chunk_id=chunk_id,
                        content=content_map[chunk_id],
                        score=round(score, 5)
                    )
                )
    
            return output
    

    Un chunk détecté par les deux moteurs reçoit deux contributions, ce qui récompense automatiquement l’accord entre eux. Notez que les poids du payload ne sont pas utilisés : il s’agit ici d’un RRF classique. Pour que dense_weight et sparse_weight aient de l’importance, il faut multiplier la contribution de chaque liste par son poids, une variante généralement appelée RRF pondéré.

    Organisation des deux recherches

    Le récupérateur demande à chaque moteur le double du nombre de candidats nécessaires, fournissant ainsi plus de données au système de fusion, puis transmet les deux listes au moteur RRF :

    class HybridMemoryRetriever:
        """
        Orchestrates Vector and BM25 search queries in parallel and applies RRF.
        """
        def __init__(self, vector_db_client: Any, bm25_index: Any):
            self.vector_db = vector_db_client
            self.bm25_index = bm25_index
            self.rrf_engine = ReciprocalRankFusionEngine(k_constant=60)
    
        async def retrieve_memory(self, query: str, top_k: int = 5) -> List[MemoryChunk]:
            # Fetch rankings from vector space and BM25 index
            vector_hits = await self._fetch_vector_results(query, fetch_k=top_k * 2)
            bm25_hits = await self._fetch_bm25_results(query, fetch_k=top_k * 2)
    
            # Merge and re-rank using Reciprocal Rank Fusion
            fused_memory = self.rrf_engine.fuse_rankings(
                vector_results=vector_hits,
                bm25_results=bm25_hits,
                top_k=top_k
            )
    
            return fused_memory
    
        async def _fetch_vector_results(self, query: str, fetch_k: int) -> List[SearchResult]:
            # Simulated vector DB query
            return [
                SearchResult(chunk_id="doc_102", content="Postgres timeout handling guide", rank=1, raw_score=0.92),
                SearchResult(chunk_id="doc_883", content="Database connection pooling configuration", rank=2, raw_score=0.85)
            ]
    
        async def _fetch_bm25_results(self, query: str, fetch_k: int) -> List[SearchResult]:
            # Simulated BM25 keyword query
            return [
                SearchResult(chunk_id="doc_883", content="Database connection pooling configuration", rank=1, raw_score=14.2),
                SearchResult(chunk_id="doc_401", content="HTTP 504 status codes and gateways", rank=2, raw_score=11.1)
            ]
    

    La documentation promet une exécution parallèle, mais les deux appels await s’exécutent séquentiellement ; utilisez asyncio.gather pour superposer les requêtes. Les méthodes _fetch_ d’un système réel appellerait votre base de données vectorielle et votre index BM25, appliquerait le filtre de métadonnées et respecterait la valeur de fetch_k. Dans les données simulées, doc_883 apparaît dans les deux listes, ce qui le place en tête du classement fusionné bien qu’il ne figure que dans l’une d’elles.

    Ajout de GraphRAG pour les questions portant sur l’ensemble du corpus

    La recherche hybride récupère toujours des fragments locaux. Pour raisonner sur des milliers de documents, GraphRAG crée une couche structurée en trois étapes :

    • Extraction d’entités. Un processus en arrière-plan identifie des entités telles que des services, des types d’erreurs et des schémas SQL, extrait les relations explicites, puis les stocke dans une base de données graphique comme Neo4j ou dans une bibliothèque en mémoire comme NetworkX.
    • Détection de communautés. Des algorithmes de clustering tels que Leiden regroupent les nœuds étroitement connectés en une hiérarchie de communautés.
    • Résumé hiérarchique. Un LLM rédige un rapport pour chaque communauté, permettant ainsi de répondre aux questions générales à partir de ces résumés plutôt que d’analyser des millions de fragments bruts.

    Le compromis réside dans le coût initial : l’indexation nécessite de nombreuses appels à un LLM, et les résumés deviennent obsolètes lorsque les documents changent. GraphRAG s’avère avantageux pour de grands corpus relativement stables consultés avec des questions globales.

    Questions fréquentes

    Pourquoi utiliser RRF plutôt que de moyener les scores ?

    Les scores denses et spars vivent sur des échelles non liées. En pratique, la similarité cosinus se situe généralement entre 0 et 1, tandis que les scores BM25 sont illimités et dépassent souvent 50. Leur normalisation introduit des paramètres fragiles qui se déforment lorsque les données changent. RRF ne prend en compte que les positions, ce qui lui confère une robustesse sur différents ensembles de données sans nécessiter d’ajustements.

    Comment GraphRAG réduit la taille des prompts ?

    Au lieu d’insérer des dizaines de fragments bruts, disons 50, l’agent lit un ou deux résumés de communautés précalculés. On cite des économies allant jusqu’à environ 70 % en moins de tokens, mais les résultats dépendent de votre corpus et de vos requêtes, il faut donc les mesurer soi-même.

    Points clés

    • Dans les domaines techniques, associez les embeddings aux méthodes BM25 et fusionnez les classements à l’aide de RRF.
  • k = 60 est la constante habituelle ; elle atténue l’influence de tout rang supérieur isolé, de sorte que les documents bien classés dans les deux listes surpassent ceux qui occupent la première place uniquement dans l’une d’elles.
  • RRF ignore les scores bruts ; ajoutez des poids explicites si un moteur doit compter davantage, et filtrez les métadonnées avant la fusion.
  • Exécutez les deux recherches en même temps, et vérifiez que le code est bien « parallèle ».
  • Utilisez les résumés communautaires de GraphRAG pour le raisonnement à l’échelle du système sur de grandes collections, et prévoyez des coûts d’indexation.