Inicio / Artículos

Memoria de agente híbrido: Fusión de BM25 y búsqueda vectorial con RRF en Python

Aprenda por qué la búsqueda vectorial pura falla como memoria de agente, cómo Reciprocal Rank Fusion combina BM25 y resultados densos en Python, y cuándo los resúmenes de GraphRAG son útiles.

Editor: REACTAPP.TOP7 min de lectura1651 palabras

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

Un agente que ejecuta flujos de trabajo prolongados durante días o semanas necesita una memoria a la que pueda acceder de manera fiable, y la opción por defecto, un almacén de vectores buscado mediante similitud de embeddings, falla rápidamente en dominios técnicos. Los códigos de error y los nombres de funciones se pierden, las preguntas entre documentos quedan sin respuesta y los prompts se llenan de fragmentos poco relacionados. Esta guía muestra cómo combinar la recuperación por palabras clave con la recuperación densa mediante Reciprocal Rank Fusion (RRF), explica paso a paso una implementación compacta en Python y sus limitaciones, y señala dónde es útil la capa GraphRAG.

Por qué la búsqueda de vectores por sí sola no es suficiente

La recuperación densa pura, a menudo denominada RAG ingenuo, presenta tres debilidades recurrentes como memoria a largo plazo de un agente:

  • Los identificadores exactos se desdibujan. Las embeddings capturan el significado, pero manejan mal los tokens precisos como los IDs de entidades, números de modelo, IDs de usuario y nombres de funciones. Una consulta por un código de error puede devolver fragmentos relacionados con uno de apariencia similar.
  • Falta una visión global. La recuperación devuelve fragmentos aislados cercanos a la consulta y no puede establecer relaciones distribuidas en todo el corpus.
  • Aumento innecesario del contexto. Pegar resultados brutos en el prompt consume tokens sin garantía de relevancia.

La respuesta habitual de los sistemas de producción es escalonada: la búsqueda híbrida ejecuta BM25 junto con vectores densos, los fusiona mediante RRF y agrega un grafo de conocimiento GraphRAG para preguntas más amplias. Para repasar los aspectos relacionados con los vectores, consulte cómo funcionan realmente las bases de datos vectoriales, desde los embeddings hasta la búsqueda híbrida.

