Como Implementar Busca Vetorial no Postgres
Implemente busca vetorial no Postgres com pgvector: adicione embeddings, consulte por distância cosseno, indexe com HNSW ou IVFFlat e combine com busca de texto completo.
O pgvector adiciona um tipo de coluna vector ao Postgres, e uma consulta de similaridade é um ORDER BY sobre um operador de distância seguido de um LIMIT.
Isso normalmente surge depois que uma busca na central de ajuda não retorna nada para “cancelar minha assinatura” porque o artigo tem o título “Encerrando seu plano”, e alguém propõe subir um banco de dados vetorial gerenciado ao lado do Postgres que você já mantém. Esse segundo serviço raramente é necessário. O resto deste artigo é o caminho completo em SQL: habilitar a extensão, dimensionar a coluna conforme o seu modelo de embedding, armazenar vetores, consultar por distância de cosseno, indexar com HNSW ou IVFFlat, fazer join dos resultados vetoriais com tabelas comuns e identificar as consultas em que a busca vetorial é a ferramenta errada e a busca full-text do Postgres deve assumir o trabalho.
Pontos Principais
- O número dentro de
vector(n)deve ser igual ao comprimento dos vetores que seu modelo de embedding produz, e vetores de modelos diferentes não podem ser comparados de forma significativa. - O operador
<=>retorna a distância de cosseno, portanto ordenar de forma ascendente coloca a correspondência mais próxima primeiro; subtraia de 1 quando precisar da similaridade de cosseno. - Tanto o HNSW quanto o IVFFlat são índices aproximados; a busca exata é o que você obtém quando não há índice vetorial na coluna.
- O planner só considera um índice vetorial quando a consulta tem um
ORDER BYdiretamente sobre um operador de distância, em ordem ascendente, com umLIMIT. - A busca vetorial encontra linhas que significam a mesma coisa; a busca full-text encontra linhas que contêm as mesmas palavras, e a busca em produção normalmente executa as duas e mescla as listas ranqueadas.
Para Que Serve a Busca Vetorial?
A busca vetorial faz a correspondência pelo significado, então uma consulta por “cancelar minha assinatura” pode retornar um documento intitulado “Encerrando seu plano” mesmo que os dois não compartilhem nenhuma palavra. Cada trecho de texto é convertido por um modelo de embedding em uma lista de números de comprimento fixo, e textos com significado semelhante ficam próximos nesse espaço. Buscar significa encontrar os vetores armazenados mais próximos do vetor da consulta. Para entender melhor como os embeddings funcionam, veja Vector Databases Explained.
O retorno aparece em três frentes: busca no site que tolera paráfrases, correspondência de tickets de suporte (encontrar tickets anteriores parecidos com este) e recuperação para RAG, em que os documentos mais próximos são fornecidos a um modelo de linguagem como contexto, conforme abordado na introdução a RAG para aplicações web.
Habilite o pgvector e Adicione uma Coluna Vetorial
O pgvector é habilitado uma vez por banco de dados com CREATE EXTENSION vector, e o tipo da coluna é vector(n), em que n é o número de dimensões. A versão 0.8.6 suporta Postgres 13 e posteriores, embora o Postgres 13 já esteja fora do suporte da comunidade, então 14 ou mais recente é o piso prático.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
embedding vector(1536) -- replace 1536 with your embedding model's output size
);
-- or, on a table you already have:
ALTER TABLE documents ADD COLUMN embedding vector(1536);
O número dentro de vector(n) deve ser igual ao comprimento dos vetores que seu modelo de embedding produz. O text-embedding-3-small da OpenAI retorna vetores de 1536 dimensões por padrão, por exemplo. Vetores de modelos diferentes não podem ser comparados de forma significativa, então trocar de modelo significa gerar novamente os embeddings de toda a coluna.
Armazene Embeddings de Qualquer Modelo
Os embeddings são gravados como qualquer outro valor de coluna: gere o vetor no código da aplicação e depois passe-o como parâmetro vinculado com cast para vector. O pgvector não chama um modelo por você, e qualquer linguagem com um driver Postgres funciona.
INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3::vector);
-- re-embed an existing row without creating a duplicate
INSERT INTO documents (id, title, body, embedding)
VALUES ($1, $2, $3, $4::vector)
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;
Para uma carga inicial, o pgvector recomenda carregar em lote com COPY ... FROM STDIN WITH (FORMAT BINARY) e construir os índices depois que os dados estiverem no lugar, e não antes.
Qual Operador de Distância do pgvector Você Deve Usar?
Use <=> para embeddings de texto, a menos que a documentação do seu modelo diga o contrário. Ele retorna a distância de cosseno, portanto ordenar de forma ascendente coloca a correspondência mais próxima primeiro; subtraia o resultado de 1 quando precisar da similaridade de cosseno para exibição.
-- five closest documents; $1 is the query embedding
SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;
O pgvector suporta seis operadores de distância no total, e cada um precisa de uma classe de operador de índice correspondente:
| Operador | Mede | Use quando | Opclass |
|---|---|---|---|
<=> | distância de cosseno | embeddings de texto; o padrão habitual | vector_cosine_ops |
<-> | distância L2 (euclidiana) | a magnitude carrega significado | vector_l2_ops |
<#> | produto interno negativo | vetores já normalizados para comprimento 1 | vector_ip_ops |
<+> | distância L1 (taxicab) | raramente para texto; apenas HNSW | vector_l1_ops |
O <#> devolve o produto interno com o sinal invertido. O Postgres varre índices apenas em ordem ascendente, então a forma negativa mantém o menor número como a correspondência mais próxima; multiplique por -1 para recuperar o produto interno simples. Se seus vetores já estiverem normalizados para comprimento 1, o produto interno é a opção mais rápida para busca exata. <~> (Hamming) e <%> (Jaccard) existem para vetores binários e estão fora do escopo aqui.
Você Deve Indexar com HNSW ou IVFFlat?
Sem índice, o pgvector compara o vetor da consulta com todas as linhas e retorna os vizinhos mais próximos exatos. Adicionar um índice HNSW ou IVFFlat muda isso para busca aproximada, que é muito mais rápida, encontra a maioria dos vizinhos verdadeiros e pode devolver linhas diferentes das que a consulta exata devolveria. Os dois tipos de índice são aproximados. A busca exata é simplesmente o que acontece quando a coluna não tem nenhum índice vetorial.
Prefira HNSW por padrão, a menos que o tempo de construção ou a memória forcem o IVFFlat. A opclass precisa corresponder ao operador com que você consulta.
-- production: avoid blocking writes
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops);
-- IVFFlat alternative; create only after the table has data
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
| HNSW | IVFFlat | |
|---|---|---|
| Estrutura | grafo com várias camadas | vetores agrupados em listas |
| Tradeoff velocidade-recall | melhor | pior |
| Tempo de construção e memória | mais lento, mais memória | mais rápido, menos memória |
| Construir em tabela vazia | sim, sem etapa de treinamento | não; os centroides vêm dos dados presentes no momento da construção |
| Ajuste de consulta | hnsw.ef_search (padrão 40) | ivfflat.probes (padrão 1; comece em sqrt(lists)) |
O HNSW não tem etapa de treinamento, então o índice pode ser construído antes de uma única linha existir na tabela. O IVFFlat tem: suas listas vêm dos dados presentes quando o índice é construído, então forneça primeiro um conjunto representativo de linhas. Aumentar ef_search ou probes com SET LOCAL dentro de uma transação melhora o recall de uma consulta ao custo da velocidade.
O planner só considerará um índice vetorial quando a consulta tiver um ORDER BY diretamente sobre um operador de distância, ordenado de forma ascendente, junto com um LIMIT; ORDER BY 1 - (embedding <=> $1) DESC não o usará. Em uma tabela pequena, o planner ainda pode preferir uma varredura sequencial, então verifique com EXPLAIN. Para medir quanto o índice lhe custou em recall, force uma busca exata e compare os dois conjuntos de resultados:
BEGIN;
SET LOCAL enable_indexscan = off; -- exact search for comparison
SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT 5;
COMMIT;
Você Precisa de um Banco de Dados Vetorial Separado?
Se você já roda Postgres, provavelmente não precisa de um banco de dados vetorial separado: o pgvector oferece busca vetorial com os mesmos backups, as mesmas transações e a capacidade de fazer join dos resultados vetoriais com tabelas normais em uma única consulta. Um documento e seu embedding são gravados em um único INSERT, então não há pipeline de sincronização entre dois repositórios nem o modo de falha de vetores órfãos. Os vetores trafegam pelo write-ahead log como qualquer outra coluna, então réplicas e restaurações point-in-time os capturam sem trabalho extra.
O join é onde isso fica concreto. Encontrar os tickets de suporte mais próximos, restritos a clientes enterprise, é uma única instrução:
SELECT t.id, t.subject, c.plan, t.embedding <=> $1::vector AS distance
FROM tickets t
JOIN customers c ON c.id = t.customer_id
WHERE c.plan = 'enterprise'
ORDER BY t.embedding <=> $1::vector
LIMIT 10;
Índices aproximados vêm com uma ressalva: o índice é varrido primeiro e a cláusula WHERE é aplicada depois sobre o que ele retornou, então um filtro seletivo pode deixar você com menos linhas do que o LIMIT pediu. Um B-tree na coluna do filtro frequentemente dá resultados exatos e rápidos; varreduras iterativas e índices parciais cuidam do resto.
Bancos de dados vetoriais dedicados ainda justificam seu lugar na casa dos bilhões de vetores ou em taxas de escrita extremas. Abaixo disso, o serviço adicional é custo operacional sem benefício visível para o usuário.
Onde a Busca Vetorial Falha: Busca Híbrida com Full-Text
A busca vetorial encontra linhas que significam a mesma coisa; a busca full-text encontra linhas que contêm as mesmas palavras, e a busca em produção normalmente executa as duas e mescla as duas listas ranqueadas. Identificadores exatos, códigos de produto, strings de erro e nomes de pessoas não têm um “significado” útil para um modelo de embedding, e uma consulta por SKU-4471 deve retornar a linha que contém esse token, não linhas sobre produtos parecidos. Essas consultas pedem a busca full-text do Postgres, que a documentação do pgvector combina com a busca vetorial em consultas híbridas.
Comece com uma coluna tsvector gerada e armazenada e um índice GIN sobre ela:
ALTER TABLE documents
ADD COLUMN body_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX ON documents USING gin (body_tsv);
Depois combine os dois rankings com Reciprocal Rank Fusion, seguindo o formato do próprio exemplo de RRF do pgvector:
-- $1 = query embedding, $2 = raw query text
WITH semantic AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1::vector) AS rnk
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
lexical AS (
SELECT id, RANK() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS rnk
FROM documents, plainto_tsquery('english', $2) AS q
WHERE body_tsv @@ q
ORDER BY ts_rank_cd(body_tsv, q) DESC
LIMIT 20
)
SELECT COALESCE(s.id, l.id) AS id,
COALESCE(1.0 / (60 + s.rnk), 0.0) + COALESCE(1.0 / (60 + l.rnk), 0.0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON l.id = s.id
ORDER BY score DESC
LIMIT 10;
Cada lista contribui com 1 / (k + rank), então um documento próximo ao topo de qualquer uma das listas pontua bem, e um presente nas duas pontua melhor. A constante k = 60 vem de Cormack, Clarke e Büttcher, que a fixaram em um estudo piloto e relataram que o valor exato não é crítico. Trate a consulta como ilustrativa e confirme com EXPLAIN (ANALYZE, BUFFERS) que tanto o índice HNSW quanto o GIN estão sendo usados nos seus dados.
Por Onde Começar
A implementação toda é uma coluna, um operador e um índice, tudo dentro do banco de dados que você já faz backup e replica. Adicione a coluna vector(n) dimensionada conforme o seu modelo, escreva a consulta ORDER BY ... <=> ... LIMIT primeiro sem índice, depois adicione o HNSW e verifique a diferença de recall com enable_indexscan = off. Quando os resultados semânticos parecerem corretos, conecte o lado full-text, porque o primeiro usuário a colar um número de pedido vai encontrar a lacuna caso contrário.
Perguntas Frequentes
O pgvector pode indexar embeddings com mais de 2.000 dimensões?
Não apenas com o tipo vector padrão. HNSW e IVFFlat indexam colunas vetoriais de até 2.000 dimensões, embora a própria coluna armazene até 16.000. Para embeddings maiores, faça cast para halfvec em um índice de expressão (indexável até 4.000 dimensões), use quantização binária com re-ranking (até 64.000), indexe um subvetor ou solicite menos dimensões de saída ao modelo, algo que os modelos text-embedding-3 da OpenAI suportam via um parâmetro dimensions.
Preciso reconstruir um índice do pgvector depois de inserir novas linhas?
Não no caso do HNSW: novas linhas são adicionadas ao grafo à medida que são inseridas, e é por isso que o índice pode ser construído em uma tabela vazia. O IVFFlat se comporta de forma diferente. Os centroides de suas listas são calculados por k-means uma única vez no momento da construção e nunca se movem, então o recall pode degradar conforme a tabela cresce ou sua distribuição muda. Reconstrua índices IVFFlat após grandes cargas ou mudanças de distribuição com REINDEX INDEX CONCURRENTLY.
Por que uma consulta retorna menos linhas do que o LIMIT depois de adicionar um índice HNSW?
Porque uma varredura HNSW retorna no máximo hnsw.ef_search candidatos, 40 por padrão, e qualquer filtro WHERE é aplicado a esses candidatos depois. Um LIMIT acima de 40, um filtro seletivo ou tuplas mortas podem todos deixar o resultado incompleto. Aumente hnsw.ef_search com SET LOCAL ou, no pgvector 0.8.0 e posteriores, habilite varreduras de índice iterativas definindo hnsw.iterative_scan como strict_order, para que a varredura continue até que linhas suficientes correspondam.
Posso armazenar embeddings de dois modelos diferentes na mesma tabela?
Sim, mas não em uma única coluna indexada compartilhada. Use uma coluna vector(n) separada por modelo, cada uma dimensionada conforme a saída daquele modelo e cada uma com seu próprio índice. O pgvector também permite uma coluna vector sem tipagem de dimensão contendo dimensões mistas, mas um índice só pode cobrir linhas de uma única dimensão, construído como índice de expressão com um cast mais um WHERE parcial sobre o identificador do modelo. Distâncias entre modelos diferentes não têm significado.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k