Saltar ao contido
19/30Capítulo 19 de 30

RAG en produción: chunking, recuperación e citas honestas

Corta ás cegas a 512 caracteres e 4 de 32 respostas morren antes do retriever. Só arranxar o chunker move o posto 115 ao 3.

Nesta páxina

Aquí tes unha pregunta real dun usuario real dun asistente real: o meu conxunto de avaliación ten 20 elementos, abonda para confiar na puntuación. O corpus contén a resposta — unha sección enteira dela. Estes son os catro fragmentos que o retriever puxo realmente no 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 dos catro comezan no medio dunha palabra. Dous son doutro capítulo sobre outro asunto. E o fragmento que responde á pregunta — o que contén Dezasete de vinte non poden distinguir un modelo do 85 % dun do 65 % — volveu no posto 115.

Agora a mesma pregunta, o mesmo embedding model, o mesmo prompt template. Cambiou unha soa cousa: como se cortaron os 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]

Do posto 115 ao posto 3. Ninguén tocou o modelo, o prompt, o limiar nin o número de ocos. Este capítulo trata desa fenda, e dos outros catro lugares onde un sistema de recuperación che mente en silencio.

Mostrar detalles

O que este capítulo necesita dos capítulos anteriores, e o único lugar onde cambia a linguaxe.

  • Capítulo 1 definiu o produto escalar e a norma L2. A sección sobre limiares de abaixo é iso mesmo, e nada máis.
  • Capítulo 8 separou a táboa de embedding dun modelo de linguaxe dun modelo de embedding de recuperación adestrado de maneira contrastiva con pares, mediu a similitude coseno e rematou prometendo que o capítulo 19 chegaría a un corte concreto. Esa promesa vence aquí. Nada diso se repite.
  • Capítulo 4 construíu o intervalo de Wilson; Capítulo 15 construíu o harness de avaliación. Todas as táboas de abaixo levan o primeiro e foron producidas polo segundo.
  • Capítulo 16 puxo prezo ao context window. O prompt ensamblado ao final deste capítulo custa 591 tokens, e ese é o orzamento polo que compiten os fragmentos.

Todo aquí é TypeScript, como desde o Capítulo 14, e este capítulo é onde a regra se xustifica: a inxestión son colas e almacenamento, a busca é unha chamada de rede, e ensamblar un prompt con citas é traballo dun servidor. A medición é o mesmo código cun marcador arredor, adrede: un retriever puntuado por unha segunda implementación é un número sobre software que non estás a enviar, e o limiar de coseno de abaixo só é crible porque ves como o varre o chunker que correrá en produción.

Todo o de abaixo mídese contra un único corpus: os trece primeiros capítulos deste curso — 13 documentos, 359.067 caracteres, 127 seccións, coa portada interna e as bibliografías eliminadas. É un corpus técnico real, con prosa, táboas, fórmulas e bloques de código, e é exactamente o tipo de cousa que a xente carga nunha base de coñecemento e logo critica.

A verdade de referencia son 32 preguntas, cada unha emparellada cunha agulla: unha frase curta e literal do corpus que a responde. Cada agulla aparece exactamente unha vez nos 359.067 caracteres, e ningunha é un título de sección — esa comprobación importa, porque un chunker que copia títulos en cada chunk puntuaríase a si mesmo. Cada pregunta formúlase dúas veces, unha en inglés do curso e outra como a redactaría un ticket de soporte: 64 consultas sobre 32 verdades de referencia.

Unha recuperación é correcta cando un chunk devolto contén a agulla enteira. Esa é a única definición que encaixa co que necesita o xerador: media frase no prompt non é unha resposta, é un risco.

O embedding model é all-MiniLM-L6-v2 — 384 dimensións, con mean-pooling e normalizado, o modelo adestrado contrastivamente que mediu o capítulo 8. Indexar o corpus leva 20,8 segundos nunha CPU, 22 ms por chunk; facer embedding dunha consulta leva 13 ms.

