English · Español
Lab 01 — Baseline de bi-encoder: retrieval denso con hit-rate@k¶
Coges un bi-encoder preentrenado (sentence-transformers), embebes los 50 chunks, embebes 30 queries de eval, calculas similitud coseno y rankeas. Mides hit-rate@k y MRR. Este es el baseline contra el cual BM25 y el híbrido del Lab 02 deben ganar.
Objetivo¶
Implementar retrieval denso sobre la KB de reglas de gramática usando un bi-encoder preentrenado. Construir un eval set de 30 consultas, calcular hit-rate@{1,3,5} y MRR, y producir un baseline que el retriever híbrido del Lab 02 deberá batir.
Setup¶
- Lab 00 hecho (
data/kb/grammar-rules/chunks.jsonlcon ≥ 50 chunks). - Librería
sentence-transformers, pineada. Modelo preentrenado:paraphrase-multilingual-MiniLM-L12-v2(gestiona EN+ES, ~117MB, corre en CPU en ~5s por cada 100 consultas). - No se necesita CUDA.
Tareas¶
Parte A — Construir el eval set¶
data/kb/grammar-rules/eval/queries.jsonl — 30 consultas, cada una con el o los chunk_id esperados:
{
"query_id": "q-001",
"language": "en",
"query": "How do I conjugate 'work' for she in present tense?",
"expected_chunks": ["en-pres-3sg-regular-s-rule-001"],
"expected_answer_pattern": "works"
}
{
"query_id": "q-002",
"language": "es",
"query": "¿Cuál es el pasado de 'go' en inglés?",
"expected_chunks": ["en-past-all-irregular-go-001"],
"expected_answer_pattern": "went"
}
Campos requeridos:
query: pregunta en lenguaje natural de 1-2 frases. EN o ES.expected_chunks: 1-3 chunk_ids que deberían recuperarse. Varios si la pregunta es genuinamente ambigua o puede salir de varias reglas.expected_answer_pattern: substring que la respuesta generada debería contener (se usa en el Lab 03). El Lab 01 ignora este campo.
Cobertura: ≥ 10 consultas EN, ≥ 10 consultas ES, ≥ 10 cross-language (consulta EN sobre forma ES o viceversa). Mezcla de fáciles (coincidencia léxica verbatim) y difíciles (parafraseadas).
Parte B — Implementar src/minirag/embed.py¶
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
class Embedder:
def __init__(self, model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"):
self.model = SentenceTransformer(model_name)
self.dim = self.model.get_sentence_embedding_dimension()
def embed_texts(self, texts: list[str]) -> np.ndarray:
"""Returns (N, dim) float32 array; L2-normalized."""
emb = self.model.encode(texts, normalize_embeddings=True,
convert_to_numpy=True, show_progress_bar=False)
return emb.astype(np.float32)
La normalización L2 es esencial — hace que la similitud coseno sea igual al producto punto, lo que acelera el bucle interno.
Parte C — Implementar src/minirag/index.py (índice plano)¶
import numpy as np
class FlatIndex:
"""Brute-force cosine similarity. For N=50 chunks, this is plenty."""
def __init__(self, embeddings: np.ndarray, chunk_ids: list[str]):
assert embeddings.ndim == 2
self.emb = embeddings # (N, D), L2-normalized
self.ids = chunk_ids
def search(self, query_emb: np.ndarray, k: int = 5) -> list[tuple[str, float]]:
"""Returns top-k (chunk_id, score) by cosine similarity."""
# query_emb is (D,) or (1, D)
if query_emb.ndim == 1:
query_emb = query_emb[None, :]
scores = self.emb @ query_emb.T # (N, 1)
scores = scores.squeeze(1)
idx = np.argsort(-scores)[:k]
return [(self.ids[i], float(scores[i])) for i in idx]
Para 50 chunks esto son microsegundos — no hace falta FAISS a esta escala. (La Teoría 02 cubrió cuándo se vuelven relevantes IVF / HNSW.)
Parte D — Implementar src/minirag/retrieve.py¶
from .embed import Embedder
from .index import FlatIndex
from .chunk import load_chunks
from pathlib import Path
class DenseRetriever:
def __init__(self, kb_path: Path, embedder: Embedder):
chunks = load_chunks(kb_path)
self.chunks = {c.chunk_id: c for c in chunks}
texts = [self._chunk_text(c) for c in chunks]
emb = embedder.embed_texts(texts)
self.index = FlatIndex(emb, [c.chunk_id for c in chunks])
self.embedder = embedder
@staticmethod
def _chunk_text(c) -> str:
"""The text the embedder sees: title + body."""
return c.title + "\n" + c.body
def search(self, query: str, k: int = 5) -> list[tuple[str, float]]:
qe = self.embedder.embed_texts([query])[0]
return self.index.search(qe, k=k)
Parte E — Evaluación¶
src/minirag/eval_retrieval.py:
def hit_rate_at_k(results, expected_chunks, k):
"""results: list[(chunk_id, score)] sorted by score desc.
expected_chunks: list[str].
Returns 1 if any expected is in top-k, else 0."""
top_k = {r[0] for r in results[:k]}
return int(bool(top_k & set(expected_chunks)))
def mrr(results, expected_chunks):
"""Reciprocal rank of the first expected chunk in results."""
expected = set(expected_chunks)
for rank, (chunk_id, _) in enumerate(results, start=1):
if chunk_id in expected:
return 1.0 / rank
return 0.0
def evaluate(retriever, queries, k_values=(1, 3, 5)):
metrics = {f"hit@{k}": [] for k in k_values}
metrics["mrr"] = []
per_query = []
for q in queries:
results = retriever.search(q["query"], k=max(k_values))
for k in k_values:
metrics[f"hit@{k}"].append(
hit_rate_at_k(results, q["expected_chunks"], k))
metrics["mrr"].append(mrr(results, q["expected_chunks"]))
per_query.append({"query_id": q["query_id"], "results": results,
"hit@5": metrics["hit@5"][-1],
"mrr": metrics["mrr"][-1]})
aggregated = {m: float(np.mean(v)) for m, v in metrics.items()}
return aggregated, per_query
Parte F — Ejecutar e informar¶
embedder = Embedder()
retriever = DenseRetriever(Path("data/kb/grammar-rules/chunks.jsonl"), embedder)
queries = [json.loads(l) for l in open("data/kb/grammar-rules/eval/queries.jsonl")]
agg, per_query = evaluate(retriever, queries)
print(agg)
Objetivo esperado: hit@5 ≥ 0.65 para el baseline solo-denso. (El DoD de la fase pide ≥ 0.80 tras híbrido.)
experiments/29-bi-encoder-baseline/REPORT.md:
- Eval set: 30 consultas, desglose de cobertura (EN/ES/cross).
- Embedder: nombre del modelo, dim, normalización.
- Métricas agregadas: hit@1, hit@3, hit@5, MRR.
- Resultados por consulta (CSV ordenable).
- Análisis de fallos: lista las 5-10 consultas con
hit@5 == 0. ¿Patrones comunes? (¿Consultas en español que fallan sobre chunks solo-EN? ¿Consultas parafraseadas que no comparten keywords?)
Parte G — Tests en tests/minirag/test_retrieve.py¶
test_embedder_shape—embed_texts(["hello", "world"])devuelve shape(2, 384)(o lo que la dim del modelo pineado sea).test_embedder_normalized— cada fila tiene norma L21.0 ± 1e-5.test_flat_index_recall— embeber un chunk y buscar con el mismo texto lo recupera como top-1.test_hit_rate_at_k— sintético: conexpected=['a']yresults=[('b',0.9),('a',0.7),('c',0.5)],hit@1=0, hit@2=1.test_mrr— mismo setup:mrr = 1/2 = 0.5.
Entregables¶
experiments/29-bi-encoder-baseline/:
- REPORT.md — los puntos anteriores.
- metrics.json — métricas agregadas.
- per_query.csv — resultados por consulta.
- failure_analysis.md — lista de consultas fallidas con causa hipotetizada.
- manifest.json — nombre del modelo, hash del eval set, commit del código.
Aceptación¶
- Pasan los 5 tests.
- hit@5 ≥ 0.50 sobre el eval set (objetivo suelto; 0.65 es el listón cómodo).
- Existe el CSV por consulta con rango y score para cada consulta.
- El análisis de fallos lista al menos 3 patrones de fallo.
Trampas¶
- Embedder descargado sin cache. La primera ejecución descarga ~117MB. Pinea
HF_HOMEen el manifest para que las re-ejecuciones sean deterministas. - Falta la normalización L2. Sin ella, similitud coseno ≠ producto punto, y
FlatIndex.searchdevuelve rankings erróneos. Pasa siemprenormalize_embeddings=True. - Embeber el chunk_id en vez del cuerpo del chunk. Bug habitual. Embebe
title + body, no los metadatos. - Fuga del eval set desde la KB. Si tus consultas literalmente usan los títulos de los chunks, el hit-rate sale artificialmente alto. Parafrasea las consultas.
- Consultas en español con embedder solo-EN. Algunos embedders son solo-EN. El modelo multilingüe recomendado gestiona ambos — confírmalo con un sanity check (embeber "hello" y "hola" debería dar vectores similares pero no idénticos).
Stretch¶
- Prueba un embedder más grande (
paraphrase-multilingual-mpnet-base-v2, ~420MB, 768 dim) y compara. Rendimientos decrecientes a esta escala. - Prueba un embedder más pequeño (
all-MiniLM-L6-v2, solo-EN). Cuantifica la pérdida sobre consultas en español. - Sustituye coseno por distancia L2 y verifica que los rankings cambian (deberían — pero no mucho, ya que los vectores están normalizados L2).
Siguiente lab: lab/02-bm25-and-hybrid.md.