Cuaderno/Proyectos/9 · RAG de protocolos y papers
Proyecto 9 de 10 · Bloque 3

RAG de protocolos y papers

El asistente del Proyecto 8 ahora busca en tus documentos antes de responder, y cita archivo y página. La parte nueva no es el modelo: es la búsqueda, y se mide con un número antes de tocar ningún prompt.

concepto · búsqueda semántica y por qué el modelo no sabe tus documentos6 sesionesfastembed · sqlite-vec · pypdf · recall@5
ConstruyesIngesta (PDF → trozos → vectores → SQLite) y consulta con cita
AprendesRecuperar antes de generar; decidir chunking con una métrica
CostoCero: los embeddings se calculan en tu laptop
Terminas coninv rag ingest · inv rag preguntar con fuentes, y recall@5 medido

Objetivos

  • Explicar qué es un embedding con un experimento numérico que corres tú misma.
  • Construir la ingesta: extraer texto de PDF/Markdown, trocearlo, vectorizarlo y guardarlo en SQLite, de forma idempotente.
  • Construir la consulta: pregunta → 5 trozos más cercanos → respuesta con cita (archivo, página, enlace).
  • Medir recall@5 sobre 20 preguntas con respuesta conocida, y mejorar el chunking al menos una vez basándote en ese número.
  • Hacer que el sistema diga "no sé" cuando la respuesta no está en los documentos.
La idea en una línea

El modelo no sabe tus protocolos: se los das en el momento, dentro del prompt. Para eso hay que encontrar los párrafos correctos entre miles. Si la recuperación falla, la mejor respuesta del mundo es inventada — por eso la recuperación se mide primero y por separado.

Temas que vas a usar

De El libro de Python y de esta guía:

Antes de empezar

Necesitas el Proyecto 8 terminado: este proyecto reutiliza responder(pregunta, contexto) y su Respuesta. Trabajas en el mismo repo inventario-lab.

cd inventario-lab
git switch main && git pull
uv run pytest -q          # todo verde antes de empezar

Dos avisos de tamaño: el modelo de embeddings pesa ~240 MB y se descarga una sola vez al primer uso (después queda en caché local). Y reúne 20–50 documentos tuyos reales (protocolos, papers en PDF); para seguir la guía usamos 4 protocolos de ejemplo que escribes en docs/.

Datos que no pueden salir

Todo lo de este proyecto corre local: ni los documentos ni las preguntas salen de tu máquina (esa es una de las razones para elegir embeddings locales). El único punto de fuga posible es el paso final del P8 —mandar los trozos recuperados al LLM—: antes de ingestar documentos confidenciales del trabajo, decide si ese último paso está permitido.

Construcción paso a paso

Paso 1 Rama, dependencias y estructura

git switch -c feat/rag
uv add fastembed sqlite-vec pypdf numpy
mkdir -p src/inventario/rag docs evals
touch src/inventario/rag/__init__.py
Las tres piezas. fastembed calcula embeddings en tu laptop (gratis, sin API key, tus documentos no salen). sqlite-vec agrega búsqueda vectorial al SQLite que ya conoces del P3 — no necesitas una "base vectorial" aparte. pypdf extrae texto de PDFs. Anota en DECISIONES.md: la alternativa era una API de embeddings (OpenAI/Voyage) + un servicio vectorial; más calidad y más costo, y tus datos viajan. Para empezar, local gana.

Paso 2 El experimento de cinco minutos: qué es un embedding

Un embedding convierte un texto en un vector (aquí, 384 números) donde cercano significa parecido en significado, aunque no compartan palabras. Compruébalo antes de creerlo:

experimento_embedding.py (temporal, en la raíz)
import numpy as np
from fastembed import TextEmbedding

modelo = TextEmbedding("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
frases = [
    "Eluir el ADN con buffer AE precalentado",
    "La elución se hace con AE a 56 grados",      # lo mismo, dicho distinto
    "Inocular una colonia en 5 mL de medio LB",   # otro tema
]
va, vb, vc = (np.array(v) for v in modelo.embed(frases))

def coseno(x, y):
    return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y)))

