◈ TRIADIC.LIFE / Engenharia de IA / Módulo 4 0/10 aulas
Módulo 04 · 32h estimadas

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.

7 aulas 2 labs + projeto Postgres + pgvector (sem instalar nada) Pré-requisitos: Módulos 2 e 3

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
Postgres sem instalar Postgres O projeto usa o PGlite, um Postgres completo compilado para WebAssembly, com a extensão pgvector. O SQL é o mesmo de um Postgres de produção (Supabase, Neon, RDS, Cloud SQL): para migrar, troque só a conexão.

4.1 O que é RAG e quando usar

Leitura · 40 min
Pergunta→Buscar trechos relevantes→ Montar o contexto: instruções + trechos + pergunta→LLM responde citando os trechos

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?

AbordagemQuando faz sentidoLimites
Contexto longo (mandar tudo)Acervo pequeno (até algumas centenas de milhares de tokens), com cache de promptCusto e latência crescem com o acervo; ruído atrapalha
RAGAcervo grande ou que muda com frequência; precisa de citações; permissões por documentoSe a busca falha, a resposta falha; mais peças para operar
Ajuste finoEnsinar formato, estilo ou uma tarefa específica (Módulo 11)Péssimo para injetar fatos que mudam; não cita fontes
Comece pelo mais simples Com janelas de contexto de um milhão de tokens e cache de prompt, um acervo pequeno pode ir inteiro no contexto. O projeto deste módulo tem a opção --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

Leitura + lab · 3h

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âmetro input_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

Leitura + lab · 2h

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.

  1. Mude k1 e b e observe o efeito nas notas.
  2. 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

Leitura + prática · 3h

A qualidade do RAG é decidida na ingestão. Lixo indexado é lixo recuperado.

Etapas da ingestão

  1. 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.
  2. Limpar: menus, rodapés, avisos legais repetidos em toda página.
  3. Dividir em trechos (chunking).
  4. Enriquecer com metadados: documento, seção, data, produto, permissões. Servem para filtrar e para citar.
  5. Gerar embeddings e indexar.

Estratégias de chunking

EstratégiaPrósContras
Tamanho fixo (ex.: 500 tokens)Simples, previsívelCorta no meio de frases, tabelas e listas
Por estrutura (títulos, parágrafos)Trechos com sentido completo; ids legíveisSeções podem ser grandes ou minúsculas demais
Estrutura + limite + sobreposiçãoO melhor dos doisUm pouco mais de código
Semântico (corta onde o assunto muda)Bom para textos sem estruturaMais 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

Leitura + prática · 4h

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"

Leitura + prática · 3h

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

Leitura + prática · 4h

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étricaPergunta
acerto@kPelo menos um trecho relevante veio entre os k primeiros?
recall@kQue fração dos trechos relevantes veio entre os k primeiros?
MRREm que posição veio o primeiro relevante? (1 = primeiro lugar)
nDCG@kOs 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:

  1. Reindexe com o embedder semântico (npm run indexar, padrão local) e rode de novo. Anote a tabela. As perguntas reformuladas (q12, q13, q15, q16) passam a ser encontradas?
  2. Rode npm run avaliar:busca -- --rerank. O reranking melhora o MRR?
  3. Rode npm run avaliar:respostas e 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

Leitura + lab · 3h

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

Projeto · 10–12h · TypeScript

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

ArquivoResponsabilidade
src/chunking.tsDivisão por seção, limite com sobreposição, cabeçalho de contexto, ids estáveis
src/embeddings.tsInterface única com embedders local, voyage e hash
src/banco.tsPGlite + pgvector: tabela, índices HNSW e GIN, buscas vetorial e textual
src/busca.tsModos vetorial, textual e híbrido (RRF) e reranking com LLM
src/resposta.tsGeração com blocos search_result e citações; detecção de "não encontrei"
src/avaliar-*.tsEvals de recuperação (acerto, recall, MRR, nDCG) e de respostas (fatos, citação, abstenção)

Entregáveis

  1. Rode o eval de recuperação com hash, local e, se puder, voyage. Monte a tabela comparativa.
  2. 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.
  3. Teste duas configurações de chunking (por exemplo, --max 400 e --max 1200) e mostre o efeito nas métricas.
  4. Compare RAG e --contexto-completo em acertos, custo e latência, e escreva sua recomendação.
  5. Adicione um filtro por documento (por exemplo, só perguntas sobre entrega buscam em entregas) usando a coluna doc.

Critérios de avaliação

CritérioPeso
Comparação de embedders e de modos de busca com métricas25%
Acervo e gabarito ampliados, com perguntas sem resposta20%
Experimento de chunking15%
RAG × contexto completo, com recomendação justificada25%
Filtro por metadados15%

Recursos para aprofundar

Todos gratuitos