Blogs / Búsqueda Híbrida: Combinando BM25 y Vectores en Postgres

Búsqueda Híbrida: Combinando BM25 y Vectores en Postgres

Publicado
24 de septiembre de 2026
Autor
Faizan Nadeem
Etiquetas
RAG PostgreSQL AI Engineering Backend Development
A magnifying glass resting beside a gray laptop computer
Photo by Agence Olloweb on Unsplash

La promesa de la búsqueda vectorial es que entiende el significado, no solo las palabras clave — pregunta por “cancelar una suscripción” y encontrará el pasaje sobre “terminar tu plan” aunque las palabras no coincidan. Eso es verdad, y también es la razón por la que la recuperación solo con vectores falla silenciosamente en las consultas que más importan en la práctica: las que tienen un código de error específico, un SKU de producto, el nombre de una persona, o una clave de configuración. Los embeddings son buenos en sinonimia y malos en exactitud, porque el propósito mismo de un embedding es comprimir tokens específicos en un vecindario semántico difuso — precisamente la propiedad que hace que ERR_429_RATE_LIMIT se vea casi igual que ERR_500_INTERNAL para un retriever denso.

La solución no es abandonar la búsqueda vectorial — es dejar de tratarla como el único mecanismo de recuperación. BM25, el algoritmo de ranking por palabras clave que tiene décadas de vida y está detrás de la mayoría de los motores de búsqueda de texto completo, es excepcional en exactamente lo que los embeddings son débiles: coincidencias exactas de términos, tokens raros, identificadores, acrónimos. Ejecutar ambos en paralelo y combinar los resultados — búsqueda híbrida — cubre el punto ciego de cada método con la fortaleza del otro. Y lo importante: no necesitas Elasticsearch ni un clúster de búsqueda separado para hacerlo. Postgres tiene un motor de búsqueda de texto completo incorporado y capaz desde la versión 8.3, y con pgvector para embeddings, ambos caminos de recuperación pueden vivir en la misma tabla, la misma consulta y la misma transacción.

Aprenderás:

  • Por qué la recuperación vectorial pura falla sistemáticamente en consultas de términos exactos y estilo identificador
  • Cómo funciona realmente la búsqueda de texto completo incorporada de Postgres (tsvector, ts_rank, ranking estilo BM25)
  • Cómo combinar scores de BM25 y vectores usando reciprocal rank fusion (RRF), con SQL ejecutable
  • Cómo ejecutar ambos caminos de recuperación en una sola consulta de Postgres sin infraestructura de búsqueda separada
  • Cómo ajustar la constante k de RRF y los pesos por fuente para tu propio mix de consultas
  • Dónde la búsqueda híbrida sigue siendo insuficiente, y qué añadir encima de ella

Tabla de Contenidos

  1. Por qué la recuperación solo vectorial pierde términos exactos
  2. BM25 y búsqueda de texto completo en Postgres
  3. Búsqueda vectorial en la misma base de datos con pgvector
  4. Reciprocal Rank Fusion: Combinando dos rankings en uno
  5. RRF vs. normalización de scores
  6. Una sola consulta híbrida en Postgres
  7. Un ejemplo concreto: Una consulta, dos rankings
  8. Ajustando RRF: La constante k y los pesos por fuente
  9. Dónde la búsqueda híbrida sigue teniendo limitaciones
  10. Notas para producción

Por qué la recuperación solo vectorial pierde términos exactos

