~ / tutoriais /rag-do-zero-chunking-embeddings-busca $ _

RAG do zero: chunking, embeddings e busca que funciona

Lucas Souza Lucas Souza 10 min de leitura Tutoriais
RAG do zero: chunking, embeddings e busca que funciona

RAG não é mágica. É engenharia de três etapas: quebrar texto, virar vetor e buscar bem.

E o ponto onde quase todo mundo erra é o primeiro. O chunking. Você pode usar o melhor modelo de embedding do mercado, o vector DB mais rápido do planeta, e ainda assim entregar um RAG que responde errado — porque quebrou os documentos no lugar errado. É aí que 80% dos RAGs nascem ruins.

Neste tutorial a gente constrói um RAG do zero, na unha: pega um conjunto de documentos, faz o chunking direito, gera os embeddings, indexa no Postgres com pgvector e busca por similaridade. Sem framework escondendo as decisões. Porque o que define qualidade não é a lib — é cada escolha que a lib normalmente faz por você sem te avisar.

TL;DR

  • O que é: pipeline mínimo de RAG (chunking → embeddings → indexação → busca) montado peça por peça, com as decisões de qualidade explícitas.
  • Stack/Modelos: Python, text-embedding-3-small da OpenAI, PostgreSQL + pgvector. SQL puro na busca.
  • Custo/Acesso: embeddings a US$ 0,02 por 1M de tokens; Postgres você já tem. Dá pra rodar o tutorial inteiro por centavos.
  • Antes de codar: se ainda está na dúvida se RAG é a escolha certa pro seu caso, lê primeiro Quando usar RAG (e quando fine-tuning ou contexto resolvem melhor).

O contexto: por que o RAG quebra no chunking, não no modelo

RAG — Retrieval-Augmented Generation — é simples de descrever. Você tem uma base de conhecimento. Quando chega uma pergunta, você busca os trechos mais relevantes e injeta no prompt do modelo junto com a pergunta. O modelo responde com base no que você recuperou, não no que ele "lembra".

A parte que todo tutorial trata como detalhe — e que na verdade decide tudo — é o retrieval. Se você recupera o trecho errado, o modelo responde errado com toda a confiança do mundo. Lixo entra, lixo sai. E o retrieval começa lá atrás, na hora de quebrar o documento.

Pensa no caso real: um PDF de 40 páginas de documentação. Se você quebrar em pedaços de 2 mil tokens, cada chunk vira uma sopa de assuntos misturados — e o embedding daquele pedaço fica genérico, sem representar bem nenhum tópico específico. Quebrou pequeno demais, em pedaços de 50 tokens? Cada chunk perde o contexto da frase anterior e a busca não acha o que precisa. O tamanho do chunk não é detalhe de implementação. É decisão de arquitetura.

E tem número pra provar. Uma análise sistemática de janeiro de 2026 identificou um "context cliff" por volta de 2.500 tokens — acima disso, a qualidade da resposta despenca, segundo o levantamento de estratégias de chunking da Firecrawl. Não é achismo. É onde o modelo começa a se perder no meio do contexto.

Pré-requisitos

O que você precisa pra acompanhar:

  • [ ] Python 3.11+ com openai e psycopg instalados (pip install openai psycopg[binary])
  • [ ] Uma chave de API da OpenAI (qualquer crédito serve, o custo é irrisório)
  • [ ] PostgreSQL 14+ com a extensão pgvector habilitada (CREATE EXTENSION vector;)
  • [ ] Conhecimento básico de SQL e de como funciona um embedding — se "vetor" ainda soa abstrato, segura aí que a gente esclarece no caminho

Não precisa de LangChain, LlamaIndex nem nada. A graça aqui é ver o encanamento por dentro.

Mão na massa: o pipeline peça por peça

Passo 1: Chunking — quebrar o documento direito

Esse é o passo que define a qualidade do RAG inteiro. Chunking é o ato de quebrar um documento grande em pedaços pequenos o suficiente pra um embedding representar bem, e grandes o suficiente pra carregar contexto.

A regra de bolso que funciona pra maioria dos casos: recursive chunking de 400 a 512 tokens, com 10 a 20% de overlap — ou seja, num chunk de 500 tokens, uns 50 a 100 tokens de sobreposição com o chunk anterior. Esse é o ponto de partida recomendado no guia da Firecrawl e bate com o que a gente vê em produção.

