Artigo

PostgreSQL com pgvector: Busca Semântica e RAG na Prática

PostgreSQL com pgvector: Busca Semântica e RAG na Prática
Tempo de leitura9 minutos

Você não precisa de um banco vetorial dedicado para fazer RAG. O PostgreSQL, que você provavelmente já usa, resolve isso com a extensão pgvector — busca por similaridade, filtros relacionais e transações no mesmo lugar. Neste guia mostro como montar uma busca semântica de ponta a ponta com Python.

Vou construir um sistema que indexa documentos e responde perguntas com base neles.

Por que pgvector

  • Menos infraestrutura: um banco em vez de dois.
  • Filtros combinados: similaridade + WHERE relacional.
  • Transações: consistência entre dados e vetores.
  • Maduro: backup, réplica e ferramentas que você já conhece.

Subindo o Postgres com pgvector

docker run -d --name pgv -p 5432:5432 \
  -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=rag \
  pgvector/pgvector:pg16
pip install psycopg[binary] pgvector openai

Ativando a extensão e criando a tabela

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documentos (
  id SERIAL PRIMARY KEY,
  conteudo TEXT NOT NULL,
  fonte TEXT,
  embedding vector(1536)
);

O 1536 é a dimensão do modelo de embedding escolhido (ex.: OpenAI text-embedding-3-small).

Gerando embeddings em Python

from openai import OpenAI
client = OpenAI()

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

Inserindo documentos

import psycopg

conn = psycopg.connect("postgresql://postgres:secret@localhost/rag")

def inserir(conteudo: str, fonte: str):
    with conn.cursor() as cur:
        cur.execute(
            "INSERT INTO documentos (conteudo, fonte, embedding) VALUES (%s, %s, %s)",
            (conteudo, fonte, embed(conteudo)),
        )
    conn.commit()

Busca por similaridade

O operador <=> calcula a distância de cosseno. Ordenando por ele, você tem o resultado mais próximo.

def buscar(pergunta: str, limite: int = 5):
    vetor = embed(pergunta)
    with conn.cursor() as cur:
        cur.execute(
            """SELECT conteudo, fonte, 1 - (embedding <=> %s) AS similaridade
                   FROM documentos
                   ORDER BY embedding <=> %s
                   LIMIT %s""",
            (vetor, vetor, limite),
        )
        return cur.fetchall()

Índice para performance

Sem índice, a busca é linear. Com muitos vetores, crie um índice HNSW.

CREATE INDEX ON documentos
USING hnsw (embedding vector_cosine_ops);

RAG completo: buscar e responder

def responder(pergunta: str) -> str:
    trechos = buscar(pergunta)
    contexto = "\n\n".join(t[0] for t in trechos)
    r = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Responda apenas com base no contexto."},
            {"role": "user", "content": f"Contexto:\n{contexto}\n\nPergunta: {pergunta}"},
        ],
    )
    return r.choices[0].message.content

Filtrando com metadados

Uma vantagem sobre bancos vetoriais simples: combinar similaridade com SQL.

def buscar_por_fonte(pergunta: str, fonte: str):
    vetor = embed(pergunta)
    with conn.cursor() as cur:
        cur.execute(
            "SELECT conteudo FROM documentos WHERE fonte = %s ORDER BY embedding <=> %s LIMIT 5",
            (fonte, vetor),
        )
        return cur.fetchall()

Boas práticas

  • Normalize o texto antes de gerar o embedding.
  • Guarde o modelo usado junto ao vetor (dimensões mudam).
  • Divida documentos longos em chunks antes de indexar.
  • Use índice HNSW quando passar de alguns milhares de vetores.

Erros comuns

  • Misturar embeddings de modelos diferentes na mesma coluna.
  • Não criar índice e culpar o banco pela lentidão.
  • Indexar chunks enormes — a busca fica imprecisa.
  • Enviar contexto demais ao LLM e estourar o limite de tokens.

Dividindo documentos em chunks

Indexar documentos inteiros prejudica a precisão. Divida em pedaços com sobreposição.

