Desarrollo IATutorial

Búsqueda Híbrida y Reranking Dentro de Postgres: Medí el Recall, No Adivines

14 de octubre de 2026
14 min de lectura
Dos corrientes de luz que se funden en una lista rankeada dentro de un cilindro de base de datos
Compartir:

Todo equipo de RAG se choca con la misma pared. Los vectores densos encuentran la paráfrasis pero se pierden el código de error, el SKU o el nombre de función exacto. La búsqueda por keywords clava el token exacto y no devuelve nada cuando el usuario formula la pregunta distinto. Cada retriever falla en un lugar diferente, y los usuarios notan los dos fallos.

En este tutorial armo la solución dentro del Postgres que ya corrés: una pata léxica (tsvector nativo, o BM25 con pg_textsearch / pg_search), una pata de pgvector, Reciprocal Rank Fusion con k=60 en una sola sentencia SQL, un rerank con cross-encoder y un harness chico que mide recall@k antes y después. Alcance honesto: el SQL y las llamadas a APIs siguen la documentación oficial enlazada en Fuentes, y no corrí ningún benchmark para este artículo — los números los generás vos.

1. El Problema: Los Vectores Pierden Términos Exactos, las Keywords Pierden Significado

Pedile a un retriever denso ’error E1042 en el checkout’ y te devuelve páginas sobre fallos de pago en general: cercanas semánticamente, prácticamente inútiles. Pedile a un índice por keywords ’¿por qué me rechazaron la tarjeta?’ cuando el documento dice ’transacción rechazada por el emisor’ y no devuelve nada. La búsqueda densa generaliza el significado y desdibuja los identificadores; la léxica clava los identificadores y no tiene idea de paráfrasis. Los corpus reales — docs, tickets, código, contratos — traen los dos tipos de consulta todos los días.

El Error Más Común

Atornillarle un reranker (o un modelo de embeddings más grande) a un stack de retrieval que nadie midió. Sin un set de consultas etiquetadas no podés saber si el cambio ayudó, si los fallos ocurren en la recuperación o en el orden, ni cuánta latencia cuesta. Construí primero la medición: son unas 60 líneas de Python y es la sección 6 de este post.

La solución tiene tres piezas, y las tres caben dentro de Postgres: dos retrievers baratos que fallan distinto, una fusión por rango que no necesita calibrar scores y un cross-encoder caro que solo ve una lista corta de candidatos. Salteo a propósito lo básico de pgvector y el chunking — la guía de bases vectoriales y la de chunking, en los artículos relacionados, los cubren — y arranco donde ellas terminan.

2. Conceptos Mínimos Antes de Tocar Código

Seis ideas cubren casi todo lo que sigue. Con esto claro, el SQL y el Python se leen como castellano simple.

🔤

Pata léxica

términos exactos e identificadores

Full-text search de Postgres (tsvector + GIN + ts_rank) o un índice BM25 de una extensión. Gana en códigos de error, SKUs, nombres de funciones y palabras raras, y es ciega a la paráfrasis.

🧭

Pata densa

significado, no ortografía

Búsqueda de vecinos más cercanos de pgvector sobre embeddings con un índice HNSW. Gana en paráfrasis y matches entre idiomas, y desdibuja los identificadores exactos.

🔀

Reciprocal Rank Fusion

score = suma de 1 / (k + rank)

Cada lista aporta por posición, no por score, así que la distancia coseno y BM25 nunca necesitan compartir escala. El paper original de 2009 fijó k = 60 en un piloto, lo encontró casi óptimo y la elección poco crítica.

🎯

Reranker cross-encoder

lee consulta y documento juntos

Un modelo de embeddings codifica consulta y documento por separado; un cross-encoder puntúa cada par (consulta, documento) en conjunto. Más preciso, mucho más caro por par, así que solo corre sobre el top-N fusionado.

📦

Profundidad de candidatos

el techo duro

Un reranker solo puede reordenar lo que el retrieval devolvió. El LIMIT por pata topea tu recall: la documentación de ParadeDB sugiere entre 100 y 200 candidatos por pata para una respuesta top-10 como punto de partida.

