← Voltar ao blog
PostgreSQL 23 de setembro de 2026 12 min de leitura

pgvector na prática: IA e busca vetorial no PostgreSQL

Guardar embeddings ao lado dos dados transacionais é tentador — e funciona. Mas índice ANN tem trade-off de recall que DBA nenhum estava acostumado a calibrar. HNSW, IVFFlat, quantização e filtros que matam a performance.

pgvector na prática: IA e busca vetorial no PostgreSQL

Em 2023 a pergunta era "dá para usar Postgres como banco vetorial?". Em 2026 a pergunta é outra: "por que eu manteria um segundo banco só para vetores?". Com pgvector, o embedding fica na mesma transação, no mesmo backup, com a mesma permissão e o mesmo JOIN dos dados de negócio. Isso é uma vantagem operacional enorme.

O que ninguém avisa é que busca vetorial quebra as intuições de índice que você levou 20 anos construindo. Índice B-tree é exato. Índice ANN é aproximado, por definição. Você passa a negociar acerto contra velocidade — e essa negociação é um parâmetro que alguém precisa escolher.

Espaço vetorial com pontos agrupados ligados por um grafo em camadas alimentando um banco de dados
HNSW é um grafo em camadas. Você navega por atalhos e aceita não visitar tudo.

O básico que funciona

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documentos (
  id         bigserial PRIMARY KEY,
  tenant_id  bigint NOT NULL,
  titulo     text   NOT NULL,
  corpo      text,
  embedding  vector(1536),       -- dimensao do seu modelo
  criado_em  timestamptz NOT NULL DEFAULT now()
);

-- Busca por similaridade de cosseno (operador <=>)
SELECT id, titulo, 1 - (embedding <=> $1) AS similaridade
FROM documentos
ORDER BY embedding <=> $1
LIMIT 10;

Os operadores importam e são confundidos direto: <-> é distância L2, <=> é cosseno e <#> é produto interno negativo. O índice só é usado se o operador do ORDER BY for o mesmo da classe de operador do índice. Erro clássico: criar índice para cosseno e consultar com L2 — o Postgres faz sequential scan e ninguém entende por quê.

HNSW ou IVFFlat?

CritérioHNSWIVFFlat
Recall com config padrãoAltoMédio
Tempo de construçãoLentoRápido
MemóriaAlta (o grafo quer caber na RAM)Baixa
Precisa de dados antes de indexarNãoSim (define centróides)
Inserção contínuaBoaDegrada; exige reindex periódico

Minha regra: HNSW por padrão. IVFFlat só quando o dataset é gigante, a memória é curta e a carga é majoritariamente em lote.

-- HNSW: m = vizinhos por no; ef_construction = esforco na construcao
CREATE INDEX idx_doc_emb_hnsw ON documentos
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- Acelera a construcao (a sessao pode usar bastante memoria)
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 4;

-- Na consulta: quanto maior o ef_search, melhor o recall e pior a latencia
SET hnsw.ef_search = 100;

Como calibrar recall de verdade

Não chute o ef_search. Meça contra a verdade absoluta. Rode a mesma consulta com o índice desligado (busca exata) e compare os IDs retornados:

SET enable_indexscan = off;   -- resultado exato, lento
-- guarde os 10 IDs retornados
RESET enable_indexscan;
-- repita com ef_search em 40, 100, 200 e compare a intersecao

Recall de 95% a 99% costuma ser o ponto doce para busca semântica de conteúdo. Para caso jurídico ou médico, onde deixar de achar um documento é grave, suba o ef_search e aceite a latência.

A armadilha do filtro

Esse é o erro de arquitetura em produção multi-tenant. Você escreve:

SELECT id, titulo
FROM documentos
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 10;

O índice HNSW busca os vizinhos mais próximos no conjunto inteiro e só depois aplica o filtro. Se o tenant 42 tem 0,1% dos documentos, os 10 vizinhos globais podem não conter nenhum dele — e aí o Postgres precisa varrer muito mais do grafo, ou devolve menos linhas do que você pediu. Duas saídas:

-- A) Indice parcial por tenant grande
CREATE INDEX idx_doc_emb_t42 ON documentos
  USING hnsw (embedding vector_cosine_ops)
  WHERE tenant_id = 42;

-- B) Particionar por tenant e indexar cada particao
CREATE TABLE documentos_p PARTITION OF documentos_base
  FOR VALUES IN (42);

Tamanho: o detalhe que estoura o disco

Um vetor de 1536 dimensões em float4 ocupa cerca de 6 KB por linha, mais o grafo HNSW. Dez milhões de documentos viram dezenas de gigabytes só de embedding — e isso compete com a sua RAM de cache. Reduza:

-- Meia precisao: metade do espaco, perda de recall geralmente marginal
ALTER TABLE documentos ALTER COLUMN embedding TYPE halfvec(1536);

CREATE INDEX ON documentos USING hnsw (embedding halfvec_cosine_ops);

-- Ou binarizacao para um primeiro filtro grosseiro, refinando depois
CREATE INDEX ON documentos
  USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);

Vale lembrar também que coluna grande vai para TOAST, e reescrita de embedding gera tupla morta como qualquer outra. Ou seja: workload vetorial pesado é gerador industrial de bloat. Revise o ajuste de VACUUM antes de colocar RAG em produção.

Busca híbrida: o que realmente funciona em RAG

Busca puramente semântica erra em nome próprio, código de produto e número de contrato. A combinação com busca textual nativa resolve:

WITH semantica AS (
  SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS pos
  FROM documentos ORDER BY embedding <=> $1 LIMIT 50
),
textual AS (
  SELECT id, row_number() OVER (
           ORDER BY ts_rank_cd(to_tsvector('portuguese', corpo),
                               plainto_tsquery('portuguese', $2)) DESC) AS pos
  FROM documentos
  WHERE to_tsvector('portuguese', corpo) @@ plainto_tsquery('portuguese', $2)
  LIMIT 50
)
SELECT d.id, d.titulo,
       COALESCE(1.0/(60 + s.pos), 0) + COALESCE(1.0/(60 + t.pos), 0) AS score
FROM documentos d
LEFT JOIN semantica s ON s.id = d.id
LEFT JOIN textual   t ON t.id = d.id
WHERE s.id IS NOT NULL OR t.id IS NOT NULL
ORDER BY score DESC
LIMIT 10;

Isso é Reciprocal Rank Fusion em SQL puro, sem serviço externo. É o tipo de coisa que só o Postgres deixa você fazer numa consulta só.

Quando eu não usaria o Postgres para vetores

  • Acima de algumas centenas de milhões de vetores com exigência de latência de poucos milissegundos — aí um mecanismo dedicado ganha.
  • Quando o embedding é o produto inteiro e não tem dado transacional junto.
  • Quando o time precisa trocar de modelo toda semana e reindexar 100% do acervo é rotina.

Fora esses casos, manter tudo em um banco só costuma valer mais do que qualquer ganho de benchmark. Veja o resto das dores no guia definitivo de PostgreSQL em produção, e o efeito disso no plano de execução em query planner imprevisível.


Sobre o autor: Alexandre Almeida atua com bancos de dados há mais de 25 anos, foi instrutor oficial de MySQL e Oracle para mais de 1.000 alunos e escreveu Aprendendo MySQL: Prática, Teoria e Laboratórios. Exemplos validados com pgvector 0.8 em PostgreSQL 16 e 17.

Fontes: documentação oficial do pgvector (índices HNSW e IVFFlat, halfvec e binary quantize); PostgreSQL Documentation — Full Text Search e TOAST.