Por que recursive, e não cortar a cada N caracteres? Porque o recursive respeita a estrutura do texto: ele tenta quebrar primeiro em parágrafos, depois em frases, depois em palavras — só corta no meio de uma frase se não tiver jeito. Cortar cego no caractere 500 parte palavra no meio e destrói o sentido.

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 75) -> list[str]:
    # quebra respeitando parágrafos; junta até encher o chunk
    paragrafos = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, atual = [], ""

    for p in paragrafos:
        if len(atual) + len(p) <= chunk_size:
            atual += "\n\n" + p
        else:
            if atual:
                chunks.append(atual.strip())
            # carrega o final do chunk anterior como overlap
            atual = atual[-overlap:] + "\n\n" + p if atual else p

    if atual:
        chunks.append(atual.strip())
    return chunks

E o overlap existe por um motivo só: garantir que uma informação que cai bem na fronteira de dois chunks não se perca. Sem overlap, a frase "a multa é de 7% do faturamento" pode ficar partida entre o fim de um chunk e o começo do outro, e nenhum dos dois representa direito.

Tem chunking mais esperto que isso? Tem. O semantic chunking quebra onde o assunto muda de verdade, medindo a distância entre embeddings de frases vizinhas, e chega a dar até ~70% de ganho sobre o baseline naive em benchmarks. O preço é processar embeddings já na hora de quebrar — mais lento e mais caro. Comece no recursive. Só suba pro semantic quando suas métricas mostrarem que precisa. Otimizar antes de medir é overengineering.

Passo 2: Embeddings — transformar o chunk em vetor

Embedding é uma forma de transformar texto em representação matemática: um vetor de números onde textos com significado parecido ficam perto no espaço. É isso que permite a busca semântica — comparar significado, não palavra.

A escolha do modelo de embedding é a segunda decisão que pesa. Os números pra colocar na mesa, da família atual da OpenAi:

  • text-embedding-3-small: 1.536 dimensões, US$ 0,02 por 1M de tokens.
  • text-embedding-3-large: 3.072 dimensões, US$ 0,13 por 1M — 6,5x mais caro, pra um ganho de ~4 pontos no MTEB (de 62% pra 66%).

Traduzindo: o 3-small resolve a esmagadora maioria dos casos. Você não começa pelo modelo caro. Começa pelo que entrega bom resultado por centavos e sobe se a avaliação pedir.

Um detalhe que pouca gente usa e ajuda muito: esses modelos suportam o parâmetro dimensions, que encurta o vetor. A OpenAI mostra que um 3-large cortado pra 256 dimensões ainda supera o antigo ada-002 em 1.536 — graças à representação Matryoshka. Vetor menor = índice menor e busca mais rápida.

from openai import OpenAI

client = OpenAI()

def embed(textos: list[str]) -> list[list[float]]:
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=textos,  # manda em lote: mais barato e mais rápido
    )
    return [d.embedding for d in resp.data]

Dica de ouro: gere embeddings em lote, não um por um. A API aceita uma lista de inputs numa chamada só — e isso reduz latência e overhead de rede de forma absurda quando você tem milhares de chunks.

Passo 3: Indexar e buscar no pgvector

Agora os vetores precisam morar em algum lugar onde você consiga buscar por proximidade. Você não precisa de um vector DB dedicado pra começar. Se já roda Postgres, o pgvector resolve — e mantém seus dados e seus vetores no mesmo banco, sem mais uma peça de infra pra operar.

A tabela e o índice:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id        bigserial PRIMARY KEY,
    conteudo  text NOT NULL,
    embedding vector(1536)   -- bate com as dims do 3-small
);

-- índice HNSW com distância de cosseno
CREATE INDEX ON chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

Duas decisões aqui. A primeira é a métrica de distância: pra busca semântica de texto, use cosseno (vector_cosine_ops). Cosseno mede ângulo entre vetores, ignorando magnitude — que é exatamente o que você quer com embeddings de texto.

A segunda é o índice HNSW, que monta um grafo em camadas e dá a melhor relação velocidade/recall — bem superior ao IVFFlat, em troca de build mais lento e mais memória, conforme a doc do pgvector. Um detalhe que pega gente desprevenida: o tipo vector tem teto de 2.000 dimensões pra índice HNSW. Se for usar o 3-large com 3.072 dims, troque pro tipo halfvec, que sobe esse limite pra 4.000. Mais um motivo pra começar no 3-small.

A busca é uma query SQL com o operador <=> (distância de cosseno):

SELECT conteudo, 1 - (embedding <=> $1) AS similaridade
FROM chunks
ORDER BY embedding <=> $1   -- usa o índice HNSW
LIMIT 5;

