Saltar para o conteúdo
19/30Capítulo 19 de 30

RAG em produção: chunking, retrieval e citações honestas

Cortar às cegas aos 512 caracteres destrói respostas antes do retriever. Corrigir só o chunker leva o rank 115 ao 3.

Nesta página

Aqui está uma pergunta real de um utilizador real de um assistente real: o meu conjunto de avaliação tem 20 itens, isso chega para confiar na pontuação. O corpus contém a resposta — uma secção inteira dela. Aqui estão os quatro fragmentos que o retriever colocou de facto 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…

Três dos quatro começam a meio de uma palavra. Dois são de outro capítulo, sobre outro assunto. E o fragmento que responde à pergunta — o que contém Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — voltou em rank 115.

Agora a mesma pergunta, o mesmo embedding model, o mesmo template de prompt. Uma coisa mudou: como os documentos foram cortados.

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]

Rank 115 para rank 3. Ninguém mexeu no modelo, no prompt, no limiar ou no número de slots. Este capítulo é sobre essa diferença, e sobre os outros quatro lugares onde um sistema de retrieval lhe mente discretamente.

Mostrar detalhes

O que este capítulo precisa dos capítulos anteriores, e o único ponto em que muda de linguagem.

  • Capítulo 1 definiu o produto escalar e a norma L2. A secção sobre limiares abaixo é essas duas coisas, e nada mais.
  • Capítulo 8 separou a tabela de embedding de um modelo de linguagem de um modelo de embedding de retrieval treinado contrastivamente em pares, mediu a semelhança por cosseno e terminou com a promessa de que o Capítulo 19 chegaria a um corte concreto. Essa promessa vence aqui. Nada disso é repetido.
  • Capítulo 4 construiu o intervalo de Wilson; Capítulo 15 construiu o harness de avaliação. Todas as tabelas abaixo usam o primeiro e foram produzidas pelo segundo.
  • Capítulo 16 pôs preço à context window. O prompt montado no fim deste capítulo custa 591 tokens, e é esse o orçamento pelo qual os fragmentos competem.

Tudo aqui é TypeScript, como desde o Capítulo 14, e este capítulo é aquele em que a regra se justifica: a ingestão são filas e armazenamento, a pesquisa é uma chamada de rede, e montar um prompt com citações é trabalho de servidor. A medição é o mesmo código com um quadro de pontuação à volta, de propósito — um retriever avaliado por uma segunda implementação é um número sobre software que não vai lançar, e o limiar de cosseno abaixo só é credível porque o vê a ser varrido pelo chunker que vai correr em produção.

Tudo abaixo é medido contra um único corpus: os primeiros treze capítulos deste curso — 13 documentos, 359.067 caracteres, 127 secções, com o front matter e as bibliografias removidos. É um corpus técnico real, com prosa, tabelas, fórmulas e blocos de código, e é exatamente o tipo de coisa que as pessoas carregam para uma base de conhecimento e depois se queixam.

A verdade de referência são 32 perguntas, cada uma emparelhada com uma agulha: uma frase curta, literal, do corpus que lhe responde. Cada agulha aparece exatamente uma vez nos 359.067 caracteres, e nenhuma é um título de secção — essa verificação importa, porque um chunker que copia títulos para todos os chunks, de outra forma, pontuar-se-ia a si próprio. Cada pergunta é feita duas vezes, uma no inglês do curso e outra como um pedido de suporte a formularia: 64 queries sobre 32 verdades de referência.

Um retrieval está correto quando um chunk devolvido contém a agulha inteira. Essa é a única definição que corresponde ao que o gerador precisa: meia frase no prompt não é uma resposta, é um risco.

O embedding model é all-MiniLM-L6-v2 — 384 dimensões, mean-pooled e normalizado, o modelo treinado contrastivamente que o Capítulo 8 mediu. Indexar o corpus demora 20,8 segundos num CPU, 22 ms por chunk; gerar o embedding de uma query demora 13 ms.

Seis estratégias a partir de três ingredientes independentes. Blind corta a cada 512 caracteres sem olhar para o texto. Boundaries nunca corta dentro de um parágrafo, recorrendo a um limite de frase apenas quando um parágrafo excede o orçamento. Header prefixa cada chunk com o título do documento e o caminho da secção. Overlap copia os últimos 64 caracteres do chunk anterior para o seguinte.

