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.

Receba novos artigos na sua caixa de entrada

Notícias de IA, guias e novidades do produto — um email curto quando publicarmos algo que valha o seu tempo.

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(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} é 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:

∥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

por isso a distância de cosseno 1−cos⁡θ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. ↩

Ponha a sua IA a trabalhar hoje

Todos os modelos e todas as ferramentas num só lugar. Comece grátis, sem cartão.