Saltar al contenido
19/30Capítulo 19 de 30

RAG en producción: chunking, recuperación y citas honestas

Cortar a ciegas en 512 caracteres destruye respuestas antes del retriever. Solo arreglar el chunker mueve el rango 115 al 3.

En esta página

Esta es una pregunta real de un usuario real de un asistente real: mi conjunto de evaluación tiene 20 elementos, ¿basta para fiarse de la puntuación?. El corpus contiene la respuesta: una sección entera. Estos son los cuatro fragmentos que el retriever puso realmente en el prompt.

four fragments, chunked blind at 512 charactersTEXT
[1] d=0.578  ship — that set has been used for fitting, and its score stops being
             unbiased. Measured on this belt: sweeping the threshold on the
             validation set picks 0.196, and the model then scores F1 = 0.4122…

[2] d=0.602  ng when the model is confidently **wrong**. Evaluate both at a few
             scores, for an example whose true label is 1: | score | p | …

[3] d=0.613  ard and watch both numbers: | | reward model's score | true quality
             | length produced | … The reward went up by a factor of 2.5. The…

[4] d=0.617  | 0.6 | +0.97 | +1.00 | +0.27 | … The reward model is working
             perfectly. It has faithfully learned the preferences it was shown…

Tres de los cuatro empiezan en mitad de una palabra. Dos vienen de un capítulo distinto sobre otro tema. Y el fragmento que responde a la pregunta —el que contiene Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one— volvió en rango 115.

Ahora la misma pregunta, el mismo embedding model, la misma plantilla de prompt. Cambió una sola cosa: cómo se cortaron los documentos.

four fragments, cut on section boundaries with a contextual headerTEXT
[1] d=0.594  [Classification, Cross-Entropy… > How many test examples do I need?]
             Read it backwards, which is how you will use it: ±5 points needs
             about 200 examples. ±2 points needs about 1,230…

[2] d=0.598  [Classification, Cross-Entropy… > Three splits, and the leak…]
             Why three splits and not two? Because the moment you use a set of
             examples to *choose* anything…

[3] d=0.600  [Classification, Cross-Entropy… > How many test examples do I need?]
             The honest reading of 17/20 is *somewhere between 64 % and 95 %*.
             …Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one.

[4] d=0.605  [Classification, Cross-Entropy… > How many test examples do I need?]
             Suppose you score a model on 20 examples and it gets 17 right. You
             report 85 %. …Wilson 95% CI : [0.6396, 0.9476]

Del rango 115 al rango 3. Nadie tocó el model, el prompt, el umbral ni el número de huecos. Este capítulo trata de esa brecha y de los otros cuatro lugares donde un sistema de recuperación te miente en silencio.

Mostrar detalles

Qué necesita este capítulo de los capítulos anteriores, y el único lugar donde cambia el lenguaje.

  • Capítulo 1 definió el producto escalar y la norma L2. La sección de umbrales de abajo usa esas dos cosas, y nada más.
  • Capítulo 8 separó la tabla de embedding de un modelo de lenguaje de un embedding model de recuperación entrenado de forma contrastiva con pares, midió la similitud coseno y terminó prometiendo que el capítulo 19 llegaría a un corte concreto. Esa promesa se cumple aquí. Nada de eso se repite.
  • Capítulo 4 construyó el intervalo de Wilson; Capítulo 15 construyó el harness de evaluación. Todas las tablas de abajo llevan lo primero y fueron producidas por lo segundo.
  • Capítulo 16 puso precio a la context window. El prompt ensamblado al final de este capítulo cuesta 591 tokens, y ese es el presupuesto por el que compiten los fragmentos.

Todo aquí es TypeScript, como desde el Capítulo 14, y este capítulo es donde la regla se justifica sola: la ingesta son colas y almacenamiento, la búsqueda es una llamada de red, y ensamblar un prompt con citas es trabajo del servidor. La medición es el mismo código con un marcador alrededor, a propósito: un retriever evaluado por una segunda implementación es un número sobre software que no vas a desplegar, y el umbral de coseno de abajo solo es creíble porque lo ves barrerse con el chunker que se ejecutará en producción.

El corpus, y qué cuenta como respuesta correcta

Enlace a la sección: El corpus, y qué cuenta como respuesta correcta

Todo lo siguiente se mide contra un corpus: los trece primeros capítulos de este curso —13 documentos, 359.067 caracteres, 127 secciones, con el front matter y las bibliografías retirados. Es un corpus técnico real, con prosa, tablas, fórmulas y bloques de código, y es exactamente el tipo de cosa que la gente carga en una knowledge base y luego se queja.