Seis estratexias a partir de tres ingredientes independentes. Ás cegas corta cada 512 caracteres sen mirar o texto. Límites nunca corta dentro dun parágrafo, e só recorre ao límite dunha frase cando un parágrafo supera o orzamento. Cabeceira prefixa cada chunk co título do documento e a ruta da sección. Solapamento copia os últimos 64 caracteres do chunk anterior no seguinte.

estratexiachunksrespostas destruídasR@1R@4R@8R@20MRR
A cego 5127084 / 320.1250.2970.4220.5940.241
B cego + solapamento80900.1720.3910.4530.6250.286
C límites94000.1560.4220.5310.6720.293
D límites + solapamento94000.1560.3590.5160.6560.277
E límites + cabeceira94000.0940.4220.5780.8280.280
F límites + cabeceira + solapamento94000.1560.3910.5620.7660.298

Con 64 consultas, o intervalo de Wilson do 95 % en R@20 é [0.471, 0.705] para A e [0.718, 0.901] para E — non se solapan, pero a maioría das demais columnas si, e unha táboa non emparellada non pode separalas. Todas as estratexias responden ás mesmas consultas, así que a proba honesta é emparellada: conta as vitorias e derrotas de cada estratexia fronte a outra e executa unha proba de signos sobre os pares discordantes. Tres resultados sobreviven.

O chunking ás cegas destrúe directamente catro das trinta e dúas respostas. Non as coloca mal no ranking: destrúeas. A agulla queda atravesada por un límite de 512 caracteres, así que ningún chunk do índice a contén, e o teito de recall para esas consultas é cero. Ningún reranker as recupera, ningún limiar axuda, ningún modelo máis grande axuda. Non podes recuperar texto que non existe dunha peza en ningures do teu índice. Este é o fallo menos contado en RAG, porque se parece exactamente a un mal retriever.

O solapamento arranxa iso e nada máis. Todas as estratexias con solapamento perden cero respostas, que é para o que serve o solapamento. Non mellora o ranking: B contra A en R@8 é +8/−6, p = 0.79; en R@20 é +9/−7, p = 0.80. Peor aínda, engadir solapamento por riba da cabeceira prexudica activamente — F contra E é +2/−6 en R@20 — e a razón é mecánica. O vector dun chunk é unha media sobre os seus tokens, así que 64 caracteres do chunk anterior arrastran esa media cara ao tema do veciño. O solapamento é un seguro contra unha resposta partida, pagado con precisión.

A cabeceira contextual é o que compra recuperación. E contra A é +18/−3 en R@20, p = 0.0015. E a ablación di que non son os límites quen o fan: E contra C — os mesmos cortes, a cabeceira como única diferenza — é +12/−2, p = 0.0129. Prefixar «Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?» a un parágrafo dille ao embedding model de que trata o parágrafo, cousa que o propio parágrafo moitas veces non di. É un resolvedor de pronomes para documentos.

Iso dálle forma ao chunker, e unha regra fácil de 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;
}

Dous textos, non un. text é o que se embebe, cabeceira incluída. content son só as palabras propias deste chunk, e é o que se lle cita de volta ao usuario. Cita text e a cita amosa unha cabeceira que non está nese punto do documento — e, con solapamento, unha cola repetida pertencente ao fragmento anterior. Entón mostra texto que non está onde di estar, o cal é peor que non mostrar nada.

A cabeceira non sae gratis. Nos 940 chunks custa 24.213 dos 114.275 tokens embebidos do índice: o 21,2 % do que pagas por facer embedding é unha cabeceira que escribiches ti mesmo. Tamén empurra os chunks contra o context window do codificador. all-MiniLM-L6-v2 acepta 256 word-pieces; a estratexia E ten 17 chunks por riba desa liña e F ten 28, todos truncados en silencio sen aviso de nada. O teu tamaño efectivo de chunk non é o número da túa configuración: é o menor entre ese número e o context window do teu codificador.

Recibe novas publicacións na túa caixa de entrada