Un modelo de embedding mapea texto a un espacio vectorial continuo donde los conceptos semánticamente relacionados quedan cerca entre sí. Esa es una compresión deliberada y útil — pero compresión significa que se descarta información, y lo que típicamente se descarta primero es la forma exacta de superficie de los tokens raros. Considera tres tipos de consultas reales que fallan bajo la recuperación solo vectorial:

  • Identificadores y códigos: INVOICE-2026-004471, SKU-88213, el nombre de la clase de excepción de un stack trace. Estos no tienen vecindario semántico — el modelo nunca ha visto este token exacto suficientes veces como para haber aprendido algo significativo sobre él, así que su embedding es esencialmente ruido respecto a identificadores de apariencia similar.
  • Términos técnicos raros: un nombre de método API específico, un flag de configuración, un compuesto químico. Si los datos de entrenamiento del modelo de embedding apenas lo cubrieron, su posición vectorial no es confiable.
  • Negación y coincidencia exacta de frases: “do NOT use --force” y “always use --force” pueden embeberse como vectores casi idénticos, porque las palabras circundantes dominan la representación y los tokens de negación cortos contribuyen poco al vector general.

BM25 no tiene estos problemas, porque no está intentando entender el significado en absoluto — puntúa documentos por frecuencia de términos (cuántas veces aparece un término de consulta en un documento) ponderado contra la frecuencia inversa de documentos (cuán raro es ese término en todo el corpus), con normalización de longitud para que los documentos largos no ganen solo por contener más palabras. Un documento que contiene la cadena literal INVOICE-2026-004471 obtendrá el score más alto para esa consulta exacta cada vez, de forma determinista, sin ningún modelo de embedding en el proceso. Esta es exactamente la clase de fallo tratada con más profundidad en por qué RAG recupera los chunks incorrectos — la asimetría consulta-documento y los puntos ciegos de los embeddings son una de las causas raíz más comunes de un pipeline de recuperación que parece roto pero en realidad solo le falta este segundo camino de recuperación.

BM25 y búsqueda de texto completo en Postgres

La búsqueda de texto completo nativa de Postgres no es literalmente la fórmula BM25 — usa ts_rank / ts_rank_cd, que es similar a BM25 (basado en frecuencia de términos y cobertura) pero no incluye por defecto el término de frecuencia inversa de documentos de BM25 ni su curva específica de normalización de longitud. Para scoring genuino de BM25, la extensión pg_search (a través de ParadeDB) o el ecosistema vectorscale/pgvectorscale lo proveen directamente. Para muchos casos de uso de búsqueda híbrida, ts_rank_cd sobre un tsvector bien configurado se acerca suficientemente como para que la distinción no importe a menos que estés comparando la calidad de búsqueda directamente contra un motor de búsqueda dedicado. Este post usa la búsqueda de texto completo incorporada de Postgres porque no requiere infraestructura adicional — el objetivo es un pipeline híbrido que viva completamente dentro de una instancia normal de Postgres.

La configuración comienza con una columna tsvector generada y un índice GIN sobre ella:

ALTER TABLE documents ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (to_tsvector('english', coalesce(body, ''))) STORED;

CREATE INDEX documents_search_idx ON documents USING GIN (search_vector);

Consulta con ranking:

SELECT id, body, ts_rank_cd(search_vector, query) AS bm25_score
FROM documents, plainto_tsquery('english', 'rate limit error') AS query
WHERE search_vector @@ query
ORDER BY bm25_score DESC
LIMIT 20;

to_tsvector maneja el stemming y la eliminación de stopwords automáticamente (en inglés, “canceling”, “cancels” y “canceled” se reducen al mismo lexema), lo cual es parte de por qué la búsqueda de texto completo sola aún recupera algo de la sinonimia que un match de substring ingenuo perdería — solo que no casi tanto como lo hace un embedding para frases genuinamente diferentes.

Búsqueda vectorial en la misma base de datos con pgvector

pgvector agrega un tipo de vector nativo y un índice de vecino más cercano aproximado (IVFFlat o HNSW) directamente a Postgres:

ALTER TABLE documents ADD COLUMN embedding vector(1536);

CREATE INDEX documents_embedding_idx ON documents
    USING hnsw (embedding vector_cosine_ops);
SELECT id, body, 1 - (embedding <=> :query_embedding) AS vector_score
FROM documents
ORDER BY embedding <=> :query_embedding
LIMIT 20;