Você gera o embedding da pergunta com o mesmo modelo do passo 2, joga como $1, e o banco devolve os 5 chunks mais próximos. Esses 5 trechos é que vão pro prompt do modelo junto com a pergunta. Pronto: isso é um RAG funcionando.

Passo 4: quando a busca pura não basta — hybrid + rerank

A busca por vetor sozinha tem um buraco: ela é ótima em significado e péssima em termo exato. Pergunta pelo código de erro "ORA-01017" e a busca semântica pode trazer trechos "sobre erros de login" sem nunca casar o código literal.

A solução é hybrid search: roda busca vetorial e busca por palavra-chave (BM25) em paralelo e funde os resultados. As falhas das duas são complementares — uma cobre o ponto cego da outra. A fusão se faz com Reciprocal Rank Fusion, que combina rankings sem se enroscar em escalas de score diferentes.

E depois disso, opcionalmente, um reranker: um modelo que reordena os candidatos por relevância real à pergunta. O ganho é concreto — num benchmark recente, hybrid + reranker da Cohere chegou a Recall@5 de 0,816 contra 0,587 da busca vetorial pura.

Mas a ordem importa, e é aqui que muita gente queima etapa: só adicione reranker depois que seu recall@50 estiver sólido, acima de 90%como aponta a referência de hybrid search. Reranker reordena o que você recuperou; se o trecho certo nem entrou no top 50, nenhum reranker vai inventá-lo. Primeiro recall, depois precisão.

Limitações e pontos de atenção

RAG resolve muito, mas não é bala de prata. Onde você vai se queimar:

  • Chunking ruim é invisível até quebrar. Não dá pra saber se seu chunk size está bom no olho. Monte um dataset de 30–50 perguntas com a resposta esperada e meça recall. Sem avaliação, você está chutando.
  • Embedding multilíngue. Se sua base é em português, confirme que o modelo lida bem com PT-BR. O 3-small é decente, mas teste no seu domínio antes de assumir.
  • Dado sensível vira vetor — e vetor é dado. Embedding não é anônimo. Se você joga documento de cliente no pgvector, ele está sujeito às mesmas regras de privacidade do texto original. Trate o índice como dado sensível.
  • Custo de reindexação. Trocar de modelo de embedding obriga a reprocessar a base inteira — embeddings de modelos diferentes não são comparáveis. Escolha pensando em ficar.

FAQ rápido

Qual chunk size eu uso se não quiser pensar? Comece com 500 tokens e 75 de overlap, recursive. Funciona pra documentação, artigos e a maioria do texto corrido. Só mexe depois de medir recall num dataset real.

Preciso de um vector DB dedicado tipo Pinecone? Pra começar, não. pgvector no Postgres que você já tem aguenta tranquilo até a casa dos milhões de vetores com índice HNSW. Migre pra solução dedicada quando escala ou latência cobrarem — não antes.

Cosseno ou distância euclidiana? Pra embeddings de texto, cosseno (vector_cosine_ops). Mede similaridade de direção, que é o que importa em significado. Euclidiana entra em casos específicos onde magnitude importa.

Por que minha busca traz trecho irrelevante mesmo com bom embedding? Provavelmente é chunking ou falta de busca lexical. Cheque o tamanho dos chunks primeiro; se a pergunta tem termo/código exato, adicione hybrid search. Veja a engenharia de contexto por trás disso.

Fechando o pipeline

Recapitulando o que você construiu: quebrou o documento com chunking recursive de ~500 tokens e overlap, virou cada chunk em vetor com o text-embedding-3-small, indexou no pgvector com HNSW e cosseno, e buscou os trechos mais próximos com uma query SQL. Esse é um RAG completo, e cada decisão de qualidade está na sua mão — não escondida dentro de um framework.

O próximo passo é parar de chutar e começar a medir: monte o dataset de avaliação, acompanhe recall, e só então decida se vale subir pro semantic chunking, pro 3-large ou pro reranker. RAG bom não é o que usa a peça mais cara. É o que mede cada decisão. Se a ideia é levar esse tipo de raciocínio pra arquitetura inteira de um produto com IA — onde RAG é só uma das peças — é exatamente o que a gente coloca na mesa no Workshop Arquitetando Soluções de IA, com agents de IA do desenho à produção.

Agora que o retrieval funciona, o passo natural é olhar o que entra no prompt e o que fica de fora: veja Engenharia de contexto: o que vai no prompt (e o que NÃO vai).

Lucas Souza
Lucas Souza

{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.

Você também pode gostar

VirguIA

beer & code assistant

conectando…

Não foi possível iniciar o chat agora.

tocando