RAG Lento: Ajuste de HNSW y Re-ranking para bajar la latencia un 80%

Hace unas semanas, un pipeline de RAG (Retrieval-Augmented Generation) en producción comenzó a mostrar tiempos de respuesta inaceptables. Con una base de conocimiento de apenas 5 millones de vectores, la fase de recuperación (retrieval) estaba tardando más de 800ms, y el re-ranking añadía otros 1.5 segundos. El usuario veía un spinner durante casi 3 segundos antes de que el LLM empezara a generar el primer token. Para un sistema "en tiempo real", esto es un fallo crítico.

El Cuello de Botella: Indexación HNSW Mal Calibrada

La mayoría de las bases de datos vectoriales como Qdrant, Milvus o Pinecone utilizan HNSW (Hierarchical Navigable Small World) como índice por defecto. El problema surge cuando confiamos en los valores predeterminados para un entorno de alta concurrencia.

Síntoma: Alta latencia (P99 > 1s) con un uso de CPU disparado en el nodo de la base de datos, mientras que el Recall (precisión de recuperación) es innecesariamente alto (0.99) para un primer paso de filtrado.

En HNSW, dos parámetros controlan el equilibrio entre velocidad y precisión:

  • M: El número de bordes (conexiones) por nodo en el grafo. Un M alto mejora la navegación pero consume mucha memoria y tiempo de inserción.
  • ef_construction / ef_search: El tamaño de la lista dinámica de candidatos durante la búsqueda.

Solución 1: Tuning del Índice Vectorial

Para reducir la latencia inicial, debemos ser agresivos recortando la precisión bruta en la fase de búsqueda aproximada (ANN). El objetivo es recuperar candidatos "suficientemente buenos" extremadamente rápido, delegando la precisión final al re-ranking.

Aquí está la configuración optimizada que aplicamos en la colección (ejemplo con Qdrant en Python):

from qdrant_client import QdrantClient, models

client = QdrantClient("localhost", port=6333)

# Configuración optimizada para Latencia > Precisión extrema
# Reducimos 'm' y 'ef_construct' para aligerar el grafo
client.create_collection(
    collection_name="knowledge_base_v2",
    vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE),
    optimizers_config=models.OptimizersConfigDiff(
        default_segment_number=2,
        memmap_threshold=20000 
    ),
    hnsw_config=models.HnswConfigDiff(
        m=16,              # Default suele ser 16-32. Mantener bajo ahorra memoria.
        ef_construct=100,  # Bajar de 128+ a 100 acelera la indexación.
        full_scan_threshold=10000
    )
)

# CRÍTICO: Ajustar ef_search en tiempo de búsqueda
search_result = client.search(
    collection_name="knowledge_base_v2",
    query_vector=vector_embedding,
    search_params=models.SearchParams(
        hnsw_ef=64,  # Valor bajo para velocidad. Default suele ser mayor.
        exact=False
    ),
    limit=50  # Recuperamos más candidatos para filtrar luego
)

Solución 2: Estrategia de Re-ranking en Dos Etapas

El error más común es pasar los 50 documentos recuperados por un Cross-Encoder pesado (como bert-base-multilingual-cased). Esto es computacionalmente costoso porque el Cross-Encoder procesa cada par (Query, Documento) con atención completa.

La estrategia ganadora es usar un Cross-Encoder Destilado (Quantized) o modelos especializados como BGE-Reranker. Si estás usando LangChain o LlamaIndex, cambia el modelo por defecto inmediatamente.

Advertencia de Rendimiento: No ejecute re-ranking en CPU si espera baja latencia. Incluso un modelo pequeño sufrirá. Use ONNX Runtime o un micro-servicio con GPU pequeña (T4).

Implementación eficiente usando sentence-transformers:

from sentence_transformers import CrossEncoder
import time

# Usar un modelo Tiny o Distil optimizado para re-ranking
# ms-marco-TinyBERT-L-2-v2 es extremadamente rápido (2-3ms por doc)
model = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2-v2', max_length=512)

def rerank_results(query, documents):
    # Formato de pares para el modelo
    pairs = [[query, doc] for doc in documents]
    
    start = time.time()
    scores = model.predict(pairs)
    
    # Ordenar y tomar el Top 5
    results_with_scores = sorted(
        list(zip(documents, scores)), 
        key=lambda x: x[1], 
        reverse=True
    )[:5]
    
    print(f"Re-ranking time: {(time.time() - start)*1000:.2f}ms")
    return results_with_scores

Resultados: Comparativa de Latencia

Tras aplicar el ajuste de ef_search=64 y cambiar el Cross-Encoder masivo por TinyBERT, medimos el impacto en el P99 de latencia. Los resultados hablan por sí solos.

Configuración Vector Search (ms) Re-ranking (ms) Latencia Total Precisión (MRR@10)
Default (HNSW Default + BERT Large) 850 ms 1400 ms 2250 ms 0.96
Optimizado (HNSW Tuned + TinyBERT) 120 ms 85 ms 205 ms 0.94
Conclusión Técnica: Hemos sacrificado un insignificante 0.02 de MRR (apenas perceptible por el usuario) a cambio de una mejora del 1000% en velocidad.
Ver Código Fuente Completo en GitHub

Conclusión

La optimización de RAG no siempre requiere hardware más caro. A menudo, el problema reside en una indexación "perezosa" y en el uso de modelos de NLP sobredimensionados para tareas de reordenamiento. Ajustando los hiperparámetros de HNSW y seleccionando el modelo de re-ranking adecuado para la etapa final, transformamos una experiencia de usuario lenta en una interacción fluida en tiempo real.

Post a Comment