English · Español
05 — RAG hecho a mano: la especificación end-to-end (sin langchain, sin llama-index)¶
No usamos
langchainnillama-index— son anti-goal del repo. Esta sección describe cada eslabón del pipeline RAG (Retrieval-Augmented Generation) como módulo Python autónomo: chunker → embedder → vector store → retriever → prompt aumentado → reader. Cada eslabón tiene una API estable, un coste medido y un test de aislamiento.
Anclajes: CLAUDE.md §0.4 (sin langchain), theory/00-motivation.md (closed- vs open-book), LYNX_CORTEX.md §10 (anti-goals).
Por qué no usamos un framework¶
El anti-goal de langchain / llama-index no es estética. El contrato pedagógico exige que veas cada call site:
- qué chunker partió qué documento y por qué;
- qué modelo de embedding produjo qué vector;
- qué métrica de similitud se aplicó;
- qué entró en el prompt y en qué orden.
Los frameworks ocultan todo eso tras abstracciones tipo cadena ("retriever | prompt | model"). Para que Borja lo entienda, cada componente debe ser una función que escribió él, importable desde src/minirag/, con un docstring legible.
El coste de hacerlo a mano: ~400 líneas de Python en total. La ganancia: control total sobre los modos de fallo que medirás en la Fase 32.
El pipeline como grafo dirigido¶
┌──────────────────────────────────────────────┐
│ Knowledge base (data/rag-kb/*.md, ~50 files)│
└──────────────────────────────────────────────┘
│
▼
┌────────────────────────┐ ┌──────────────────────────┐
┌──▶│ Chunker │───▶│ chunks: list[Chunk] │
│ │ src/minirag/chunk.py │ │ Chunk = {id, text, meta}│
│ └────────────────────────┘ └──────────────────────────┘
│ │
│ ▼
│ ┌────────────────────────┐ ┌──────────────────────────┐
│ │ Embedder │───▶│ vectors: (N, d) np.ndarr│
│ │ src/minirag/embed.py │ │ + chunk.id index │
│ └────────────────────────┘ └──────────────────────────┘
│ │
│ ▼
│ ┌────────────────────────┐
│ │ Vector store (in-mem) │ one-time build
│ │ src/minirag/store.py │
│ └────────────────────────┘
│ │ query time
│ ▼
│ ┌──────────────┐ ┌────────────┐ ┌──────────────────┐
Query─┴─▶│ Embedder │───▶│ Retriever │───▶│ top-k Chunks │
└──────────────┘ │ cosine │ └──────────────────┘
│ search │ │
└────────────┘ ▼
┌──────────────────┐
│ Prompt augmenter │
│ src/minirag/prompt.py
└──────────────────┘
│
▼
┌──────────────────┐
│ Reader (MiniGPT) │
│ src/minimodel/...│
└──────────────────┘
│
▼
┌──────────────────┐
│ Answer + cites │
└──────────────────┘
Cinco ficheros en src/minirag/. Sin dependencias externas más allá de numpy y el MiniGPT de la Fase 17.
Componente 1: Chunker¶
Tarea. Partir documentos en chunks de 100-300 tokens con solapamiento, preservando metadatos.
API (solo la firma — Borja implementa):
@dataclass(frozen=True)
class Chunk:
id: str # stable hash of doc_id + offset
text: str # the chunk content
doc_id: str # path to source document
offset: int # token offset within doc_id
metadata: dict[str, Any] # e.g., {"section": "Irregular Verbs"}
def chunk_document(doc_path: Path, target_tokens: int = 150, overlap: int = 20) -> list[Chunk]:
"""Split a markdown doc into overlapping token-bounded chunks."""
Notas de implementación.
- Tokenización: usa el tokenizer BPE de la Fase 11 (
src/minitokenizer/bpe.py). No importes otro tokenizer ajeno solo porque los papers de RAG usen uno. - Respeto de fronteras: prefiere cortar en
\n\no en límites de frase dentro de ±10% deltarget_tokens. El chunking de ventana fija pura es el fallback. - IDs estables:
chunk.id = sha256(doc_path + ':' + offset).hexdigest()[:12]. Determinista entre ejecuciones.
Test de aislamiento. Round-trip: chunk del documento → re-ensamblar chunks solapados → comprobar que el texto original es reconstruible hasta la región de solape.
Coste. O(L) donde L es la longitud del documento. Una sola pasada; corre en milisegundos para la KB de gramática §A13.
Componente 2: Embedder¶
Tarea. Mapear una lista de strings a una matriz (N, d) de vectores float32.
API:
class Embedder:
"""Pluggable embedder. For Phase 29 default: a hand-trained bi-encoder on §A13."""
d: int # output vector dim
def embed(self, texts: list[str]) -> np.ndarray: # (len(texts), self.d)
...
Para la Fase 29 entregamos dos backends:
MiniBiEncoder— un MLP pequeño de dos capas que hace pooling sobre embeddings de tokens BPE, entrenado brevemente con una pérdida contrastiva (in-batch negatives). Implementa el patrón bi-encoder detheory/01-embeddings-and-biencoders.md. Por defectod = 64(coincide con la dimensión oculta de Mini-GPT — más fácil de depurar end-to-end).HashingEmbedder— un baseline de 256 dim que hace TF-IDF hashing. Sin entrenamiento. Se usa como control: siMiniBiEncoderno le gana aHashingEmbedder, el retrieval está roto.
Test de aislamiento. Estabilidad del embedding: embedder.embed(["work"]) devuelve el mismo vector en cada ejecución (controlado por seed).
Coste. O(N · d) para embeber N chunks; O(d) por consulta.
Componente 3: Vector store¶
Tarea. Mantener la matriz (N, d) + un array paralelo de Chunk.ids. Soportar search(query_vec, k) → top-k chunks.
API:
class FlatVectorStore:
"""O(N) brute-force cosine search. Correct, slow, easy to debug.
For Mini-GPT-scale (~50 chunks) this is faster than any tree-based index."""
def __init__(self, embedder: Embedder):
self.embedder = embedder
self.vectors: np.ndarray = np.empty((0, embedder.d), dtype=np.float32)
self.chunks: list[Chunk] = []
def add(self, chunks: list[Chunk]) -> None: ...
def search(self, query_text: str, k: int = 5) -> list[tuple[Chunk, float]]: ...
Implementación: cosine sobre vectores normalizados:
def search(self, query_text: str, k: int = 5) -> list[tuple[Chunk, float]]:
q = self.embedder.embed([query_text])[0]
q /= np.linalg.norm(q) + 1e-9
norms = np.linalg.norm(self.vectors, axis=1, keepdims=True) + 1e-9
sims = (self.vectors / norms) @ q # (N,)
top_k_idx = np.argsort(-sims)[:k]
return [(self.chunks[i], float(sims[i])) for i in top_k_idx]
¿Por qué no FAISS / HNSW? Son correctos (leeremos FAISS en el lab 04 de un setup extendido), pero para ~50 chunks la búsqueda cosine por fuerza bruta es la estructura de datos óptima. HNSW solo recupera su ventaja de factor logarítmico por encima de ~10⁴ chunks. Añadimos índices basados en árboles solo cuando la escala lo exige; en la Fase 29, el escaneo lineal es la elección correcta sobre la CPU de Borja.
Test de aislamiento. Un store vacío devuelve []. Un store con un solo chunk devuelve [chunk] con similitud 1.0 para una consulta de coincidencia exacta.
Componente 4: Retriever¶
Tarea. Combinar la búsqueda del vector store con (opcionalmente) búsqueda léxica BM25, devolviendo los top-k chunks rankeados.
API:
class Retriever:
def __init__(self, store: FlatVectorStore, bm25: BM25Index | None = None):
self.store = store
self.bm25 = bm25
def retrieve(self, query: str, k: int = 3, hybrid: bool = False) -> list[Chunk]:
...
Híbrido (cuando hybrid=True): aplica Reciprocal Rank Fusion sobre los dos rankings:
La constante 60 es el valor por defecto de Cormack et al.. Pequeños fragmentos de código; las matemáticas son triviales.
¿Por qué híbrido? El retrieval denso pierde coincidencias exactas de cadena; BM25 pierde paráfrasis. Para consultas de gramática §A13 el match literal §"past tense of eat" es amigable a BM25, mientras que una paráfrasis "what's the simple past of the verb to eat" es amigable a lo denso. Los sistemas en producción casi siempre mezclan ambos.
Test de aislamiento. Una consulta que contenga el contenido exacto de un chunk debería recuperar ese chunk en primera posición bajo ambos backends.
Componente 5: Prompt augmenter¶
Tarea. Formatear los chunks recuperados + la consulta del usuario en un prompt que el reader pueda ingerir.
API:
def build_prompt(query: str, chunks: list[Chunk], template: str = DEFAULT_TEMPLATE) -> str:
"""Render the prompt; include citation markers [#1], [#2] keyed to chunk ids."""
Plantilla por defecto (cada byte es intencional — esta plantilla determina lo que el modelo puede citar):
You are a grammar tutor. Use only the facts in the context to answer.
If the answer is not in the context, say "I don't know — not in the rules I have."
Context:
[#1] {chunks[0].text}
[#2] {chunks[1].text}
[#3] {chunks[2].text}
Question: {query}
Answer with a one-sentence response, followed by the citation in brackets.
Restricciones a vigilar:
- Longitud del prompt ≤
context_length = 64de Mini-GPT (según la Fase 17). Para el chunker, esto implicatarget_tokens ≤ 12por chunk en el peor caso — chunks pequeños para nuestro modelo pequeño. En un setup de producción esta restricción se relaja a "≤ longitud de contexto menos el presupuesto de respuesta". - El orden de los chunks es descendente por rango; los readers atienden con más fuerza al último chunk por sesgo de recencia. Invertir el orden es un experimento de laboratorio medible.
Test de aislamiento. build_prompt(q, []) devuelve un prompt que provoca la respuesta "I don't know" — nunca cae silenciosamente en modo closed-book.
Componente 6: Reader¶
El Mini-GPT de la Fase 17, opcionalmente con el adaptador LoRA de la Fase 28. El reader toma el prompt aumentado y emite una respuesta.
Sin API nueva. Reutiliza generate(prompt, max_new_tokens) de src/minimodel/mini_gpt.py.
El reader es el único componente con parámetros aprendidos en la cadena. El retriever usa embeddings fijos (tras un entrenamiento puntual); el chunker es basado en reglas; la plantilla del prompt está escrita a mano.
Call site end-to-end¶
# At repo top-level: src/minirag/pipeline.py
def rag_answer(query: str, store: FlatVectorStore, reader: MiniGPT, k: int = 3) -> str:
retriever = Retriever(store=store)
chunks = retriever.retrieve(query, k=k)
prompt = build_prompt(query, chunks)
return reader.generate(prompt, max_new_tokens=20)
Ese es el pipeline RAG entero en 4 líneas una vez existen los componentes. Lee cada función. Recórrela paso a paso en un depurador. No hay magia.
Hooks de evaluación (lo que mide theory/04-evaluation.md)¶
- Recall@k de retrieval: para las 30 preguntas de "conjugación de verbos" del eval set §A13, el gold chunk debería aparecer en el top-k de
retriever.retrieve(...). Recall@5 debería alcanzar 0.95+. - Faithfulness: la afirmación de la respuesta debe estar respaldada por los chunks recuperados. El lab
03-end-to-end-rag.mdlo verifica manualmente para el set §A13. - Latencia: respuesta end-to-end < 500 ms sobre la CPU de Borja para el corpus §A13.
Lo que esta sección no cubre¶
- Re-ranking con cross-encoders. Cubierto en
theory/03-hybrid-search-and-reranking.mdy en el lab 02. Los cross-encoders puntúan los pares (query, chunk) conjuntamente — más precisos que un bi-encoder, mucho más lentos. Las pilas de producción los usan como segunda etapa. - Vector stores persistentes. Nuestro
FlatVectorStorevive en memoria. Variantes con backend SQLite o FAISS en disco quedan fuera del alcance de la Fase 29 (no añaden conceptos nuevos a esta escala). - Retrieval en streaming. Respuestas a medida que se recuperan (p. ej., para UIs de chat) es un patrón de ingeniería de producción, no un concepto de RAG.
Por qué esta composición NO se reduce a "llamar a un framework"¶
Compara con un setup tipo langchain:
Es elegante pero opaco. Cuando la cadena responde mal, no sabes qué paso falló. Tampoco sabes qué eligió el chunker, qué rankeó el retriever en segunda posición, ni qué pinta tiene el prompt en bytes.
El pipeline hecho a mano es la misma cadena lógica, escrita de forma que cada valor intermedio sea una variable con nombre. Es el diseño que permite a lab/03-end-to-end-rag.md preguntar: "tu retriever devolvió estos 3 chunks — explica cuál es el más relevante y cuál es un near-miss". No podrías preguntar eso a un pipeline de langchain sin desempaquetarlo antes.
Citas¶
- Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks, NeurIPS 2020. arXiv:2005.11401.
- Cormack, Clarke, Buettcher, Reciprocal Rank Fusion, SIGIR 2009 — la constante 60 lleva 15 años siendo el valor por defecto.
- Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, EMNLP 2020. arXiv:2004.04906 — la base del bi-encoder + dense retrieval.
Recapitulación en un párrafo¶
El pipeline RAG end-to-end es una cadena de seis etapas: chunker → embedder → vector store → retriever → prompt augmenter → reader. Cada etapa es una función con nombre en src/minirag/, ~50-80 líneas de Python, con firma estable y test de aislamiento. El FlatVectorStore hace cosine por fuerza bruta porque es la estructura de datos correcta a 50 chunks de escala; nos ganamos el derecho a usar HNSW solo cuando la escala lo exija. El retrieval híbrido mezcla denso y BM25 mediante RRF; la plantilla del prompt está ajustada a mano para el contexto de 64 tokens de Mini-GPT. El total de LOC ronda los 400 — la mitad que un wrapper de framework, y 100× más legible.
Siguiente: lab/00-kb-curation.md para construir la KB §A13, y luego recorrer el resto del pipeline.