Novas de IA, guías e actualizacións do produto — un correo breve cando publiquemos algo que pague a pena.

A recuperación densa ten unha debilidade sistemática e non é sutil: emparella significado, así que lle dá igual exactamente que cadea escribiches. Un número de peza, un código de erro, unha sigla, un apelido: nada diso ten un significado útil para embedding, e o veciño máis próximo dun código de erro é calquera outro código de erro do teu corpus.

A resposta clásica é máis vella que todo isto e ocupa vinte liñas. BM25 puntúa un documento segundo a frecuencia coa que aparecen nel os termos da consulta, amortecendo cada termo a medida que sobe a súa frecuencia e penalizando documentos longos que acumulan coincidencias por pura lonxitude.1 O termo tt contribúe

idf(t)⋅ft,d (k1+1)ft,d+k1(1−b+b ∣d∣∣d∣‾)\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)}

onde ft,df_{t,d} é o reconto do termo no documento, ∣d∣|d| a súa lonxitude, ∣d∣‾\overline{|d|} a lonxitude media, e k1=1.2k_1 = 1.2 e b=0.75b = 0.75 as dúas constantes convencionais — k1k_1 fixa o rápido que a repetición deixa de axudar, bb o duro que se castiga a lonxitude.

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, puntúa unha consulta en 1,14 ms sen índice ningún alén de dous mapas hash. E non é unha peza de museo:

retrieverR@1R@4R@8MRRcusto por consulta
denso (coseno)0.0940.4220.5780.28013 ms para embedding + 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áis que duplica a exactitude top-1 do retriever denso neste corpus, e perde con claridade contra el no posto 8. Fallan en consultas diferentes, que é todo o argumento para executar ambos.

Fusionándoos é onde o enfoque obvio é incorrecto. As distancias coseno e as puntuacións BM25 non están na mesma escala, non teñen os mesmos límites, e normalizalas por consulta fai que o peso dependa de como de bo fose casualmente o mellor resultado. Reciprocal rank fusion tira as puntuacións e conserva só os postos: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);
}

E aquí a lectura honesta da táboa importa máis ca a táboa. O híbrido supera BM25 en R@4 por +10/−3, p = 0.09. Supera o denso por +10/−6, p = 0.45. Neste corpus, con 64 consultas, a recuperación híbrida non se distingue da recuperación densa. É mellor nas estimacións puntuais e en todas as columnas de recall, e a evidencia non chega á significación. Case todos os posts de blog sobre busca híbrida en internet presentan unha táboa como a anterior e ningún intervalo; isto é o que di o intervalo.

Bi-encoder, cross-encoder, e onde está realmente a mellora

Ligazón á sección: Bi-encoder, cross-encoder, e onde está realmente a mellora

Todo ata aquí é un bi-encoder: a consulta pasa polo modelo soa, cada chunk pasou por el só hai meses, e os dous nunca se atopan agás como produto escalar. Iso é o que fai posible un índice — facer embedding unha vez, reutilizar para sempre — e tamén é o teito. O modelo nunca mira a consulta e o chunk xuntos.

Un cross-encoder fai exactamente iso: toma o par como unha única entrada e devolve unha puntuación de relevancia. Nada se pode precalcular, así que non pode rankear un índice — pero si pode rerankear unha lista curta. O reranking do top 25 híbrido con ms-marco-MiniLM-L-6-v2 move R@1 de 0.094 (denso) a 0.312 e MRR de 0.280 a 0.447: a maior mellora individual deste capítulo, e a única que toca a parte alta da lista en vez da cola.

Custa 569 ms por consulta nunha CPU, fronte a 1,14 ms para BM25 e 0,3 ms para o escaneo vectorial. Aproximadamente dúas mil veces o custo de recuperación, para vinte e cinco documentos. Ese é todo o intercambio bi-encoder/cross-encoder nun único número, e por iso a arquitectura sempre ten a mesma forma: un retriever barato con recall amplo, despois un puntuador caro sobre unha lista curta que podes pagar. ColBERT queda entre os dous, precalculando vectores por token e facendo unha interacción tardía máis barata ca un cross-encoder e máis precisa ca un produto escalar.3