La verdad de referencia son 32 preguntas, cada una emparejada con una aguja: una frase breve y literal del corpus que la responde. Cada aguja aparece exactamente una vez en los 359.067 caracteres, y ninguna es un encabezado de sección; esa comprobación importa, porque un chunker que copia encabezados en cada chunk se puntuaría a sí mismo. Cada pregunta se formula dos veces, una en el inglés del curso y otra como la redactaría un ticket de soporte: 64 consultas sobre 32 verdades de referencia.

Una recuperación es correcta cuando un chunk devuelto contiene la aguja entera. Esa es la única definición que coincide con lo que necesita el generador: media frase en el prompt no es una respuesta, es un riesgo.

El embedding model es all-MiniLM-L6-v2: 384 dimensiones, mean-pooled y normalizado, el modelo entrenado de forma contrastiva que midió el capítulo 8. Indexar el corpus tarda 20,8 segundos en CPU, 22 ms por chunk; generar el embedding de una consulta tarda 13 ms.

Seis estrategias a partir de tres ingredientes independientes. A ciegas corta cada 512 caracteres sin mirar el texto. Límites nunca corta dentro de un párrafo, y solo recurre a un límite de frase cuando un párrafo supera el presupuesto. Encabezado antepone a cada chunk el título del documento y la ruta de sección. Overlap copia los últimos 64 caracteres del chunk anterior en el siguiente.

estrategiachunksrespuestas destruidasR@1R@4R@8R@20MRR
A ciego 5127084 / 320,1250,2970,4220,5940,241
B ciego + overlap80900,1720,3910,4530,6250,286
C límites94000,1560,4220,5310,6720,293
D límites + overlap94000,1560,3590,5160,6560,277
E límites + encabezado94000,0940,4220,5780,8280,280
F límites + encabezado + overlap94000,1560,3910,5620,7660,298

Con 64 consultas, el intervalo de Wilson al 95 % en R@20 es [0,471, 0,705] para A y [0,718, 0,901] para E: no se solapan, pero la mayoría de las demás columnas sí, y una tabla no emparejada no puede separarlas. Todas las estrategias responden a las mismas consultas, así que la prueba honesta es emparejada: cuenta las victorias y derrotas de cada estrategia contra otra y aplica una prueba de signos sobre los pares discordantes. Tres resultados sobreviven.

El blind chunking destruye directamente cuatro de las treinta y dos respuestas. No las ordena mal: las destruye. La aguja cruza un límite de 512 caracteres, así que ningún chunk del índice la contiene, y el techo de recall para esas consultas es cero. Ningún reranker las recupera, ningún umbral ayuda, ningún modelo mayor ayuda. No puedes recuperar texto que no está entero en ningún lugar de tu índice. Este es el fallo más infrarreportado en RAG, porque se parece exactamente a un mal retriever.

El overlap arregla eso y nada más. Todas las estrategias con overlap pierden cero respuestas, que es para lo que sirve el overlap. No mejora el ranking: B contra A en R@8 es +8/−6, p = 0,79; en R@20 es +9/−7, p = 0,80. Peor aún, añadir overlap encima del encabezado perjudica activamente: F contra E es +2/−6 en R@20, y la razón es mecánica. El vector de un chunk es una media sobre sus tokens, así que 64 caracteres del chunk anterior arrastran esa media hacia el tema del vecino. El overlap es un seguro contra una respuesta partida, pagado en precisión.

El encabezado contextual es lo que compra la recuperación. E contra A es +18/−3 en R@20, p = 0,0015. Y la ablación dice que los límites no lo explican: E contra C —los mismos cortes, el encabezado como única diferencia— es +12/−2, p = 0,0129. Anteponer «Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?» a un párrafo le dice al embedding model de qué trata el párrafo, algo que el propio párrafo a menudo no dice. Es un resolvedor de pronombres para documentos.

Eso da forma al chunker, y una regla que es fácil equivocarse:

chunk.tsTS
export interface Chunked {
  /** What gets EMBEDDED: contextual header + this chunk's own content. */
  text: string;              
  /** ONLY this chunk's own content: what is quoted back to the user. */
  content: string;           
  section: string;
  /** Character range in the document's canonical text. Sliceable. */
  from: number;
  to: number;
}

