RAG de Ponta a Ponta
O modelo não conhece os documentos da sua empresa, e o que ele sabe tem data de corte. RAG (Retrieval-Augmented Generation) resolve isso buscando os trechos certos e entregando-os ao modelo na hora de responder. Neste módulo você constrói um assistente de documentação completo, com banco vetorial, busca híbrida, citações e avaliação de cada etapa.
Ao final do módulo você vai
- Decidir entre RAG, contexto longo e ajuste fino para um problema.
- Explicar embeddings e busca vetorial, e saber quando a busca por palavras vence.
- Dividir documentos em trechos que recuperam bem.
- Indexar em Postgres com pgvector e combinar busca vetorial, textual e reranking.
- Gerar respostas com citações verificáveis e que admitem "não encontrei".
- Medir recuperação e respostas separadamente, para saber qual etapa melhorar.
Preparando o ambiente
cd o-arco/curso-ia/codigo/modulo-04
pip install -r python/requirements.txt # só numpy: os labs não chamam API
cd projeto && npm install # inclui o PGlite: Postgres + pgvector dentro do Node
4.1 O que é RAG e quando usar
RAG não ensina nada ao modelo: ele só coloca a informação certa no contexto na hora da pergunta. Por isso é barato de atualizar (mudou o documento, reindexe) e auditável (dá para mostrar de onde veio cada afirmação).
RAG, contexto longo ou ajuste fino?
| Abordagem | Quando faz sentido | Limites |
|---|---|---|
| Contexto longo (mandar tudo) | Acervo pequeno (até algumas centenas de milhares de tokens), com cache de prompt | Custo e latência crescem com o acervo; ruído atrapalha |
| RAG | Acervo grande ou que muda com frequência; precisa de citações; permissões por documento | Se a busca falha, a resposta falha; mais peças para operar |
| Ajuste fino | Ensinar formato, estilo ou uma tarefa específica (Módulo 11) | Péssimo para injetar fatos que mudam; não cita fontes |
--contexto-completo exatamente
para você comparar as duas abordagens com números, em vez de assumir que RAG é sempre necessário.
Quiz rápido
A política de trocas da empresa muda todo mês e o assistente precisa citar a versão vigente. Melhor abordagem?
Fatos que mudam pertencem ao contexto, não aos pesos do modelo. E só o contexto permite citar a fonte.
4.2 Embeddings e busca vetorial
Um modelo de embedding transforma um texto num vetor de centenas ou milhares de números, de modo que textos de significado parecido fiquem próximos. "Vou viajar, preciso pagar o plano?" e "Pausar a assinatura" quase não têm palavras em comum, mas ficam perto nesse espaço. A busca vetorial é: transformar a pergunta em vetor e pegar os trechos mais próximos.
Escolhendo um modelo de embedding
- Idioma: use um modelo multilíngue ou treinado em português. Modelos só de inglês vão mal em PT-BR.
- Local ou API: modelos locais (via transformers.js, sentence-transformers) são grátis e privados; APIs (Voyage, OpenAI, Google, Cohere…) costumam ter mais qualidade e nenhuma infraestrutura. Rankings públicos como o MTEB ajudam a fazer a primeira lista.
- Consulta ≠ documento: muitos modelos pedem que você diga se o texto é uma consulta ou um documento (prefixos como
query:/passage:, ou um parâmetroinput_type). - O mesmo modelo dos dois lados: vetores de modelos diferentes não são comparáveis. Trocou de modelo, reindexe tudo.
O projeto traz três embedders atrás da mesma interface: local (modelo multilíngue E5
rodando na sua máquina, padrão), voyage (API) e hash, um "embedding de
brinquedo" por pedaços de letras que roda offline e não entende significado. Ele serve
para testes e como contraste: você vai ver na prática a diferença entre parecer e significar.
LAB 4.1 — Similaridade e escala
python python/02_vetores_e_escala.py
documento cosseno produto euclid.
prazo de entrega 0.969 0.690 0.265
frete e transporte 0.990 0.680 0.173
...
Repare: pelo cosseno, 'frete e transporte' fica em 1º; pelo produto interno, 'prazo de entrega'.
PARTE 2 — busca exata ('força bruta') conforme a base cresce
10.000 vetores × 384 dim · 15 MB · 1.1 ms por consulta
100.000 vetores × 384 dim · 154 MB · 6.0 ms por consulta
1.000.000 vetores × 384 dim · 1536 MB · 55.7 ms por consulta
Duas lições: normalize os vetores (aí cosseno e produto interno concordam) e, até dezenas de milhares de trechos, a busca exata já é rápida. Índices aproximados como HNSW passam a valer a pena com milhões de vetores ou muitas consultas por segundo. (Os tempos variam com a máquina.)
Quiz rápido
Você indexou os documentos com o modelo A e agora quer gerar o vetor das perguntas com o modelo B, mais barato. Funciona?
Mesmo com a mesma dimensão, o "significado" de cada eixo é diferente. Trocou de modelo, reindexe.
4.3 Busca por palavras: BM25 e por que ela ainda importa
Antes dos embeddings, buscadores ranqueavam por palavras com BM25: termos raros pesam mais (IDF), repetir uma palavra ajuda mas satura, e documentos longos não ganham só por serem longos. É rápido, não precisa de modelo, e é imbatível em termos exatos: códigos de produto (BX2-F), nomes próprios, números, siglas, mensagens de erro.
LAB 4.2 — BM25 do zero
python python/01_bm25_do_zero.py
python python/01_bm25_do_zero.py "como cancelo a assinatura"
"posso pausar o plano durante uma viagem" → termos: ['posso', 'pausar', 'plano', 'durante', 'viagem']
14.52 plano-filtro-em-dia#Pausar a assinatura
2.64 plano-filtro-em-dia#O que é
"vou viajar dois meses, tenho que continuar pagando?" → termos: ['vou', 'viajar', 'doi', 'mese', ...]
4.32 plano-filtro-em-dia#Mais de um aparelho
3.33 app-brisa-casa#Problemas de conexão
2.27 garantia-e-trocas#Garantia
"barulho no modo de dormir" → termos: ['barulho', 'modo', 'dormir']
4.23 manual-brisa-x2#9. Solução de problemas
2.11 manual-brisa-x2#5. Modos de operação
Mesma necessidade, palavras diferentes: "viajar" não é "viagem" para um radicalizador simples, e "pagar" não aparece na seção certa. O documento fala em "ruído" e modo "Sono", o cliente fala em "barulho" e "dormir". É o descompasso de vocabulário, que embeddings resolvem.
- Mude
k1ebe observe o efeito nas notas. - Adicione sinônimos à consulta ("ruído", "sono"). Por que isso não escala como solução?
Quiz rápido
O cliente pergunta pelo filtro "BX2-F". Qual busca tende a acertar?
Embeddings podem achar "algum filtro parecido"; a busca por termos acha exatamente o código. Por isso a combinação (híbrida) é o padrão.
4.4 Ingestão e chunking
A qualidade do RAG é decidida na ingestão. Lixo indexado é lixo recuperado.
Etapas da ingestão
- Extrair texto preservando estrutura: títulos, listas e tabelas. PDF é o formato mais traiçoeiro (colunas, cabeçalhos repetidos, tabelas quebradas). Converta para Markdown quando possível.
- Limpar: menus, rodapés, avisos legais repetidos em toda página.
- Dividir em trechos (chunking).
- Enriquecer com metadados: documento, seção, data, produto, permissões. Servem para filtrar e para citar.
- Gerar embeddings e indexar.
Estratégias de chunking
| Estratégia | Prós | Contras |
|---|---|---|
| Tamanho fixo (ex.: 500 tokens) | Simples, previsível | Corta no meio de frases, tabelas e listas |
| Por estrutura (títulos, parágrafos) | Trechos com sentido completo; ids legíveis | Seções podem ser grandes ou minúsculas demais |
| Estrutura + limite + sobreposição | O melhor dos dois | Um pouco mais de código |
| Semântico (corta onde o assunto muda) | Bom para textos sem estrutura | Mais caro e menos previsível |
O projeto usa a terceira: um trecho por seção (##), quebrando seções longas por
parágrafo com um parágrafo de sobreposição, sem nunca cortar uma tabela.
O truque do cabeçalho de contexto
Um trecho que diz só "Não há fidelidade. Pode ser cancelada a qualquer momento" não diz do
quê. Antes de gerar o embedding, o projeto prefixa cada trecho com o título do documento e da
seção: Plano Filtro em Dia — Fidelidade e cancelamento. O texto citado ao usuário
continua limpo. Técnicas mais elaboradas usam um LLM para escrever esse contexto por trecho.
$ npm run indexar -- --embedder hash
6 documentos → 41 trechos
tamanho dos trechos: mín 84 · mediana 237 · máx 691 caracteres
Quiz rápido
Por que ids como entregas#frete-gratis em vez de números sequenciais?
Com ids sequenciais, qualquer documento novo desloca todos os números e quebra o dataset de avaliação.
4.5 Postgres + pgvector, busca híbrida e reranking
Por que Postgres
Bancos vetoriais dedicados existem (Pinecone, Qdrant, Weaviate, Milvus…), mas para a maioria dos projetos o Postgres com pgvector basta e simplifica a vida: vetores, texto, metadados, permissões e transações no mesmo banco que você já opera. Considere um dedicado quando a escala (centenas de milhões de vetores) ou recursos específicos justificarem.
CREATE TABLE trechos (
id text PRIMARY KEY,
doc text NOT NULL,
secao text NOT NULL,
texto text NOT NULL,
indexado text NOT NULL, -- texto com cabeçalho de contexto
emb vector(384) NOT NULL, -- o embedding
tsv tsvector GENERATED ALWAYS AS (to_tsvector('portuguese', indexado)) STORED
);
CREATE INDEX ON trechos USING hnsw (emb vector_cosine_ops); -- busca vetorial aproximada
CREATE INDEX ON trechos USING gin (tsv); -- busca textual
-- vetorial: menor distância de cosseno (operador <=>)
SELECT id, 1 - (emb <=> $1) AS similaridade FROM trechos ORDER BY emb <=> $1 LIMIT 5;
-- textual: dicionário português (radicais e stopwords)
SELECT id, ts_rank_cd(tsv, websearch_to_tsquery('portuguese', $1)) AS nota
FROM trechos WHERE tsv @@ websearch_to_tsquery('portuguese', $1) ORDER BY nota DESC LIMIT 5;
Busca híbrida com RRF
As notas da busca vetorial (similaridade de 0 a 1) e da textual (rank do Postgres) não estão na
mesma escala, então não dá para somar. O Reciprocal Rank Fusion usa só a
posição: cada lista dá 1/(60 + posição) a cada trecho, e somamos. Quem aparece
bem nas duas listas sobe.
Reranking
Busque mais candidatos do que precisa (ex.: 20) com métodos baratos, e use um modelo mais
cuidadoso para reordená-los e ficar com os 5 melhores. Pode ser um modelo cross-encoder
especializado ou, como no projeto, um LLM pequeno (claude-haiku-5-5) dando nota de 0 a 3
para cada trecho. Custa uma chamada a mais por pergunta; meça se o ganho compensa.
npm run perguntar -- --so-busca "Vou viajar dois meses, preciso pagar o plano?"
npm run perguntar -- --modo vetorial --so-busca "..."
npm run perguntar -- --rerank "..."
Quiz rápido
Por que não somar diretamente a similaridade vetorial com a nota do BM25?
O RRF contorna isso usando só a posição em cada lista, sem calibrar escalas.
4.6 Geração com citações e o direito de dizer "não sei"
Recuperar é metade do trabalho. A outra metade é fazer o modelo responder só com
base nos trechos e mostrar de onde tirou cada afirmação. A API da Anthropic aceita
os trechos como blocos search_result com citações ativadas, e devolve o texto já
ligado às fontes:
const resultados = trechos.map((t) => ({
type: "search_result" as const,
source: t.id, // ex.: "entregas#frete-gratis"
title: `${t.tituloDoc} — ${t.secao}`,
content: [{ type: "text" as const, text: t.texto }],
citations: { enabled: true },
}));
const r = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4000,
system: SISTEMA, // "use SOMENTE os resultados..."
messages: [{ role: "user", content: [...resultados, { type: "text", text: pergunta }] }],
});
for (const bloco of r.content) {
if (bloco.type === "text")
for (const c of bloco.citations ?? [])
if (c.type === "search_result_location") console.log(c.source, c.cited_text);
}
Instruções que importam
- Use só os resultados. E explique por quê: um prazo inventado vira promessa feita ao cliente.
- Uma frase exata para "não encontrei": fica fácil de detectar no código e de avaliar.
- Trechos são dados, não instruções. Documentos indexados podem conter texto malicioso (Módulo 12).
Outros provedores têm recursos parecidos, ou você pode pedir citações no formato que quiser com saída estruturada. O importante é que a citação seja verificável pelo código, não só texto bonito.
Quiz rápido
A pergunta não tem resposta na documentação. Qual o melhor comportamento?
Num assistente de empresa, uma resposta inventada é pior que nenhuma. O eval de respostas cobra isso nos dois sentidos.
4.7 Avaliando RAG: recuperação e respostas
Quando uma resposta sai errada, a culpa pode ser da busca (o trecho certo nem chegou ao modelo) ou da geração (chegou, mas o modelo errou). Avalie as duas etapas separadamente.
Métricas de recuperação
| Métrica | Pergunta |
|---|---|
| acerto@k | Pelo menos um trecho relevante veio entre os k primeiros? |
| recall@k | Que fração dos trechos relevantes veio entre os k primeiros? |
| MRR | Em que posição veio o primeiro relevante? (1 = primeiro lugar) |
| nDCG@k | Os relevantes estão no topo ou espalhados no fim? |
Métricas de resposta
- Fidelidade: tudo o que foi dito está nos trechos? (citações verificáveis ajudam; um LLM-juiz completa)
- Correção: contém os fatos-chave esperados?
- Abstenção correta, nos dois sentidos: diz "não encontrei" quando não há resposta, e não diz quando há.
Na prática: comparando os modos de busca
O projeto tem 24 perguntas com gabarito (avaliacao/perguntas.jsonl): perguntas com as
mesmas palavras dos documentos, perguntas com outras palavras, perguntas que exigem dois
documentos e 3 perguntas sem resposta. Resultado real com o embedder de brinquedo
(hash):
$ npm run avaliar:busca
21 perguntas · k = 5 · embedder hash
modo acerto@k recall@k MRR nDCG@k
textual 0.90 0.84 0.75 0.75
vetorial 0.67 0.60 0.57 0.56
hibrida 0.86 0.79 0.64 0.65
Com um "embedding" que só compara grafia, a busca vetorial perde para a textual, e a híbrida fica pior que a textual sozinha: o componente ruim puxa o resultado para baixo. Lição: híbrida só ajuda se os dois componentes forem bons, e só a medição diz se são.
Sua vez:
- Reindexe com o embedder semântico (
npm run indexar, padrãolocal) e rode de novo. Anote a tabela. As perguntas reformuladas (q12, q13, q15, q16) passam a ser encontradas? - Rode
npm run avaliar:busca -- --rerank. O reranking melhora o MRR? - Rode
npm run avaliar:respostase depois com--contexto-completo. Compare acertos e custo por pergunta. Para este acervo, você usaria RAG?
RAG agêntico
No RAG clássico, o código busca uma vez e entrega ao modelo. No RAG agêntico, a busca vira uma ferramenta (Módulo 2, aula 2.5): o modelo decide o que buscar, reformula, busca de novo e combina resultados, o que é ótimo para perguntas que cruzam documentos (como a q05). O custo é mais chamadas e mais latência. Você vai construir isso no Módulo 6.
4.8 Extração de documentos difíceis
O RAG só é tão bom quanto o texto que entra nele. Se a tabela vira uma lista solta de números, nenhuma busca encontra "quantos assinantes o Plano Família tinha em fevereiro". Na prática, a extração é o primeiro lugar onde um RAG falha, antes do embedding e antes do modelo.
LAB 4.8 — Cinco métodos, três PDFs difíceis, uma nota
O lab gera três PDFs com gabarito: uma tabela com célula de duas linhas, um boletim em duas colunas e um contrato escaneado (só imagem, levemente torto). A nota confere se cada valor da tabela ficou na sua linha, se cada frase saiu inteira na ordem de leitura e se cada campo do contrato foi lido.
cd codigo/modulo-04/python
pip install -r requirements.txt # OCR: apt install tesseract-ocr tesseract-ocr-por poppler-utils
python ../documentos/gerar_pdfs.py
python 03_extracao_de_documentos.py # --claude inclui o modelo lendo o PDF
método relatorio boletim contrato total tempo
pypdf 15/15 6/6 0/6 78% 0.0s
pdfplumber 15/15 0/6 0/6 56% 0.1s
ocr 12/15 6/6 6/6 89% 7.2s
hibrido 15/15 6/6 6/6 100% 3.5s
- pypdf acertou as colunas por sorte: o PDF foi gravado coluna por coluna. Em outro gerador, a ordem muda.
- pdfplumber extrai a tabela perfeita, mas lê o boletim linha a linha, atravessando as colunas: "A nova central de atendimento passou a A manutenção da rede no bairro Jardim". Nenhuma frase sobrevive.
- OCR lê a página escaneada sem erro, mas perde o rótulo de duas linhas da tabela ("Plano Família (até 4 telas)" virou "(até colas)"), e os três números daquela linha ficam sem dono.
- Híbrido decide por página: OCR só onde não há camada de texto, tabelas detectadas viram markdown, e um vão vertical no meio da página indica duas colunas lidas separadamente. É o que ferramentas como Unstructured e LlamaParse fazem em escala, com mais heurísticas.
Com o Claude (--claude): o modelo recebe cada página como texto e imagem e transcreve em markdown. Nesta versão do curso essa linha não foi medida contra a API real; rode com a sua chave e compare custo e nota. Em documentos variados, um modelo de visão costuma resolver os casos que quebram heurísticas, a um custo por página muito maior: use-o onde o híbrido falha, não em tudo.
Regras para produção
- Tenha um gabarito da sua base, mesmo pequeno: 20 páginas representativas com o que deveria sair. Sem nota, a troca de ferramenta é palpite.
- Escolha por página, não por documento: uma mesma proposta tem páginas digitadas, tabelas e um anexo escaneado.
- Tabelas viram markdown ou linhas autocontidas ("Plano Família, fev/2026: 2.910 assinaturas") antes do chunking: um chunk com só números não é recuperável.
- Guarde a origem (arquivo, página, método) em cada chunk, para citar e para reprocessar quando o extrator melhorar.
Quiz rápido
Um PDF tem páginas digitadas, uma tabela de preços e um anexo escaneado. Qual estratégia?
No lab, nenhum método único acertou tudo: o OCR perdeu um rótulo da tabela que a camada de texto lia perfeitamente, e a camada de texto não tem nada na página escaneada.
Projeto do módulo: assistente de documentação com RAG
Um assistente que responde dúvidas de clientes da Brisa (empresa fictícia de purificadores de ar) com base em 6 documentos: manual, garantia, entregas, assinatura, aplicativo e privacidade.
cd projeto
npm install
npm test # 16 testes, offline (inclui Postgres em memória)
npm run indexar # embedder local (baixa o modelo na 1ª vez)
npm run indexar -- --embedder hash # alternativa offline
npm run perguntar -- "Posso usar o purificador na varanda?"
npm run avaliar:busca # eval de recuperação (sem API)
npm run avaliar:respostas # eval ponta a ponta (com API)
Arquitetura
| Arquivo | Responsabilidade |
|---|---|
src/chunking.ts | Divisão por seção, limite com sobreposição, cabeçalho de contexto, ids estáveis |
src/embeddings.ts | Interface única com embedders local, voyage e hash |
src/banco.ts | PGlite + pgvector: tabela, índices HNSW e GIN, buscas vetorial e textual |
src/busca.ts | Modos vetorial, textual e híbrido (RRF) e reranking com LLM |
src/resposta.ts | Geração com blocos search_result e citações; detecção de "não encontrei" |
src/avaliar-*.ts | Evals de recuperação (acerto, recall, MRR, nDCG) e de respostas (fatos, citação, abstenção) |
Entregáveis
- Rode o eval de recuperação com
hash,locale, se puder,voyage. Monte a tabela comparativa. - Adicione 2 documentos novos (por exemplo, uma FAQ e uma tabela de preços de acessórios) e 10 perguntas com gabarito, incluindo 2 sem resposta.
- Teste duas configurações de chunking (por exemplo,
--max 400e--max 1200) e mostre o efeito nas métricas. - Compare RAG e
--contexto-completoem acertos, custo e latência, e escreva sua recomendação. - Adicione um filtro por documento (por exemplo, só perguntas sobre entrega buscam em
entregas) usando a colunadoc.
Critérios de avaliação
| Critério | Peso |
|---|---|
| Comparação de embedders e de modos de busca com métricas | 25% |
| Acervo e gabarito ampliados, com perguntas sem resposta | 20% |
| Experimento de chunking | 15% |
| RAG × contexto completo, com recomendação justificada | 25% |
| Filtro por metadados | 15% |
Recursos para aprofundar
- pgvector · documentação da extensão: índices, operadores e ajuste do HNSW
- PGlite · Postgres em WebAssembly, usado no projeto
- Contextual Retrieval · a técnica de adicionar contexto a cada trecho, com resultados medidos
- MTEB Leaderboard · ranking público de modelos de embedding (filtre por idioma)
- Reciprocal Rank Fusion (2009) · o artigo original do RRF
- Documentação da API da Anthropic · citações e blocos de resultado de busca