L2, coseno e un limiar que aínda non gañaches

Ligazón á sección: L2, coseno e un limiar que aínda non gañaches

As bases de datos vectoriais informan distancias, e que distancia usan é unha opción de configuración. En vectores normalizados a escolla é cosmética, e paga a pena facer a identidade unha vez porque todo o posterior depende de que os vectores sexan realmente unitarios. Para ∥a∥=∥b∥=1\lVert a \rVert = \lVert b \rVert = 1:

∥a−b∥2=∥a∥2+∥b∥2−2 a⋅b=2−2cos⁡θ\lVert a - b \rVert^2 = \lVert a \rVert^2 + \lVert b \rVert^2 - 2\,a \cdot b = 2 - 2\cos\theta

así que a distancia coseno 1−cos⁡θ1 - \cos\theta é exactamente d2/2d^2/2. Ese é o produto escalar e a norma do capítulo 1, cobrados. Comprobado en dous vectores de chunks reais do índice anterior, e logo en 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 ata o ruído de coma flotante — e só porque os vectores están normalizados. Salta a normalización e a identidade é falsa, o teu limiar non significa nada, e a distancia que informa un documento depende do longo que fose o seu texto.

Agora o número que ninguén deriva. Un retriever sempre devolve algo: ordena todo o índice e entrégache a parte superior da lista, estea ou non a resposta en calquera parte do corpus. O limiar é a única parte do sistema que pode dicir non — e para fixalo necesitas consultas que deberían non devolver nada. Aquí hai trinta: vinte e unha sobre cousas que este corpus de verdade non cobre — streaming, límites de taxa, prompt caching, esquemas JSON, agent loops, bases de datos vectoriais, prompt injection, xeración de imaxes — e nove sobre paella, pasaportes e políticas de reembolso. Contra o mesmo índice:

distancia coseno top-1
consultas dentro do dominio, as 64media 0.445, intervalo 0.270 – 0.721
dentro do dominio, top-1 realmente correctomedia 0.370
dentro do dominio, top-1 incorrectomedia 0.452
fóra do dominio, as 30media 0.699, intervalo 0.497 – 0.867

As distribucións sepáranse, e solápanse. A peor consulta dentro do dominio está máis lonxe da súa resposta (0.721) ca a mellor consulta fóra do dominio dun parágrafo irrelevante (0.497), así que ningún limiar acerta ambas. Varréndoo sobre a porta real — conserva como moito catro chunks, e só os que están por baixo do corte:

limiardentro do dominio respondidasnas que a resposta estaba dentrofóra do 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
ningún64 / 642730 / 30

Le a última columna como faroles. Sen limiar, o asistente produce unha resposta confiada e ben citada a «como renovo o meu pasaporte español» desde un corpus sobre backpropagation, trinta de trinta veces. En 0.675 faino dez de trinta. En 0.525 faino dúas, e renuncia a doce preguntas que podería ter respondido.

Ese intercambio é unha decisión de produto, e o extremo correcto depende do que che custe unha resposta incorrecta. O que non é negociable é que exista a última columna. Se nunca mediches o teu retriever contra preguntas que debería rexeitar, non tes un limiar: tes un número.

Dous dos dez faroles en 0.675 amosan as dúas maneiras en que isto 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."

O primeiro é un case acerto: o corpus explica o KV cache en detalle, a consulta vai sobre o prompt cache, as palabras son as mesmas palabras, e 0.497 está máis preto ca a maioría das recuperacións correctas dentro do dominio de todo o experimento. Un embedding non sabe que dúas cachés co mesmo nome son máquinas distintas. O segundo é unha coincidencia literal sen resposta: o corpus contén a frase exacta «cal é a capital de Francia», usada como exemplo dunha pregunta que non precisa razoamento. O retriever ten razón; a resposta non está alí. Calquera sistema que lea «atopei algo parecido» como «atopei a resposta» afirmará París con esa evidencia — ou, peor, non o fará.