estratégiachunksrespostas destruídasR@1R@4R@8R@20MRR
A blind 5127084 / 320.1250.2970.4220.5940.241
B blind + overlap80900.1720.3910.4530.6250.286
C boundaries94000.1560.4220.5310.6720.293
D boundaries + overlap94000.1560.3590.5160.6560.277
E boundaries + header94000.0940.4220.5780.8280.280
F boundaries + header + overlap94000.1560.3910.5620.7660.298

Com 64 queries, o intervalo de Wilson a 95 % em R@20 é [0.471, 0.705] para A e [0.718, 0.901] para E — estes não se sobrepõem, mas a maioria das outras colunas sim, e uma tabela não emparelhada não os consegue separar. Todas as estratégias respondem às mesmas queries, por isso o teste honesto é emparelhado: conte as vitórias e derrotas de cada estratégia contra outra e execute um teste dos sinais nos pares discordantes. Três resultados sobrevivem.

Blind chunking destrói por completo quatro das trinta e duas respostas. Não as classifica mal — destrói-as. A agulha atravessa um limite de 512 caracteres, por isso nenhum chunk no índice a contém, e o teto de recall para essas queries é zero. Nenhum reranker as recupera, nenhum limiar ajuda, nenhum modelo maior ajuda. Não se pode recuperar texto que não está inteiro em lado nenhum no índice. Esta é a falha mais subnotificada em RAG, porque parece exatamente um mau retriever.

Overlap corrige isso e nada mais. Todas as estratégias com overlap perdem zero respostas, que é para isso que serve o overlap. Não melhora o ranking: B contra A em R@8 é +8/−6, p = 0.79; em R@20 é +9/−7, p = 0.80. Pior, adicionar overlap em cima do header prejudica ativamente — F contra E é +2/−6 em R@20 — e a razão é mecânica. O vetor de um chunk é uma média dos seus tokens, por isso 64 caracteres do chunk anterior puxam essa média para o tópico do vizinho. Overlap é um seguro contra uma resposta dividida, pago em precisão.

O header contextual é o que compra retrieval. E contra A é +18/−3 em R@20, p = 0.0015. E a ablação diz que não são os boundaries: E contra C — os mesmos cortes, com o header como única diferença — é +12/−2, p = 0.0129. Prefixar "Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?" a um parágrafo diz ao embedding model do que o parágrafo trata, coisa que o próprio parágrafo muitas vezes não diz. É um resolvedor de pronomes para documentos.

Isto dá forma ao chunker, e uma regra que é fácil errar:

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;
}

Dois textos, não um. text é o que recebe embedding, header e tudo. content são apenas as palavras deste chunk, e é o que é citado de volta ao utilizador. Cite text e a citação mostra um header que não está no documento naquele ponto — e, com overlap, uma cauda repetida que pertence ao fragmento anterior. Passa então a mostrar texto que não está onde diz estar, o que é pior do que não mostrar nada.

O header não é grátis. Nos 940 chunks custa 24.213 dos 114.275 tokens incorporados no índice: 21,2 % do que paga para gerar embeddings é um header que escreveu você mesmo. Também empurra chunks contra a janela do encoder. all-MiniLM-L6-v2 aceita 256 word-pieces; a estratégia E tem 17 chunks acima dessa linha e F tem 28, todos truncados silenciosamente sem aviso de coisa nenhuma. O tamanho efetivo do seu chunk não é o número na configuração — é o menor entre esse número e a janela do encoder.

Dense retrieval tem uma fraqueza sistemática e ela não é subtil: corresponde significado, por isso é indiferente a exatamente que string escreveu. Um número de peça, um código de erro, um acrónimo, um apelido — nada disso tem um significado útil para embedding, e o vizinho mais próximo de um código de erro é qualquer outro código de erro no corpus.

A resposta clássica é mais antiga do que tudo isto e ocupa vinte linhas. BM25 pontua um documento pela frequência com que os termos da query aparecem nele, amortecendo cada termo à medida que a sua frequência sobe e penalizando documentos longos que acumulam correspondências apenas pelo tamanho.1 O termo tt contribui

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} é a contagem do termo no documento, d|d| o seu comprimento, d\overline{|d|} o comprimento médio, e k1=1.2k_1 = 1.2 e b=0.75b = 0.75 as duas constantes convencionais — k1k_1 define quão depressa a repetição deixa de ajudar, bb quão severamente o comprimento é punido.

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, isso pontua uma query em 1,14 ms sem índice nenhum além de dois hash maps. E não é peça de museu:

retrieverR@1R@4R@8MRRcusto por query
dense (cosine)0.0940.4220.5780.28013 ms para embedding + 0,3 ms para varrer
lexical (BM25)0.2190.3750.4690.3131,14 ms
hybrid (RRF)0.2030.4840.6090.346ambos
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 mais do que duplica a precisão top-1 do dense retriever neste corpus, e perde claramente para ele até ao rank 8. Falham em queries diferentes, que é todo o argumento para correr ambos.

Fundi-los é o único lugar onde a abordagem óbvia está errada. Distâncias de cosseno e pontuações BM25 não estão na mesma escala, não são limitadas da mesma forma, e normalizá-las por query faz com que o peso dependa de quão bom calhou ser o melhor resultado. Reciprocal rank fusion deita fora as pontuações e guarda apenas os ranks: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 aqui a leitura honesta da tabela importa mais do que a tabela. Hybrid supera BM25 em R@4 por +10/−3, p = 0.09. Supera dense por +10/−6, p = 0.45. Neste corpus, com 64 queries, hybrid retrieval não se distingue de dense retrieval. É melhor nas estimativas pontuais e em todas as colunas de recall, e a evidência não chega à significância. Quase todos os posts de blog sobre pesquisa hybrid na internet apresentam uma tabela como a acima e nenhum intervalo; isto é o que o intervalo diz.

Bi-encoder, cross-encoder, e onde está realmente o ganho

Ligação para a secção: Bi-encoder, cross-encoder, e onde está realmente o ganho

Tudo até aqui é um bi-encoder: a query passa pelo modelo sozinha, cada chunk passou por ele sozinho há meses, e os dois nunca se encontram exceto como produto escalar. É isso que torna possível um índice — embed uma vez, reutilize para sempre — e é também o teto. O modelo nunca olha para a query e para o chunk em conjunto.

Um cross-encoder faz exatamente isso: recebe o par como uma única entrada e devolve uma pontuação de relevância. Nada pode ser pré-computado, por isso não consegue ordenar um índice — mas consegue reranking de uma shortlist. Fazer reranking do top 25 hybrid com ms-marco-MiniLM-L-6-v2 move R@1 de 0.094 (dense) para 0.312 e MRR de 0.280 para 0.447: a maior melhoria isolada deste capítulo, e a única que toca no topo da lista em vez da cauda.

Custa 569 ms por query num CPU, contra 1,14 ms para BM25 e 0,3 ms para a varredura vetorial. Cerca de duas mil vezes o custo de retrieval, para vinte e cinco documentos. É toda a troca bi-encoder/cross-encoder num único número, e é por isso que a arquitetura tem sempre a mesma forma: um retriever barato com recall amplo, depois um pontuador caro numa shortlist que consegue pagar. ColBERT fica entre os dois, pré-computando vetores por token e fazendo uma interação tardia que é mais barata do que um cross-encoder e mais nítida do que um produto escalar.3

Bases de dados vetoriais reportam distâncias, e a distância é uma opção de configuração. Em vetores normalizados, a escolha é cosmética, e vale a pena fazer a identidade uma vez porque tudo o que vem depois depende de os vetores serem realmente unitários. 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

por isso a distância de cosseno 1cosθ1 - \cos\theta é exatamente d2/2d^2/2. É o produto escalar e a norma do Capítulo 1, convertidos em dinheiro. Verificado em dois vetores reais de chunks do índice acima, e depois em 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

Exato até ao ruído de vírgula flutuante — e apenas porque os vetores estão normalizados. Salte a normalização e a identidade é falsa, o seu limiar não significa nada, e a distância que um documento reporta depende de quão longo era o seu texto.

Agora o número que ninguém deriva. Um retriever devolve sempre alguma coisa: ordena o índice inteiro e entrega-lhe o topo da lista, quer a resposta esteja no corpus quer não. O limiar é a única parte do sistema que consegue dizer não — e, para definir um, precisa de queries que não deveriam devolver nada. Aqui estão trinta: vinte e uma sobre coisas que este corpus genuinamente não cobre — streaming, limites de taxa, prompt caching, esquemas JSON, loops de agent, bases de dados vetoriais, prompt injection, geração de imagem — e nove sobre paella, passaportes e políticas de reembolso. Contra o mesmo índice:

distância de cosseno top-1
queries in-domain, todas as 64média 0.445, intervalo 0.270 – 0.721
in-domain, top-1 realmente corretomédia 0.370
in-domain, top-1 erradomédia 0.452
out-of-domain, todas as 30média 0.699, intervalo 0.497 – 0.867