export function chunkDocument(doc: string, docTitle: string, target = 512): Chunked[] {
  const out: Chunked[] = [];
  const heads = [...doc.matchAll(/^## (.+)$/gm)].map((m) => ({ at: m.index!, title: m[1].trim() }));
  const spans = heads.length
    ? heads.map((h, i) => ({ ...h, end: i + 1 < heads.length ? heads[i + 1].at : doc.length }))
    : [{ at: 0, title: "", end: doc.length }];

  for (const s of spans) {
    const header = s.title ? `${docTitle} > ${s.title}` : docTitle;     
    const skip = /^## .+\n/.exec(doc.slice(s.at, s.end))?.[0].length ?? 0;
    const body = doc.slice(s.at + skip, s.end);
    const origin = s.at + skip;

    // The offset is FOUND in the document, never accumulated: adding up
    // lengths drifts by a character wherever a separator was normalised,
    // and a citation anchor off by one points at the wrong line.
    const emit = (from: number, to: number) => {
      const raw = body.slice(from, to);
      const lead = raw.length - raw.trimStart().length;
      const content = raw.trim();
      if (!content) return;
      out.push({ text: `[${header}]\n${content}`, content, section: s.title,
                 from: origin + from + lead, to: origin + from + lead + content.length });
    };

    let open: [number, number] | null = null;
    for (const m of body.matchAll(/[^\n]([^\n]|\n(?!\n))*/g)) {          // paragraphs
      const [pf, pt] = [m.index!, m.index! + m[0].length];
      if (pt - pf > target) {                                            // one huge paragraph
        if (open) { emit(open[0], open[1]); open = null; }
        let cur: [number, number] | null = null;
        for (const sm of body.slice(pf, pt).matchAll(/[^.!?]*[.!?]*\s*/g)) {
          if (!sm[0]) continue;
          const [sf, st] = [pf + sm.index!, pf + sm.index! + sm[0].length];
          if (cur && st - cur[0] > target) { emit(cur[0], cur[1]); cur = null; }
          cur = cur ? [cur[0], st] : [sf, st];
        }
        if (cur) emit(cur[0], cur[1]);
        continue;
      }
      if (open && pt - open[0] > target) { emit(open[0], open[1]); open = null; }
      open = open ? [open[0], pt] : [pf, pt];
    }
    if (open) emit(open[0], open[1]);
  }
  return out;
}

Dos textos, no uno. text es lo que se convierte en embedding, con encabezado y todo. content son solo las propias palabras de este chunk, y es lo que se cita al usuario. Cita text y la cita muestra un encabezado que no está en el documento en ese punto y, con overlap, una cola repetida que pertenece al fragmento anterior. Entonces muestra texto que no está donde dice estar, lo cual es peor que no mostrar nada.

El encabezado no es gratis. En los 940 chunks cuesta 24.213 de los 114.275 tokens embebidos del índice: el 21,2 % de lo que pagas por embeber es un encabezado que escribiste tú. También empuja los chunks contra la ventana del encoder. all-MiniLM-L6-v2 acepta 256 word-pieces; la estrategia E tiene 17 chunks por encima de esa línea y F tiene 28, todos truncados en silencio sin aviso de nada. Tu tamaño efectivo de chunk no es el número de tu configuración: es el menor entre ese número y la ventana de tu encoder.

Veinte líneas de BM25, que todo el mundo se salta

Enlace a la sección: Veinte líneas de BM25, que todo el mundo se salta

La recuperación densa tiene una debilidad sistemática y no es sutil: empareja significado, así que le da igual qué cadena exacta has escrito. Un número de pieza, un código de error, un acrónimo, un apellido: nada de eso tiene un significado útil que embeber, y el vecino más cercano de un código de error es cualquier otro código de error de tu corpus.

La respuesta clásica es más antigua que todo esto y ocupa veinte líneas. BM25 puntúa un documento según la frecuencia con que aparecen en él los términos de la consulta, amortiguando cada término a medida que aumenta su frecuencia y penalizando documentos largos que acumulan coincidencias por pura longitud.1 El término tt aporta

idf(t)ft,d(k1+1)ft,d+k1(1b+bdd)\mathrm{idf}(t)\cdot\frac{f_{t,d}\,(k_1+1)}{f_{t,d} + k_1\left(1 - b + b\,\frac{|d|}{\overline{|d|}}\right)}

donde ft,df_{t,d} es el recuento del término en el documento, d|d| su longitud, d\overline{|d|} la longitud media, y k1=1.2k_1 = 1.2 y b=0.75b = 0.75 las dos constantes convencionales: k1k_1 marca lo rápido que deja de ayudar la repetición, bb lo fuerte que se castiga la longitud.

bm25.tsTS
const toks = (s: string) => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];

export class BM25 {
  private tf: Map<string, number>[] = [];
  private len: number[] = [];
  private idf = new Map<string, number>();
  private avg = 0;
  private k1: number; private b: number;
  constructor(docs: string[], k1 = 1.2, b = 0.75) {
    this.k1 = k1; this.b = b;
    const df = new Map<string, number>();
    for (const d of docs) {
      const t = new Map<string, number>(); const ws = toks(d);
      for (const w of ws) t.set(w, (t.get(w) ?? 0) + 1);
      for (const w of t.keys()) df.set(w, (df.get(w) ?? 0) + 1);
      this.tf.push(t); this.len.push(ws.length);
    }
    this.avg = this.len.reduce((a, b) => a + b, 0) / this.len.length;
    const N = docs.length;
    for (const [w, n] of df) this.idf.set(w, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
  }
  scores(query: string): number[] {
    const q = toks(query);
    return this.tf.map((tf, i) => {
      const L = this.len[i]; let s = 0;
      for (const w of q) {
        const f = tf.get(w); if (!f) continue;
        s += (this.idf.get(w) ?? 0) * (f * (this.k1 + 1)) /
             (f + this.k1 * (1 - this.b + (this.b * L) / this.avg));
      }
      return s;
    });
  }
}

Sobre 940 chunks, eso puntúa una consulta en 1,14 ms sin índice alguno más allá de dos mapas hash. Y no es una pieza de museo:

retrieverR@1R@4R@8MRRcoste por consulta
denso (coseno)0,0940,4220,5780,28013 ms para embeber + 0,3 ms para escanear
léxico (BM25)0,2190,3750,4690,3131,14 ms
híbrido (RRF)0,2030,4840,6090,346ambos
híbrido + cross-encoder0,3120,5780,7030,447+ 569 ms

BM25 más que duplica la precisión top-1 del retriever denso en este corpus, y pierde claramente contra él en el rango 8. Fallan en consultas distintas, que es todo el argumento para ejecutar ambos.

Fusionarlos es el único lugar donde el enfoque obvio es incorrecto. Las distancias de coseno y las puntuaciones de BM25 no están en la misma escala, no están acotadas del mismo modo, y normalizarlas por consulta hace que el peso dependa de lo bueno que resultara ser el mejor acierto. Reciprocal rank fusion tira las puntuaciones y se queda solo con los rangos:2

RRF(d)=lists1k+rank(d),k=60\mathrm{RRF}(d) = \sum_{\text{lists}} \frac{1}{k + \mathrm{rank}(d)}, \qquad k = 60
retrieve.tsTS
/** Reciprocal rank fusion: ranks, not scores. Nothing to calibrate. */
export function rrf(lists: number[][], k = 60): number[] {
  const acc = new Map<number, number>();
  for (const list of lists)
    list.forEach((id, r) => acc.set(id, (acc.get(id) ?? 0) + 1 / (k + r + 1)));
  return [...acc.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}

Y aquí la lectura honesta de la tabla importa más que la tabla. El híbrido supera a BM25 en R@4 por +10/−3, p = 0,09. Supera al denso por +10/−6, p = 0,45. En este corpus, con 64 consultas, la recuperación híbrida no se distingue de la recuperación densa. Es mejor en ambas estimaciones puntuales y en todas las columnas de recall, y la evidencia no alcanza significación. Casi todas las entradas de blog sobre búsqueda híbrida de internet publican una tabla como la anterior y ningún intervalo; esto es lo que dice el intervalo.

Bi-encoder, cross-encoder y dónde está realmente la mejora

Enlace a la sección: Bi-encoder, cross-encoder y dónde está realmente la mejora

Todo hasta ahora es un bi-encoder: la consulta pasa por el modelo sola, cada chunk pasó por él solo hace meses, y ambos nunca se encuentran salvo como producto escalar. Eso es lo que hace posible un índice —embeber una vez, reutilizar siempre— y también es el techo. El modelo nunca mira la consulta y el chunk juntos.

Un cross-encoder hace exactamente eso: toma el par como una sola entrada y devuelve una puntuación de relevancia. Nada puede precalcularse, así que no puede ordenar un índice, pero sí puede rerankear una lista corta. Rerankear el top 25 híbrido con ms-marco-MiniLM-L-6-v2 mueve R@1 de 0,094 (denso) a 0,312 y MRR de 0,280 a 0,447: la mayor mejora individual de este capítulo, y la única que toca la parte alta de la lista en vez de la cola.

Cuesta 569 ms por consulta en CPU, frente a 1,14 ms para BM25 y 0,3 ms para el escaneo vectorial. Aproximadamente dos mil veces el coste de recuperación, para veinticinco documentos. Ese es todo el intercambio bi-encoder/cross-encoder en un número, y por eso la arquitectura siempre tiene la misma forma: un retriever barato con recall amplio, luego un evaluador caro sobre una lista corta que puedas permitirte. ColBERT se sitúa entre ambos, precalculando vectores por token y haciendo una interacción tardía más barata que un cross-encoder y más afilada que un producto escalar.3

Las bases de datos vectoriales informan distancias, y qué distancia usan es una opción de configuración. En vectores normalizados la elección es cosmética, y merece la pena hacer la identidad una vez porque todo lo que viene después depende de que los vectores sean realmente unitarios. Para a=b=1\lVert a \rVert = \lVert b \rVert = 1:

ab2=a2+b22ab=22cosθ\lVert a - b \rVert^2 = \lVert a \rVert^2 + \lVert b \rVert^2 - 2\,a \cdot b = 2 - 2\cos\theta

así que la distancia coseno 1cosθ1 - \cos\theta es exactamente d2/2d^2/2. Ese es el producto escalar y la norma del capítulo 1, cobrados. Comprobado en dos vectores de chunk reales del índice anterior, y luego sobre 40.000 pares:

TEXT
||a|| = 1.000000   ||b|| = 1.000000
L2 = 0.795183   L2^2/2 = 0.316158   1 - cos = 0.316158   diff = 7.66e-08
max |L2^2/2 - (1 - cos)| over 200 x 200 pairs = 8.3e-07

Exacto hasta el ruido de punto flotante, y solo porque los vectores están normalizados. Sáltate la normalización y la identidad es falsa, tu umbral no significa nada, y la distancia que informa un documento depende de lo largo que era su texto.

Ahora el número que nadie deriva. Un retriever siempre devuelve algo: ordena todo el índice y te entrega la parte alta de la lista, esté o no la respuesta en el corpus. El umbral es la única parte del sistema que puede decir no, y para fijarlo necesitas consultas que no deberían devolver nada. Aquí hay treinta: veintiuna sobre cosas que este corpus realmente no cubre —streaming, límites de tasa, prompt caching, esquemas JSON, bucles de agent, bases de datos vectoriales, prompt injection, generación de imágenes— y nueve sobre paella, pasaportes y políticas de reembolso. Contra el mismo índice:

distancia coseno top-1
consultas dentro del dominio, las 64media 0,445, rango 0,270 – 0,721
dentro del dominio, top-1 realmente correctomedia 0,370
dentro del dominio, top-1 incorrectomedia 0,452
fuera del dominio, las 30media 0,699, rango 0,497 – 0,867

Las distribuciones se separan, y se solapan. La peor consulta dentro del dominio está más lejos de su respuesta (0,721) que la mejor consulta fuera del dominio de un párrafo irrelevante (0,497), así que ningún umbral acierta ambas. Barriéndolo sobre la puerta real —mantener como mucho cuatro chunks, y solo los que estén por debajo del corte—:

umbraldentro del dominio respondidasde ellas, la respuesta estaba incluidafuera del dominio respondidas
0,40017 / 6460 / 30
0,45038 / 64130 / 30
0,50050 / 64191 / 30
0,52552 / 64202 / 30
0,55055 / 64213 / 30
0,60060 / 64255 / 30
0,67562 / 642710 / 30
0,80064 / 642726 / 30
ninguno64 / 642730 / 30

Lee la última columna como faroles. Sin umbral, el asistente produce una respuesta segura y bien citada a «cómo renuevo mi pasaporte español» a partir de un corpus sobre backpropagation, treinta veces de treinta. A 0,675 lo hace diez veces de treinta. A 0,525 lo hace dos veces, y renuncia a doce preguntas que podría haber respondido.

Ese intercambio es una decisión de producto, y el extremo correcto depende de cuánto te cuesta una respuesta equivocada. Lo que no es negociable es que la última columna exista. Si nunca has medido tu retriever contra preguntas que debería rechazar, no tienes un umbral: tienes un número.

Dos de los diez faroles a 0,675 muestran las dos formas en que esto falla.

the two shapes of a confident wrong retrievalTEXT
query: "how much does prompt caching save on a long conversation"
  [1] d=0.497  13-inference-optimization > Prefill and decode are two different machines
  [2] d=0.532  13-inference-optimization > The cache is also the bill

query: "what is the capital of france"
  [1] d=0.671  12-reasoning > The model does not think. It computes for longer.
      "…it is why 'think step by step' does nothing for what is the capital of France."

El primero es un casi acierto: el corpus explica la KV cache en detalle, la consulta va sobre la prompt cache, las palabras son las mismas, y 0,497 está más cerca que la mayoría de recuperaciones correctas dentro del dominio en todo el experimento. Un embedding no sabe que dos cachés con el mismo nombre son máquinas distintas. El segundo es una coincidencia literal sin respuesta: el corpus contiene la frase exacta «what is the capital of France», usada como ejemplo de una pregunta que no necesita razonamiento. El retriever tiene razón; la respuesta no está ahí. Cualquier sistema que lea «he encontrado algo parecido» como «he encontrado la respuesta» afirmará París con esa evidencia o, peor, no lo hará.

Un modelo no tiene una facultad separada para los hechos. Producir una frase verdadera y producir una plausible son la misma operación —la predicción del siguiente token del capítulo 8—, y nada en esa operación marca cuál es cuál. El análisis de 2025 que replanteó esto sostiene que la tubería de entrenamiento y evaluación recompensa activamente adivinar: los benchmarks puntúan con exactitud binaria y no dan crédito por abstenerse, así que un modelo que responde siempre supera a un modelo idéntico que dice «no lo sé» cuando no lo sabe, y el postentrenamiento optimiza en consecuencia.6 La alucinación, leída así, no es un defecto misterioso. Es lo que obtienes cuando corriges un examen tipo test sin penalizar las respuestas erróneas.

Mira su forma. Al pedirle ocho artículos sobre embeddings de frases contrastivos, con identificadores, Qwen2.5-0.5B-Instruct produjo ocho líneas en formato perfecto. Los ocho identificadores están bien formados. Los ocho resuelven a artículos reales en arXiv. Cero de los ocho son el artículo que afirmaban ser.

8 references, checked one by one against the arXiv APITEXT
claimed  arXiv:1907.06432 - Contrastive Sentence Embeddings for Text Retrieval
actual   A Neural Turing~Machine for Conditional Transition Graph Modeling

claimed  arXiv:1809.08669 - Contrastive Learning of Sentence Representations…
actual   Collapsing Superstring Conjecture

claimed  arXiv:1807.08669 - Contrastive Learning of Sentence Representations…
actual   Automatic Speech Recognition for Humanitarian Applications in Somali

Este es un modelo pequeño y la tasa es la suya; un modelo frontier inventa muchos menos. El mecanismo generaliza, y es la razón de la regla que sigue. Un validador que comprueba «¿existe este identificador?» aprueba los ocho, y un usuario que hace clic aterriza en una página real de un archivo real sin forma de saber que el mapeo fue inventado. El fallo no está en el identificador ni en el formato. Está en la asociación: justo lo que un modelo de lenguaje produce por plausibilidad.

Así que: el modelo escribe [1] y [2], y nunca escribe el enlace. Los números remiten a fragmentos que el servidor recuperó, y el servidor —que sabe exactamente de qué documento y de qué offsets salió cada número— adjunta después el documento, la etiqueta y la URL. No hay nada que el modelo pueda inventar porque nunca se le pide lo único que inventaría.

prompt.tsTS
export function buildContext(question: string, hits: Scored[]) {
  const citations: Citation[] = hits.map((h, i) => ({
    index: i + 1,
    documentId: h.chunk.documentId,
    documentName: h.chunk.documentName,
    locatorLabel: label(h.chunk),
    fragment: `#char=${h.chunk.locator.flow.from},${h.chunk.locator.flow.to}`,  
    quote: h.chunk.content,          // the OWN content, never `text`
    cosineDistance: h.cosineDistance,
  }));
  const blocks = citations
    .map((c) => `[${c.index}] ${c.documentName} - ${c.locatorLabel}\n${c.quote}`)
    .join("\n\n");
  const prompt =
    `Answer using ONLY the numbered sources below. Cite every claim as [n].\n` +
    `If the sources do not contain the answer, say so and stop.\n\n` +
    `SOURCES\n${blocks}\n\nQUESTION\n${question}`;
  return { prompt, citations };
}

Ejecútalo sobre la pregunta inicial y los cuatro chunks se convierten en un prompt de 591 tokens y una tabla que el modelo nunca ve:

TEXT
[1] 04-classification  How many test examples do I need?      #char=28215,28701  d=0.594
[2] 04-classification  Three splits, and the leak…            #char=20329,20839  d=0.598
[3] 04-classification  How many test examples do I need?      #char=25873,26272  d=0.600
[4] 04-classification  How many test examples do I need?      #char=25554,25871  d=0.605

El localizador es la parte que la gente se salta y luego no puede añadir. #char=25873,26272 es un rango en el texto canónico del documento; para un PDF el equivalente es #page=12, para audio o vídeo #t=132.4,158.9, para una hoja de cálculo una hoja y un rango A1. Esos dos no son invenciones: #page= son PDF Open Parameters y #t= son W3C Media Fragments, respetados de forma nativa por los navegadores en elementos de vídeo y audio. Una cita sin localizador es un nombre de documento, y un nombre de documento no es una cita; es una sugerencia para que el usuario vaya a mirar.

Y cuando nada pasa el umbral, la tubería ni siquiera llega al modelo:

TEXT
NO ANSWER: nothing under cosine distance 0.675 for "what is the offside rule in football"
NO ANSWER: nothing under cosine distance 0.675 for "how do i renew my spanish passport"
NO ANSWER: nothing under cosine distance 0.675 for "how do i build an agent loop with tools"

Eso es un rechazo más barato y más fiable que cualquier instrucción en un system prompt, porque es una comparación entre dos números y no una petición a un sistema probabilístico.

Todas las mediciones de este capítulo puntúan el retriever y ni una sola vez piden a un modelo que escriba una respuesta. Es deliberado, y es la parte que se saltan la mayoría de equipos.

Un sistema RAG tiene dos modos de fallo que se ven idénticos desde fuera. El retriever no encontró el pasaje; o lo encontró y el generador lo ignoró, lo contradijo o lo mezcló con algo que ya creía. Puntúa solo la respuesta final y los dos son indistinguibles, así que ajustas prompts contra un problema que vive en tu chunker. Recall@k, MRR y el recuento de respuestas destruidas no necesitan ninguna llamada de generación, son lo bastante baratos para ejecutarse en cada deploy, y son el harness del capítulo 15 con una función de puntuación distinta: la misma petición, plazo, concurrencia y recuento, sobre un conjunto fijo de preguntas en vez de una conversación en vivo.

Infórmalos con intervalos. La aritmética del capítulo 4 se aplica sin cambios: con 64 consultas, un recall de 0,5 lleva un intervalo de Wilson al 95 % de aproximadamente ±0,12, así que una estrategia cuatro puntos por delante de otra no te ha dicho nada. Usa la prueba emparejada siempre que ambas estrategias respondan a las mismas preguntas, que aquí lo hacen siempre: es lo que convirtió «E parece mejor que A» en p = 0,0015.

Y la última honestidad: RAG reduce la alucinación y no la elimina. Poner el pasaje correcto en el prompt no obliga al modelo a usarlo, y la literatura lo dice desde el artículo original.7 Dos cosas lo empeoran en producción. Los contextos largos se degradan: un modelo encuentra información al principio y al final de un prompt largo con más fiabilidad que en el medio, así que veinte chunks en vez de cuatro pueden bajar la precisión mientras suben la factura, un efecto medido en el Capítulo 24. Y la recuperación puede ser correcta y aun así insuficiente, como mostraron las dos cachés anteriores. SelfCheckGPT marca afirmaciones que no sobreviven al remuestreo;8 Self-RAG entrena al modelo para emitir sus propios tokens de recuperar y criticar;9 TruthfulQA hizo legible el modo de fallo desde el principio.10 Nada cierra la brecha, y un sistema que presenta texto recuperado como prueba ha confundido con fuente con verdadero.

La mitad del sistema que se ejecuta antes de cualquier consulta

Enlace a la sección: La mitad del sistema que se ejecuta antes de cualquier consulta

Un retriever es la parte visible de una tubería cuyos fallos ocurren todos antes, en la oscuridad. Tres se repiten.

La extracción es donde muere el contenido. Un PDF no es texto; son instrucciones de dibujo. Los diseños a dos columnas se entremezclan, las tablas se convierten en sopa de palabras, los encabezados de página se repiten en cada chunk, y una página escaneada no tiene texto alguno hasta que el OCR se lo da, con una confianza. Todo lo medido arriba asumía que el extractor hizo su trabajo; en producción a menudo no lo hace, y el síntoma aparece como mala recuperación tres capas más lejos.

El índice queda sellado con el modelo que lo construyó. Los embeddings de dos modelos no son comparables: no «menos precisos», sino no comparables, porque son puntos en espacios distintos. Cambia el embedding model y cada vector del almacén es basura hasta que se reconstruya. Por eso el nombre del modelo, el número de dimensiones, la versión de la tubería y la versión del extractor se escriben junto a cada documento al indexar. Sin ellos, el día de la actualización no puedes saber qué documentos están obsoletos y cuáles están al día, y un índice medio migrado devuelve disparates seguros sin error en ninguna parte.

Un documento roto no debe romper la carpeta, y los contadores deben contar lo ocurrido. Un documento cuya extracción falla termina en estado failed con su motivo, visible y reintentable, mientras los otros noventa y nueve siguen siendo buscables; y el número de chunks indexados lo escribe el servidor cuando termina, no lo declara el cliente al subirlo. Una carpeta que informa de 400 fragmentos y contiene 40 es una mentira que solo aflora como una pregunta imposible de responder.

El sistema de este capítulo responde preguntas cuyas respuestas están escritas. Las recupera, las ordena, se niega cuando no puede y cita dónde miró. Eso es la mayor parte de lo que la gente quiere de un asistente sobre sus propios documentos, y está acotado de una forma concreta: la recuperación solo puede devolver lo que alguien escribió.

Lo que deja la otra mitad. Parte de lo que quieres que haga un modelo no es un hecho dentro de un documento: un formato que debe mantener, un tono, una taxonomía con cuatrocientas etiquetas, una forma de decidir que vive en diez mil ejemplos pasados y en ningún párrafo. La recuperación no puede entregar eso, porque no hay nada que recuperar; un prompt más largo solo paga la factura del capítulo 16 por una descripción de una skill en vez de la skill.

El Capítulo 20 es esa decisión —fine-tune, recuperar o prompt— y su conclusión es que la decisión es económica antes que técnica: las tres opciones se presupuestan de extremo a extremo sobre la misma pregunta, y el cruce es un recuento de tokens. La pregunta que lo abre es la que este capítulo no puede responder. No dónde está escrita la respuesta, sino qué haces cuando nunca lo estuvo.


Todo lo medido en este capítulo usó un corpus y un instrumento, y ambos son reproducibles. El corpus son los capítulos 1 a 13 de este curso tal como estaban el 7 de septiembre de 2026: 13 documentos, 359.067 caracteres, 127 secciones, front matter y bibliografías eliminados. Esos capítulos siguen editándose, así que aplicar la misma regla hoy cuenta unos miles de caracteres más: el número de secciones no cambia y tampoco cambia ninguna conclusión de abajo, pero el total de caracteres es una instantánea y está etiquetado como tal. La verdad de referencia son 32 preguntas, cada una emparejada con una frase literal que aparece exactamente una vez en el corpus y nunca es un encabezado de sección, formuladas de dos maneras para 64 consultas. Los embeddings de recuperación son sentence-transformers/all-MiniLM-L6-v2 (384 dimensiones, mean-pooled, normalizados L2, ventana de 256 tokens); el reranking es cross-encoder/ms-marco-MiniLM-L-6-v2 sobre el top 25; el ejemplo de generación es Qwen/Qwen2.5-0.5B-Instruct con decodificación codiciosa. Todos los tiempos son de CPU monohilo. No se llamó a ninguna API de pago para producir este capítulo, por eso también cada latencia aquí es local y está etiquetada como tal.

El chunker mostrado en TypeScript es el chunker que se midió: el instrumento de Python que implementa la misma regla y ts/chunk.ts se compararon chunk a chunk sobre todo el corpus y coinciden en los 940 chunks, textos y offsets incluidos. Los intervalos son Wilson al 95 %; las comparaciones emparejadas son pruebas exactas de signos bilaterales sobre los pares discordantes.

Los catorce identificadores citados arriba se resolvieron contra la API de arXiv y se comprobaron título por título el 7 de septiembre de 2026; visto lo ocurrido con los ocho que no lo eran, parecía lo mínimo que podía hacer este capítulo en concreto.

  1. Robertson, S. y Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). La fuente de la función de saturación y de las dos constantes usadas arriba, y el lugar donde leer por qué bb existe siquiera.

  2. Cormack, G. V., Clarke, C. L. A. y Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. El k=60k = 60 es suyo, y el punto del método es que no necesita calibración entre las escalas de puntuación que fusiona.

  3. Khattab, O. y Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). El punto medio entre un producto escalar y un cross-encoder. Reimers, N. y Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), es el bi-encoder sobre el que se construye el índice de este capítulo y se midió en el capítulo 8.

  4. Malkov, Yu. A. y Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). El índice de grafos detrás de la mayoría de bases de datos vectoriales que se venden actualmente.

  5. Johnson, J., Douze, M. y Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, y la implementación de referencia del IVF medido en el recuadro anterior.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. y Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). El argumento de que la alucinación se produce por una calificación de exactitud binaria que nunca recompensa la abstención y, por tanto, es un problema de evaluación antes que de modelado.

  7. Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S. y Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020). El artículo que dio nombre al patrón y el que hay que leer para saber qué arregla y qué no. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), es el trabajo contemporáneo que entrena el retriever conjuntamente con el modelo en vez de atornillarlo después; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), es de donde viene el retriever denso de dos encoders usado en todo este capítulo; e Izacard y Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), es la disposición fusion-in-decoder para alimentar muchos pasajes a un generador. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), es el mapa de todo lo que vino después, incluido HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), que embebe una respuesta hipotética en vez de la pregunta.

  8. Manakul, P., Liusie, A. y Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). Detección por remuestreo, sin acceso a los internos del modelo y sin knowledge base externa.

  9. Asai, A., Wu, Z., Wang, Y., Sil, A. y Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). Entrenar al modelo para decidir cuándo recuperar, en vez de recuperar en cada turno.

  10. Lin, S., Hilton, J. y Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). El benchmark construido con preguntas donde la respuesta plausible y la verdadera difieren, que es toda la dificultad en una sola frase.

¿Listo para dejar que elija LIA?

Crea con todos los modelos de IA en un mismo sitio. Empieza gratis hoy.