Un modelo non ten unha facultade separada para os feitos. Producir unha frase verdadeira e producir unha plausible son a mesma operación — a predición do seguinte token do capítulo 8 — e nada nesa operación marca cal é cal. A análise de 2025 que reformulou isto sostén que a canalización de adestramento e avaliación recompensa activamente adiviñar: os benchmarks puntúan con exactitude binaria e non dan crédito por absterse, así que un modelo que sempre responde supera a un modelo idéntico que di «non o sei» cando non o sabe, e o post-adestramento optimiza en consecuencia.6 A alucinación, nesa lectura, non é un defecto misterioso. É o que obtés cando corrixes un exame tipo test sen penalizar unha resposta incorrecta.

Mira a súa forma. Ao pedirlle oito artigos sobre contrastive sentence embeddings, con identificadores, Qwen2.5-0.5B-Instruct produciu oito liñas en formato perfecto. Os oito identificadores están ben formados. Os oito resolven a artigos reais en arXiv. Cero dos oito son o artigo que dicían 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 é un modelo pequeno e a súa taxa é súa; un modelo frontier inventa moito menos. O mecanismo xeneralízase, e é a razón da regra que vén. Un validador que comprobe «existe este identificador» aproba os oito, e un usuario que prema nun deles aterra nunha páxina real dun arquivo real sen maneira de saber que o emparellamento foi inventado. O fallo non está no identificador nin no formato. Está na asociación — precisamente o que un modelo de linguaxe produce por plausibilidade.

Así que: o modelo escribe [1] e [2], e nunca escribe a ligazón. Os números refírense a fragmentos que o servidor recuperou, e o servidor — que sabe exactamente de que documento e de que offsets saíu cada número — engade despois o documento, a etiqueta e a URL. Non hai nada que o modelo poida inventar porque nunca se lle pide a única cousa 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 };
}

Execútao na pregunta inicial e os catro chunks convértense nun prompt de 591 tokens e nunha táboa que o 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

O localizador é a parte que a xente salta e logo non pode engadir máis tarde. #char=25873,26272 é un intervalo no texto canónico do documento; para un PDF o equivalente é #page=12, para audio ou vídeo #t=132.4,158.9, para unha folla de cálculo unha folla e un intervalo A1. Eses dous non son invencións — #page= é PDF Open Parameters e #t= é W3C Media Fragments, respectados de maneira nativa polos navegadores en elementos de vídeo e audio. Unha cita sen localizador é un nome de documento, e un nome de documento non é unha cita; é unha suxestión de que o usuario vaia mirar.

E cando nada pasa o limiar, a canalización nin sequera chega ao 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"

Iso é unha negativa máis barata e máis fiable ca calquera instrución nun system prompt, porque é unha comparación entre dous números e non unha petición a un sistema probabilístico.

Todas as medicións deste capítulo puntúan o retriever e nin unha soa vez lle piden a un modelo que escriba unha resposta. É deliberado, e é a peza que a maioría dos equipos salta.

Un sistema RAG ten dous modos de fallo que desde fóra parecen idénticos. O retriever non atopou a pasaxe; ou atopouna e o xerador ignorouna, contradíxoa ou mesturouna con algo que xa cría. Puntúa só a resposta final e os dous son indistinguibles, así que axustas prompts contra un problema que vive no teu chunker. Recall@k, MRR e o reconto de respostas destruídas non necesitan ningunha chamada de xeración, son baratos abondo para executalos en cada deploy, e son o harness do capítulo 15 cunha función de puntuación distinta — a mesma petición, prazo, concorrencia e reconto, sobre un conxunto fixo de preguntas en vez dunha conversa en vivo.

Infórmaos con intervalos. A aritmética do capítulo 4 aplícase sen cambios: con 64 consultas, un recall de 0.5 leva un intervalo de Wilson do 95 % de arredor de ±0.12, así que unha estratexia catro puntos por diante doutra non che dixo nada. Usa a proba emparellada sempre que ambas estratexias respondan ás mesmas preguntas, que aquí sempre o fan — foi o que converteu «E parece mellor ca A» en p = 0.0015.

