Hybrid search: BM25 + dense vectors
El problema que dense no resuelve
Sección titulada «El problema que dense no resuelve»Tu RAG anda bien. Las preguntas conceptuales las contesta bárbaro. Pero un día le preguntás:
“¿Qué hace
useEffecten 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 en 5 minutos
Sección titulada «BM25 en 5 minutos»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.
El archivo
Sección titulada «El archivo»Vive en packages/01-vercel-ai-sdk/src/retrievers/hybrid.ts. Tres exports:
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.
Tokenize: el detalle que importa
Sección titulada «Tokenize: el detalle que importa»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:
- Lowercase:
useEffectyuseeffectson lo mismo. (Pierde un poco de señal en lenguajes con casing significativo, pero el tradeoff vale.) - Remover puntuación preservando letras Unicode (ñ, á, é, etc.) y números. La regex
\p{L}\p{N}cubre acentos y caracteres latinos sin enumerarlos. - Split por whitespace.
- 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.
Reciprocal Rank Fusion (RRF)
Sección titulada «Reciprocal Rank Fusion (RRF)»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.
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.
El comando
Sección titulada «El comando»Para el orquestador query.adv.ts que vamos a usar en este nivel y los siguientes:
pnpm vercel:query:adv "<pregunta>" --hybridSin --hybrid, corre dense puro (idéntico a Nivel 1). Con --hybrid, hace dense + BM25 + RRF.
Lab: ver hybrid en acción
Sección titulada «Lab: ver hybrid en acción»Probá esta secuencia:
# Pregunta semántica — dense ganapnpm 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 ayudarpnpm vercel:query:adv "¿Qué dice el principio LSP?"pnpm vercel:query:adv "¿Qué dice el principio LSP?" --hybridCuándo NO usar hybrid
Sección titulada «Cuándo NO usar 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.
Lo que viene
Sección titulada «Lo que viene»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.