📏

recall@k y MRR

el único marcador honesto

recall@k es la proporción de documentos relevantes que aparecen en el top k. MRR es el promedio de 1 / rank del primer acierto relevante. Medí los dos antes y después de cada cambio.

El modelo mental a conservar

Recuperá ancho y barato, rankeá angosto y caro. Dos patas rápidas maximizan la chance de que el chunk correcto esté en algún lugar de la lista; el reranker gasta su presupuesto solo donde importa. Si el recall de candidatos es bajo, ningún reranker te salva. Si es alto y el top sigue mal, ese es justamente el trabajo del reranker.

3. Elegí la Pata Léxica: tsvector, pg_textsearch o pg_search

Postgres te da tres opciones realistas para el lado de keywords, y la correcta depende menos de las features que de lo que tu hosting te deja instalar. Chequeé los datos de abajo contra la documentación de cada proyecto en octubre de 2026; estos proyectos se mueven rápido, así que verificá antes de fijar una versión.

🧱

tsvector nativo

funciona en cualquier Postgres

to_tsvector, un índice GIN y ts_rank / ts_rank_cd. Sin extensión, sin reinicio. La documentación de Postgres es explícita: las funciones de ranking ’no usan ninguna información global’, así que no es BM25: no hay IDF a nivel corpus. Sigue siendo el primer baseline correcto.

🐯

pg_textsearch

BM25 · PostgreSQL License

La extensión de Tiger Data: un índice bm25 más el operador <@>. Su README lista v1.5.0 como última versión, v1.0.0 como primer release GA, y soporta Postgres 17 y 18. Tiene que estar en shared_preload_libraries, así que tu proveedor debe incluirla.

🧬

pg_search (ParadeDB)

BM25 y más · AGPL-3.0

La extensión de ParadeDB, v0.26.1 en GitHub al 8 de octubre de 2026. La edición community es AGPL-3.0. Desde la 0.25.0 el índice se crea con USING paradedb (bm25 queda como alias) y pgvector es un prerrequisito.

Qué es realista en Postgres administrado

Esto es lo que pude confirmar. La documentación de Tiger llama a pg_textsearch lista para producción en Tiger Cloud, y un comunicado de prensa de Tiger Data del 21 de septiembre de 2026 dice que está en public preview en Google Cloud AlloyDB y Cloud SQL (afirmación del vendor: chequeá la documentación de Google). ParadeDB documenta clusters self-hosted, ParadeDB Cloud y BYOC, y la documentación de Neon dice que Neon retiró pg_search el 21 de septiembre de 2026 y apunta a su propio índice BM25 sobre tsvector, lakebase_text. De RDS, Supabase y todo lo demás no pude confirmar nada, así que preguntale a la propia base antes de diseñar alrededor de una extensión.

Preguntale a tu base qué puede instalar realmente

SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name IN ('vector', 'pg_textsearch', 'pg_search');

Mi default para 2026

Arrancá con tsvector nativo, medí con el harness de la sección 6 y pasá a BM25 solo si la pata léxica resulta ser el eslabón débil. Mantené la pata léxica dentro de un solo CTE para que cambiarla sea un cambio de diez líneas, no una migración.

4. Paso 1 — Schema y la Query Fusionada (Una Sola Sentencia SQL)

Una tabla guarda el texto, el embedding y un tsvector generado. Los dos índices son estándar: GIN para la pata léxica, HNSW con cosine ops para la densa. Las 384 dimensiones coinciden con el modelo de sentence-transformers de los pasos en Python; cambialo para que coincida con el tuyo.

SQL — tabla e índices

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id        bigserial PRIMARY KEY,
    doc_id    text NOT NULL,
    content   text NOT NULL,
    embedding vector(384),
    tsv       tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
);

CREATE INDEX chunks_tsv_idx ON chunks USING GIN (tsv);
CREATE INDEX chunks_emb_idx ON chunks USING hnsw (embedding vector_cosine_ops);