print(f"similitud(elución-1, elución-2) = {coseno(va, vb):.3f}")
print(f"similitud(elución-1, cultivo)   = {coseno(va, vc):.3f}")
uv run python experimento_embedding.py
Deberías ver (la primera vez descarga el modelo, ~240 MB)
similitud(elución-1, elución-2) = 0.250
similitud(elución-1, cultivo)   = 0.136
Lee los números, no su tamaño. Lo que importa es el orden: las dos frases de elución están casi al doble de similitud que la de cultivo, sin compartir casi palabras ("eluir/elución" vs "AE a 56 grados"). Ese orden es todo el truco del RAG: la pregunta y el párrafo que la responde quedan cerca en ese espacio. El coseno mide el ángulo entre vectores; la distancia coseno que usaremos después es 1 − similitud: más chica = más parecido. Borra el script cuando termines (o muévelo a notebooks/).
Por qué este modelo. paraphrase-multilingual-MiniLM-L12-v2: multilingüe (tus protocolos están en español, los papers en inglés — un modelo solo-inglés como bge-small-en-v1.5 pierde en español), 384 dimensiones, corre en CPU. Es un trade-off medible: si dudas entre dos modelos, la métrica del paso 8 decide, no la intuición.

Paso 3 El almacén: SQLite + sqlite-vec

Tres tablas: documentos (con su hash), trozos (con documento, página y posición) y los vectores. Solo persistencia: este módulo no sabe de PDFs ni de modelos.

src/inventario/rag/store.py
"""Almacén de trozos y vectores en SQLite (+ extensión sqlite-vec).

Solo persistencia: no sabe de PDFs ni de modelos de embeddings.
"""

from __future__ import annotations

import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

import sqlite_vec

DIM = 384  # dimensión de los vectores del modelo elegido (ver ingest.MODELO)


@dataclass(frozen=True)
class Trozo:
    documento_id: int
    pagina: int
    pos: int
    texto: str


@dataclass(frozen=True)
class Resultado:
    trozo_id: int
    texto: str
    documento: str
    ruta: str
    pagina: int
    pos: int
    distancia: float


class Store:
    def __init__(self, path: str | Path = "rag.db") -> None:
        self.path = str(path)
        self.db = sqlite3.connect(self.path)
        self.db.row_factory = sqlite3.Row
        self.db.enable_load_extension(True)
        sqlite_vec.load(self.db)
        self.db.enable_load_extension(False)
        self._crear_tablas()

    def _crear_tablas(self) -> None:
        self.db.executescript(
            f"""
            CREATE TABLE IF NOT EXISTS documentos (
                id INTEGER PRIMARY KEY,
                nombre TEXT NOT NULL,
                ruta TEXT NOT NULL UNIQUE,
                hash TEXT NOT NULL,
                paginas INTEGER NOT NULL,
                ingestado_en TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS trozos (
                id INTEGER PRIMARY KEY,
                documento_id INTEGER NOT NULL REFERENCES documentos(id) ON DELETE CASCADE,
                pagina INTEGER NOT NULL,
                pos INTEGER NOT NULL,
                texto TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS ix_trozos_doc ON trozos(documento_id);
            CREATE VIRTUAL TABLE IF NOT EXISTS vec_trozos USING vec0(
                trozo_id INTEGER PRIMARY KEY,
                embedding float[{DIM}] distance_metric=cosine
            );
            """
        )
        self.db.execute("PRAGMA foreign_keys = ON")

    # --- documentos -------------------------------------------------------
    def documento_por_ruta(self, ruta: str) -> sqlite3.Row | None:
        return self.db.execute("SELECT * FROM documentos WHERE ruta = ?", (ruta,)).fetchone()

    def borrar_documento(self, documento_id: int) -> None:
        ids = [
            r[0]
            for r in self.db.execute(
                "SELECT id FROM trozos WHERE documento_id = ?", (documento_id,)
            )
        ]
        for tid in ids:
            self.db.execute("DELETE FROM vec_trozos WHERE trozo_id = ?", (tid,))
        self.db.execute("DELETE FROM trozos WHERE documento_id = ?", (documento_id,))
        self.db.execute("DELETE FROM documentos WHERE id = ?", (documento_id,))
        self.db.commit()

    def guardar_documento(self, nombre: str, ruta: str, hash_: str, paginas: int) -> int:
        cur = self.db.execute(
            "INSERT INTO documentos (nombre, ruta, hash, paginas, ingestado_en) VALUES (?,?,?,?,?)",
            (nombre, ruta, hash_, paginas, datetime.now(UTC).isoformat()),
        )
        return int(cur.lastrowid)

    # --- trozos + vectores ------------------------------------------------
    def guardar_trozos(self, trozos: list[Trozo], embeddings: list[list[float]]) -> None:
        if len(trozos) != len(embeddings):
            raise ValueError("trozos y embeddings deben tener el mismo largo")
        with self.db:  # transacción: o entran todos o ninguno
            for trozo, emb in zip(trozos, embeddings, strict=True):
                cur = self.db.execute(
                    "INSERT INTO trozos (documento_id, pagina, pos, texto) VALUES (?,?,?,?)",
                    (trozo.documento_id, trozo.pagina, trozo.pos, trozo.texto),
                )
                self.db.execute(
                    "INSERT INTO vec_trozos (trozo_id, embedding) VALUES (?, ?)",
                    (cur.lastrowid, sqlite_vec.serialize_float32(emb)),
                )

    def buscar_vectores(self, embedding: list[float], k: int = 5) -> list[Resultado]:
        filas = self.db.execute(
            """
            SELECT t.id, t.texto, d.nombre, d.ruta, t.pagina, t.pos, v.distance
            FROM vec_trozos v
            JOIN trozos t ON t.id = v.trozo_id
            JOIN documentos d ON d.id = t.documento_id
            WHERE v.embedding MATCH ? AND k = ?
            ORDER BY v.distance
            """,
            (sqlite_vec.serialize_float32(embedding), k),
        ).fetchall()
        return [Resultado(f[0], f[1], f[2], f[3], f[4], f[5], float(f[6])) for f in filas]

    # --- utilidades -------------------------------------------------------
    def stats(self) -> dict[str, int]:
        docs = self.db.execute("SELECT COUNT(*) FROM documentos").fetchone()[0]
        trozos = self.db.execute("SELECT COUNT(*) FROM trozos").fetchone()[0]
        return {"documentos": docs, "trozos": trozos}

    def close(self) -> None:
        self.db.close()