E a última honestidade: RAG reduce a alucinación e non a elimina. Poñer a pasaxe correcta no prompt non obriga o modelo a usala, e a literatura vén dicíndoo desde o artigo orixinal.7 Dúas cousas empeórano en produción. Os contextos longos degrádanse — un modelo atopa información ao principio e ao final dun prompt longo con máis fiabilidade ca no medio, así que vinte chunks en vez de catro poden baixar a exactitude mentres soben a factura, un efecto medido no Capítulo 24. E a recuperación pode estar ben e seguir sendo insuficiente, como amosaron as dúas cachés de arriba. SelfCheckGPT marca afirmacións que non sobreviven á remostraxe;8 Self-RAG adestra o modelo para emitir os seus propios tokens de recuperar-e-criticar;9 TruthfulQA fixo lexible o modo de fallo desde o principio.10 Ningún pecha a fenda, e un sistema que presenta texto recuperado como proba confundiu con fonte con verdadeiro.

A metade do sistema que corre antes de calquera consulta

Ligazón á sección: A metade do sistema que corre antes de calquera consulta

Un retriever é a parte visible dunha canalización cuxos fallos acontecen todos antes, na escuridade. Tres deles repítense.

A extracción é onde morre o contido. Un PDF non é texto; son instrucións de debuxo. Os deseños de dúas columnas entrelázanse, as táboas convértense en sopa de palabras, as cabeceiras de páxina repítense en cada chunk, e unha páxina escaneada non ten texto ningún ata que o OCR lle dá algún, cunha confianza. Todo o medido arriba asumiu que o extractor fixo o seu traballo; en produción moitas veces non o fai, e o síntoma aparece como mala recuperación tres capas máis lonxe.

O índice queda selado co modelo que o construíu. Embeddings de dous modelos non son comparables — non «menos precisos», senón non comparables, porque son puntos en espazos distintos. Cambia o embedding model e todos os vectores do almacén son lixo ata que se reconstrúa. Por iso o nome do modelo, o reconto de dimensións, a versión da canalización e a versión do extractor escríbense xunto a cada documento no momento de indexar. Sen iso, o día da actualización non podes saber que documentos están obsoletos e cales están actuais, e un índice a medio migrar devolve disparates confiados sen erro en ningures.

Un documento roto non debe romper o cartafol, e os contadores deben contar o que pasou. Un documento que falla na extracción remata nun estado failed co seu motivo, visible e reintentable, mentres os outros noventa e nove seguen sendo buscables; e o número de chunks indexados escríbeo o servidor cando remata, non o declara o cliente cando sube. Un cartafol que informa de 400 fragmentos e contén 40 é unha mentira que só emerxe como unha pregunta sen resposta.

O sistema deste capítulo responde preguntas cuxas respostas están escritas. Recupéraas, ordénaas, négase cando non pode, e cita onde mirou. Iso é a maior parte do que a xente quere dun asistente sobre os seus propios documentos, e está limitado dun xeito concreto: a recuperación só pode devolver o que alguén escribiu.

O que deixa a outra metade. Parte do que queres que faga un modelo non é un feito nun documento en absoluto: un formato que debe manter, un ton, unha taxonomía con catrocentas etiquetas, unha maneira de decidir que vive en dez mil exemplos pasados e en ningún parágrafo de ningures. A recuperación non pode entregar iso, porque non hai nada que recuperar; un prompt máis longo só paga a factura do capítulo 16 por unha descrición dun skill en vez do skill.

O Capítulo 20 é esa decisión — fine-tune, recuperar ou prompt — e o seu achado é que a decisión é económica antes de ser técnica: as tres opcións prezáronse de punta a punta sobre a mesma pregunta, e o punto de cruce é un reconto de tokens. A pregunta que o abre é a que este capítulo non pode responder. Non onde está escrita a resposta, senón que fas cando nunca o estivo.


