Índice
- O que é RAG e por que você precisa
- Por que PostgreSQL e pgvector
- Conceitos: embeddings e similaridade
- Instalando o pgvector
- Esquema do banco para RAG
- Gerando e armazenando embeddings
- Buscando contexto: a mágica do RAG
- Prompt e geração de resposta
- Pipeline completo em Python
- Otimizando: índices e chunking
- RAG avançado: reranking e filtros
- Avaliando qualidade do RAG
- 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:
- Você guarda seus documentos em um banco preparado para busca semântica
- Quando o usuário pergunta algo, o sistema busca os trechos relevantes
- Esses trechos são injetados no prompt enviado ao LLM, como "contexto"
- 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ério | pgvector | Banco vetorial dedicado |
|---|---|---|
| Infraestrutura nova | Não (reaproveita PG) | Sim |
| Transações ACID | Sim (nativo do PG) | Geralmente não |
| Join com dados relacionais | Sim, na mesma query | Não, precisa sincronizar |
| Backup e replicação | Igual ao seu PG atual | Configuração separada |
| Custo adicional | $0 (extensão livre) | $20–200+/mês |
| Escala para 100M+ vetores | So-so, precisa tuning | Sim, nativo |
| Curva de aprendizado | Baixa (sabe SQL? sabe usar) | Nova stack |
| Tipos de índice | IVFFlat, HNSW | Vá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
| Modelo | Dimensão | Custo (1M tok) | Notas |
|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 | $0,02 | Padrão da indústria |
| OpenAI text-embedding-3-large | 3072 | $0,13 | Mais qualidade |
| Cohere embed-multilingual-v3 | 1024 | $0,10 | Muito bom em PT-BR |
| Voyage voyage-3 | 1024 | $0,12 | Estado 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:
- Papel: "Você é um assistente de atendimento..."
- Regra de fonte: "Responda só com base no contexto."
- Regra de honestidade: "Se não souber, diga que não sabe."
- Citação: "Cite a fonte entre colchetes: Fonte: ..."
- Contexto: o que foi recuperado do banco
- Pergunta: o que o usuário perguntou
- 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:
| Índice | Como funciona | Pró | Contra |
|---|---|---|---|
| IVFFlat | Agrupa vetores em listas, busca só nas listas próximas | Rápido, leve | Precisa de tuning (lists, probes) |
| HNSW | Grafo hierárquico de vizinhos próximos | Qualidade alta, build rápido | Ocupa 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_size | Quando usar | Pró | Contra |
|---|---|---|---|
| 256–512 | Documentos curtos, FAQ | Precisão | Perde contexto amplo |
| 800–1200 | Padrão, maioria dos casos | Equilíbrio | — |
| 1500–3000 | Documentos longos, técnicos | Contexto amplo | Pode diluir relevância |
Regras práticas:
- Sempre use
chunk_overlapde 10–20% dochunk_sizepara 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:
- Recupere 20 chunks por similaridade (barato, aproximado)
- Use um modelo de reranking (ex: Cohere Rerank, BGE-reranker) para reordenar os 20 por relevância real
- 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}
- Misturar modelos de embedding: indexar com um modelo, consultar com outro. Resultado: busca sem sentido. Sempre use o mesmo modelo.
- Chunk muito grande ou muito pequeno: 5000 caracteres dilui relevância; 50 caracteres perde contexto. Teste 800–1200 como ponto de partida.
- Não usar índice vetorial: com 50k documentos, busca sem HNSW vira segundo. Crie o índice assim que a tabela crescer.
- Esquecer do overlap no chunking: cortar uma ideia no meio faz o LLM perder contexto. Use 10–20% de overlap.
- 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.
- Temperature alta em RAG:
temperature=0.7faz o LLM "criar" além do contexto. Em RAG, use 0.1–0.3. - Não ter avaliação automatizada: acham que está bom, até cliente reclamar. Monte conjunto de teste desde o início.
- Não atualizar embeddings: documento mudou, embedding não. Resposta
fica desatualizada. Use
updated_atpara reindexar só o que mudou. - Mandar contexto demais: 20 chunks de 2000 tokens cada = 40k tokens de input, caro e confuso. Top-k de 3–6 é o sweet spot.
- 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.