Fíjate en lo conocido. Es el mismo SQLite del P3: claves foráneas, transacción (with self.db) para que trozos y vectores entren juntos o ninguno, consultas parametrizadas siempre. Lo único nuevo es vec0, una tabla virtual donde MATCH devuelve los k vecinos más cercanos por distancia coseno. Y DIM = 384 tiene que coincidir con el modelo: si mañana cambias de modelo, cambia la dimensión y hay que reingestar todo.

Paso 4 Trocear: lógica pura, tests primero

¿Por qué trocear? El embedding de una página entera "promedia" demasiados temas y no queda cerca de ninguna pregunta concreta (lo vas a medir en el paso 8). Trozos de ~500 caracteres respetando párrafos, con solape de 50 para no cortar una idea justo en el borde. Es lógica pura → tests sin modelo ni DB:

tests/test_chunking.py
"""El chunking es lógica pura: se testea sin modelo ni DB."""

import pytest

from inventario.rag.ingest import trocear


def test_texto_corto_es_un_trozo():
    trozos = trocear("Un párrafo corto.")
    assert len(trozos) == 1
    assert trozos[0] == (0, "Un párrafo corto.")


def test_parrafos_se_agrupan_hasta_el_limite():
    texto = "aaa\n\nbbb\n\nccc"
    assert trocear(texto, tam=9, solape=2) == [(0, "aaa\nbbb"), (10, "ccc")]


def test_parrafo_gigante_se_corta_con_solape():
    texto = "x" * 1200
    trozos = trocear(texto, tam=500, solape=50)
    assert [pos for pos, _ in trozos] == [0, 450, 900]
    assert all(len(t) <= 500 for _, t in trozos)


def test_solape_mayor_que_tam_lanza_error():
    with pytest.raises(ValueError):
        trocear("hola", tam=50, solape=50)

Y la función, en el módulo de ingesta (completo en el paso siguiente):

src/inventario/rag/ingest.py (fragmento)
TAM_TROZO = 500  # caracteres, aprox.
SOLAPE = 50