Tres métodos de recuperación y sus fortalezas

  • Búsqueda de vectores densos (HNSW o índices planos) mapea el texto a un espacio de embeddings y busca similitudes conceptuales e intencionales, incluso cuando la redacción difiere.
  • Búsqueda de palabras clave dispersas (BM25 o TF-IDF) evalúa coincidencias exactas de tokens, lo cual es indispensable para códigos de error, IDs únicos, firmas de funciones y nombres propios.
  • GraphRAG extrae entidades y relaciones para formar un grafo de conocimiento, siendo adecuado para sintetizar preguntas como "¿Cómo manejan todos los servicios del Clúster B los tiempos de espera de la base de datos?" Dichos grafos son grafos dirigidos generales, no necesariamente acíclicos.
  • La estructura de una consulta de memoria híbrida

    Un agente que consulta su memoria envía una solicitud que parametriza ambos motores:

    {
      "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"
      }
    }
    

    Además del texto de la consulta y de top_k, la carga útil incluye pesos para cada motor, la constante RRF k, y un filtro de metadatos que restringe los resultados a un entorno y servicio específicos. El filtrado es importante: un incidente en producción no debe responderse con notas de entorno de pruebas.

    La respuesta fusionada devuelve fragmentos puntuados junto con su fuente:

    {
      "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"
        }
      ]
    }
    

    El rrf_score está diseñado para ser bajo. Con k = 60, un fragmento clasificado primero en una lista y tercero en otra obtiene una puntuación de 1/61 + 1/63, aproximadamente 0.032. Estas puntuaciones solo son significativas en relación entre sí dentro de una misma consulta, por lo que se deben evitar umbrales absolutos. (El ejemplo muestra total_retrieved como 2, pero solo lista un resultado; trátelo como ilustrativo.)

    Implementación de búsqueda híbrida y RRF en Python

    La implementación a continuación utiliza Pydantic y asyncio con servidores backend simulados, por lo que demuestra la lógica de fusión en lugar de ser un componente listo para usar.

    Modelos de datos para fragmentos y resultados clasificados

    SearchResult es lo que devuelve cada motor, con su posición (rank) y su puntuación original; MemoryChunk es la salida fusionada que se entrega al agente. Mantener raw_score ayuda en la depuración, aunque la fusión lo ignora.

    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
    

    El motor de fusión

    RRF calcula la puntuación de cada documento como la suma de 1 / (k + rank) para cada lista en la que aparece. La clase aplica esto a las listas vectoriales y BM25, guarda el contenido de cada fragmento, ordena según la puntuación combinada y devuelve los resultados principales:

    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 fragmento detectado por ambos motores recibe dos contribuciones, por lo que el acuerdo se recompensa automáticamente. Tenga en cuenta que los pesos del payload no se utilizan: esto es RRF puro. Para que dense_weight y sparse_weight tengan importancia, multiplique la contribución de cada lista por su peso; esta variante se denomina generalmente RRF ponderado.

    Orquestación de ambas búsquedas

    El recuperador solicita a cada motor el doble de candidatos de los necesarios, proporcionando así más información para el proceso de fusión, y luego pasa ambas listas al motor 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 documentación promete una ejecución paralela, pero las dos llamadas await se realizan de forma secuencial; utilice asyncio.gather para superponer los viajes de ida y vuelta. Los métodos _fetch_ de un sistema real llamarían a su base de datos vectorial e índice BM25, aplicarían el filtro de metadatos y respetarían el valor de fetch_k. En los datos simulados, doc_883 aparece en ambas listas, por lo que ocupa el primer lugar en la clasificación fusionada a pesar de estar solo primero en una de ellas.

    Agregar GraphRAG para preguntas a nivel de todo el corpus

    La búsqueda híbrida sigue recuperando fragmentos locales. Para razonar sobre miles de documentos, GraphRAG crea una capa estructurada en tres etapas:

    • Extracción de entidades. Un proceso en segundo plano identifica entidades como servicios, tipos de errores y esquemas SQL, extrae relaciones explícitas y las almacena en una base de datos gráfica como Neo4j o en una biblioteca en memoria como NetworkX.
    • Detección de comunidades. Algoritmos de clustering como Leiden agrupan nodos estrechamente conectados en una jerarquía de comunidades.
    • Resumen jerárquico. Un LLM escribe un informe para cada comunidad, y las preguntas generales se responden a partir de esos resúmenes en lugar de analizar millones de fragmentos brutos.

    El compromiso radica en el costo inicial: la indexación requiere muchas llamadas al LLM, y los resúmenes se vuelven obsoletos a medida que cambian los documentos. GraphRAG resulta útil para corpus grandes y relativamente estables a los que se realizan consultas con preguntas globales.

    Preguntas frecuentes

    ¿Por qué usar RRF en lugar de promediar las puntuaciones?

    Las puntuaciones densas y dispersas operan en escalas no relacionadas. En la práctica, la similitud coseno suele situarse entre 0 y 1, mientras que las puntuaciones BM25 no tienen límite y a menudo superan los 50. Normalizarlas introduce parámetros frágiles que se alteran cuando cambian los datos. RRF utiliza únicamente las posiciones, lo que le confiere robustez en diferentes conjuntos de datos sin necesidad de ajustes.

    ¿Cómo reduce GraphRAG el tamaño del prompt?

    En lugar de insertar docenas de fragmentos brutos, digamos 50, el agente lee uno o dos resúmenes de comunidades precalculados. Se mencionan ahorros de hasta un 70 por ciento menos en tokens, pero los resultados dependen de su corpus y consultas, así que mézcalos usted mismo.

    Puntos clave

    • En campos técnicos, combine los embeddings con BM25 y fusione las clasificaciones con RRF.
  • k = 60 es la constante habitual; atenúa cualquier posición de primer lugar, por lo que los documentos que obtienen buenas calificaciones en ambas listas superan a aquellos que están en primer lugar solo en una de ellas.
  • RRF ignora las puntuaciones brutas; añada pesos explícitos si algún motor debe tener más importancia, y filtre los metadatos antes de la fusión.
  • Ejecute ambas búsquedas de forma concurrente y verifique que realmente sea un proceso “paralelo”.
  • Utilice los resúmenes de la comunidad GraphRAG para el razonamiento a nivel del sistema sobre grandes colecciones, y tenga en cuenta el costo de indexación.