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.

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(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)}

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:

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 a distancia coseno 1cosθ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 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.

Listo para deixar que LIA escolla por ti?

Crea con todos os modelos de IA nun só sitio: empeza gratis hoxe mesmo.