As distribuições separam-se, e sobrepõem-se. A pior query in-domain está mais longe da sua resposta (0.721) do que a melhor query out-of-domain está de um parágrafo irrelevante (0.497), por isso nenhum limiar acerta em ambas. Varrendo-o sobre o portão real — manter no máximo quatro chunks, e apenas os que ficam abaixo do corte:

limiarin-domain respondidasdas quais a resposta estava incluídaout-of-domain 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
nenhum64 / 642730 / 30

Leia a última coluna como bluffs. Sem limiar, o assistente produz uma resposta confiante e bem citada a "how do I renew my Spanish passport" a partir de um corpus sobre backpropagation, trinta vezes em trinta. A 0.675 fá-lo dez vezes em trinta. A 0.525 fá-lo duas vezes, e desiste de doze perguntas a que poderia ter respondido.

Essa troca é uma decisão de produto, e o extremo certo depende de quanto custa uma resposta errada. O que não é negociável é a existência da última coluna. Se nunca mediu o seu retriever contra perguntas que ele deveria recusar, não tem um limiar — tem um número.

Dois dos dez bluffs a 0.675 mostram as duas formas como isto falha.

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 é um quase acerto: o corpus explica a KV cache em detalhe, a query é sobre a prompt cache, as palavras são as mesmas palavras, e 0.497 é mais próximo do que a maioria dos retrievals in-domain corretos em toda a experiência. Um embedding não sabe que duas caches com o mesmo nome são máquinas diferentes. O segundo é uma correspondência literal sem resposta: o corpus contém a frase exata "what is the capital of France", usada como exemplo de uma pergunta que não precisa de raciocínio. O retriever está certo; a resposta não está lá. Qualquer sistema que leia "encontrei algo semelhante" como "encontrei a resposta" afirmará Paris com essa evidência — ou, pior, não o fará.

Um modelo não tem uma faculdade separada para factos. Produzir uma frase verdadeira e produzir uma plausível são a mesma operação — a previsão do próximo token do Capítulo 8 — e nada nessa operação marca qual é qual. A análise de 2025 que reformulou isto argumenta que a pipeline de treino e avaliação recompensa ativamente o palpite: os benchmarks pontuam com exatidão binária e não dão crédito por abstenção, por isso um modelo que responde sempre supera um modelo idêntico que diz "não sei" quando não sabe, e o pós-treino otimiza em conformidade.6 Alucinação, nessa leitura, não é um defeito misterioso. É o que se obtém quando se corrige um exame de escolha múltipla sem penalização por resposta errada.

Veja a forma disto. Perante o pedido de oito artigos sobre sentence embeddings contrastivos, com identificadores, Qwen2.5-0.5B-Instruct produziu oito linhas em formato perfeito. Todos os oito identificadores estão bem formados. Todos os oito resolvem para artigos reais no arXiv. Zero dos oito são o artigo alegado.

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 é um modelo pequeno e a taxa é a sua; um modelo frontier inventa muito menos. O mecanismo generaliza, e é a razão para a regra seguinte. Um validador que verifica "este identificador existe" aprova os oito, e um utilizador que clica num deles aterra numa página real de um arquivo real sem forma de perceber que o mapeamento foi inventado. A falha não está no identificador nem no formato. Está na associação — precisamente aquilo que um modelo de linguagem produz por plausibilidade.

Portanto: o modelo escreve [1] e [2], e nunca escreve o link. Os números referem-se a fragmentos que o servidor recuperou, e o servidor — que sabe exatamente de que documento e de que offsets veio cada número — anexa depois o documento, a etiqueta e o URL. Não há nada para o modelo inventar porque nunca lhe pedem a única coisa que inventaria.

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 };
}

Execute isto na pergunta inicial e os quatro chunks tornam-se um prompt de 591 tokens e uma tabela que o modelo nunca vê:

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 as pessoas saltam e depois não conseguem acrescentar. #char=25873,26272 é um intervalo no texto canónico do documento; para um PDF o equivalente é #page=12, para áudio ou vídeo #t=132.4,158.9, para uma folha de cálculo uma folha e um intervalo A1. Estes dois não são invenções — #page= é PDF Open Parameters e #t= é W3C Media Fragments, respeitados nativamente pelos browsers em elementos de vídeo e áudio. Uma citação sem localizador é um nome de documento, e um nome de documento não é uma citação; é uma sugestão para o utilizador ir procurar.