La fusión vive en una sola sentencia, adaptada del ejemplo de RRF del repo Python de pgvector. Cada pata es una subquery con ORDER BY y LIMIT, así que el índice hace el trabajo; ROW_NUMBER convierte posición en rank; un FULL OUTER JOIN suma weight / (k + rank) con COALESCE para los chunks que solo devolvió una pata. Los placeholders %(name)s son parámetros nombrados de psycopg. Uso k = 60 y pesos iguales como puntos de partida, no como conclusiones.

SQL — hybrid.sql (parámetros nombrados de psycopg)

WITH semantic AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY dist, id) AS rank
    FROM (
        SELECT id, embedding <=> %(embedding)s AS dist
        FROM chunks
        ORDER BY embedding <=> %(embedding)s
        LIMIT %(n)s
    ) s
),
lexical AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, id) AS rank
    FROM (
        SELECT id, ts_rank_cd(tsv, q) AS score
        FROM chunks, websearch_to_tsquery('english', %(query)s) AS q
        WHERE tsv @@ q
        ORDER BY score DESC, id
        LIMIT %(n)s
    ) l
),
fused AS (
    SELECT COALESCE(s.id, l.id) AS id,
           COALESCE(%(w_dense)s / (%(k)s + s.rank), 0.0)
         + COALESCE(%(w_lex)s   / (%(k)s + l.rank), 0.0) AS score
    FROM semantic s
    FULL OUTER JOIN lexical l ON s.id = l.id
)
SELECT c.id, c.doc_id, c.content, f.score
FROM fused f
JOIN chunks c ON c.id = f.id
ORDER BY f.score DESC, c.id
LIMIT %(limit)s;

Dos detalles que importan

Primero, LIMIT ordenado adentro y rank afuera: el índice HNSW o GIN maneja la query interna y los ranks se asignan sobre a lo sumo n filas. Segundo, los pesos: la documentación de ParadeDB recomienda arrancar iguales y ajustar contra un set de consultas etiquetadas en vez de por intuición (su propio ejemplo baja el peso vectorial a 0.7). Sumá un desempate determinista como id donde un LIMIT pueda cortar entre scores empatados.

Por qué la pata léxica devuelve menos de lo que esperás

websearch_to_tsquery une los términos simples, sin comillas, con & (AND), así que una pregunta larga en lenguaje natural solo matchea chunks que contienen todos los términos que no son stopwords. Un modismo común es relajarlo a OR con replace(websearch_to_tsquery('english', q)::text, '&', '|')::tsquery. Ese modismo no viene de la documentación de Postgres, así que probalo con el harness. El operador ||| de ParadeDB, en cambio, está documentado como match de uno o más de los términos de la consulta.

Cuando el harness diga que el lado de keywords es el eslabón débil, cambiá solo el CTE léxico. Esta es la versión con pg_textsearch, siguiendo su README: creás el índice bm25 con un text_config y ordenás por el operador <@>, que devuelve scores BM25 negativos, así que el orden ascendente pone primero al mejor match.

SQL — pata léxica BM25 con pg_textsearch

-- después de agregar pg_textsearch a shared_preload_libraries y reiniciar
CREATE EXTENSION pg_textsearch;

CREATE INDEX chunks_bm25_idx ON chunks
    USING bm25 (content) WITH (text_config = 'english');

-- reemplaza el CTE "lexical" de hybrid.sql
lexical AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY score, id) AS rank
    FROM (
        SELECT id, content <@> to_bm25query(%(query)s, 'chunks_bm25_idx') AS score
        FROM chunks
        ORDER BY content <@> to_bm25query(%(query)s, 'chunks_bm25_idx')
        LIMIT %(n)s
    ) l
),

Chequeá cada pata por separado

