RAG em produção: chunking, retrieval e citações honestas
Corte às cegas em 512 caracteres e 4 de 32 respostas morrem antes do retriever. Só ajustar o chunker leva o rank 115 ao 3.
Nesta página
Aqui está uma pergunta real de um usuário real de um assistente real: meu conjunto de avaliação tem 20 itens, isso basta para confiar na pontuação. O corpus contém a resposta — uma seção inteira dela. Aqui estão os quatro fragmentos que o retriever realmente colocou no prompt.
[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 no meio de uma palavra. Dois vêm de outro capítulo, sobre outro assunto. E o fragmento que responde à pergunta — aquele que contém Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — voltou no rank 115.
Agora a mesma pergunta, o mesmo embedding model, o mesmo template de prompt. Uma coisa mudou: como os documentos foram cortados.
[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 rank 115 para o rank 3. Ninguém tocou no model, no prompt, no limite nem no número de slots. Este capítulo é sobre essa lacuna e sobre os outros quatro lugares em que um sistema de retrieval mente para você em silêncio.
Mostrar detalhes
O que este capítulo precisa dos capítulos anteriores, e o único lugar em que ele muda a linguagem.
- Capítulo 1 definiu o produto escalar e a norma L2. A seção de limite abaixo é isso: esses dois, e nada mais.
- Capítulo 8 separou a tabela de embedding de um language model de um embedding model de retrieval treinado contrastivamente em pares, mediu a similaridade de cosseno e terminou prometendo que o Capítulo 19 chegaria a um ponto de 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. Toda tabela abaixo carrega o primeiro e foi produzida pelo segundo.
- Capítulo 16 precificou 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 é onde a regra se justifica: ingestão é filas e armazenamento, busca é uma chamada de rede, e montar um prompt com citações é trabalho de servidor. A medição é o mesmo código com um placar em volta, de propósito — um retriever pontuado por uma segunda implementação é um número sobre um software que você não está colocando em produção, e o limite de cosseno abaixo só é crível porque você o vê sendo varrido pelo chunker que vai rodar em produção.
O corpus, e o que conta como resposta certa
Link para a seção: O corpus, e o que conta como resposta certaTudo abaixo é medido contra um corpus: os primeiros treze capítulos deste curso — 13 documentos, 359.067 caracteres, 127 seções, com front matter e 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 em uma knowledge base e depois reclamam.
A verdade de referência são 32 perguntas, cada uma pareada com uma agulha: uma frase curta e literal do corpus que a responde. Cada agulha aparece exatamente uma vez nos 359.067 caracteres, e nenhuma é um título de seção — essa verificação importa, porque um chunker que copia títulos para cada chunk, caso contrário, pontuaria a si mesmo. Cada pergunta é feita duas vezes, uma no inglês do curso e outra do jeito que um ticket de suporte a formularia: 64 consultas sobre 32 verdades de referência.
Um retrieval está correto quando um chunk retornado 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 model treinado contrastivamente que o Capítulo 8 mediu. Indexar o corpus leva 20,8 segundos em CPU, 22 ms por chunk; gerar o embedding de uma consulta leva 13 ms.
Chunking, medido de seis formas
Link para a seção: Chunking, medido de seis formasSeis estratégias a partir de três ingredientes independentes. Cego corta a cada 512 caracteres sem olhar para o texto. Limites nunca corta dentro de um parágrafo, recorrendo a um limite de frase apenas quando um parágrafo passa do orçamento. Header prefixa cada chunk com o título do documento e o caminho da seção. Overlap copia os últimos 64 caracteres do chunk anterior para o próximo.
| estratégia | chunks | respostas destruídas | R@1 | R@4 | R@8 | R@20 | MRR |
|---|---|---|---|---|---|---|---|
| A cego 512 | 708 | 4 / 32 | 0.125 | 0.297 | 0.422 | 0.594 | 0.241 |
| B cego + overlap | 809 | 0 | 0.172 | 0.391 | 0.453 | 0.625 | 0.286 |
| C limites | 940 | 0 | 0.156 | 0.422 | 0.531 | 0.672 | 0.293 |
| D limites + overlap | 940 | 0 | 0.156 | 0.359 | 0.516 | 0.656 | 0.277 |
| E limites + header | 940 | 0 | 0.094 | 0.422 | 0.578 | 0.828 | 0.280 |
| F limites + header + overlap | 940 | 0 | 0.156 | 0.391 | 0.562 | 0.766 | 0.298 |
Com 64 consultas, o intervalo de Wilson de 95 % em R@20 é [0.471, 0.705] para A e [0.718, 0.901] para E — eles não se sobrepõem, mas a maioria das outras colunas se sobrepõe, e uma tabela não pareada não consegue separá-los. Toda estratégia responde às mesmas consultas, então o teste honesto é pareado: conte as vitórias e derrotas de cada estratégia contra outra e rode um teste dos sinais nos pares discordantes. Três resultados sobrevivem.
Chunking cego destrói quatro das trinta e duas respostas de saída. Não as ranqueia mal — destrói. A agulha cruza um limite de 512 caracteres, então nenhum chunk no índice a contém, e o teto de recall para essas consultas é zero. Nenhum reranker as recupera, nenhum limite ajuda, nenhum model maior ajuda. Você não consegue recuperar texto que não existe inteiro em nenhum lugar do seu índice. Essa é a falha mais subnotificada em RAG, porque se parece exatamente com um retriever ruim.
Overlap corrige isso e nada mais. Toda estratégia com overlap perde zero respostas, que é para isso que overlap serve. Ele 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 atrapalha ativamente — F contra E é +2/−6 em R@20 — e a razão é mecânica. O vetor de um chunk é uma média sobre seus tokens, então 64 caracteres do chunk anterior puxam essa média na direção do tópico vizinho. Overlap é 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 os limites não são o motivo: E contra C — mesmos cortes, 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, o que o próprio parágrafo muitas vezes não diz. É um resolvedor de pronomes para documentos.
Isso dá forma ao chunker, e uma regra fácil de errar:
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 próprias deste chunk, e é o que é citado de volta para o usuário. Cite text e a citação mostra um header que não está no documento naquele ponto — e, com overlap, uma cauda repetida pertencente ao fragmento anterior. Ela então exibe texto que não está onde diz estar, o que é pior do que não mostrar nada.
O header não é de graça. Nos 940 chunks, ele custa 24.213 dos 114.275 tokens embedded do índice: 21,2 % do que você paga para embed é um header que você mesmo escreveu. Ele também empurra chunks contra o window 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 nada. O tamanho efetivo do seu chunk não é o número na sua configuração — é o menor entre esse número e o window do seu encoder.
Vinte linhas de BM25, que todo mundo pula
Link para a seção: Vinte linhas de BM25, que todo mundo pulaDense retrieval tem uma fraqueza sistemática, e ela não é sutil: ele corresponde significado, então é indiferente a qual string exata você digitou. Um número de peça, um código de erro, uma sigla, um sobrenome — nada disso tem um significado útil para embed, e o vizinho mais próximo de um código de erro é todo outro código de erro no seu corpus.
A resposta clássica é mais antiga do que tudo isso e leva vinte linhas. BM25 pontua um documento por quantas vezes os termos da consulta aparecem nele, amortecendo cada termo conforme sua frequência cresce e penalizando documentos longos que acumulam correspondências apenas pelo tamanho.1 O termo contribui
onde é a contagem do termo no documento, seu comprimento, o comprimento médio, e e as duas constantes convencionais — definindo quão rápido a repetição deixa de ajudar, quão duramente o comprimento é punido.
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;
});
}
}Em 940 chunks, isso pontua uma consulta em 1,14 ms sem nenhum índice além de dois hash maps. E não é peça de museu:
| retriever | R@1 | R@4 | R@8 | MRR | custo por consulta |
|---|---|---|---|---|---|
| dense (cosseno) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms para embed + 0,3 ms para varrer |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1,14 ms |
| híbrido (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | ambos |
| híbrido + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 mais que dobra a precisão top-1 do dense retriever neste corpus, e perde feio para ele até o rank 8. Eles falham em consultas diferentes, que é todo o argumento para rodar os dois.
Fundir os dois é o único lugar em que 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 consulta faz o peso depender de quão bom por acaso foi o melhor resultado. Reciprocal rank fusion joga fora as pontuações e mantém apenas os ranks:2
/** 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. Híbrido vence BM25 em R@4 por +10/−3, p = 0,09. Vence dense por +10/−6, p = 0,45. Neste corpus, com 64 consultas, hybrid retrieval não é distinguível de dense retrieval. Ele é melhor nas estimativas pontuais e em toda coluna de recall, e a evidência não alcança significância. Quase todo post de blog sobre busca híbrida na internet relata uma tabela como a acima e nenhum intervalo; é isso que o intervalo diz.
Bi-encoder, cross-encoder, e onde o ganho realmente está
Link para a seção: Bi-encoder, cross-encoder, e onde o ganho realmente estáTudo até aqui é um bi-encoder: a consulta passa pelo model sozinha, cada chunk passou por ele sozinho meses atrás, e os dois nunca se encontram exceto como produto escalar. É isso que torna um índice possível — embed uma vez, reutilize para sempre — e também é o teto. O model nunca olha para a consulta e o chunk juntos.
Um cross-encoder faz exatamente isso: recebe o par como uma única entrada e retorna uma pontuação de relevância. Nada pode ser pré-computado, então ele não consegue ranquear um índice — mas consegue rerank uma lista curta. Reranking do top 25 híbrido 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 individual deste capítulo, e a única que toca o topo da lista em vez da cauda.
Custa 569 ms por consulta em CPU, contra 1,14 ms para BM25 e 0,3 ms para a varredura vetorial. Aproximadamente duas mil vezes o custo de retrieval, para vinte e cinco documentos. Esse é todo o trade-off bi-encoder/cross-encoder em um número, e é por isso que a arquitetura sempre tem a mesma forma: um retriever barato com recall amplo, depois um scorer caro em uma lista curta que você consegue pagar. ColBERT fica entre os dois, pré-computando vetores por token e fazendo uma interação tardia que é mais barata que um cross-encoder e mais afiada que um produto escalar.3
L2, cosseno e um limite que você não mereceu
Link para a seção: L2, cosseno e um limite que você não mereceuBancos de dados vetoriais reportam distâncias, e qual distância é uma opção de configuração. Em vetores normalizados, a escolha é cosmética, e vale fazer a identidade uma vez porque tudo depois depende de os vetores realmente serem unitários. Para :
então a distância de cosseno é exatamente . É o produto escalar e a norma do Capítulo 1, pagos. Verificado em dois vetores reais de chunks do índice acima e depois em 40.000 pares:
||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-07Exato até ruído de ponto flutuante — e só porque os vetores são normalizados. Pule a normalização e a identidade é falsa, seu limite não significa nada, e a distância que um documento reporta depende de quão longo era seu texto.
Agora o número que ninguém deriva. Um retriever sempre retorna alguma coisa: ele ordena o índice inteiro e entrega o topo da lista, esteja a resposta em algum lugar do corpus ou não. O limite é a única parte do sistema que pode dizer não — e, para definir um, você precisa de consultas que deveriam não trazer 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, bancos 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 | |
|---|---|
| consultas in-domain, todas as 64 | média 0,445, intervalo 0,270 – 0,721 |
| in-domain, top-1 realmente correto | média 0,370 |
| in-domain, top-1 errado | média 0,452 |
| out-of-domain, todas as 30 | média 0,699, intervalo 0,497 – 0,867 |
As distribuições se separam, e se sobrepõem. A pior consulta in-domain está mais longe da resposta (0,721) do que a melhor consulta out-of-domain está de um parágrafo irrelevante (0,497), então nenhum limite acerta as duas. Varrendo sobre o portão real — manter no máximo quatro chunks, e só os que estiverem abaixo do corte:
| limite | in-domain respondidas | das quais a resposta estava incluída | out-of-domain respondidas |
|---|---|---|---|
| 0.400 | 17 / 64 | 6 | 0 / 30 |
| 0.450 | 38 / 64 | 13 | 0 / 30 |
| 0.500 | 50 / 64 | 19 | 1 / 30 |
| 0.525 | 52 / 64 | 20 | 2 / 30 |
| 0.550 | 55 / 64 | 21 | 3 / 30 |
| 0.600 | 60 / 64 | 25 | 5 / 30 |
| 0.675 | 62 / 64 | 27 | 10 / 30 |
| 0.800 | 64 / 64 | 27 | 26 / 30 |
| nenhum | 64 / 64 | 27 | 30 / 30 |
Leia a última coluna como blefes. Sem limite, o assistente produz uma resposta confiante e bem citada para “como renovo meu passaporte espanhol” a partir de um corpus sobre backpropagation, trinta vezes em trinta. Em 0.675, faz isso dez vezes em trinta. Em 0.525, faz isso duas vezes e desiste de doze perguntas que poderia ter respondido.
Esse trade-off é uma decisão de produto, e o lado certo dele depende de quanto custa uma resposta errada para você. O que não é negociável é a última coluna existir. Se você nunca mediu seu retriever contra perguntas que ele deveria recusar, você não tem um limite — você tem um número.
Dois dos dez blefes em 0.675 mostram as duas formas como isso falha.
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 o KV cache em detalhe, a consulta é sobre prompt cache, as palavras são as mesmas palavras, e 0,497 é mais perto do que a maioria dos retrievals in-domain corretos em todo o experimento. Um embedding não sabe que dois 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 parecido” como “encontrei a resposta” vai afirmar Paris com base nessa evidência — ou, pior, não vai.
Por que a citação não é escrita pelo model
Link para a seção: Por que a citação não é escrita pelo modelUm model não tem uma faculdade separada para fatos. 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 isso argumenta que o pipeline de treinamento e avaliação recompensa ativamente o palpite: benchmarks pontuam com acurácia binária e não dão crédito por abstenção, então um model que sempre responde supera um model idêntico que diz “não sei” quando não sabe, e o pós-treinamento otimiza de acordo.6 Hallucination, nessa leitura, não é um defeito misterioso. É o que você obtém quando corrige uma prova de múltipla escolha sem penalidade para resposta errada.
Veja o formato disso. Ao pedir oito artigos sobre contrastive sentence embeddings, com identificadores, Qwen2.5-0.5B-Instruct produziu oito linhas em formato perfeito. Todos os oito identificadores são bem formados. Todos os oito resolvem para artigos reais no arXiv. Zero dos oito é o artigo alegado.
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 SomaliEste é um model pequeno e a taxa é própria dele; um model de fronteira inventa muito menos. O mecanismo generaliza, e é a razão da regra a seguir. Um validador que verifica “este identificador existe?” aprova todos os oito, e um usuário que clica em um deles chega a uma página real de um repositório real sem ter como saber que o mapeamento foi inventado. A falha não está no identificador nem no formato. Está na associação — precisamente aquilo que um language model produz por plausibilidade.
Então: o model escreve [1] e [2], e nunca escreve o link. Os números se referem a fragmentos que o servidor recuperou, e o servidor — que sabe exatamente de qual documento e de quais offsets cada número veio — anexa o documento, o rótulo e a URL depois. Não há nada para o model inventar, porque ele nunca é solicitado a fornecer justamente aquilo que inventaria.
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 };
}Rode isso na pergunta de abertura e os quatro chunks viram um prompt de 591 tokens e uma tabela que o model nunca vê:
[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.605O localizador é a parte que as pessoas pulam e depois não conseguem adicionar. #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 planilha uma aba e um intervalo A1. Esses dois não são invenções — #page= é PDF Open Parameters e #t= é W3C Media Fragments, reconhecidos nativamente por navegadores 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 usuário ir procurar.
E quando nada passa pelo limite, o pipeline nunca chega ao model:
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"Essa é uma recusa mais barata e mais confiável do que qualquer instrução em um system prompt, porque é uma comparação entre dois números, não um pedido a um sistema probabilístico.
Avalie o retriever separado do gerador
Link para a seção: Avalie o retriever separado do geradorToda medição neste capítulo pontua o retriever e nem uma vez pede a um model que escreva uma resposta. Isso é deliberado, e é a parte que a maioria das equipes pula.
Um sistema RAG tem dois modos de falha que parecem idênticos de fora. O retriever não encontrou a passagem; ou encontrou e o gerador a ignorou, contradisse ou misturou com algo em que já acreditava. Pontue apenas a resposta final e os dois são indistinguíveis, então você ajusta prompts contra um problema que mora no seu chunker. Recall@k, MRR e a contagem de respostas destruídas não precisam de nenhuma chamada de geração, são baratos o bastante para rodar em todo deploy, e são o harness do Capítulo 15 com uma função de pontuação diferente — a mesma solicitação, 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 se aplica sem mudanças: com 64 consultas, um recall de 0,5 carrega um intervalo de Wilson de 95 % de aproximadamente ±0,12, então uma estratégia quatro pontos à frente de outra não disse nada. Use o teste pareado sempre que ambas as estratégias respondem às mesmas perguntas, o que elas sempre fazem aqui — foi isso que transformou “E parece melhor que A” em p = 0,0015.
E a última honestidade: RAG reduz hallucination e não a remove. Colocar a passagem certa no prompt não obriga o model a usá-la, e a literatura diz isso desde o artigo original.7 Duas coisas pioram isso em produção. Contextos longos degradam — um model encontra informações no início e no fim de um prompt longo com mais confiabilidade do que no meio, então vinte chunks em vez de quatro podem reduzir a acurácia enquanto aumentam a conta, um efeito medido no Capítulo 24. E o retrieval pode estar certo e ainda ser insuficiente, como os dois caches acima mostraram. SelfCheckGPT sinaliza alegações que não sobrevivem à reamostragem;8 Self-RAG treina o model para emitir seus próprios tokens de recuperar e criticar;9 TruthfulQA tornou o modo de falha legível em primeiro lugar.10 Nenhum fecha a lacuna, e um sistema que apresenta texto recuperado como prova confundiu com fonte com verdadeiro.
A metade do sistema que roda antes de qualquer consulta
Link para a seção: A metade do sistema que roda antes de qualquer consultaUm retriever é a parte visível de um pipeline cujas falhas acontecem todas antes, no escuro. Três delas se repetem.
Extração é onde o conteúdo morre. Um PDF não é texto; são instruções de desenho. Layouts em duas colunas se entrelaçam, tabelas viram sopa de palavras, cabeçalhos de página se repetem em todo chunk, e uma página escaneada não tem texto algum até que o OCR forneça algum, com uma confiança. Tudo medido acima pressupôs que o extrator fez seu trabalho; em produção, muitas vezes não faz, e o sintoma aparece como retrieval ruim três camadas depois.
O índice é carimbado com o model que o construiu. Embeddings de dois models 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 todo vetor no armazenamento é lixo até ser reconstruído. Portanto, o nome do model, a contagem de dimensões, a versão do pipeline e a versão do extrator são escritos ao lado de cada documento no momento da indexação. Sem eles, no dia do upgrade, você não consegue dizer quais documentos estão obsoletos e quais estão atuais, e um índice migrado pela metade retorna besteira confiante sem erro em lugar nenhum.
Um documento quebrado não deve quebrar a pasta, e os contadores precisam contar o que aconteceu. Um documento que falha na extração termina em um estado failed com seu motivo, visível e retentá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ó vem à tona como uma pergunta impossível de responder.
Para onde isso vai agora
Link para a seção: Para onde isso vai agoraO sistema neste capítulo responde a perguntas cujas respostas estão escritas. Ele as recupera, ranqueia, recusa quando não consegue e cita onde procurou. Isso é a maior parte do que as pessoas querem de um assistente sobre seus próprios documentos, e é limitado de uma forma específica: retrieval só pode retornar o que alguém escreveu.
O que deixa a outra metade. Parte do que você quer que um model faça não é um fato em um documento — é um formato que ele precisa manter, um tom, uma taxonomia com quatrocentos rótulos, uma forma de decidir que vive em dez mil exemplos passados e em nenhum parágrafo em lugar algum. Retrieval não consegue entregar isso, porque não há nada a recuperar; um prompt mais longo só paga a conta 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 sua descoberta é 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 é a que este capítulo não consegue responder. Não onde a resposta está escrita, mas o que você faz quando ela nunca esteve.
Fontes e método
Link para a seção: Fontes e métodoTudo 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 como estavam em 7 de setembro de 2026 — 13 documentos, 359.067 caracteres, 127 seções, front matter e bibliografias removidos. Esses capítulos continuam sendo editados, então aplicar a mesma regra hoje conta alguns milhares de caracteres a mais: a contagem de seções permanece inalterada, assim como toda conclusão abaixo, mas o total de caracteres é um snapshot e é rotulado como tal. A verdade de referência são 32 perguntas, cada uma pareada com uma frase literal que ocorre exatamente uma vez no corpus e nunca é um título de seção, feitas em duas formulações para 64 consultas. Os retrieval embeddings são sentence-transformers/all-MiniLM-L6-v2 (384 dimensões, mean-pooled, normalizados em L2, window 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. Todos os tempos são em CPU single-threaded. Nenhuma API paga foi chamada para produzir este capítulo, o que também é por isso que toda latência aqui é local e é rotulada 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 por chunk em todo o corpus e concordam em todos os 940 chunks, textos e offsets igualmente. Os intervalos são Wilson a 95 %; comparações pareadas são testes exatos dos sinais, bicaudais, nos pares discordantes.
Todos os quatorze identificadores citados acima foram resolvidos contra a API do arXiv e verificados título por título em 7 de setembro de 2026 — o que, considerando os oito que não eram, pareceu o mínimo que este capítulo em particular poderia fazer.
Referências
Link para a seção: Referências-
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 para ler por que existe. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. O é deles, e o ponto do método é que ele não precisa de calibração entre as escalas de pontuação que está fundindo. ↩
-
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 no qual o índice deste capítulo foi construído e foi medido no Capítulo 8. ↩
-
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 dos bancos de dados vetoriais vendidos hoje. ↩
-
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. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). O argumento de que hallucination é produzida por avaliação de acurácia binária que nunca recompensa abstenção e, portanto, é um problema de avaliação antes de ser um problema de modelagem. ↩
-
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 entender o que ele corrige e o que 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 junto com o model em vez de acoplá-lo 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), é o arranjo 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 embed uma resposta hipotética em vez da pergunta. ↩
-
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). Detecção por reamostragem, sem acesso aos internos do model e sem knowledge base externa. ↩
-
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 model para decidir quando recuperar, em vez de recuperar em todo turno. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). O benchmark construído com perguntas em que a resposta plausível e a resposta verdadeira diferem, que é toda a dificuldade em uma frase. ↩