volver al blog

Búsqueda híbrida en RAG: cuando el vector no basta

ragbúsquedaembeddings

El día que el vector no encontró “ERR_2041”

Tienes un RAG que funciona. Preguntas en lenguaje natural y recupera el fragmento adecuado con una soltura que sigue impresionando. Hasta que un usuario busca ERR_2041, el código exacto de un error que aparece literal en la documentación, y el sistema devuelve tres párrafos sobre “gestión de errores” y ninguno con el código. Estaba en el corpus. El vector no lo trajo.

No es un fallo de tu índice ni de tu modelo de embeddings. Es el punto ciego estructural de la búsqueda semántica: entiende el significado y por eso mismo se despista con lo que no tiene significado. Un código, una referencia, un nombre propio, un SKU. Para el embedding, ERR_2041 y ERR_2043 viven casi en el mismo punto del espacio, porque semánticamente son lo mismo: dos códigos de error. Y ahí es donde el clásico que dabas por muerto vuelve a la mesa.

Dos formas de buscar que fallan en lo contrario

La búsqueda por palabras clave y la búsqueda por vectores no son versiones vieja y nueva de lo mismo. Son dos señales distintas que aciertan y fallan en sitios opuestos.

BM25, el algoritmo léxico que lleva décadas moviendo buscadores, cuenta coincidencias de términos y las pondera por rareza: cuanto menos común es una palabra, más pesa que aparezca. Es literal por diseño. Encuentra ERR_2041 clavado porque busca esa cadena, no su sentido. Lo que no sabe es que “coche” y “automóvil” son lo mismo: si el usuario no usa la palabra exacta del documento, BM25 no lo ve.

La búsqueda por vectores hace justo lo contrario. Comprime pregunta y documento en embeddings y compara distancias, así que “cómo reduzco la factura de la luz” recupera un texto sobre “bajar el consumo eléctrico” aunque no compartan una sola palabra. Entiende el sinónimo, la paráfrasis, la intención. Y por eso mismo se difumina en lo literal: los identificadores, los términos raros y los nombres propios se le escurren.

Ninguna de las dos gana siempre. La una acierta donde la otra falla. La búsqueda híbrida no elige: usa ambas y combina lo que devuelven.

El problema real es fusionar, no buscar

Lanzar las dos búsquedas es la parte fácil. Cualquier motor decente hace BM25 y vectorial en la misma consulta. Lo difícil es juntar los dos resultados, porque hablan idiomas distintos: BM25 puntúa con números sin techo (un 8,3 aquí, un 22,1 allá, según los términos), mientras que la similitud vectorial se mueve entre 0 y 1. Normalizar y sumar esas escalas es frágil y hay que recalibrarlo cada vez que cambian los datos.

El patrón que uso para evitar ese barrizal es Reciprocal Rank Fusion (RRF). La idea es dejar de mirar las puntuaciones y mirar solo las posiciones. A cada documento le importa en qué puesto quedó en cada lista, no con qué nota. Un documento que sale primero en léxica y tercero en vectorial suma más que uno que aparece décimo en ambas. La fórmula es de una línea: por cada lista donde aparece el documento, sumas 1 / (k + posición), con una k pequeña (60 es el valor de referencia) que suaviza el peso desmesurado de los primeros puestos.

def reciprocal_rank_fusion(listas_ranking: list[list[str]], k: int = 60) -> list[str]:
    """Fusiona varias listas ordenadas de IDs en un único ranking.
    Solo mira posiciones, no las puntuaciones originales: así evitamos
    normalizar escalas incompatibles (BM25 sin techo vs coseno 0-1)."""
    puntuaciones: dict[str, float] = {}
    for ranking in listas_ranking:
        for posicion, doc_id in enumerate(ranking):
            # posicion empieza en 0; k amortigua el peso de los primeros puestos.
            puntuaciones[doc_id] = puntuaciones.get(doc_id, 0.0) + 1 / (k + posicion)
    # De mayor a menor puntuación fusionada.
    return sorted(puntuaciones, key=puntuaciones.get, reverse=True)


# Fase 1: cada motor recupera de más, por su cuenta.
lexica   = bm25.buscar(pregunta, top_k=20)       # ["doc-ERR2041", "doc-17", ...]
vectorial = indice.buscar(pregunta, top_k=20)    # ["doc-consumo", "doc-42", ...]

# Fase 2: RRF los funde sin tocar sus escalas.
fusionados = reciprocal_rank_fusion([lexica, vectorial])[:8]

Lo elegante de RRF es que no tiene nada que calibrar. No hay pesos que ajustar a mano ni escalas que normalizar cada vez que cambia el corpus. Un documento que ambas búsquedas colocan arriba sube; uno que solo una encuentra, pero muy arriba, también entra. Es tosco y funciona, que suele ser la mejor combinación en producción.

Qué elegir según el caso

No siempre necesitas las dos. La decisión depende de qué tipo de texto guardas y de cómo pregunta la gente.

BM25 (léxica)Vectorial (semántica)Híbrida (RRF)
Acierta enTérminos exactos, códigos, nombres propiosSinónimos, paráfrasis, intenciónAmbos a la vez
Falla enSinónimos, reformulacionesIdentificadores, términos rarosCasi nada, pero cuesta más
CosteMuy bajoMedio (embeddings + índice)Suma de los dos + fusión
Infra extraÍndice invertidoBase de datos vectorialLas dos + capa de fusión
CuándoCorpus técnico, mucho código y jergaLenguaje natural, dominio homogéneoDocumentación mixta: prosa con referencias

La regla que sigo: si tu corpus es prosa razonablemente uniforme y la gente pregunta con sus palabras, la búsqueda vectorial sola ya te lleva lejos y añadir léxica es complejidad de más. Si guardas documentación técnica —manuales con códigos de error, referencias de API, nombres de producto mezclados con explicaciones en prosa— la híbrida deja de ser un lujo. Ese es exactamente el terreno donde el vector se queda a medias.

Híbrida y reranking no son lo mismo

Conviene no confundir dos piezas que a veces aparecen juntas. La búsqueda híbrida amplía el recall: mete en la lista candidatos que una sola señal habría dejado fuera, como el fragmento del ERR_2041. El reranking mejora la precisión del orden: coge esos candidatos y decide cuál va arriba del todo. Resuelven problemas distintos y se combinan de maravilla. El pipeline completo que monto cuando la calidad importa de verdad es en tres tiempos: recuperación híbrida para no perder nada, RRF para fundir, y un reranker al final para afinar el orden de los que sobreviven.

Por dónde empezar

Si ya tienes un RAG vectorial y notas que se atraganta con lo literal —códigos, referencias, nombres exactos que están en el corpus pero no salen—, no reentrenes nada todavía. Añade un índice BM25 en paralelo (la mayoría de motores, de Elasticsearch a Postgres con pg_trgm, lo traen de fábrica), lanza las dos búsquedas y fúndelas con RRF. Son unas pocas decenas de líneas y cero calibración.

Mídelo con tu propio conjunto de preguntas, incluyendo a propósito las que llevan un código o un nombre propio, que son las que delatan al vector. Si tras la híbrida esos fragmentos empiezan a aparecer donde antes no salían, ya tienes la respuesta. La búsqueda semántica fue un salto enorme, pero no jubiló a la léxica: la volvió la mitad que faltaba.