Corré la pata léxica sola con un término inventado (¿cuántas filas vuelven?) y con un identificador raro (¿el chunk correcto está en el top 3?). El README de pg_textsearch documenta scores negativos, pero no ese caso borde de cero matches, así que no lo doy por sentado. Leé también sus limitaciones: las estadísticas BM25 incluyen filas ocultas por row-level security, las estadísticas son locales a cada partición, y combinar un filtro booleano con ranking BM25 todavía no es un único index scan.

5. Paso 2 — Rerank con Cross-Encoder (Local o Hosteado)

Traé el top-N desde SQL, puntuá cada par (consulta, chunk) con un cross-encoder y ordená por ese score. La función rerank recibe las filas que devolvió el SQL, así que podés cambiar de modelo sin tocar el retrieval. La versión local usa BAAI/bge-reranker-v2-m3 vía FlagEmbedding, siguiendo su model card.

Python — retrieval.py (SQL híbrido + rerank local)

from pathlib import Path

import psycopg
from FlagEmbedding import FlagReranker
from pgvector.psycopg import register_vector
from sentence_transformers import SentenceTransformer

conn = psycopg.connect("dbname=hybrid_demo", autocommit=True)
register_vector(conn)

embedder = SentenceTransformer("multi-qa-MiniLM-L6-cos-v1")  # 384 dims, como en el schema
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

HYBRID_SQL = Path("hybrid.sql").read_text()


def hybrid(query, n=50, k=60, limit=50, w_dense=1.0, w_lex=1.0):
    params = {
        "embedding": embedder.encode(query), "query": query,
        "n": n, "k": k, "limit": limit, "w_dense": w_dense, "w_lex": w_lex,
    }
    return conn.execute(HYBRID_SQL, params).fetchall()  # (id, doc_id, content, score)


def rerank(query, rows, top_k=10):
    pairs = [[query, content] for _, _, content, _ in rows]
    scores = reranker.compute_score(pairs, normalize=True)  # sigmoid -> 0..1
    if isinstance(scores, float):  # un solo par devuelve un escalar
        scores = [scores]
    order = sorted(range(len(rows)), key=lambda i: scores[i], reverse=True)
    return [rows[i] for i in order[:top_k]]

¿Qué modelo? Las dos opciones abiertas de abajo son Apache-2.0 en sus model cards de Hugging Face, y las dos APIs hosteadas exponen la misma forma: mandás una consulta y una lista de textos, recibís índices y scores de relevancia. Tomá los tamaños y límites como los declara la documentación hoy y volvé a chequearlos antes de comprometerte.

🧠

bge-reranker-v2-m3

Apache-2.0 · 0.6B · multilingüe

Modelo local. La salida cruda es un logit; normalize=True lo mapea a 0–1 con una sigmoide. El ejemplo con transformers de la card trunca las entradas en 512 tokens, así que mantené los chunks cerca de ese tamaño.

🐉

Qwen3-Reranker 0.6B / 4B / 8B

Apache-2.0 · contexto 32k · 100+ idiomas

Local, con instrucciones: podés pasarle una instrucción de tarea. Necesita transformers 4.51.0 o superior. Los tamaños mayores cuestan más por par, así que arrancá con 0.6B y dejá que el harness justifique subir.

🟣

Cohere Rerank

hosteado · POST /v2/rerank

La documentación lista rerank-v4.0-pro, rerank-v4.0-fast y rerank-v3.5. Los parámetros incluyen top_n y max_tokens_per_doc (default 4096); la documentación recomienda no más de 1,000 documentos por request.

⛵

Voyage rerank

hosteado · contexto 32k

Los modelos actuales son rerank-3 y rerank-3-lite; rerank-2.5 y rerank-2.5-lite figuran como anteriores, y son los que la documentación describe como compatibles con instrucciones. Hasta 1,000 documentos, y los tokens totales (tokens de la consulta x documentos + tokens de los documentos) deben quedar bajo 600K.

Python — alternativas intercambiables (misma firma)

# Local: Qwen3-Reranker vía sentence-transformers (requiere transformers>=4.51.0)
from sentence_transformers import CrossEncoder

qwen = CrossEncoder("Qwen/Qwen3-Reranker-0.6B")