def chunkar(texto: str, tamanho: int = 800, sobreposicao: int = 100):
    palavras = texto.split()
    chunks, i = [], 0
    while i < len(palavras):
        pedaco = " ".join(palavras[i:i + tamanho])
        chunks.append(pedaco)
        i += tamanho - sobreposicao
    return chunks

Indexando um acervo

from pathlib import Path

def indexar_pasta(pasta: str):
    for arquivo in Path(pasta).glob("*.txt"):
        texto = arquivo.read_text(encoding="utf-8")
        for chunk in chunkar(texto):
            inserir(chunk, fonte=arquivo.name)

Múltiplas dimensões e modelos

Dimensões diferentes exigem colunas/tabelas separadas. Guarde o nome do modelo para não misturar vetores incompatíveis.

ALTER TABLE documentos ADD COLUMN modelo TEXT DEFAULT 'text-embedding-3-small';

HNSW vs IVFFlat

Índice Prós Contras
HNSW Rápido, boa precisão Mais memória, build mais lento
IVFFlat Menos memória Precisa de tuning e dados para treinar

Re-ranking dos resultados

Busca vetorial traz candidatos; um re-ranker melhora a ordem final. Mesmo um filtro simples por palavra-chave ajuda.

def rerank(pergunta: str, resultados):
    termos = set(pergunta.lower().split())
    return sorted(resultados, key=lambda r: -len(termos & set(r[0].lower().split())))

Avaliando a qualidade do RAG

Monte um conjunto de perguntas com respostas esperadas e meça se o trecho correto aparece no top-k. É a métrica que importa de verdade.

CASOS = [{"pergunta": "...", "trecho_esperado": "..."}]

def acerto_topk(casos, k=5):
    acertos = 0
    for c in casos:
        trechos = [t[0] for t in buscar(c["pergunta"], k)]
        acertos += any(c["trecho_esperado"] in t for t in trechos)
    return acertos / len(casos)

Docker Compose completo

services:
  db:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: rag
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
volumes:
  pgdata:

Migrations com Alembic

Mudanças de schema devem ser versionadas.

alembic init migrations
alembic revision --autogenerate -m "add documentos"
alembic upgrade head

Custo de embeddings

Gerar embeddings custa por token. Para acervos grandes, faça em lote e reuse os vetores.

def embed_lote(textos: list[str]) -> list[list[float]]:
    r = client.embeddings.create(model="text-embedding-3-small", input=textos)
    return [d.embedding for d in r.data]

Cache de embeddings

import hashlib, json

def embed_cache(texto: str) -> list[float]:
    k = "emb:" + hashlib.sha256(texto.encode()).hexdigest()
    if v := r.get(k):
        return json.loads(v)
    v = embed(texto)
    r.set(k, json.dumps(v), ex=86400)
    return v

Perguntas frequentes

Preciso de banco vetorial dedicado? Só acima de milhões de vetores ou requisitos muito específicos.

Qual modelo de embedding? Comece com um barato e bom; reavalie com métricas.

Como melhorar a precisão? Chunking adequado, re-ranking e bom prompt de sistema.

Metadados e filtros avançados

Guarde metadados em colunas indexadas para filtrar antes da busca vetorial — reduz o universo e acelera.

ALTER TABLE documentos ADD COLUMN categoria TEXT;
CREATE INDEX ON documentos (categoria);
def buscar_categoria(pergunta: str, categoria: str, k: int = 5):
    vetor = embed(pergunta)
    with conn.cursor() as cur:
        cur.execute(
            """SELECT conteudo FROM documentos
               WHERE categoria = %s
               ORDER BY embedding <=> %s LIMIT %s""",
            (categoria, vetor, k),
        )
        return cur.fetchall()

Híbrido: vetorial + full-text

Combinar busca semântica com busca textual (BM25) melhora resultados em muitos casos.

SELECT conteudo,
       (1 - (embedding <=> %s)) AS sim_vetorial,
       ts_rank(to_tsvector('portuguese', conteudo), plainto_tsquery('portuguese', %s)) AS sim_textual