E quando nada passa o limiar, a pipeline nunca 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"

Isso é uma recusa mais barata e mais fiável do que qualquer instrução num system prompt, porque é uma comparação entre dois números em vez de um pedido a um sistema probabilístico.

Todas as medições deste capítulo pontuam o retriever e nem uma vez pedem a um modelo que escreva uma resposta. É deliberado, e é a parte que a maioria das equipas salta.

Um sistema RAG tem dois modos de falha que parecem idênticos de fora. O retriever não encontrou a passagem; ou encontrou-a e o gerador ignorou-a, contradisse-a ou misturou-a com algo em que já acreditava. Pontue apenas a resposta final e os dois tornam-se indistinguíveis, por isso acaba a afinar prompts contra um problema que vive no chunker. Recall@k, MRR e a contagem de respostas destruídas não precisam de qualquer chamada de geração, são baratos o suficiente para correr em cada deploy, e são o harness do Capítulo 15 com uma função de pontuação diferente — o mesmo pedido, prazo, concorrência e contagem, sobre um conjunto fixo de perguntas em vez de uma conversa ao vivo.

Reporte-os com intervalos. A aritmética do Capítulo 4 aplica-se sem alterações: com 64 queries, um recall de 0.5 traz um intervalo de Wilson a 95 % de cerca de ±0.12, por isso uma estratégia quatro pontos à frente de outra não lhe disse nada. Use o teste emparelhado sempre que ambas as estratégias respondem às mesmas perguntas, o que aqui acontece sempre — foi isso que transformou "E parece melhor do que A" em p = 0.0015.

E a última honestidade: RAG reduz a alucinação e não a elimina. Colocar a passagem certa no prompt não obriga o modelo a usá-la, e a literatura diz isso desde o artigo original.7 Duas coisas pioram isto em produção. Contextos longos degradam-se — um modelo encontra informação no início e no fim de um prompt longo de forma mais fiável do que no meio, por isso vinte chunks em vez de quatro podem baixar a precisão enquanto aumentam a fatura, um efeito medido no Capítulo 24. E o retrieval pode estar certo e ainda assim ser insuficiente, como mostraram as duas caches acima. SelfCheckGPT assinala afirmações que não sobrevivem à reamostragem;8 Self-RAG treina o modelo para emitir os seus próprios tokens de recuperar-e-criticar;9 TruthfulQA tornou antes de mais o modo de falha legível.10 Nada fecha a lacuna, e um sistema que apresenta texto recuperado como prova confundiu com fonte com verdadeiro.

Um retriever é a parte visível de uma pipeline cujas falhas acontecem todas antes, às escuras. Três delas repetem-se.

A extração é onde o conteúdo morre. Um PDF não é texto; são instruções de desenho. Layouts em duas colunas entrelaçam-se, tabelas tornam-se sopa de palavras, cabeçalhos de página repetem-se em todos os chunks, e uma página digitalizada não tem texto nenhum até o OCR lho dar, com uma confiança. Tudo o que foi medido acima assumiu que o extrator fez o seu trabalho; em produção muitas vezes não faz, e o sintoma aparece como mau retrieval três camadas depois.

O índice é carimbado com o modelo que o construiu. Embeddings de dois modelos não são comparáveis — não "menos precisos", não comparáveis, porque são pontos em espaços diferentes. Mude o embedding model e todos os vetores no armazenamento são lixo até serem reconstruídos. Por isso, o nome do modelo, a contagem de dimensões, a versão da pipeline e a versão do extrator são escritos ao lado de cada documento no momento de indexação. Sem isso, no dia da atualização, não consegue saber que documentos estão obsoletos e quais estão atuais, e um índice meio migrado devolve disparates confiantes sem erro em lado nenhum.

Um documento avariado não deve avariar a pasta, e os contadores devem contar o que aconteceu. Um documento cuja extração falha acaba num estado failed com a respetiva razão, visível e repetível, enquanto os outros noventa e nove continuam pesquisáveis; e o número de chunks indexados é escrito pelo servidor quando termina, não declarado pelo cliente quando faz upload. Uma pasta que reporta 400 fragmentos e contém 40 é uma mentira que só aparece como uma pergunta sem resposta.