def rerank_qwen(query, rows, top_k=10):
    scores = qwen.predict([(query, r[2]) for r in rows])
    order = sorted(range(len(rows)), key=lambda i: scores[i], reverse=True)
    return [rows[i] for i in order[:top_k]]


# Hosteado: API v2 de Cohere Rerank (el cliente lee la API key del entorno)
import cohere

co = cohere.ClientV2()

def rerank_cohere(query, rows, top_k=10):
    resp = co.rerank(
        model="rerank-v4.0-pro",  # o rerank-v4.0-fast / rerank-v3.5
        query=query,
        documents=[r[2] for r in rows],
        top_n=top_k,
    )
    return [rows[r.index] for r in resp.results]


# Hosteado: Voyage rerank (usa la variable de entorno VOYAGE_API_KEY)
import voyageai

vo = voyageai.Client()

def rerank_voyage(query, rows, top_k=10):
    out = vo.rerank(query, [r[2] for r in rows], model="rerank-3", top_k=top_k)
    return [rows[r.index] for r in out.results]

Local vs hosteado: decidí con datos, no con gustos

Los rerankers hosteados no necesitan GPU, pero tus chunks salen de tu infraestructura, así que revisá primero los términos de datos de cada vendor y envolvé siempre la llamada en un timeout con fallback al orden fusionado. Los modelos locales Apache-2.0 mantienen los datos en casa y te dan el perfil de latencia, al costo de operarlos. El trabajo por query crece con la cantidad de candidatos y su largo en ambos casos (Voyage incluso define su presupuesto de tokens como tokens de la consulta x documentos + tokens de los documentos), una razón más para rerankear entre 30 y 100 chunks, no 1,000.

6. Paso 3 — Medí recall@k Antes y Después

Sin este paso todo lo anterior es sensación. Escribí entre 50 y 100 consultas reales (de logs de búsqueda, tickets de soporte o de la gente que conoce el corpus) y, para cada una, listá los doc_ids que realmente la responden. La guía de chunking en los artículos relacionados muestra cómo armar un set así; acá es solo un archivo JSONL.

gold.jsonl — una consulta etiquetada por línea (formato)

{"query": "error E1042 on checkout", "relevant": ["doc-0172"]}
{"query": "how do I rotate refresh tokens?", "relevant": ["doc-0031", "doc-0032"]}

Python — eval.py: cuatro estrategias, mismas consultas

import json
import statistics
import time

from retrieval import conn, embedder, hybrid, rerank

N = 50  # candidatos por pata y largo de la lista fusionada
KS = (5, 10, N)  # recall@N es el techo de candidatos

DENSE_SQL = """
SELECT id, doc_id, content, 0.0 AS score FROM chunks
ORDER BY embedding <=> %(embedding)s LIMIT %(n)s
"""
LEXICAL_SQL = """
SELECT id, doc_id, content, ts_rank_cd(tsv, q) AS score
FROM chunks, websearch_to_tsquery('english', %(query)s) AS q
WHERE tsv @@ q ORDER BY score DESC LIMIT %(n)s
"""


def dense(q):
    return conn.execute(DENSE_SQL, {"embedding": embedder.encode(q), "n": N}).fetchall()


def lexical(q):
    return conn.execute(LEXICAL_SQL, {"query": q, "n": N}).fetchall()


STRATEGIES = {
    "dense": dense,
    "lexical": lexical,
    "hybrid RRF": lambda q: hybrid(q, n=N, limit=N),
    "hybrid + rerank": lambda q: rerank(q, hybrid(q, n=N, limit=N), top_k=N),
}


def doc_order(rows):  # chunks -> doc_ids únicos en orden de rank
    seen, out = set(), []
    for _, doc_id, _, _ in rows:
        if doc_id not in seen:
            seen.add(doc_id)
            out.append(doc_id)
    return out


gold = [json.loads(line) for line in open("gold.jsonl", encoding="utf-8")]
hybrid(gold[0]["query"])  # warm-up: la carga de modelos y las cachés no deben contar