FROM documentos
ORDER BY (sim_vetorial * 0.7 + sim_textual * 0.3) DESC
LIMIT 5;

Operadores de distância

pgvector oferece três operadores, e escolher o certo importa:

Operador Distância Uso
<=> Cosseno Texto (mais comum)
<-> L2 (euclidiana) Vetores normalizados/imagens
<#> Produto interno Quando já normalizado

Para texto, use cosseno — é o padrão dos modelos de embedding modernos.

Normalização de vetores

import math

def normalizar(v: list[float]) -> list[float]:
    norma = math.sqrt(sum(x * x for x in v))
    return [x / norma for x in v] if norma else v

Evitando contexto irrelevante

Buscar muitos chunks aumenta o custo e pode confundir o modelo. Um bom limiar de similaridade corta o ruído.

def buscar_relevantes(pergunta: str, limiar: float = 0.75, k: int = 5):
    return [t for t in buscar(pergunta, k) if t[2] >= limiar]

Manutenção do índice

Índices HNSW precisam de rebuild após grandes inserções. Monitore o tamanho e reindexe periodicamente.

REINDEX INDEX documentos_embedding_idx;

Recomendações por similaridade

A mesma mecânica de busca serve para recomendar itens parecidos.

def similares(item_id: int, k: int = 5):
    with conn.cursor() as cur:
        cur.execute("SELECT embedding FROM documentos WHERE id = %s", (item_id,))
        vetor = cur.fetchone()[0]
        cur.execute(
            "SELECT id, conteudo FROM documentos WHERE id != %s ORDER BY embedding <=> %s LIMIT %s",
            (item_id, vetor, k),
        )
        return cur.fetchall()

Deduplicação de documentos

Evitar indexar o mesmo conteúdo duas vezes economiza custo e melhora resultados.

import hashlib

def ja_indexado(texto: str) -> bool:
    h = hashlib.sha256(texto.encode()).hexdigest()
    with conn.cursor() as cur:
        cur.execute("SELECT 1 FROM hashes WHERE hash = %s", (h,))
        return cur.fetchone() is not None

Multilíngue

Modelos de embedding multilíngues permitem buscar em um idioma e recuperar de outro. Guarde o idioma junto ao chunk para filtros opcionais.

Backup e recuperação

Como os vetores vivem no Postgres, o backup normal do banco já cobre o RAG. Garanta que o pg_dump inclua a extensão e os índices.

pg_dump -Fc rag > rag.dump
pg_restore -d rag_novo rag.dump

Escalando com partições

Para acervos muito grandes, particione por fonte ou data — cada partição com seu índice.

CREATE TABLE documentos_2026 PARTITION OF documentos
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

Casos de uso além de RAG

  • Busca semântica em base própria: FAQ, documentação, tickets.
  • Detecção de duplicatas: encontrar textos quase iguais.
  • Recomendação: "quem viu isto também viu".
  • Classificação por vizinhança: rotular por exemplos próximos.

Quando NÃO usar pgvector

  • Volume na casa de bilhões de vetores com latência extrema.
  • Necessidade de busca multimodal muito especializada.
  • Time sem familiaridade com Postgres e com pressa.

Para a maioria dos projetos, porém, o Postgres é mais que suficiente.

Checklist de implementação

  1. Ativar a extensão vector no banco.
  2. Criar a tabela com a coluna vector(N) correta.
  3. Gerar embeddings em lote e cachear para não pagar duas vezes.
  4. Chunkar documentos com sobreposição.
  5. Criar índice HNSW depois de carregar os dados.
  6. Medir acerto no top-k com um conjunto de perguntas de teste.
  7. Combinar filtros relacionais quando fizer sentido.

Resumo

pgvector transforma o PostgreSQL em um banco vetorial competente, com a vantagem de combinar busca semântica e filtros relacionais. Para muitos projetos, é a escolha mais simples e robusta — menos uma peça de infraestrutura para manter.

Anterior e próximo

Leituras recentes

Temas parecidos para continuar navegando no blog.

Conteúdo criado para leitura limpa, boa hierarquia visual e navegação clara.