def trocear(texto: str, tam: int = TAM_TROZO, solape: int = SOLAPE) -> list[tuple[int, str]]:
    """Parte el texto en trozos de ~tam caracteres respetando párrafos.

    Devuelve (posición del primer carácter, texto). Un párrafo más largo que tam
    se corta en ventanas de tam con solape.
    """
    if solape >= tam:
        raise ValueError("solape debe ser menor que tam")
    trozos: list[tuple[int, str]] = []
    actual, inicio = "", 0
    cursor = 0
    for parrafo in texto.split("\n\n"):
        p = parrafo.strip()
        pos_p = texto.find(parrafo, cursor)
        cursor = pos_p + len(parrafo)
        if not p:
            continue
        if len(p) > tam:  # párrafo gigante: ventanas con solape
            if actual:
                trozos.append((inicio, actual))
                actual = ""
            paso = tam - solape
            for i in range(0, len(p), paso):
                trozos.append((pos_p + i, p[i : i + tam]))
                if i + tam >= len(p):
                    break
            continue
        if actual and len(actual) + 1 + len(p) > tam:
            trozos.append((inicio, actual))
            actual, inicio = p, pos_p
        else:
            if not actual:
                inicio = pos_p
            actual = f"{actual}\n{p}" if actual else p
    if actual:
        trozos.append((inicio, actual))
    return trozos
uv run pytest tests/test_chunking.py -q
Deberías ver
5 passed in 0.02s

Paso 5 Ingesta idempotente

La ingesta une todo: hash del archivo → ¿ya lo tengo igual? → extraer páginas → trocear → embeddings → guardar. El hash es lo que la hace idempotente: reingestar la misma carpeta no duplica nada ni recalcula embeddings ya pagados (en tiempo, aquí; en dinero, si usaras una API).

src/inventario/rag/ingest.py (el resto)
"""Ingesta: archivo -> páginas de texto -> trozos -> embeddings -> Store.

Idempotente: el mismo archivo (mismo hash) no se vuelve a procesar ni a recalcular.
"""

from __future__ import annotations

import hashlib
import os
from dataclasses import dataclass
from pathlib import Path

from fastembed import TextEmbedding
from pypdf import PdfReader

from inventario.rag.store import Store, Trozo

MODELO = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"  # multilingüe, 384 dims


class Embedder:
    """Envuelve el modelo local. Se instancia una vez: cargarlo cuesta segundos."""

    def __init__(self, modelo: str = MODELO, cache_dir: str | None = None) -> None:
        cache_dir = cache_dir or os.environ.get("RAG_MODEL_CACHE")
        self.modelo = modelo
        self._m = TextEmbedding(model_name=modelo, cache_dir=cache_dir)

    def embed(self, textos: list[str]) -> list[list[float]]:
        return [vec.tolist() for vec in self._m.embed(textos)]