for name, fn in STRATEGIES.items():
    recall, rr, lat = {k: 0.0 for k in KS}, 0.0, []
    for ex in gold:
        t0 = time.perf_counter()
        docs = doc_order(fn(ex["query"]))
        lat.append((time.perf_counter() - t0) * 1000)
        rel = set(ex["relevant"])
        for k in KS:
            recall[k] += len(rel & set(docs[:k])) / len(rel)
        first = next((i for i, d in enumerate(docs, 1) if d in rel), None)
        rr += 1.0 / first if first else 0.0
    n = len(gold)
    p95 = statistics.quantiles(lat, n=20)[18]  # requiere al menos 2 consultas
    cols = " ".join(f"recall@{k}={recall[k] / n:.3f}" for k in KS)
    print(f"{name:16} {cols} MRR={rr / n:.3f} p50={statistics.median(lat):.0f}ms p95={p95:.0f}ms")

Corrélo una vez ahora para obtener tu baseline solo-denso, y de nuevo después de cada cambio. Esta es la forma de salida que deberías esperar. Dejo los números como placeholders a propósito: cualquier cifra que imprimiera acá sería inventada, y las tuyas dependen de tu corpus, tus chunks, tu embedder y tu hardware.

Forma de la salida esperada (placeholders, no resultados)

dense            recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms
lexical          recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms
hybrid RRF       recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms
hybrid + rerank  recall@5=?.??? recall@10=?.??? recall@50=?.??? MRR=?.??? p50=??ms p95=??ms

Cómo leer la tabla

  • • El recall@50 de hybrid RRF es tu techo. Si es bajo, arreglá el retrieval (profundidad de candidatos, text config, chunking, embedder): el reranker no puede recuperar lo que nunca se trajo.
  • • ¿Híbrido no mejor que denso? Chequeá la pata léxica sola y la semántica AND del tsquery antes de culpar a RRF.
  • • ¿El rerank sube el MRR pero duplica el p95? Es un trade-off que hay que tomar explícitamente: bajá N, usá un modelo fast o lite, o rerankeá solo cuando los scores fusionados están cerca.
  • • Barré n (20, 50, 100, 200), después k (20, 60, 100) y después los pesos, de a uno, y llevá un registro de las corridas.

Los sets chicos son ruidosos

Con 50 consultas una diferencia de dos o tres puntos puede ser ruido. Mirá ganancias y pérdidas por consulta, no solo los promedios, hacé crecer el set a partir de fallos reales y nunca ajustes k y los pesos con las mismas consultas que usás para reportar el resultado final.

7. Operarlo: Latencia, Filtros e Higiene de Índices

El camino de la query es la parte fácil. Esta es la rutina que lo mantiene honesto cuando llegan tráfico real y cambios de datos reales:

🔁 Cada query (milisegundos)

  1. 1. Dale al paso de rerank un presupuesto de tiempo y caé al orden fusionado ante un timeout: el retrieval nunca debe depender de que el reranker esté arriba
  2. 2. Queries con filtros (tenant, idioma, ACL): pgvector aplica los filtros después del index scan, así que un filtro que matchea el 10% de las filas devuelve en promedio solo unos 4 de 40 candidatos; activá los iterative scans (hnsw.iterative_scan = relaxed_order) y volvé a chequear el recall
  3. 3. Logueá el rank que cada pata le dio al chunk que finalmente se usó: es el dataset más barato que vas a tener para ajustar k y los pesos

🧪 Cada deploy (CI, minutos)

  1. Gate de recall: corré eval.py sobre el gold set y fallá el build cuando recall@10 o MRR caigan por debajo del último baseline aceptado
  2. Chequeo de plan: hacé EXPLAIN de las dos patas y confirmá que se usa el índice GIN, HNSW o BM25; en tablas chicas el planner puede elegir un sequential scan (el README de pg_textsearch sugiere SET enable_seqscan = off para probar el plan con índice)
  3. Pin de modelos: pineá los ids o versiones del embedder y del reranker; un cambio silencioso de modelo mueve todos los números del harness