El operador <=> es la distancia coseno; 1 - distancia lo convierte a un score de similitud para mantener consistencia con ts_rank_cd, que ya es un score de “mayor es mejor”. Ambos índices — GIN para texto completo, HNSW para vectores — coexisten en la misma tabla, que es precisamente el punto: sin duplicar datos en un segundo sistema, sin mantener sincronizadas dos bases de datos, sin un segundo punto de fallo.

Reciprocal Rank Fusion: Combinando dos rankings en uno

La parte difícil de la búsqueda híbrida nunca fue ejecutar dos consultas — es combinar dos scores incomparables en un solo ranking. Los scores de BM25 y los scores de similitud coseno viven en escalas y distribuciones completamente diferentes; un score BM25 de 8.2 y una similitud coseno de 0.81 no son directamente comparables, y promediarlos ingenuamente deja que cualquier score que tenga un rango numérico más grande domine la combinación.

Reciprocal rank fusion evita completamente el problema de escala descartando los scores brutos y usando en su lugar la posición de ranking de cada resultado:

RRF_score(doc) = Σ  1 / (k + rank_i(doc))

sumado sobre cada método de recuperación i que devolvió doc, donde rank_i(doc) es la posición con índice 1 del documento en los resultados del método i, y k es una constante de suavizado (60 es el valor predeterminado estándar del paper original de RRF) que aplana la curva para que la diferencia entre el rango 1 y el rango 2 no domine el score.

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return dict(sorted(scores.items(), key=lambda kv: kv[1], reverse=True))

Un documento que se posiciona bien en ambos — BM25 y búsqueda vectorial — acumula un score combinado más alto que un documento que queda primero solo en uno — exactamente el comportamiento deseado: el acuerdo fuerte entre dos señales independientes es más confiable que una señal fuerte de un solo método.

RRF vs. normalización de scores

RRF no es la única manera de combinar dos rankings, y vale la pena saber a qué renuncia en comparación con la alternativa. El otro enfoque común es la normalización de scores: normalizar min-max o z-score los scores brutos de cada método a un rango común de 0–1, luego tomar una suma ponderada de los scores normalizados directamente, en lugar de descartarlos y usar la posición de ranking.

def minmax_normalize(scores: dict[str, float]) -> dict[str, float]:
    lo, hi = min(scores.values()), max(scores.values())
    span = (hi - lo) or 1.0
    return {doc_id: (s - lo) / span for doc_id, s in scores.items()}

La normalización conserva más información que RRF — preserva cuánto mejor fue el resultado superior, no solo que fue el primero —, lo que puede importar cuando el resultado principal de un método es claramente superior y el siguiente está muy por detrás. El costo es fragilidad: los scores de BM25 en particular tienen una distribución que cambia con el corpus y la rareza de términos de la consulta, por lo que un rango de normalización calculado por consulta puede comportarse inconsistentemente entre consultas de maneras difíciles de detectar sin monitoreo cercano. El enfoque de RRF basado solo en rango es menos expresivo pero mucho más robusto ante exactamente ese tipo de deriva, lo que lo convierte en el predeterminado más común para búsqueda híbrida en la práctica — recurre a la normalización de scores de forma deliberada, cuando hayas verificado que tus distribuciones de scores son suficientemente estables para justificarlo, no como predeterminado.

Una sola consulta híbrida en Postgres

Ambos caminos de recuperación y la fusión pueden expresarse como una sola consulta SQL usando CTEs, de modo que un único round-trip a Postgres devuelve el conjunto de resultados final con ranking híbrido:

WITH bm25_results AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_vector, query) DESC) AS rank
    FROM documents, plainto_tsquery('english', :query_text) AS query
    WHERE search_vector @@ query
    LIMIT 50
),
vector_results AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :query_embedding) AS rank
    FROM documents
    ORDER BY embedding <=> :query_embedding
    LIMIT 50
),
fused AS (
    SELECT COALESCE(b.id, v.id) AS id,
           COALESCE(1.0 / (60 + b.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score
    FROM bm25_results b
    FULL OUTER JOIN vector_results v ON b.id = v.id
)
SELECT d.id, d.body, f.rrf_score
FROM fused f
JOIN documents d ON d.id = f.id
ORDER BY f.rrf_score DESC
LIMIT 10;

El FULL OUTER JOIN es lo que hace esto correcto: un documento que solo BM25 encontró (porque contiene un identificador exacto que ningún embedding ranquearía alto) aún obtiene un score RRF distinto de cero y una oportunidad de aparecer, y lo mismo aplica simétricamente para un documento encontrado solo por la búsqueda vectorial. Ampliar cada conjunto de candidatos a 50 antes de fusionar (en lugar de fusionar solo el top-10 de cada método) importa — un documento que habría quedado en el puesto 30 por similitud vectorial pero en el puesto 2 por BM25 necesita estar en el pool de candidatos vectoriales para que el paso de fusión lo vea.

Un ejemplo concreto: Una consulta, dos rankings

Para hacer concreta la combinación, aquí hay un trace ilustrativo (construido a mano, no de un sistema en producción) para la consulta “rate limit error” contra una tabla pequeña de documentos de soporte:

DocumentoRango BM25Rango vectorialPor qué
doc_412 — “Handling ERR_429_RATE_LIMIT”19Contiene el término literal adyacente al código de error “rate limit”, pero su redacción es concisa y cargada de código, por lo que se embebe más lejos de una consulta conversacional
doc_88 — “Throttling and backoff strategies”141Semánticamente sobre el mismo problema, pero nunca usa las palabras “rate” o “limit” juntas
doc_203 — “API quotas overview”34Usa “limit” y es temáticamente adyacente en ambas señales

Fusionando con k = 60: doc_412 obtiene 1/(60+1) + 1/(60+9) = 0.0164 + 0.0145 = 0.0309; doc_88 obtiene 1/(60+14) + 1/(60+1) = 0.0135 + 0.0164 = 0.0299; doc_203 obtiene 1/(60+3) + 1/(60+4) = 0.0159 + 0.0156 = 0.0315. doc_203 supera a ambos líderes de método único porque se posicionó respetablemente en ambas señales — exactamente el comportamiento de “el acuerdo supera a una señal fuerte única” que RRF está diseñado para producir. Observa también que ni doc_412 ni doc_88 habrían aparecido de forma confiable cerca del tope bajo el otro método solo; un pipeline solo vectorial entierra el doc_412 de coincidencia exacta en el rango 9, y un pipeline solo BM25 entierra el doc_88 semánticamente relevante en el rango 14. Esa es la forma concreta del fallo que la búsqueda híbrida existe para cerrar.

Ajustando RRF: La constante k y los pesos por fuente

El predeterminado k = 60 es un punto de partida razonable, no una constante universal. Los valores menores de k afilan la curva, dando a los resultados de rango 1 una influencia desproporcionada sobre el score fusionado relativo al rango 10 — útil si tus resultados top de BM25 y vectorial suelen ser genuinamente mejores que el resto del pool de candidatos. Los valores mayores de k la aplanan, útil cuando los rankings dentro de cada método son ruidosos y no quieres que la fusión reaccione exageradamente a la elección principal de un solo método.

RRF tal como está escrito arriba trata a BM25 y la búsqueda vectorial como igualmente confiables, lo que no será cierto para todo mix de consultas. Una variante ponderada te permite inclinar el balance:

COALESCE(1.5 / (60 + b.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score

Un peso de 1.5x en el término BM25 sesga el ranking fusionado hacia resultados de coincidencia exacta — apropiado para un corpus con mucho contenido de identificadores y cargado de código (documentación de API, búsqueda de logs, sistemas de tickets), donde la precisión de palabras clave suele importar más que la amplitud semántica. Un corpus de documentos largos y conversacionales (transcripciones de soporte, artículos de base de conocimiento) se beneficia más frecuentemente de inclinar el peso en la otra dirección. La única manera confiable de elegir estos pesos es la misma metodología de eval set usada para comparaciones de estrategias de chunking — construye un conjunto de consultas de tu propio corpus, mide recall@k bajo algunas combinaciones de pesos, y deja que los datos decidan en lugar de portar un predeterminado del sistema de alguien más.

Dónde la búsqueda híbrida sigue teniendo limitaciones

La búsqueda híbrida resuelve el problema específico de que la búsqueda vectorial pierde términos exactos — no resuelve problemas que viven antes o después de la recuperación. Si los límites de chunk separan un identificador de la oración que explica qué significa, BM25 aún puede encontrar el chunk que contiene el identificador, pero el LLM que genera una respuesta de ese chunk trabaja con el mismo contexto incompleto de todas formas. Ampliar el pool de candidatos a 50 por método antes de fusionar también significa más candidatos para un reranker que los ordene si estás usando uno — ve los compromisos en reranking en RAG para determinar cuándo ese paso extra vale la latencia adicional sobre un conjunto de candidatos de búsqueda híbrida. Y la búsqueda híbrida recupera dentro del conjunto de documentos al que la consulta está limitada — no dice nada sobre si ese alcance en sí mismo es correcto, lo que importa enormemente en cualquier sistema que sirva a más de un tenant.

Notas para producción

Algunas cosas que importan una vez que esto supera el prototipo:

  • Reconstruye el índice GIN de forma concurrente en tablas grandes. CREATE INDEX CONCURRENTLY evita bloquear escrituras durante la creación del índice, lo que importa una vez que documents tiene más de unos pocos cientos de miles de filas.
  • El tiempo de construcción y la memoria de HNSW escalan con el volumen de datos. Configura maintenance_work_mem generosamente durante la creación del índice, y presupuesta tiempo real para ello en tablas con millones de filas — no es instantáneo como una reconstrucción GIN en volúmenes de texto modestos.
  • Limita los tamaños del pool de candidatos deliberadamente. Fusionar sobre el top-50 de cada método (en lugar de 10 o 500) es un control de ajuste real — demasiado pequeño y te pierdes los desacuerdos entre métodos que la fusión debe capturar; demasiado grande y estás pagando por un scan vectorial y cálculo de rango sobre candidatos que nunca aparecerán.
  • plainto_tsquery es el predeterminado seguro para entrada de usuario. No soporta los operadores de consulta booleana de Postgres (&, |, !), lo que significa que tampoco puede romperse por entradas de usuario que los contengan — usa websearch_to_tsquery solo si específicamente quieres exponer soporte de frases entre comillas y exclusiones a los usuarios finales.
  • Vuelve a ejecutar el eval set después de cada cambio de modelo de embedding o tokenizador. Un pipeline híbrido tiene dos partes móviles que pueden regresar independientemente; cambiar una sin volver a medir ambos scores arriesga desplazar silenciosamente el balance de fusión que ajustaste.

Conclusión

La búsqueda vectorial y BM25 no son enfoques competidores para el mismo problema — están resolviendo dos problemas diferentes que resultan llamarse ambos “búsqueda”. Uno entiende el significado y pierde tokens exactos; el otro coincide con tokens exactos y no entiende nada sobre el significado. Reciprocal rank fusion es una forma genuinamente simple de combinarlos sin necesidad de reconciliar dos escalas de puntuación incomparables, y Postgres — con tsvector y pgvector viviendo en la misma tabla — significa que la búsqueda híbrida no requiere montar un segundo clúster de búsqueda para lograrlo. Si tu pipeline RAG es solo vectorial hoy, la ganancia de corrección más rápida disponible no es un modelo de embedding más grande — es darle a BM25 un lugar en la misma mesa, literalmente.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Más artículos