English · Español
Lab 02 — Baseline BM25 + híbrido RRF¶
BM25 es búsqueda léxica clásica: pondera palabras por TF-IDF, normaliza por longitud, devuelve un ranking. Es la otra cara del bi-encoder. Si combinas ambos con Reciprocal Rank Fusion, el ranking conjunto suele superar a cada uno por separado. Este lab construye los dos sistemas y prueba la hipótesis.
Objetivo¶
Implementar BM25 sobre la KB de reglas de gramática, evaluarlo en solitario, y luego fusionarlo con el retriever denso del Lab 01 usando Reciprocal Rank Fusion (RRF). Demostrar que el híbrido le gana a ambos retrievers por separado por ≥ 5pp en hit@5.
Setup¶
- Lab 01 hecho (retriever denso en
src/minirag/retrieve.py; eval set con 30 consultas). - Python puro; sin librería externa de BM25 (lo implementamos para entenderlo).
theory/03-hybrid-search-and-reranking.mdcubre la matemática.
La fórmula BM25¶
Para una query \(q = \{q_1, q_2, ..., q_n\}\) y un documento \(D\):
Donde:
- \(f(q_i, D)\) = frecuencia del término \(q_i\) en \(D\).
- \(|D|\) = longitud del documento en tokens.
- \(\text{avgdl}\) = longitud media de documento en el corpus.
- \(\text{IDF}(q_i) = \log\left( \frac{N - \text{df}(q_i) + 0.5}{\text{df}(q_i) + 0.5} + 1 \right)\) donde \(N\) = tamaño del corpus, \(\text{df}(q_i)\) = frecuencia documental.
- \(k_1 = 1.5\), \(b = 0.75\) — valores por defecto estándar.
La intuición: las palabras comunes en este documento pero raras en el corpus contribuyen más; los documentos largos se penalizan ligeramente.
Tareas¶
Parte A — Implementar src/minirag/bm25.py¶
import math
import re
from collections import Counter
from dataclasses import dataclass
@dataclass
class BM25:
chunk_ids: list[str]
tokens_per_doc: list[list[str]]
avgdl: float
df: Counter # term → number of docs containing it
N: int # corpus size
k1: float = 1.5
b: float = 0.75
@classmethod
def from_chunks(cls, chunks) -> "BM25":
ids = []
tokens_per_doc = []
df = Counter()
for c in chunks:
ids.append(c.chunk_id)
t = tokenize(c.title + " " + c.body)
tokens_per_doc.append(t)
for term in set(t):
df[term] += 1
avgdl = sum(len(t) for t in tokens_per_doc) / len(tokens_per_doc)
return cls(ids, tokens_per_doc, avgdl, df, N=len(ids))
def idf(self, term: str) -> float:
return math.log((self.N - self.df[term] + 0.5)
/ (self.df[term] + 0.5) + 1.0)
def score(self, query_tokens: list[str], doc_idx: int) -> float:
doc = self.tokens_per_doc[doc_idx]
doc_len = len(doc)
tf = Counter(doc)
s = 0.0
for q in query_tokens:
if q not in self.df:
continue
num = tf[q] * (self.k1 + 1)
den = tf[q] + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
s += self.idf(q) * (num / den)
return s
def search(self, query: str, k: int = 5) -> list[tuple[str, float]]:
q = tokenize(query)
scores = [(self.chunk_ids[i], self.score(q, i)) for i in range(self.N)]
scores.sort(key=lambda x: -x[1])
return scores[:k]
TOKEN_RE = re.compile(r"\w+", re.UNICODE)
def tokenize(text: str) -> list[str]:
return [t.lower() for t in TOKEN_RE.findall(text)]
Notas:
- Sin stemming en la Fase 29 — la KB es suficientemente pequeña como para que la coincidencia exacta de forma sea aceptable. Stretch: stemmer español.
- Sin eliminación de stop-words por la misma razón. Stretch: probar stop-words en español.
- La regex de token coincide con alfanuméricos incluidos caracteres acentuados (
re.UNICODE).
Parte B — Evaluar BM25 en solitario¶
Reutiliza la función evaluate del Lab 01 con BM25Retriever envolviendo la clase BM25:
class BM25Retriever:
def __init__(self, kb_path):
from .chunk import load_chunks
chunks = load_chunks(kb_path)
self.bm25 = BM25.from_chunks(chunks)
def search(self, query, k=5):
return self.bm25.search(query, k)
Esperado: el hit@5 de BM25 solo cae en algún punto de [0.40, 0.65]. Fuerte en consultas con coincidencia de keywords; débil en consultas parafraseadas / cross-language.
Parte C — Implementar RRF en src/minirag/retrieve.py¶
def reciprocal_rank_fusion(results_a: list[tuple[str, float]],
results_b: list[tuple[str, float]],
k_rrf: int = 60,
top_k: int = 5) -> list[tuple[str, float]]:
"""Fuse two ranked lists. RRF score = sum 1 / (k_rrf + rank).
Scores from A and B are NOT used — only ranks."""
score = {}
for rank, (cid, _) in enumerate(results_a, start=1):
score[cid] = score.get(cid, 0) + 1.0 / (k_rrf + rank)
for rank, (cid, _) in enumerate(results_b, start=1):
score[cid] = score.get(cid, 0) + 1.0 / (k_rrf + rank)
fused = sorted(score.items(), key=lambda x: -x[1])
return fused[:top_k]
class HybridRetriever:
def __init__(self, dense, bm25, k_rrf: int = 60):
self.dense = dense
self.bm25 = bm25
self.k_rrf = k_rrf
def search(self, query: str, k: int = 5) -> list[tuple[str, float]]:
n = max(k, 20)
ra = self.dense.search(query, k=n)
rb = self.bm25.search(query, k=n)
return reciprocal_rank_fusion(ra, rb, k_rrf=self.k_rrf, top_k=k)
El valor por defecto k_rrf=60 es el estándar del paper de RRF (Cormack 2009). Prueba otros valores en la sección de stretch.
Parte D — Comparar tres retrievers¶
Ejecuta evaluate del Lab 01 sobre los tres:
results = {
"dense": evaluate(DenseRetriever(...), queries),
"bm25": evaluate(BM25Retriever(...), queries),
"hybrid":evaluate(HybridRetriever(...),queries),
}
Produce una tabla comparativa:
| Retriever | hit@1 | hit@3 | hit@5 | MRR |
|---|---|---|---|---|
| Dense (Lab 01) | 0.42 | 0.58 | 0.67 | 0.52 |
| BM25 | 0.38 | 0.55 | 0.60 | 0.48 |
| Hybrid (RRF, k=60) | 0.50 | 0.70 | 0.78 | 0.61 |
Esperado: el híbrido le gana a ambos en solitario en hit@5 por ≥ 5pp. Si no lo hace, depura:
- ¿Están produciendo denso y BM25 top-5s solapados para la mayoría de las consultas? Si es así, RRF no puede añadir diversidad.
- ¿Están los rankings densos dominados por un pequeño conjunto de chunks "siempre arriba"? Comprueba el CSV por consulta.
- ¿Es el parámetro
k_rrfdemasiado bajo (sobre-pondera el top-1) o demasiado alto (todo parece igual)?
Parte E — Desgloses por idioma y por dificultad¶
Corta el eval set por:
- Idioma (consulta EN / consulta ES / cross): ¿le gana denso a BM25 en cross-language? (Debería — los embeddings densos puentean idiomas.)
- Dificultad (verbatim-keyword / parafraseada): ¿domina BM25 en verbatim? ¿Domina denso en parafraseada? ¿Promedia el híbrido entre ellos con elegancia?
Reporta esto en experiments/29-bm25-hybrid/SLICED_REPORT.md.
Parte F — Tests en tests/minirag/test_bm25.py¶
test_tokenize_accented—tokenize("ÉL trabajó")devuelve["él", "trabajó"](minúsculas, acento preservado).test_bm25_keyword_hit— dados 3 docs y una query con una keyword presente en 1 doc, ese doc rankea primero.test_bm25_idf_rare_wins— dada una query con 2 palabras, una rara (en 1 doc) y otra común (en los 3), gana el doc con la palabra rara.test_rrf_fusion_combines— dadas dos listas rankeadas donde el doc A está en rango 2 en ambas, y el doc B está en rango 1 solo en una, el score RRF de A supera al de B.test_hybrid_beats_alone_on_eval— sobre un eval sintético pequeño, hit@5 del híbrido ≥ max(dense, bm25) hit@5.
Entregables¶
experiments/29-bm25-hybrid/:
- REPORT.md — la tabla comparativa a 3 bandas + interpretación.
- SLICED_REPORT.md — desgloses por idioma y por dificultad.
- metrics.json — números agregados de los tres retrievers.
- per_query.csv — tres columnas de resultados (dense, bm25, hybrid) por consulta.
- manifest.json.
Aceptación¶
- Pasan los 5 tests.
- Hit@5 del híbrido ≥ max(dense, bm25) hit@5 + 5pp.
- El análisis por slices muestra el patrón predicho: dense > BM25 en parafraseado, BM25 ≥ dense en verbatim, hybrid ≥ ambos en la mayoría de slices.
- Si el híbrido NO gana por 5pp, el informe contiene un análisis de depuración que explica por qué (p. ej., "BM25 y denso están muy correlacionados en esta KB pequeña").
Trampas¶
- Mismatch de tokenización entre BM25 y el embedder denso. BM25 usa tu función
tokenize; el embedder usa su propio tokenizer. Se supone que difieren — BM25 es léxico, denso es semántico. No intentes unificarlos. - Incluir metadatos del chunk en el texto BM25. Si tokenizas el objeto JSON (p. ej., nombres de campo como
"language"), todos los chunks tienen esas palabras y el IDF de BM25 queda destruido. Tokeniza solotitle + body. - El score RRF demasiado sensible a
k_rrf. El default 60 es robusto. Si bajas a 5, el top-1 domina. Si subes a 1000, todo es igual. - Híbrido peor que solo. Si ves esto, o bien denso y BM25 apuntan a respuestas erróneas distintas (combinarlos esparce la masa), o un retriever aporta puro ruido. Diagnóstico con el CSV por consulta.
- Stop-words españolas contando como contenido. Palabras como
de,el,la,queestán en todas las consultas en español; sin eliminación de stop-words, el IDF de BM25 las pondera a la baja pero aun así contaminan ligeramente el score. Aceptable a N=50.
Stretch¶
- Añade stemming en español (Snowball) al tokenizer de BM25. Mide el cambio de hit-rate por idioma.
- Prueba distintos valores de
k_rrf(5, 20, 60, 200). Dibuja hit@5 vsk_rrf. - Implementa una fusión alternativa: combinación lineal de scores normalizados. Compara con RRF.
- Añade un cross-encoder re-ranker (teoría 03) sobre el top-20 del híbrido. Mide la mejora.
Siguiente lab: lab/03-end-to-end-rag.md.