def hash_archivo(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for bloque in iter(lambda: f.read(1 << 20), b""):
            h.update(bloque)
    return h.hexdigest()


def extraer_paginas(path: Path) -> list[str]:
    """Texto por página. PDF con pypdf; .md/.txt cuentan como una sola 'página'."""
    sufijo = path.suffix.lower()
    if sufijo == ".pdf":
        return [(p.extract_text() or "") for p in PdfReader(str(path)).pages]
    if sufijo in {".md", ".txt"}:
        return [path.read_text(encoding="utf-8")]
    raise ValueError(f"formato no soportado: {path.name}")


@dataclass(frozen=True)
class ResultadoIngesta:
    nombre: str
    trozos: int
    omitido: bool  # True = ya estaba con el mismo hash; no se hizo nada


def ingestar(
    store: Store,
    path: Path,
    embedder: Embedder,
    tam: int = TAM_TROZO,
    solape: int = SOLAPE,
) -> ResultadoIngesta:
    path = Path(path)
    ruta = str(path.resolve())
    h = hash_archivo(path)
    previo = store.documento_por_ruta(ruta)
    if previo is not None:
        if previo["hash"] == h:
            return ResultadoIngesta(path.name, 0, omitido=True)
        store.borrar_documento(previo["id"])  # cambió el archivo: se reemplaza entero
    paginas = extraer_paginas(path)
    doc_id = store.guardar_documento(path.name, ruta, h, len(paginas))
    trozos: list[Trozo] = []
    for n, texto in enumerate(paginas, start=1):
        for pos, t in trocear(texto, tam, solape):
            trozos.append(Trozo(doc_id, n, pos, t))
    if trozos:
        store.guardar_trozos(trozos, embedder.embed([t.texto for t in trozos]))
    return ResultadoIngesta(path.name, len(trozos), omitido=False)


def ingestar_carpeta(
    store: Store, carpeta: Path, embedder: Embedder, tam: int = TAM_TROZO, solape: int = SOLAPE
) -> list[ResultadoIngesta]:
    archivos = sorted(
        p for p in Path(carpeta).iterdir() if p.suffix.lower() in {".pdf", ".md", ".txt"}
    )
    return [ingestar(store, p, embedder, tam, solape) for p in archivos]

Y sus tests de comportamiento (usan el modelo real una vez por sesión, con una fixture scope="session" en tests/conftest.py):

tests/test_ingesta.py (fragmento)
def test_reingesta_no_duplica(store, embedder, tmp_path):
    doc = escribir_doc(tmp_path)
    ingestar(store, doc, embedder)
    antes = store.stats()
    r = ingestar(store, doc, embedder)  # mismo archivo, mismo hash
    assert r.omitido
    assert store.stats() == antes


def test_archivo_modificado_se_reemplaza(store, embedder, tmp_path):
    doc = escribir_doc(tmp_path)
    ingestar(store, doc, embedder)
    escribir_doc(tmp_path, texto="# Nuevo\n\nContenido distinto sobre centrifugación.")
    r = ingestar(store, doc, embedder)
    assert not r.omitido
    assert store.stats()["documentos"] == 1  # reemplazado, no duplicado
uv run pytest -q
Deberías ver
9 passed in …s   (la primera corrida tarda: carga el modelo)
git add . && git commit -m "RAG: almacén sqlite-vec, chunking e ingesta idempotente"

Paso 6 Documentos de ejemplo, CLI, y la primera búsqueda

Escribe 3–4 protocolos cortos y reales en docs/ (extracción de ADN, PCR, cultivo de E. coli, electroforesis — o los tuyos). Al menos uno en PDF para probar esa ruta. Agrega el grupo rag al CLI inv (comandos ingest, buscar, preguntar; el código sigue el patrón del P3). Luego:

uv run inv rag ingest docs
Deberías ver
cultivo-ecoli.md                    3 trozos
electroforesis-agarosa.pdf          4 trozos
extraccion-adn-columna.md           4 trozos
pcr-punto-final.md                  3 trozos
total: {'documentos': 4, 'trozos': 14}

Córrelo otra vez, tal cual. Esta es la prueba de idempotencia en vivo:

Deberías ver
cultivo-ecoli.md                    omitido (sin cambios)
electroforesis-agarosa.pdf          omitido (sin cambios)
extraccion-adn-columna.md           omitido (sin cambios)
pcr-punto-final.md                  omitido (sin cambios)
total: {'documentos': 4, 'trozos': 14}

Y busca:

uv run inv rag buscar "¿a qué concentración final se usa la ampicilina?"
Deberías ver (tus distancias variarán)
[1] d=0.454  cultivo-ecoli.md, p. 1
    ## Crioconservación Mezclar 500 µL de cultivo en fase exponencial con 500 µL de glicerol…
[2] d=0.498  pcr-punto-final.md, p. 1
    # Protocolo: PCR de punto final (Taq polimerasa) ## Mezcla de reacción (25 µL)…
[3] d=0.513  extraccion-adn-columna.md, p. 1
    ## Lisis Transferir 25 mg de tejido a un tubo de 1.5 mL…
Mira esto con atención. El documento correcto (cultivo-ecoli.md) salió primero… pero el trozo [1] es el de crioconservación, no el párrafo del medio LB que menciona la ampicilina. La búsqueda semántica es buena, no mágica. ¿Está el trozo correcto dentro del top-5? ¿En cuántas de 20 preguntas? Exactamente eso es lo que mide el paso 8 — la intuición de "parece que funciona" se acaba aquí.

Paso 7 Responder con cita, y decir "no sé"

query.py une búsqueda y asistente: los trozos recuperados, numerados y con su cita, se vuelven el contexto del responder() del P8. Dos reglas del contrato: sin cita no vale (cada trozo lleva archivo, página y un enlace ruta#page=N que abre el PDF en esa página), y si nada quedó por debajo del umbral de distancia, se responde "no sé" sin llamar al LLM.

src/inventario/rag/query.py (fragmento)
UMBRAL_DISTANCIA = 0.6  # distancia coseno; por encima, el trozo casi no tiene que ver


def buscar(store: Store, embedder: Embedder, pregunta: str, k: int = 5) -> list[Hit]:
    if not pregunta.strip():
        raise ValueError("la pregunta está vacía")
    [vec] = embedder.embed([pregunta])
    return [_hit(r) for r in store.buscar_vectores(vec, k)]


def armar_contexto(hits: list[Hit]) -> str:
    """Los trozos numerados, con su cita, listos para el prompt del asistente (P8)."""
    return "\n\n".join(f"[{i}] ({h.cita})\n{h.texto}" for i, h in enumerate(hits, start=1))


def responder_con_rag(pregunta, store, embedder, cliente=None, k=5, umbral=UMBRAL_DISTANCIA):
    """Pipeline completo. Si nada relevante se recuperó, responde 'no sé' sin llamar al LLM."""
    hits = [h for h in buscar(store, embedder, pregunta, k) if h.distancia <= umbral]
    if not hits:
        return Respuesta(texto="No encuentro eso en los documentos ingestados.", ...), []
    respuesta = responder(pregunta, armar_contexto(hits), cliente=cliente)
    return respuesta, hits

El test del "no sé" (con el cliente falso del P8: aquí no se paga ni una llamada):

tests/test_ingesta.py (fragmento)
def test_pregunta_fuera_de_los_documentos_dice_no_se(store, embedder, tmp_path):
    ingestar(store, escribir_doc(tmp_path), embedder)
    respuesta, hits = responder_con_rag(
        "¿cuál es la capital de Australia?", store, embedder, umbral=0.35
    )
    assert hits == []
    assert "No encuentro" in respuesta.texto
El umbral se calibra, no se inventa. En nuestros datos, los trozos relevantes quedaron a distancia ≈0.45–0.55 y los irrelevantes por encima de 0.6; por eso el default es 0.6 (y el test usa un caso extremo con 0.35). Con tus documentos los números cambian: imprime las distancias de tus evals y elige el corte mirándolas. Es la misma lógica que un umbral de Ct en qPCR.

Paso 8 Medir la recuperación: recall@5

Escribe evals/preguntas.json: 20 preguntas donde tú sabes en qué documento (y página) está la respuesta. Es tu control positivo. El módulo inventario.rag.evals reingesta los docs en una DB temporal con el chunking que le pidas y calcula: ¿en cuántas preguntas el documento correcto apareció en el top-5?

evals/preguntas.json (fragmento; 20 en total)
[
  {"pregunta": "¿A qué temperatura y por cuánto tiempo se incuba la lisis con proteinasa K?",
   "documento": "extraccion-adn-columna.md", "pagina": 1},
  {"pregunta": "¿Cuántas unidades de Taq polimerasa lleva una reacción de 25 microlitros?",
   "documento": "pcr-punto-final.md", "pagina": 1},
  {"pregunta": "¿A qué voltaje y por cuánto tiempo se corre el gel de agarosa?",
   "documento": "electroforesis-agarosa.pdf", "pagina": 2},
  …
]

Ahora el experimento. Tres chunkings, mismo comando, y que decida el número:

uv run python -m inventario.rag.evals --tam 900 --solape 100
uv run python -m inventario.rag.evals --tam 500 --solape 50
uv run python -m inventario.rag.evals --tam 280 --solape 40
Deberías ver (resultados reales de esta guía)
docs=4 trozos=8  tam=900 solape=100 k=5
recall@5 documento: 95%   documento+página: 95%   (n=20)

docs=4 trozos=14 tam=500 solape=50 k=5
recall@5 documento: 100%   documento+página: 100%   (n=20)

docs=4 trozos=25 tam=280 solape=40 k=5
recall@5 documento: 100%   documento+página: 100%   (n=20)

¿Cuál falló con trozos de 900? Pídele el detalle (--detalle):

Deberías ver
--  ¿Cuántos ciclos lleva el programa del termociclador?
    ->  ['electroforesis-agarosa.pdf, p. 2', 'electroforesis-agarosa.pdf, p. 1', 'cultivo-ecoli.md, p. 1']
La trampa del trozo gigante, medida. Con tam=900 el protocolo de PCR entero cabe en un solo trozo: su embedding promedia mezcla de reacción + programa + controles y termina lejos de la pregunta concreta por los ciclos — hasta el punto de que ganan documentos equivocados. Con tam=500 vuelve el 100%. Y 280 también da 100% pero con 25 trozos (casi el doble de vectores que almacenar y comparar: más costo, cero ganancia aquí). Decisión: 500/50, anotada en DECISIONES.md con esta tabla. Con tus documentos reales repite el experimento; el óptimo puede ser otro. Bonus: corre con --k 3 (a nosotros nos dio 95%/90%) para ver que k también es un dial.
Complejidad. vec0 compara tu pregunta contra todos los vectores: O(n), y con 14 —o 10.000— trozos va sobrado. Con millones de trozos usarías un índice aproximado (ANN, como HNSW): responde en O(log n) a cambio de aproximar — puede saltarse al vecino exacto. El mismo trade-off del índice del P3 y del k-mer de BLAST: pagar estructura para buscar rápido, y aquí además pagar exactitud. Tu recall@5 es quien te dice si esa moneda te costó algo.
git add . && git commit -m "RAG: consulta con cita, umbral de no-sé y evals de recall@5"

Paso 9 Docker, PR y cierre

El modelo (~240 MB) no va dentro de la imagen: se descarga al primer uso en /modelos, que montas como volumen para no bajarlo en cada corrida. Agrega al Dockerfile del proyecto:

Dockerfile (líneas nuevas)
# El modelo de embeddings NO va en la imagen (240 MB): se descarga al primer uso
# en /modelos, que montamos como volumen para no bajarlo en cada corrida.
ENV RAG_MODEL_CACHE=/modelos
docker build -t inventario-rag .
docker run --rm -v rag-modelos:/modelos -v "$PWD/docs:/app/docs" inventario-rag \
  uv run inv rag ingest docs

Cierra con el ritual de siempre: uv run pytest y uv run ruff check . en verde, DECISIONES.md al día (modelo elegido y por qué, chunking con la tabla del experimento, umbral, sqlite-vec vs alternativa), push y PR para Rodrigo:

git push -u origin feat/rag
gh pr create --title "RAG de protocolos con métrica de recuperación" \
  --body "Ingesta idempotente (fastembed + sqlite-vec), consulta con cita y umbral de no-sé. recall@5 = 100% (n=20) con tam=500/solape=50; experimento en DECISIONES.md." \
  --reviewer rotorrest

Entiende el código

Estructura nueva

inventario-lab/
├── src/inventario/
│   ├── rag/
│   │   ├── store.py       persistencia: documentos, trozos, vectores (sqlite-vec)
│   │   ├── ingest.py      hash → páginas → trozos → embeddings → store
│   │   ├── query.py       pregunta → top-k → contexto con citas → responder (P8)
│   │   └── evals.py       recall@k sobre evals/preguntas.json
│   ├── asistente.py       (P8) responder(pregunta, contexto) — sin cambios de firma
│   └── cli.py             + grupo `inv rag`: ingest / buscar / preguntar
├── docs/                  tus protocolos y papers (.pdf .md .txt)
└── evals/preguntas.json   20 preguntas con documento y página esperados

Las decisiones que importan

  • Ingesta y consulta separadas, con la DB en medio. Puedes reingestar de noche sin tocar la consulta, y consultar sin tener los PDFs a mano. Es la misma separación load/transform del P2, a escala de sistema.
  • Embeddings locales (fastembed): gratis, reproducible, y los documentos no salen de tu máquina. El precio: un modelo pequeño; con documentos difíciles, un modelo por API daría mejor recall. Tu métrica te dirá si lo necesitas.
  • sqlite-vec y no una base vectorial dedicada: una dependencia mínima sobre una tecnología que ya dominas del P3. Si sqlite-vec te diera problemas de instalación, la alternativa honesta a este tamaño es una tabla normal con los vectores en BLOB + numpy calculando cosenos: 20 líneas, mismo resultado hasta decenas de miles de trozos.
  • Cada trozo sabe de dónde vino (documento, página, posición). La cita no es un adorno: es lo que convierte "el modelo dice" en "el protocolo dice, página 2, míralo".
  • El "no sé" vive antes del LLM. Si la recuperación no trajo nada relevante, no hay nada que preguntar: se corta ahí, gratis y sin riesgo de invención.

Con Claude

A esta altura Claude ya escribe contigo. Lo que no se delega en este proyecto: elegir el chunking (lo decide tu experimento), escribir las 20 preguntas de evals (nadie más sabe qué debería ser recuperable de tus documentos) y el umbral del "no sé".

Pedidos que sí valen la pena

Explícame la tabla virtual vec0 de sqlite-vec: qué hace MATCH, qué es distance_metric=cosine, y qué pasa si inserto un vector de otra dimensión. Mi recall@5 con mis papers reales es 70%. Aquí está la salida con --detalle [pegar]. ¿Qué patrones ves en las que fallan? Dame tres hipótesis ordenadas y cómo probar cada una. Propón 5 preguntas de evals difíciles para este protocolo [pegar]: que la respuesta esté, pero dicha con otras palabras. ¿Cuándo me convendría un re-ranker o búsqueda híbrida (BM25 + vectores)? ¿Qué mediría para saber si lo necesito? Revisa mi trocear(): ¿qué casos borde no cubren mis tests? No los arregles; lístalos.

Si algo falla

sqlite3.OperationalError: no such module: vec0

La extensión no se cargó. Revisa que llamas a sqlite_vec.load(db) antes de crear/usar la tabla, entre enable_load_extension(True) y (False). En macOS, el Python de python.org a veces compila sqlite sin soporte de extensiones: usa el Python de uv (uv run python), que sí lo trae — otra razón para no correr nada fuera del entorno.

La primera corrida tarda minutos o parece colgada

Está descargando el modelo (~240 MB) a la caché. Pasa una sola vez; verás la carpeta en ~/.cache o en RAG_MODEL_CACHE si la definiste. Si el laboratorio tiene proxy, exporta HTTPS_PROXY. En los tests, la fixture con scope="session" evita recargar el modelo en cada test: sin eso, la suite tarda 10× más.

extract_text() devuelve vacío o basura para un PDF

Ese PDF es una imagen escaneada (no tiene capa de texto) o usa una codificación rara. Compruébalo: ábrelo y prueba a seleccionar texto con el cursor. Si no se puede, necesitas OCR (p. ej. ocrmypdf) antes de ingestar: pásalo por OCR una vez y guarda el resultado en docs/. No intentes "arreglarlo" en el código de ingesta.

Reingesto y los trozos se duplican

Tu clave de idempotencia no es estable. Causas típicas: guardaste la ruta relativa una vez y absoluta otra (usa siempre path.resolve()), o el archivo cambia de verdad en cada corrida (un PDF regenerado cambia metadatos → cambia el hash). El test test_reingesta_no_duplica existe para esto: si falla, la ingesta miente.

recall@5 bajo, y las preguntas fallidas recuperan el documento correcto pero la página equivocada

El chunking está cortando la respuesta en dos, o el trozo que la contiene es demasiado grande y su embedding se diluye. Baja --tam, sube el solape, y mira los trozos culpables imprimiéndolos. Si la respuesta vive en una tabla del PDF, recuerda que extract_text aplana tablas de forma mediocre: a veces conviene convertir esa tabla a texto plano en el documento fuente.

El sistema responde con seguridad algo que no está en los documentos

El umbral está muy alto (deja pasar trozos irrelevantes y el LLM rellena) o el prompt del P8 no exige el "NO_SE". Imprime las distancias de los hits de esa pregunta y compáralas con las de tus evals; ajusta UMBRAL_DISTANCIA. Y agrega esa pregunta a un eval negativo: preguntas cuya respuesta correcta es "no sé" también se miden.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"Tu recall@5 es 100% con 4 documentos. Mañana ingieres 200 papers, ¿sigue valiendo el número? ¿Qué cambiarías de tus evals antes de creértelo?" (Pista: las 20 preguntas también tienen que crecer y cubrir los documentos nuevos — un control de hace un mes no controla el experimento de hoy.)

Siguiente

En el Proyecto 10 · Servidor MCP le das la vuelta a la mesa: en vez de que tú preguntes al sistema, un agente (Claude) usa tu inventario y tu RAG como herramientas — con permisos, confirmaciones y auditoría. Es el cierre: cuatro puertas de entrada al mismo sistema, y tú decides qué puede tocar cada una.