Todo o medido neste capítulo usou un corpus e un instrumento, e ambos son reproducibles. O corpus son os capítulos 1 ao 13 deste curso tal como estaban o 7 de setembro de 2026 — 13 documentos, 359.067 caracteres, 127 seccións, portada interna e bibliografías eliminadas. Eses capítulos seguen editándose, así que aplicar hoxe a mesma regra conta uns poucos miles de caracteres máis: o reconto de seccións non cambia e tampouco ningunha das conclusións de abaixo, pero o total de caracteres é unha instantánea e está etiquetado como tal. A verdade de referencia son 32 preguntas, cada unha emparellada cunha frase literal que aparece exactamente unha vez no corpus e nunca é un título de sección, formuladas en dúas versións para 64 consultas. Os embeddings de recuperación son sentence-transformers/all-MiniLM-L6-v2 (384 dimensións, mean-pooled, normalizados con L2, context window de 256 tokens); o reranking é cross-encoder/ms-marco-MiniLM-L-6-v2 sobre o top 25; o exemplo de xeración é Qwen/Qwen2.5-0.5B-Instruct con decodificación greedy. Todas as latencias son de CPU cun só fío. Non se chamou ningunha API de pago para producir este capítulo, que tamén é por que cada latencia aquí é local e está etiquetada como tal.

O chunker mostrado en TypeScript é o chunker que se mediu: o instrumento en Python que implementa a mesma regra e ts/chunk.ts comparáronse chunk a chunk sobre todo o corpus e coinciden nos 940 chunks, textos e offsets por igual. Os intervalos son Wilson ao 95 %; as comparacións emparelladas son probas exactas de signos bilaterais sobre os pares discordantes.

Os catorce identificadores citados arriba resolvéronse contra a API de arXiv e comprobáronse título por título o 7 de setembro de 2026 — o cal, tendo en conta os oito que non o eran, parecía o mínimo que podía facer este capítulo en particular.

  1. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). A fonte da función de saturación e das dúas constantes usadas arriba, e o lugar onde ler por que bb existe. ↩

  2. Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. O k=60k = 60 é deles, e o punto do método é que non necesita calibración entre as escalas de puntuación que fusiona. ↩

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). O punto medio entre un produto escalar e un cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), é o bi-encoder sobre o que se constrúe o índice deste capítulo e foi medido no capítulo 8. ↩

  4. Malkov, Yu. A. and Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). O índice de grafo detrás da maioría das bases de datos vectoriais que se venden hoxe. ↩

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, e a implementación de referencia do IVF medido no cadro anterior. ↩

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). O argumento de que a alucinación a produce unha avaliación de exactitude binaria que nunca recompensa a abstención, e polo tanto é un problema de avaliación antes de selo de modelaxe. ↩

  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. and Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020). O artigo que lle puxo nome ao patrón e o que hai que ler para entender que arranxa e que non. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), é o traballo contemporáneo que adestra o retriever conxuntamente co modelo en vez de engadilo por fóra; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), é de onde vén o retriever denso de dous codificadores usado ao longo deste capítulo; e Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), é a disposición fusion-in-decoder para alimentar moitas pasaxes a un único xerador. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), é o mapa de todo o que veu despois, incluído HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), que embebe unha resposta hipotética en vez da pregunta. ↩

  8. Manakul, P., Liusie, A. and Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). Detección por remostraxe, sen acceso aos internos do modelo e sen base de coñecemento externa. ↩

  9. Asai, A., Wu, Z., Wang, Y., Sil, A. and Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). Adestrar o modelo para decidir cando recuperar, en vez de recuperar en cada quenda. ↩

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). O benchmark construído con preguntas nas que a resposta plausible e a verdadeira difiren, que é toda a dificultade nunha soa frase. ↩

Pon a túa IA a traballar hoxe

Todos os modelos e todas as ferramentas nun só lugar. Comeza gratis, sen tarxeta.