🧹 Cada cambio de corpus o configuración

  1. Re-embebé todo junto: un modelo de embeddings nuevo implica re-embeber cada fila y reconstruir el índice HNSW; nunca mezcles vectores de dos modelos en una columna
  2. Paridad de text config: el to_tsvector o text_config usado al indexar debe coincidir con el usado al consultar; para contenido en español usá una configuración en español y medila por separado
  3. Higiene de índices: con pg_textsearch, creá el índice después de las cargas masivas y hacé REINDEX en una ventana tranquila si la latencia con caché fría se degrada en tablas con muchas actualizaciones (ambos puntos salen de su README); volvé a correr el harness después de cualquier re-chunking

8. Errores Comunes (Todos Arreglables en una Tarde)

La mayoría de las decepciones con búsqueda híbrida se explican por uno de estos baldes. La columna izquierda es lo que hacen los stacks que funcionan; la derecha es lo que empeora el retrieval en silencio.

✅ Hacé esto

  • • Fusioná por rango (RRF), nunca sumando distancia coseno cruda a un score de BM25 o ts_rank
  • • Traé listas profundas de candidatos por pata y rerankeá solo el top-N fusionado
  • • Agregá un desempate determinista (id) a cada ORDER BY ... LIMIT
  • • Usá el mismo texto de chunk y la misma configuración de text search para indexar y consultar
  • • Deduplicá por documento antes de calcular recall y antes de armar el contexto del LLM
  • • Mantené el gold set en control de versiones y condicioná los deploys a él

❌ No esto

  • • Promediar una similitud con un score BM25 y llamarlo híbrido
  • • Agregar un reranker antes de medir el recall de la lista de candidatos
  • • Rerankear 1,000 chunks por query porque la API lo permite
  • • Asumir que plainto_tsquery o websearch_to_tsquery se comportan como OR en preguntas largas
  • • Comparar estrategias con diez consultas elegidas a mano y shippear la ganadora
  • • Hacer fallar todo el request cuando el reranker hosteado da timeout

Regla de oro

El retrieval decide qué es posible, la fusión decide qué es probable, el reranking decide qué se muestra. Medí lo primero, ajustá lo segundo y gastá latencia en lo tercero solo donde el harness demuestre que rinde.

Conclusión

Ya tenés el loop completo dentro de Postgres: una pata léxica (tsvector nativo, o BM25 con pg_textsearch o pg_search), una pata de pgvector, Reciprocal Rank Fusion con k = 60 en una sola sentencia SQL, un rerank con cross-encoder que es un cambio de una función entre modelos locales Apache-2.0 y Cohere o Voyage hosteados, y un harness que convierte ’siento que mejora’ en recall@k y MRR.

Esta semana: escribí 50 consultas etiquetadas, corré eval.py sobre el retrieval solo-denso de hoy para tener el baseline, y después sumá una cosa por vez (pata léxica, fusión, reranker) quedándote solo con lo que los números justifiquen. Una última nota de honestidad: no corrí benchmarks para este artículo, así que toda afirmación sobre calidad es algo que tu propio harness tiene que confirmar.

Machete del Tutorial

Recuperar

  • • tsvector primero, BM25 si se mide
  • • pgvector HNSW, cosine ops
  • • 100–200 candidatos por pata

Fusionar + rerankear

  • • RRF, k = 60, pesos iguales
  • • cross-encoder sobre el top-N
  • • timeout + orden de fallback

Medir

  • • 50+ consultas etiquetadas
  • • recall@k, MRR, p95
  • • condicioná cada deploy a esto

Fuentes

Diego Rodriguez

Diego Rodriguez

Ingeniero Senior Full-Stack & AI

Diego tiene mas de 9 anos de experiencia construyendo aplicaciones potenciadas por IA de produccion, desde orquestacion de LLMs y pipelines RAG hasta deteccion de riesgos con ML y sistemas de trading algoritmico.

Conoce mas sobre Diego →