O sistema deste capítulo responde a perguntas cujas respostas estão escritas. Recupera-as, ordena-as, recusa quando não consegue e cita onde procurou. Isso é a maior parte do que as pessoas querem de um assistente sobre os seus próprios documentos, e é limitado de uma forma específica: retrieval só consegue devolver aquilo que alguém escreveu.

Fica a outra metade. Parte do que quer que um modelo faça não é um facto num documento — é um formato que tem de manter, um tom, uma taxonomia com quatrocentas etiquetas, uma forma de decidir que vive em dez mil exemplos passados e em nenhum parágrafo. Retrieval não consegue entregar isso, porque não há nada para recuperar; um prompt mais longo apenas paga a fatura do Capítulo 16 por uma descrição de uma skill em vez da skill.

O Capítulo 20 é essa decisão — fine-tune, retrieve ou prompt — e a sua conclusão é que a decisão é económica antes de ser técnica: os três são precificados de ponta a ponta na mesma pergunta, e o ponto de cruzamento é uma contagem de tokens. A pergunta que o abre é aquela a que este capítulo não consegue responder. Não onde está escrita a resposta, mas o que faz quando ela nunca esteve.


Tudo o que foi medido neste capítulo usou um corpus e um instrumento, e ambos são reproduzíveis. O corpus são os capítulos 1 a 13 deste curso tal como existiam em 7 de setembro de 2026 — 13 documentos, 359.067 caracteres, 127 secções, front matter e bibliografias removidos. Esses capítulos continuam a ser editados, por isso aplicar a mesma regra hoje conta mais alguns milhares de caracteres: a contagem de secções não mudou e todas as conclusões abaixo também não, mas o total de caracteres é um instantâneo e está identificado como tal. A verdade de referência são 32 perguntas, cada uma emparelhada com uma frase literal que ocorre exatamente uma vez no corpus e nunca é um título de secção, feitas em duas formulações para 64 queries. Os embeddings de retrieval são sentence-transformers/all-MiniLM-L6-v2 (384 dimensões, mean-pooled, normalizados por L2, janela de 256 tokens); o reranking é cross-encoder/ms-marco-MiniLM-L-6-v2 sobre o top 25; o exemplo de geração é Qwen/Qwen2.5-0.5B-Instruct com greedy decoding. Todas as medições de tempo são CPU single-threaded. Nenhuma API paga foi chamada para produzir este capítulo, o que também explica que todas as latências aqui sejam locais e estejam identificadas como tal.

O chunker mostrado em TypeScript é o chunker que foi medido: o instrumento em Python que implementa a mesma regra e ts/chunk.ts foram comparados chunk a chunk em todo o corpus e concordam em todos os 940 chunks, textos e offsets incluídos. Os intervalos são Wilson a 95 %; as comparações emparelhadas são testes exatos dos sinais, bilaterais, nos pares discordantes.

Todos os catorze identificadores citados acima foram resolvidos contra a API do arXiv e verificados título a título em 7 de setembro de 2026 — o que, dados os oito que não o foram, pareceu o mínimo que este capítulo em particular podia fazer.

  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 função de saturação e das duas constantes usadas acima, e o lugar onde ler porque bb existe sequer.

  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 ponto do método é não precisar de calibração entre as escalas de pontuação que está a fundir.

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). O meio-termo entre um produto escalar e um cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), é o bi-encoder em que o índice deste capítulo se baseia 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 em grafo por trás da maioria das bases de dados vetoriais atualmente vendidas.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, e a implementação de referência do IVF medido na caixa acima.

  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 alucinação é produzida por avaliação de exatidão binária que nunca recompensa a abstenção e, portanto, é um problema de avaliação antes de ser um problema de modelação.

  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 deu nome ao padrão e aquele a ler para perceber o que ele corrige e não corrige. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), é o trabalho contemporâneo que treina o retriever em conjunto com o modelo em vez de o aparafusar por fora; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), é de onde vem o dense retriever de dois encoders 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 configuração fusion-in-decoder para alimentar muitas passagens a um gerador. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), é o mapa de tudo o que veio depois, incluindo HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), que gera o embedding de uma resposta hipotética em vez da pergunta.

  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). Deteção por reamostragem, sem acesso aos internos do modelo e sem knowledge base 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). Treinar o modelo para decidir quando fazer retrieval, em vez de recuperar em cada turno.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). O benchmark construído a partir de perguntas em que a resposta plausível e a resposta verdadeira diferem, que é toda a dificuldade numa frase.

Pronto para deixar a LIA escolher?

Construa com todos os modelos de IA num só sítio — comece grátis hoje.