Skip to content

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.jsonl con ≥ 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:

  1. Eval set: 30 consultas, desglose de cobertura (EN/ES/cross).
  2. Embedder: nombre del modelo, dim, normalización.
  3. Métricas agregadas: hit@1, hit@3, hit@5, MRR.
  4. Resultados por consulta (CSV ordenable).
  5. 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

  1. test_embedder_shapeembed_texts(["hello", "world"]) devuelve shape (2, 384) (o lo que la dim del modelo pineado sea).
  2. test_embedder_normalized — cada fila tiene norma L2 1.0 ± 1e-5.
  3. test_flat_index_recall — embeber un chunk y buscar con el mismo texto lo recupera como top-1.
  4. test_hit_rate_at_k — sintético: con expected=['a'] y results=[('b',0.9),('a',0.7),('c',0.5)], hit@1=0, hit@2=1.
  5. 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_HOME en el manifest para que las re-ejecuciones sean deterministas.
  • Falta la normalización L2. Sin ella, similitud coseno ≠ producto punto, y FlatIndex.search devuelve rankings erróneos. Pasa siempre normalize_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.