Ir al contenido

Hybrid search: BM25 + dense vectors

Tu RAG anda bien. Las preguntas conceptuales las contesta bárbaro. Pero un día le preguntás:

“¿Qué hace useEffect en React?”

Y te trae chunks sobre “efectos secundarios en arquitectura limpia”. El embedding no entiende que useEffect es un nombre propio de una función — lo trata como dos palabras: “use” y “effect”. Y semánticamente “effect” se parece a “side effects”, “consequences”, “impact”. Match falso. Resultado: tu RAG te falló porque la query tiene un término léxico que el embedding no respeta.

Esto pasa con:

  • Nombres de funciones (useEffect, parseInt).
  • IDs y siglas (CVE-2024-12345, JWT, OIDC).
  • Versiones (TypeScript 5.7, Node 24).
  • Términos técnicos no comunes (idempotency, monorepo, hexagonal).

BM25 — búsqueda léxica clásica de los 80s — es exactamente lo opuesto: encuentra coincidencias literales pero no entiende sinónimos. Combinar ambos cubre los huecos.

BM25 score de un chunk para una query es una suma sobre los términos de la query:

score = Σ_term IDF(term) · (tf · (k1 + 1)) / (tf + k1 · (1 − b + b · len/avg_len))

Donde:

  • tf (term frequency): cuántas veces aparece el término en el chunk.
  • IDF (inverse document frequency): qué tan raro es el término en el corpus. Términos rarísimos pesan más; “de” y “la” pesan casi nada.
  • len / avg_len: penalización por longitud — chunks largos no ganan automáticamente solo por contener el término.
  • k1, b: constantes (1.5 y 0.75 en el curso, los defaults clásicos).

No necesitás internalizar la fórmula. Lo que importa es la intuición: BM25 te da score alto cuando el chunk tiene muchas veces el término raro que pediste, y no se infla por chunk largo.

Vive en packages/01-vercel-ai-sdk/src/retrievers/hybrid.ts. Tres exports:

hybrid.ts: API
export function buildBM25(chunks: Chunk[]): BM25Index;
export function searchBM25(index: BM25Index, query: string, topK: number): BM25Hit[];
export function rrf(rankings: RankedItem[][], k = 60): FusedHit[];

La función buildBM25 recorre el corpus, computa term frequency por chunk e IDF global. searchBM25 toma una query, tokeniza, y aplica la fórmula. rrf (la próxima sección) fusiona varios rankings.

hybrid.ts: tokenize
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.split(/\s+/)
.filter((t) => t.length >= 2 && !STOPWORDS.has(t));
}

Cuatro pasos:

  1. Lowercase: useEffect y useeffect son lo mismo. (Pierde un poco de señal en lenguajes con casing significativo, pero el tradeoff vale.)
  2. Remover puntuación preservando letras Unicode (ñ, á, é, etc.) y números. La regex \p{L}\p{N} cubre acentos y caracteres latinos sin enumerarlos.
  3. Split por whitespace.
  4. Filtrar tokens cortos y stopwords (“de”, “la”, “el”, “the”, “of”, etc.). Stopwords inflarían el score de chunks largos sin aportar señal.

Acá conviven dos lenguajes (español y un poco de inglés) en el corpus. La lista de stopwords del curso es chiquita (~30 palabras) — un BM25 productivo tendría una más completa, idealmente con stemming.

Tenés dos rankings: dense (embeddings) y sparse (BM25). ¿Cómo los combinás?

Tentación obvia: sumar los scores. Mal: los scores de Cosine son entre 0 y 1, los de BM25 son ilimitados (depende del IDF). Los de BM25 dominan; el dense desaparece.

RRF resuelve esto ignorando los scores absolutos y usando solo el rank (posición en cada lista):

score_RRF(chunk) = Σ_lista 1 / (k + rank_en_lista)

k es una constante (60 es canónico) que dampens la diferencia entre top-1 y top-2. Un chunk que es #1 en dense y #5 en BM25 contribuye más que uno que es #20 en ambos. Si solo aparece en una lista, contribuye solo de esa lista.

hybrid.ts: rrf
export function rrf(rankings: RankedItem[][], k = 60): FusedHit[] {
const fused = new Map<string, FusedHit>();
for (const ranking of rankings) {
for (const { chunk, rank } of ranking) {
const contribution = 1 / (k + rank);
const cur = fused.get(chunk.id);
if (cur) cur.score += contribution;
else fused.set(chunk.id, { chunk, score: contribution });
}
}
return Array.from(fused.values()).sort((a, b) => b.score - a.score);
}

15 líneas. Simple y robusto. Hay alternativas (CombSUM normalizado, weighted sum), pero RRF es el default seguro en casi cualquier paper reciente de retrieval.

Para el orquestador query.adv.ts que vamos a usar en este nivel y los siguientes:

Ventana de terminal
pnpm vercel:query:adv "<pregunta>" --hybrid

Sin --hybrid, corre dense puro (idéntico a Nivel 1). Con --hybrid, hace dense + BM25 + RRF.

Probá esta secuencia:

Ventana de terminal
# Pregunta semántica — dense gana
pnpm vercel:query:adv "¿Qué propone la regla de dependencia?"
pnpm vercel:query:adv "¿Qué propone la regla de dependencia?" --hybrid
# Pregunta con término técnico literal — hybrid debería ayudar
pnpm vercel:query:adv "¿Qué dice el principio LSP?"
pnpm vercel:query:adv "¿Qué dice el principio LSP?" --hybrid

Hybrid no es free. Tres situaciones donde dense puro alcanza:

  • Corpus puramente narrativo / conceptual, sin nombres propios ni IDs (ensayos, blog posts en lenguaje natural).
  • Queries siempre en lenguaje natural y formal, sin terminología técnica.
  • Restricciones de latencia tight (hybrid agrega ~5-20ms para el corpus chico, más en producción si el BM25 está en memoria).

Si tu corpus es 50% código de funciones, 100% siglas, o tiene IDs/versiones, hybrid es casi siempre win.

Hybrid es el primer “retriever paralelo”. El siguiente paso es rerankear: traer top-K=20 con dense (o hybrid) y aplicarle un segundo modelo más caro para filtrar a top-4. Es la mejor relación esfuerzo/ganancia del Nivel 3.