Índice

  1. O que é RAG e por que você precisa
  2. Por que PostgreSQL e pgvector
  3. Conceitos: embeddings e similaridade
  4. Instalando o pgvector
  5. Esquema do banco para RAG
  6. Gerando e armazenando embeddings
  7. Buscando contexto: a mágica do RAG
  8. Prompt e geração de resposta
  9. Pipeline completo em Python
  10. Otimizando: índices e chunking
  11. RAG avançado: reranking e filtros
  12. Avaliando qualidade do RAG
  13. Erros comuns

O que é RAG e por que você precisa {#o-que-e-rag}

Um LLM (Large Language Model) é um modelo de IA treinado para entender e gerar texto — o ChatGPT é o exemplo mais conhecido. O problema do LLM puro: ele só sabe o que aprendeu no treino. Pergunte ao GPT-4 qual a política de reembolso da sua empresa e ele vai inventar algo plausível — porque não tem como saber.

RAG (Retrieval-Augmented Generation, algo como "geração aumentada por recuperação") resolve isso. A ideia é simples:

  1. Você guarda seus documentos em um banco preparado para busca semântica
  2. Quando o usuário pergunta algo, o sistema busca os trechos relevantes
  3. Esses trechos são injetados no prompt enviado ao LLM, como "contexto"
  4. O LLM responde olhando para o contexto, em vez de adivinhar

Analogia: imagina que o LLM é um médico generalista muito bem formado. Sem RAG, ele atende no escuro — só com o que lembra da faculdade. Com RAG, você entrega o prontuário do paciente antes da consulta. Ele continua sendo o mesmo médico, mas agora responde com base naquele caso específico.

RAG é o que transforma um LLM genérico em um assistente que entende do seu negócio. E a boa notícia: você não precisa de um banco de dados novo. Se já usa PostgreSQL, basta instalar a extensão pgvector.

Por que PostgreSQL e pgvector {#por-que-pgvector}

Um vector database (banco vetorial) é um banco otimizado para armazenar e buscar vetores numéricos — que é como representamos o "sentido" dos textos. Existem bancos dedicados (Pinecone, Qdrant, Weaviate, Milvus), mas para a maioria dos projetos, usar o PostgreSQL que você já tem é a escolha mais inteligente.

pgvector é uma extensão open source do PostgreSQL que adiciona o tipo vector e operações de busca por similaridade. É mantida pela comunidade, suportada por AWS RDS, Google Cloud SQL e Supabase, e roda em qualquer Postgres 13+.

Vantagens do pgvector sobre bancos dedicados

CritériopgvectorBanco vetorial dedicado
Infraestrutura novaNão (reaproveita PG)Sim
Transações ACIDSim (nativo do PG)Geralmente não
Join com dados relacionaisSim, na mesma queryNão, precisa sincronizar
Backup e replicaçãoIgual ao seu PG atualConfiguração separada
Custo adicional$0 (extensão livre)$20–200+/mês
Escala para 100M+ vetoresSo-so, precisa tuningSim, nativo
Curva de aprendizadoBaixa (sabe SQL? sabe usar)Nova stack
Tipos de índiceIVFFlat, HNSWVários

Quando NÃO usar pgvector: se você tem 50M+ vetores e precisa de latência abaixo de 50ms em toda busca, um banco dedicado (Qdrant, Milvus) tende a performar melhor. Para 99% dos projetos corporativos, pgvector resolve com folga.

Conceitos: embeddings e similaridade {#conceitos}

Dois conceitos-chave. Vamos com calma.

Embeddings

Um embedding é uma lista de números (um vetor) que representa o sentido de um texto. Textos com significado parecido têm vetores parecidos. É gerado por um modelo treinado para isso — não pelo LLM que gera as respostas, mas por um modelo especializado em embeddings (ex: text-embedding-3-small da OpenAI).

Analogia: pense em embeddings como coordenadas GPS do sentido. "Cachorro late" e "O perro ladra" ficam em pontos próximos do mapa, porque querem dizer a mesma coisa. "Cachorro late" e "Bicicleta vermelha" ficam longe um do outro.

Um embedding típico tem 384, 768, 1536 ou 3072 dimensões (depende do modelo). Quanto mais dimensões, mais nuance captura — e mais espaço ocupa.

Similaridade (distância)

Para encontrar documentos relevantes, comparamos vetores. As duas métricas mais comuns:

  • Cosseno (<=> no pgvector): mede o ângulo entre vetores. Ideal para texto, porque ignora o tamanho (quantas palavras) e olha só a direção (sentido).
  • L2 / Euclidiana (<-> no pgvector): mede a distância reta. Funciona, mas é menos comum em RAG.

Valores menores = mais similares (no cosseno, 0 = idêntico, 2 = oposto).

-- Exemplo: buscar os 5 documentos mais parecidos com um vetor de consulta
SELECT id, conteudo
FROM documentos
ORDER BY embedding <=> '[0.1, 0.2, ...]'  -- vetor da pergunta
LIMIT 5;

Comentário: o operador <=> calcula a distância-cosseno entre cada embedding da tabela e o vetor da pergunta; o ORDER BY ... LIMIT 5 pega os 5 mais próximos. Simples assim — é só SQL.

Instalando o pgvector {#instalando}

Em Docker (recomendado para desenvolvimento)

# Imagem oficial do Postgres já com pgvector instalado
docker run -d \
  --name pgvector-db \
  -e POSTGRES_PASSWORD=suasenha \
  -e POSTGRES_DB=ia \
  -p 5432:5432 \
  pgvector/pgvector:pg16

Comentário: a imagem pgvector/pgvector:pg16 é Postgres 16 com a extensão pré-instalada. Sem compilação, sem dor de cabeça.

Em AWS RDS

A partir do Postgres 13, o pgvector já vem pré-instalado em RDS — basta ativar:

CREATE EXTENSION IF NOT EXISTS vector;

Em Postgres self-hosted

# Compilar e instalar a extensão
cd /tmp
git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git
cd pgvector
make
make install  # pode precisar de sudo

Depois, no banco:

CREATE EXTENSION IF NOT EXISTS vector;
SELECT * FROM pg_extension WHERE extname = 'vector';

Esquema do banco para RAG {#esquema}

A estrutura típica de uma tabela de RAG:

CREATE TABLE documentos (
    id          BIGSERIAL PRIMARY KEY,
    -- Origem do documento: arquivo, URL, sistema
    fonte       TEXT NOT NULL,
    -- Metadados para filtro (JSONB é flexível)
    -- ex: {"tipo": "manual", "produto": "X100", "data": "2026-01"}
    metadados   JSONB DEFAULT '{}'::jsonb,
    -- Texto original do chunk (pedaço do documento)
    conteudo    TEXT NOT NULL,
    -- Vetor (dimensão depende do modelo de embedding)
    embedding   vector(1536) NOT NULL,
    -- Quando foi inserido/atualizado (para re-indexar só o que mudou)
    criado_em   TIMESTAMPTZ DEFAULT now()
);

-- Índice para filtrar por metadados (ex: só documentos de um produto)
CREATE INDEX idx_documentos_metadados
    ON documentos USING gin (metadados);

-- Índice vetorial - explicado adiante em "Otimizando"
CREATE INDEX idx_documentos_embedding
    ON documentos USING hnsw (embedding vector_cosine_ops);

Cada linha é um chunk (pedaço) do documento original. O motivo de quebrar em pedaços é que o LLM tem limite de contexto e fica confuso com textos muito longos. Em vez de armazenar o PDF inteiro, armazenamos pedaços de ~1000 caracteres cada, com seus embeddings.

Gerando e armazenando embeddings {#gerando}

Em Python, com a biblioteca oficial da OpenAI

# pip install openai psycopg[binary] tiktoken

from openai import OpenAI
import psycopg

client = OpenAI()  # lê OPENAI_API_KEY do ambiente

def gerar_embedding(texto: str) -> list[float]:
    """Gera embedding do texto usando o modelo small da OpenAI.
    Retorna um vetor de 1536 dimensões."""
    resposta = client.embeddings.create(
        model="text-embedding-3-small",
        input=texto,
    )
    return resposta.data[0].embedding

def inserir_documento(conn, fonte: str, conteudo: str, metadados: dict):
    """Insere um chunk no banco com seu embedding."""
    embedding = gerar_embedding(conteudo)
    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO documentos (fonte, metadados, conteudo, embedding)
            VALUES (%s, %s, %s, %s)
            """,
            (fonte, metadados, conteudo, embedding),
        )
    conn.commit()

Comentários: gerar_embedding chama a API da OpenAI, que devolve o vetor. inserir_documento salva no Postgres junto com metadados — esses metadados permitem filtrar a busca depois (ex: só buscar em manuais do produto X).

Custo da geração de embeddings

Modelo text-embedding-3-small: $0,02 por 1 milhão de tokens. Para uma base de 10.000 documentos de ~1.000 tokens cada:

  • 10M tokens × $0,02/1M = $0,20 USD

Ou seja, indexar uma base inteira custa menos que um café. Por isso, sempre use a OpenAI (ou Cohere) para embeddings, mesmo que seu LLM de resposta seja open source. Não vale a pena hospedar seu próprio modelo de embedding até ter volume enorme.

Alternativas de modelo de embedding

ModeloDimensãoCusto (1M tok)Notas
OpenAI text-embedding-3-small1536$0,02Padrão da indústria
OpenAI text-embedding-3-large3072$0,13Mais qualidade
Cohere embed-multilingual-v31024$0,10Muito bom em PT-BR
Voyage voyage-31024$0,12Estado da arte em RAG
BGE-large-zh-v1.5 (open source)1024$0 (self-host)Chines/inglês
SBERT multilingual (open source)384$0 (self-host)Leve, menos preciso

Buscando contexto: a mágica do RAG {#buscando}

A função central do RAG: dada a pergunta, buscar chunks relevantes.

def buscar_contexto(conn, pergunta: str, top_k: int = 4,
                    filtro_metadados: dict | None = None) -> list[dict]:
    """Busca os top_k chunks mais parecidos com a pergunta.
    Opcionalmente filtra por metadados (ex: {"produto": "X100"})."""

    # 1. Gerar embedding da pergunta (mesmo modelo dos documentos!)
    embedding_pergunta = gerar_embedding(pergunta)

    # 2. Construir a query SQL
    #    O operador <=> é a distância-cosseno (pgvector)
    #    Quanto menor, mais parecido
    sql = """
        SELECT id, fonte, conteudo, metadados,
               embedding <=> %s::vector AS distancia
        FROM documentos
    """
    params = [embedding_pergunta]

    # 3. Aplicar filtro de metadados se vier
    if filtro_metadados:
        clausulas = []
        for chave, valor in filtro_metadados.items():
            clausulas.append("metadados @> %s::jsonb")
            params.append({chave: valor})
        sql += " WHERE " + " AND ".join(clausulas)

    # 4. Ordenar por distância (mais parecido primeiro) e limitar
    sql += " ORDER BY distancia LIMIT %s"
    params.append(top_k)

    with conn.cursor() as cur:
        cur.execute(sql, params)
        colunas = [desc[0] for desc in cur.description]
        resultados = [dict(zip(colunas, row)) for row in cur.fetchall()]

    return resultados

Cada passo numerado explica o que está acontecendo: gera embedding da pergunta, monta a query, aplica filtro opcional, ordena por distância. O pulo do gato é o operador <=> que o pgvector adiciona ao SQL.

Por que o mesmo modelo de embedding?

Embeddings só são comparáveis se vierem do mesmo modelo. Se você gerou os documentos com text-embedding-3-small e a pergunta com text-embedding-3-large, a busca fica sem sentido — são vetores em "mapas" diferentes. Sempre use o mesmo modelo para indexar e para consultar.

Prompt e geração de resposta {#prompt}

Agora que temos contexto, montamos o prompt e chamamos o LLM:

def responder(pergunta: str, contexto: list[dict]) -> str:
    """Gera resposta do LLM usando o contexto recuperado."""
    contexto_texto = "\n\n---\n\n".join(
        f"[Fonte: {c['fonte']}]\n{c['conteudo']}" for c in contexto
    )

    prompt = f"""Você é um assistente que responde estritamente com base
no contexto fornecido. Se a informação não estiver no contexto, diga
explicitamente "Não tenho essa informação na base de conhecimento."

Contexto:
{contexto_texto}

Pergunta: {pergunta}

Resposta:"""

    resposta = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.2,  # baixa: respostas mais determinísticas
        messages=[{"role": "user", "content": prompt}],
    )
    return resposta.choices[0].message.content

Comentários explicam cada parte: montamos o contexto com marcadores de fonte (para citar depois), o prompt diz ao LLM o que fazer, e temperature=0.2 deixa a resposta mais conservadora — em RAG, você quer fidelidade ao contexto, não criatividade.

Template de prompt recomendado

Elementos de um bom prompt de RAG:

  1. Papel: "Você é um assistente de atendimento..."
  2. Regra de fonte: "Responda só com base no contexto."
  3. Regra de honestidade: "Se não souber, diga que não sabe."
  4. Citação: "Cite a fonte entre colchetes: Fonte: ..."
  5. Contexto: o que foi recuperado do banco
  6. Pergunta: o que o usuário perguntou
  7. Formato: "Responda em no máximo 3 parágrafos."

Pipeline completo em Python {#pipeline}

Juntando tudo:

# pip install openai psycopg[binary] tiktoken python-dotenv

import os
from openai import OpenAI
import psycopg
from dotenv import load_dotenv

load_dotenv()
client = OpenAI()

def conectar():
    return psycopg.connect(os.getenv("DATABASE_URL"))

def gerar_embedding(texto: str) -> list[float]:
    r = client.embeddings.create(model="text-embedding-3-small", input=texto)
    return r.data[0].embedding

def buscar_contexto(conn, pergunta: str, top_k: int = 4) -> list[dict]:
    emb = gerar_embedding(pergunta)
    with conn.cursor() as cur:
        cur.execute("""
            SELECT fonte, conteudo, metadados,
                   embedding <=> %s::vector AS distancia
            FROM documentos
            ORDER BY distancia
            LIMIT %s
        """, (emb, top_k))
        return [dict(zip(["fonte","conteudo","metadados","distancia"], r))
                for r in cur.fetchall()]

def responder(pergunta: str, contexto: list[dict]) -> str:
    ctx = "\n\n".join(f"[{c['fonte']}] {c['conteudo']}" for c in contexto)
    r = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.2,
        messages=[{"role":"user","content":f"""
Responda só com base no contexto. Se não souber, diga que não tem informação.

Contexto:
{ctx}

Pergunta: {pergunta}
"""}])
    return r.choices[0].message.content

def rag(pergunta: str) -> str:
    """Pipeline completo: pergunta -> busca -> resposta."""
    with conectar() as conn:
        contexto = buscar_contexto(conn, pergunta)
        print(f"Recuperados {len(contexto)} chunks")
        for c in contexto:
            print(f"  - {c['fonte']} (distância: {c['distancia']:.3f})")
        return responder(pergunta, contexto)

# Uso
if __name__ == "__main__":
    print(rag("Qual a política de reembolso do produto X100?"))

Comentários no topo mostram como instalar; cada função tem uma responsabilidade clara. O fluxo rag() é o ponto de entrada: conecta, busca contexto, gera resposta. Em produção, você troca o __main__ por um endpoint FastAPI.

Otimizando: índices e chunking {#otimizando}

Índices vetoriais

Sem índice, o pgvector calcula a distância da pergunta contra todas as linhas — funciona para 10k documentos, vira lento em 100k+. Existem dois índices principais:

ÍndiceComo funcionaPróContra
IVFFlatAgrupa vetores em listas, busca só nas listas próximasRápido, levePrecisa de tuning (lists, probes)
HNSWGrafo hierárquico de vizinhos próximosQualidade alta, build rápidoOcupa mais memória

Recomendação: use HNSW para a maioria dos casos. Configuração padrão já funciona bem:

CREATE INDEX idx_documentos_embedding
    ON documentos USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

m = 16 é o número máximo de conexões por nó (mais = mais qualidade, mais memória); ef_construction = 64 controla a qualidade da construção do índice. Para queries, ajuste ef_search (padrão 40) — mais alto, mais precisão e mais lento.

Chunking: o tamanho do pedaço

A decisão mais impactante na qualidade do RAG é como cortar os documentos:

chunk_sizeQuando usarPróContra
256–512Documentos curtos, FAQPrecisãoPerde contexto amplo
800–1200Padrão, maioria dos casosEquilíbrio
1500–3000Documentos longos, técnicosContexto amploPode diluir relevância

Regras práticas:

  • Sempre use chunk_overlap de 10–20% do chunk_size para não cortar ideias no meio
  • Para PDFs com seções, quebre por cabeçalho (H1, H2) em vez de tamanho fixo — preserve a estrutura
  • Para código, quebre por função/classe
  • Para manuais, quebre por seção do sumário
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
)
# Tenta quebrar primeiro por cabeçalhos H2, depois H3,
# depois parágrafos, depois linhas, etc. - preserva estrutura.

RAG avançado: reranking e filtros {#avancado}

Reranking

Recuperar por similaridade de embeddings é rápido, mas impreciso — às vezes os 4 chunks mais "parecidos" não são os mais úteis. Reranking resolve isso:

  1. Recupere 20 chunks por similaridade (barato, aproximado)
  2. Use um modelo de reranking (ex: Cohere Rerank, BGE-reranker) para reordenar os 20 por relevância real
  3. Use só os top 4 do reranking no prompt

Custo: a chamada de reranking é pequena (20 documentos) e barata. Qualidade sobe significativamente — especialmente em bases grandes.

# pip install cohere
import cohere
co = cohere.Client(os.getenv("COHERE_API_KEY"))

def rerank(pergunta: str, documentos: list[dict], top_n: int = 4) -> list[dict]:
    """Reordena documentos por relevância real, não só similaridade vetorial."""
    r = co.rerank(
        model="rerank-multilingual-v3.0",
        query=pergunta,
        documents=[d["conteudo"] for d in documentos],
        top_n=top_n,
    )
    return [documentos[doc.index] for doc in r.results]

Filtros por metadados

Já vimos filtro na query SQL. Para casos comuns:

  • Por produto: {"produto": "X100"} — só busca no manual daquele produto
  • Por tipo: {"tipo": "faq"} — só em FAQs, ignora manuais longos
  • Por data: {"data": {"$gte": "2026-01-01"}} — só documentos recentes
  • Por idioma: {"idioma": "pt-BR"} — só em português

Filtrar antes da busca vetorial reduz o conjunto e melhora precisão. Sempre que o usuário puder especificar escopo (produto, período, categoria), use isso como filtro.

Avaliando qualidade do RAG {#avaliando}

RAG sem avaliação é fé. Você não sabe se está bom ou se só "parece bom". Duas métricas centrais:

1. Recall@k

Dos chunks que deveriam estar no top-k, quantos aparecem? Para medir, crie um conjunto de ~50 perguntas com a resposta esperada e os documentos que deveriam ser recuperados. Rode o RAG e compare.

2. Fidelidade da resposta (faithfulness)

A resposta do LLM está apoiada no contexto recuperado, ou ele alucinou? Ferramentas como Ragas ou TruLens automatizam essa avaliação:

# pip install ragas
from ragas.metrics import faithfulness, answer_relevancy
from ragas import evaluate
from datasets import Dataset

# dados: perguntas, respostas geradas, contextos recuperados, respostas esperadas
dados = Dataset.from_dict({
    "question": [...],
    "answer": [...],         # o que o LLM respondeu
    "contexts": [...],       # o que foi recuperado
    "ground_truth": [...],   # a resposta correta
})
resultado = evaluate(dados, metrics=[faithfulness, answer_relevancy])
print(resultado)

faithfulness mede o quanto a resposta está apoiada no contexto; answer_relevancy mede o quanto ela responde à pergunta. Ideal: ambos acima de 0,8.

Conjunto de avaliação

Mantenha um conjunto de 30–100 pares pergunta/resposta esperada. Rode a avaliação a cada mudança (modelo, prompt, chunking). Sem isso, qualquer ajuste é tiro no escuro.

Erros comuns {#erros}

  1. Misturar modelos de embedding: indexar com um modelo, consultar com outro. Resultado: busca sem sentido. Sempre use o mesmo modelo.
  2. Chunk muito grande ou muito pequeno: 5000 caracteres dilui relevância; 50 caracteres perde contexto. Teste 800–1200 como ponto de partida.
  3. Não usar índice vetorial: com 50k documentos, busca sem HNSW vira segundo. Crie o índice assim que a tabela crescer.
  4. Esquecer do overlap no chunking: cortar uma ideia no meio faz o LLM perder contexto. Use 10–20% de overlap.
  5. Não filtrar por metadados: deixar o usuário perguntar sobre "produto X" e o RAG buscar em todos os produtos. Use a intenção do usuário como filtro.
  6. Temperature alta em RAG: temperature=0.7 faz o LLM "criar" além do contexto. Em RAG, use 0.1–0.3.
  7. Não ter avaliação automatizada: acham que está bom, até cliente reclamar. Monte conjunto de teste desde o início.
  8. Não atualizar embeddings: documento mudou, embedding não. Resposta fica desatualizada. Use updated_at para reindexar só o que mudou.
  9. Mandar contexto demais: 20 chunks de 2000 tokens cada = 40k tokens de input, caro e confuso. Top-k de 3–6 é o sweet spot.
  10. Ignorar a fonte: RAG sem citação de fonte vira "diz-que-disse". Sempre mostre de onde veio a informação — aumenta confiança e permite auditoria.

Quer um RAG funcionando com os dados da sua empresa? A Inicialize Tec implementa do esquema do banco ao pipeline completo, com avaliação de qualidade e monitoramento. Entre em contato e mostramos como fica com a sua base.