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.
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.
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.