Banco de dados vetorial: guardando, atualizando e filtrando vetores
O que um banco de dados vetorial guarda, como mantém o índice atualizado com filtros e quando vale usar um dedicado em vez de uma extensão.
Publicada em · 52 min de leitura
Um índice vetorial, sozinho, é uma estrutura na memória de um programa. Este texto trata do que falta para ele virar um serviço: um banco de dados vetorial, que guarda registros com id, vetor, metadados e texto, aceita atualizações e filtros, e sobrevive a reinícios. Explica o que um banco guarda, como ele mantém o índice atualizado e quando vale usar um banco dedicado em vez de uma extensão do banco que você já tem.
A aula parte do zero: basta ter lido a anterior. A escada vai do conceito em linguagem de fichário até o mecanismo de escrita no disco, passando por exemplos de três bancos reais. Todo o código da Parte 3 foi executado por mim, numa máquina com macOS e Python 3.12, e as saídas coladas são as que o programa imprimiu. O que muda com o tempo (limites de dimensão, recursos, valores padrão) vem da documentação oficial consultada em 15 de outubro de 2026, e o que eu não consegui verificar está declarado na seção de fontes. As figuras são animadas e foram geradas com o ArchFlow.
Mapa da aula
| Parte | Seções | Pergunta que responde |
|---|---|---|
| 1. Do índice ao banco | 1 a 4 | O que é um banco vetorial, o que há num registro e o que é uma coleção? |
| 2. O que o banco faz por dentro | 5 a 8 | Que operações existem, como uma escrita chega ao disco, como os filtros funcionam e como o banco cresce? |
| 3. Criando um do zero | 9 a 14 | Como ficam o Qdrant, o Chroma, o pgvector e as bibliotecas em código, com números? |
| 4. Decidir e seguir adiante | 15 a 20 | Qual escolher, que erros evitar, e para ir além? |
Pré-requisito: Busca vetorial. Os diagramas seguem um mapa de cores fixo: laranja para o que a pessoa ou o programa pede (consulta, escrita), azul para vetores, índices e estrutura do banco, verde para dados guardados e resultados bons, cinza para o que é neutro e rosa para o que dá errado, custa caro ou ainda não foi ensinado (o chunking aparece em rosa tracejado).
Parte 1: Do índice ao banco
1. Um fichário que sabe achar parecidos
Pense num fichário de biblioteca antigo. Cada ficha tem um número, o título do livro, algumas etiquetas (assunto, ano, autor) e, num canto, a posição do livro num mapa de significado: livros parecidos ficam em pontos próximos desse mapa. Agora imagine que o bibliotecário sabe responder a um pedido do tipo “me traga as dez fichas mais próximas deste ponto do mapa, mas só as de 2026”. Além disso, ele aceita fichas novas, troca uma ficha por outra quando você corrige um dado, joga fora as fichas que você mandar, e nada disso se perde quando as luzes da biblioteca apagam. Esse bibliotecário com o fichário é, em miniatura, um banco de dados vetorial.
A definição sem metáfora: um banco de dados vetorial é um sistema que guarda registros que contêm vetores (os embeddings de Embeddings) e consegue, dado um vetor de consulta, devolver os registros mais próximos, usando um índice de vizinhos aproximados como o HNSW. O que o distingue de uma biblioteca de índice é o que vem em volta da busca: armazenamento durável, operações de escrita e remoção, filtros por metadados, acesso por vários clientes e, em alguns casos, distribuição por várias máquinas. Por essa razão, quando alguém pergunta “qual banco vetorial usar?”, a pergunta real é quanta dessa parte em volta você precisa, e quem vai operá-la.
2. O que faltava ao índice
Em Busca vetorial, o índice era um objeto: você o construía, fazia search e recebia ids e distâncias. Funciona bem num notebook. No dia em que isso vira um serviço que atende um chat, aparecem as lacunas, e cada uma tem nome. Persistência: o índice vive na RAM do processo, então um reinício o apaga. Atualização: o acervo muda (entra um manual novo, sai uma política antiga), e é preciso inserir e remover sem reconstruir tudo. Conteúdo: a busca devolve números de ids, e quem fala com o usuário precisa do texto e da origem do trecho. Filtros: a pergunta quase sempre vem com uma restrição (“só desta loja”, “só deste cliente”, “só documentos vigentes”). Concorrência: vários clientes leem e escrevem ao mesmo tempo. Operação: backup, monitoramento, permissões, réplicas. Um banco de dados vetorial é o pacote que resolve essas lacunas em torno de um índice.
A figura coloca os dois mundos lado a lado. À esquerda, o programa laranja chama search num índice na RAM e recebe “12, 15, 8”: só ids e distâncias, nada de texto. A seta pontilhada seguinte termina em ✕ no retângulo rosa: ao fechar o programa, índice, texto e etiquetas desaparecem. À direita, o mesmo programa conversa com a API do banco (seta laranja grossa, que vai e volta). A escrita desce pela direita: a API anota a operação no log de escrita (seção 5), que persiste os dados no cilindro verde, que alimenta o índice HNSW e, de forma mais lenta, uma réplica em outra máquina. A busca desce pela esquerda: o filtro por metadados escolhe candidatos, o índice os ordena, e o resultado volta como um documento completo, com texto e payload. Repare que o índice azul da direita é o mesmo da esquerda; o que mudou é tudo o que o cerca.
3. O que fica em cada registro
Um registro de banco vetorial tem, na maioria dos sistemas, quatro partes. O id é a identidade do registro, escolhida por você: é por ele que você atualiza e remove. O vetor é o embedding (384 números de float32 no nosso exemplo, ou seja, 1.536 bytes). O payload (também chamado de metadados) é um conjunto de pares chave e valor com os atributos do trecho, como categoria, ano, loja ou o arquivo de origem; é com ele que se filtra. E o texto original, que é o que o RAG vai entregar ao modelo, e que alguns sistemas guardam junto (o Chroma tem um campo documents, e no Qdrant ou no Postgres você o coloca no payload ou numa coluna).
Vale entender por que o texto mora junto do vetor. O vetor é uma compressão com perda do significado: dele, você não reconstrói a frase. Se a busca devolve só o id e o texto está noutra tabela, noutro serviço, toda consulta vira duas viagens. E se os dois ficam em lugares diferentes, eles saem de sincronia com facilidade (o trecho foi corrigido, mas o vetor é do texto antigo). Por isso a regra prática é: o que a consulta precisa ler fica no registro, e o que só a ingestão precisa (o PDF original, por exemplo) fica fora.
No topo, o trecho original (“O estorno de uma compra no cartão aparece em até duas faturas.”) segue dois caminhos. Pela seta cinza ele chega ao modelo de embedding, que gira porque está calculando, e sai uma seta azul grossa com os 384 números até o vetor. Pela seta verde pontilhada ele é copiado, tal como está, para o campo de texto. Os metadados vão para o payload. O id, escolhido por você, completa o grupo “Um registro”. Embaixo, o mesmo registro aparece com os nomes que cada banco usa: no Qdrant é um point (id, vector, payload), no Chroma é um record com id, embedding, metadata e document, e no pgvector é uma linha comum de tabela, em que o vetor é só mais uma coluna do tipo vector(384).
Três exemplos de contextos diferentes mostram o padrão. Numa central de atendimento, o registro é um trecho de FAQ, com categoria e loja no payload. Num acervo de notas de reunião, é um parágrafo, com data, participantes e o caminho do arquivo. Num catálogo de produtos, é a descrição de um item, com preço, estoque e categoria; aqui o payload pesa mais que o texto, porque filtrar por faixa de preço e disponibilidade é metade da consulta. O que muda entre os três é o que vai no payload, e a escolha dos campos de filtro deve nascer das perguntas que os usuários farão.
4. Coleções: o espaço em que os vetores convivem
Um banco vetorial agrupa registros em coleções. A documentação do Qdrant define uma coleção como “um conjunto nomeado de pontos (vetores com um payload) entre os quais você pode buscar”, e acrescenta a regra que importa: todos os pontos de uma coleção têm vetores com a mesma dimensionalidade, comparados por uma única métrica. No Chroma o nome é igual (collection), e no Postgres o equivalente é uma tabela com uma coluna vector(384) e um índice com uma classe de operador (vector_cosine_ops) que fixa a métrica.
A razão dessa rigidez está em Similaridade entre vetores: só faz sentido comparar vetores que vivem no mesmo espaço, o que significa o mesmo modelo de embedding (ou ao menos a mesma dimensão e o mesmo significado de cada eixo). Um vetor de 768 números nem sequer pode ser comparado com um de 384, e dois modelos com 384 dimensões cada, mas treinados de forma diferente, produzem números sem relação entre si. O índice HNSW também é construído para uma métrica e uma dimensão, e não admite misturas. Na prática, isso gera uma regra operacional: trocar o modelo de embedding significa criar uma coleção nova, reindexar todos os textos nela e só então apontar as consultas para a nova coleção. Muitas equipes nomeiam as coleções com a versão do modelo (faq_v1, faq_v2) para que a migração seja uma troca de nome, e a coleção antiga continue disponível até o novo recall ser conferido.
A figura mostra duas coleções. A coleção v1 (384 dimensões, cosseno) recebe, do modelo A, vetores de 384 números (seta azul grossa) e os passa a um índice HNSW construído para 384 e cosseno. A coleção v2 faz o mesmo com um modelo B de 768 dimensões. A seta que leva os 768 números do modelo B até a coleção v1 é barrada com ✕ e o rótulo “768 ≠ 384, recusado”: o banco recusa a escrita em vez de aceitar um vetor incomparável. A consulta laranja, embutida com o modelo A, só procura na coleção v1. Se alguém consultasse a v1 com um vetor do modelo B, receberia um erro de dimensão (se a dimensão diferir) ou, pior, resultados sem sentido (se coincidir), e nenhum banco consegue detectar essa segunda situação por você.
Parte 2: O que o banco faz por dentro
5. Operações: upsert, leitura, remoção e busca
As operações de um banco vetorial cabem em poucos verbos. O upsert grava um registro: se o id não existe, insere; se existe, substitui. A documentação do Qdrant descreve exatamente assim (“por padrão, uma operação de upsert insere um ponto se ele não existe, ou o atualiza se existe”) e observa que reenviar o mesmo id sobrescreve o ponto, o que torna a operação idempotente (repeti-la dá o mesmo resultado que fazê-la uma vez). Isso é valioso: uma ingestão que cai na metade pode ser rodada de novo sem criar duplicatas, desde que os ids sejam determinísticos (derivados, por exemplo, do caminho do arquivo e do número do trecho, e não de um contador). A leitura por id (retrieve no Qdrant, get no Chroma, SELECT ... WHERE id = ... no Postgres) devolve o registro sem buscar por similaridade. A remoção pode ser por id ou por filtro: o Qdrant aceita um filtro no lugar da lista de ids, e o Chroma também (delete(where=...)), o que serve para apagar todos os trechos de um documento que foi atualizado. E a busca recebe um vetor, um limit e, opcionalmente, um filtro.
Os pontos de id têm uma pequena diferença entre sistemas. O Qdrant aceita inteiros sem sinal de 64 bits e UUIDs. O Chroma usa strings (no meu código, str(d[0])). No Postgres a chave é uma coluna como outra qualquer. Em todos os casos, o id é seu, e escolhê-lo bem é metade da higiene de um pipeline de ingestão, tema de Ingestão de documentos.
Vale olhar o que acontece quando o upsert chega ao servidor, porque é aí que a palavra “durabilidade” ganha sentido.
O programa laranja envia upsert(id=10, vetor, payload) ao servidor (seta laranja grossa, com a confirmação verde voltando). O servidor valida a dimensão e os tipos e, no passo 1, anexa a operação ao log de escrita (o WAL, de write-ahead log, “log de escrever antes”), que é um arquivo em que só se acrescenta ao final, uma operação sequencial e barata. A seta é lenta porque é aqui que o sistema espera a gravação no disco. Só depois disso a operação é aplicada na memória (passo 2), o índice liga o vetor novo ao grafo (passo 3) e, de tempos em tempos, os dados são gravados nos arquivos definitivos do disco (passo 4). À direita, o caminho de falha: se a energia cai depois do passo 1 e antes do passo 4, os dados em memória se perdem, mas o log sobreviveu (seta pontilhada verde), e no reinício o banco relê o log e refaz o que faltou. A documentação do Qdrant descreve essa mesma ordem: a alteração é escrita primeiro no WAL e, a partir daí, “os dados não se perdem mesmo se a energia falhar”. A documentação do pgvector diz que a extensão usa o WAL do Postgres, o que permite replicação e recuperação em um ponto do tempo.
6. Persistência e durabilidade: uma escada
Durabilidade é a garantia de que um dado que o sistema confirmou continua existindo depois de uma falha. Ela tem graus, e as opções para guardar vetores formam uma escada de quatro degraus, do mais frágil ao mais robusto.
No primeiro degrau está o objeto na RAM (um faiss.IndexFlatIP, um hnswlib.Index): tudo some quando o processo termina. No segundo, você salva o índice num arquivo (faiss.write_index, Index.save_index da hnswlib) e o recarrega no próximo início; sobrevive ao reinício, mas só até o último salvamento, e o arquivo guarda vetores e ids, não texto nem metadados. No terceiro degrau estão os bancos embarcados, que rodam dentro do seu processo e gravam numa pasta: o Chroma com PersistentClient(path=...) e o Qdrant com QdrantClient(path=...). Na pasta de cada um, vi arquivos SQLite (o Chroma grava um chroma.sqlite3 mais uma pasta com identificador, o Qdrant local um storage.sqlite). A documentação do Chroma diz que, no cliente persistente, os dados são salvos em path e carregados ao iniciar. O quarto degrau é o servidor com log de escrita e, opcionalmente, réplicas: Postgres com pgvector, Qdrant em modo servidor. É o único que, com réplicas, sobrevive também à perda de uma máquina.
Leia a figura da esquerda para a direita. Cada seta entre os degraus traz o que se acrescenta (salvar, embutir, servidor) e as cores indicam a robustez: rosa para o que se perde ao fechar, cinza para o intermediário, azul para a pasta embarcada e verde para o servidor. A segunda linha diz o que sobrevive: o objeto na RAM só existe enquanto o processo vive (seta barrada com ✕); o arquivo sobrevive ao reinício, até o último salvamento; a pasta embarcada também, mas aceita um processo por pasta, sem rede; o servidor sobrevive à queda e, com réplicas, à perda da máquina. A terceira linha mostra um segundo critério, que costuma passar despercebido: onde ficam metadados e texto. Nos dois primeiros degraus ficam por sua conta (um dicionário em memória, um JSON ao lado do arquivo); a partir do terceiro, ficam no registro.
Para sentir a diferença sem confiar na teoria, fiz o teste mais brutal que o Postgres permite. Depois de carregar 16 registros e confirmar a transação, parei o servidor com pg_ctl -D <pasta> stop -m immediate, que equivale a derrubar o processo sem desligamento limpo, e o iniciei de novo. A consulta select count(*) from faq devolveu 16, e o log do servidor registrou a fase de recuperação (“redo done”), que é o WAL sendo reaplicado. Esse teste vale para o Postgres; não repeti o equivalente no Qdrant em modo servidor (não verificado, ver fontes).
7. Filtros por metadados
A pergunta de um RAG real quase nunca é só “o mais parecido com isto”. É “o mais parecido com isto, entre os documentos desta loja, de 2026 em diante”. Os metadados existem para isso, e o banco precisa combinar duas coisas que são difíceis de combinar: a busca aproximada do índice (que não conhece a regra do filtro) e a condição exata do filtro (que não conhece a geometria). Há três estratégias, e vale conhecer as três porque cada banco escolhe uma, ou mistura.
A primeira, à esquerda na figura, é filtrar depois (pós-filtro). O índice devolve os 40 candidatos mais próximos de qualquer loja, o losango aplica “loja = b” e só 10% passam, gerando cerca de 4 linhas, e o LIMIT 10 não se completa (retângulo rosa degradado). A segunda, no meio, é filtrar antes (pré-filtro): acha primeiro as linhas que satisfazem a condição e compara a consulta com todas elas, de forma exata. O resultado é exato, e o custo cresce com o tamanho do subconjunto; por isso a estratégia só compensa quando o filtro é muito seletivo. A terceira, à direita, é filtrar durante a navegação: o índice é construído conhecendo o filtro (o Qdrant acrescenta arestas ao grafo HNSW por valor de metadado indexado) ou continua explorando o grafo até completar o número pedido (a varredura iterativa do pgvector). O motor escolhe, e a documentação do Qdrant acrescenta um detalhe de ordem de operações: o índice de payload deve ser criado antes da ingestão dos dados, porque o grafo só ganha as arestas extras do filtro quando é gerado depois que o índice de payload existe; se o índice for criado depois, é preciso reconstruir o HNSW.
O efeito do filtro sobre o índice é fácil de medir, e medi no pgvector 0.8.7 com 100.000 vetores sintéticos (os mesmos de Busca vetorial), um HNSW com os valores padrão (m = 16, ef_construction = 64) e uma coluna cat com 10 valores sorteados, de modo que cada categoria vale 10% das linhas. A consulta: os 10 mais próximos entre as linhas com cat = 3, repetida para 100 consultas. O código está na seção 12.
O primeiro retângulo (rosa) é o comportamento padrão, com hnsw.ef_search = 40 e o filtro aplicado depois: em média 4,0 linhas devolvidas de 10 pedidas, exatamente o número que o README do pgvector descreve (“cerca de 4”). As quatro saídas (uma azul e três verdes) devolvem as 10 linhas: aumentar o ef_search para 200, ligar a varredura iterativa (relaxed_order ou strict_order) e criar um índice parcial WHERE cat = 3. Os tempos ficaram na mesma ordem de grandeza (de 2 a 4 ms por consulta, incluindo a viagem até o servidor local), com uma exceção: o índice parcial caiu para cerca de 0,7 a 0,8 ms, porque o grafo contém só as linhas que passam no filtro. O preço é um índice por valor, o que só é razoável com poucos valores. O último retângulo, cinza, é a varredura sem índice, que leva 25,8 ms e devolve o resultado exato, a referência para medir o recall. O que a figura ensina é uma regra: sempre teste os filtros reais, de preferência os mais seletivos, porque o padrão do motor pode devolver menos linhas do que você pediu e nenhum erro avisa.
Para completar o quadro: o strict_order da varredura iterativa preserva a ordem exata por distância, e o relaxed_order aceita uma ordem aproximada em troca de velocidade; a varredura é limitada por hnsw.max_scan_tuples (20.000 por padrão, segundo o README). O Qdrant, como visto em Busca vetorial, troca sozinho para varredura completa quando estima que poucos pontos passam no filtro (o parâmetro full_scan_threshold). Os nomes e os padrões variam e mudam entre versões, então consulte a documentação da versão que você usa. Em nenhum dos casos medi o Qdrant em modo servidor: o modo local, usado na Parte 3, não tem índice de payload (a biblioteca avisa: “Payload indexes have no effect in the local Qdrant”), o que quer dizer que o filtro ali é só uma conta feita sobre os pontos, não uma demonstração do HNSW com filtro.
8. Escala: sharding e réplicas
Um banco que cresce enfrenta dois limites distintos. O primeiro é de capacidade: os dados e o índice deixam de caber numa máquina. O segundo é de disponibilidade e vazão: uma máquina só cai, e uma máquina só atende um número limitado de consultas por segundo. Os bancos respondem com duas ferramentas que se combinam. O sharding divide a coleção em pedaços (shards), cada um com uma parte dos pontos, e espalha os pedaços por nós diferentes: cada nó guarda e indexa menos. A replicação mantém cópias de cada shard em outros nós: se um nó cai, uma cópia assume, e as leituras podem ser distribuídas entre cópias.
Antes de distribuir, a documentação do Qdrant recomenda escalar na vertical (mais memória, mais CPU, quantização) e só passar à distribuição horizontal quando os dados “não cabem mais em um nó, mesmo com quantização”. A mesma página resume que um único nó comporta tipicamente em torno de 100 milhões de vetores, dependendo da dimensão e da quantização. Esse número vem da página de escalabilidade por resumo (não li a página de escala vertical em separado) e depende muito do hardware: use-o como ordem de grandeza, não como promessa. No caso do pgvector, o README diz para escalar na horizontal “com réplicas, ou usar Citus, PgDog ou outra abordagem para sharding”, e para reduzir a memória com halfvec nas tabelas e quantização binária nos índices.
A figura mostra uma coleção em três shards, espalhados por três nós, com uma réplica de cada shard guardada em outro nó (Nó 1 tem o shard 1 e a réplica do 3; Nó 2 tem o shard 2 e a réplica do 1; Nó 3 tem o shard 3 e a réplica do 2). A consulta laranja chega a um nó, que a espalha: cada shard calcula o seu top 10 local (ida e volta, laranja e azul) e o nó que recebeu junta as respostas e devolve o top 10 global (seta verde de volta ao programa). Esse arranjo é a razão de a busca distribuída parecer com um mapa e reduzir: cada pedaço responde pelo seu pedaço, e uma etapa final une e reordena. O desenho mostra também o caso de falha: o Nó 3 caiu (shard 3 em rosa, tremendo), a seta para ele é barrada com ✕ e a seta grossa “assume o shard 3” vai para a réplica guardada no Nó 1, que responde no lugar. Sem réplica, as respostas do shard 3 simplesmente faltariam.
Dois parâmetros do Qdrant tratam de como a consistência se comporta nesse cenário. O write_consistency_factor define quantas réplicas precisam confirmar uma escrita antes de a resposta voltar ao cliente: o padrão é 1, e o valor máximo é o número de réplicas de cada shard. Na leitura, a consistência padrão também é 1, ou seja, a consulta usa um nó escolhido ao acaso; as opções majority, quorum e all consultam mais nós para reduzir a chance de ler dado desatualizado. A documentação indica ainda que um cluster mínimo tolerante a falhas precisa de três nós e fator de replicação 2 ou mais. Não consegui confirmar os valores padrão de shard_number e replication_factor (a página de consistência não os informa), e não montei um cluster para testar.
Parte 3: Criando um do zero
9. Os dados do exemplo
Para que os três bancos respondam à mesma pergunta, preciso de um conjunto pequeno e legível. São 16 trechos de uma FAQ de e-commerce, com quatro etiquetas cada: categoria, ano e loja (a ou b). Preferi texto real a vetores aleatórios porque quero que você veja o resultado fazer sentido.
# (id, texto, categoria, ano, loja)
DOCS = [
(1, "O pagamento por Pix é confirmado em até 10 segundos.", "pagamento", 2025, "a"),
(2, "Cartões de crédito podem ser parcelados em até 12 vezes sem juros.", "pagamento", 2025, "a"),
(3, "Boletos vencem em 3 dias úteis e depois são cancelados.", "pagamento", 2024, "a"),
(4, "Se o cartão for recusado, tente outro cartão ou use o Pix.", "pagamento", 2026, "b"),
(5, "O frete grátis vale para pedidos acima de 200 reais.", "entrega", 2025, "a"),
(6, "O prazo de entrega para capitais é de 2 a 5 dias úteis.", "entrega", 2026, "a"),
(7, "Você pode acompanhar o pedido pelo código de rastreio enviado por e-mail.", "entrega", 2025, "b"),
(8, "Se ninguém estiver em casa, o entregador tenta de novo no dia seguinte.", "entrega", 2024, "b"),
(9, "Para trocar um produto, abra uma solicitação em até 7 dias após o recebimento.", "devolucao", 2025, "a"),
(10, "O estorno de uma compra no cartão aparece em até duas faturas.", "devolucao", 2026, "a"),
(11, "Produtos com defeito têm troca gratuita durante 90 dias.", "devolucao", 2024, "b"),
(12, "A devolução do valor pago por Pix acontece em até 2 dias úteis.", "devolucao", 2026, "b"),
(13, "Para redefinir a senha, clique em esqueci minha senha na tela de login.", "conta", 2025, "a"),
(14, "Você pode alterar o e-mail da conta nas configurações de perfil.", "conta", 2025, "a"),
(15, "Para excluir sua conta, envie um pedido ao suporte; os dados somem em 30 dias.", "conta", 2026, "b"),
(16, "Ative a verificação em duas etapas para proteger o acesso à conta.", "conta", 2026, "b"),
]
O modelo é o paraphrase-multilingual-MiniLM-L12-v2, de 384 dimensões, que vem do cache do Hugging Face da minha máquina. O script abaixo gera os vetores dos trechos e o vetor da pergunta (“quero meu dinheiro de volta”) e os grava em arquivos .npy, de modo que os exemplos seguintes não precisem recarregar o modelo.
import numpy as np
from sentence_transformers import SentenceTransformer
from corpus import DOCS # lista de (id, texto, categoria, ano, loja)
modelo = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
vetores = modelo.encode([d[1] for d in DOCS], normalize_embeddings=True) # (16, 384)
np.save("vetores.npy", vetores)
consulta = modelo.encode(["quero meu dinheiro de volta"], normalize_embeddings=True)[0]
np.save("consulta.npy", consulta)
Um aviso de honestidade: esse modelo, pequeno, nem sempre acerta (em testes paralelos, “como pago com pix?” trouxe o trecho do estorno no cartão antes do trecho do Pix). A pergunta “quero meu dinheiro de volta” funciona bem para o que a aula precisa mostrar, e o foco aqui é o banco, não o modelo. A qualidade da recuperação volta como assunto em Busca híbrida, Reranking e Avaliação de RAG.
Sobre o ambiente: usei um ambiente virtual descartável do Python 3.12 na pasta temporária da sessão, com qdrant-client 1.19.1, chromadb 1.5.9, psycopg 3.3.6 (com o binário embutido), pgvector (o pacote Python) 0.5.1, faiss-cpu 1.15.1 e hnswlib 0.8.0. Os embeddings foram gerados por outro ambiente da mesma sessão, o que tinha sentence-transformers e torch. Nada foi instalado no sistema. O Postgres 18.1 já existia na máquina, mas não havia o pgvector: compilei a extensão (versão 0.8.7, do repositório oficial) com make install DESTDIR=... para uma pasta temporária e iniciei um servidor de teste num diretório descartável, usando a configuração extension_control_path do Postgres 18 para apontar para essa pasta. Em produção, você instalaria o pacote da extensão no servidor e rodaria CREATE EXTENSION vector, como o README do pgvector descreve.
10. Qdrant em modo local
O Qdrant tem um cliente que roda sem servidor: QdrantClient(path="pasta") grava tudo numa pasta local, e QdrantClient(":memory:") guarda só na memória. É a forma mais barata de conhecer a API, que é a mesma do servidor. O script abaixo cria uma coleção de 384 dimensões com cosseno, faz o upsert dos 16 pontos (cada um com um payload), busca com e sem filtro, mostra que o upsert no mesmo id troca o conteúdo, apaga por filtro e, no fim, abre a pasta em outro cliente para provar que os dados persistiram.
import numpy as np
from qdrant_client import QdrantClient, models
from corpus import DOCS
X = np.load("vetores.npy"); q = np.load("consulta.npy")
cli = QdrantClient(path="qdrant_dados") # modo local: grava numa pasta, sem servidor
cli.create_collection(
"faq",
vectors_config=models.VectorParams(size=384, distance=models.Distance.COSINE),
)
cli.upsert("faq", points=[
models.PointStruct(id=d[0], vector=X[i].tolist(),
payload={"texto": d[1], "categoria": d[2], "ano": d[3], "loja": d[4]})
for i, d in enumerate(DOCS)
])
print("pontos:", cli.count("faq").count)
print("\n-- busca simples")
for p in cli.query_points("faq", query=q.tolist(), limit=3).points:
print(p.id, round(p.score, 3), p.payload["categoria"], "|", p.payload["texto"])
print("\n-- busca com filtro loja=b e ano>=2026")
filtro = models.Filter(must=[
models.FieldCondition(key="loja", match=models.MatchValue(value="b")),
models.FieldCondition(key="ano", range=models.Range(gte=2026)),
])
for p in cli.query_points("faq", query=q.tolist(), query_filter=filtro, limit=3).points:
print(p.id, round(p.score, 3), p.payload["loja"], p.payload["ano"], "|", p.payload["texto"])
print("\n-- upsert no mesmo id troca o conteúdo")
cli.upsert("faq", points=[models.PointStruct(id=12, vector=X[11].tolist(),
payload={"texto": "TEXTO NOVO", "categoria": "devolucao", "ano": 2026, "loja": "b"})])
print(cli.count("faq").count, cli.retrieve("faq", ids=[12])[0].payload["texto"])
print("\n-- delete por filtro (tudo de 2024)")
cli.delete("faq", points_selector=models.FilterSelector(filter=models.Filter(must=[
models.FieldCondition(key="ano", match=models.MatchValue(value=2024))])))
print("restam:", cli.count("faq").count)
cli.close()
print("\n-- reabrindo a pasta em outro cliente")
cli2 = QdrantClient(path="qdrant_dados")
print("pontos persistidos:", cli2.count("faq").count)
print(cli2.get_collection("faq").config.params.vectors)
cli2.close()
A saída, tal como o programa imprimiu:
pontos: 16
-- busca simples
10 0.396 devolucao | O estorno de uma compra no cartão aparece em até duas faturas.
13 0.305 conta | Para redefinir a senha, clique em esqueci minha senha na tela de login.
9 0.272 devolucao | Para trocar um produto, abra uma solicitação em até 7 dias após o recebimento.
-- busca com filtro loja=b e ano>=2026
12 0.266 b 2026 | A devolução do valor pago por Pix acontece em até 2 dias úteis.
15 0.241 b 2026 | Para excluir sua conta, envie um pedido ao suporte; os dados somem em 30 dias.
16 0.22 b 2026 | Ative a verificação em duas etapas para proteger o acesso à conta.
-- upsert no mesmo id troca o conteúdo
16 TEXTO NOVO
-- delete por filtro (tudo de 2024)
restam: 13
-- reabrindo a pasta em outro cliente
pontos persistidos: 13
size=384 distance=<Distance.COSINE: 'Cosine'> hnsw_config=None quantization_config=None on_disk=None memory=None datatype=None multivector_config=None
Alguns pontos merecem atenção. A busca simples devolve o trecho do estorno no topo (similaridade 0,396), que é uma resposta razoável para “quero meu dinheiro de volta”. Com o filtro, o topo muda para o trecho 12, o do reembolso por Pix, que só aparece porque os trechos da loja a e os de 2024 foram removidos da disputa: o filtro mudou a resposta, e isso é exatamente o que se espera dele. O upsert com id 12 manteve o total em 16 (não criou o 17º) e trocou o texto, e o delete por filtro apagou os três trechos de 2024 (ids 3, 8 e 11), deixando 13. Ao reabrir a pasta, os 13 pontos estavam lá, e a configuração da coleção também, o que mostra que o “banco” é a pasta, e não o objeto Python.
O modo local tem limites que a própria biblioteca declara. Ao tentar abrir a mesma pasta com um segundo cliente enquanto o primeiro estava aberto, recebi RuntimeError: Storage folder qdrant_dados is already accessed by another instance of Qdrant client. If you require concurrent access, use Qdrant server instead. Ou seja, um processo por pasta. E, como vimos na seção 7, os índices de payload não têm efeito. O modo local serve para prototipar e testar. Para um serviço com vários clientes e filtros pesados, o caminho é o servidor.
11. Chroma
O Chroma tem a mesma ideia, com um vocabulário um pouco mais enxuto. A documentação lista os tipos de cliente: o em memória (chromadb.Client(), só em Python, sem persistência, para experimentos), o persistente (PersistentClient, que grava em path, e o padrão é .chroma) e o cliente de servidor (HTTP). O código abaixo faz o mesmo que o do Qdrant. Passo os embeddings já calculados (o Chroma pode calcular embeddings sozinho, com uma função padrão, mas aqui quero usar os mesmos vetores nos três bancos), o texto vai no campo documents e as etiquetas em metadatas.
import numpy as np, chromadb
from corpus import DOCS
X = np.load("vetores.npy"); q = np.load("consulta.npy")
cli = chromadb.PersistentClient(path="chroma_dados") # grava numa pasta
col = cli.get_or_create_collection("faq", metadata={"hnsw:space": "cosine"})
col.upsert(
ids=[str(d[0]) for d in DOCS],
embeddings=X.tolist(),
documents=[d[1] for d in DOCS], # o texto original fica junto
metadatas=[{"categoria": d[2], "ano": d[3], "loja": d[4]} for d in DOCS],
)
print("registros:", col.count())
r = col.query(query_embeddings=[q.tolist()], n_results=3)
print("\n-- busca simples")
for i, dist, doc in zip(r["ids"][0], r["distances"][0], r["documents"][0]):
print(i, round(dist, 3), "|", doc)
r = col.query(query_embeddings=[q.tolist()], n_results=3,
where={"$and": [{"loja": "b"}, {"ano": {"$gte": 2026}}]})
print("\n-- com filtro loja=b e ano>=2026")
for i, dist, doc in zip(r["ids"][0], r["distances"][0], r["documents"][0]):
print(i, round(dist, 3), "|", doc)
col.delete(where={"ano": 2024})
print("\nrestam:", col.count())
cli2 = chromadb.PersistentClient(path="chroma_dados")
print("persistidos:", cli2.get_collection("faq").count())
A saída:
registros: 16
-- busca simples
10 0.604 | O estorno de uma compra no cartão aparece em até duas faturas.
13 0.695 | Para redefinir a senha, clique em esqueci minha senha na tela de login.
9 0.728 | Para trocar um produto, abra uma solicitação em até 7 dias após o recebimento.
-- com filtro loja=b e ano>=2026
12 0.734 | A devolução do valor pago por Pix acontece em até 2 dias úteis.
15 0.759 | Para excluir sua conta, envie um pedido ao suporte; os dados somem em 30 dias.
16 0.78 | Ative a verificação em duas etapas para proteger o acesso à conta.
restam: 13
persistidos: 13
Os ids e a ordem coincidem com os do Qdrant. Os números são diferentes porque o Chroma devolve distância (1 menos a similaridade do cosseno: 1 − 0,396 = 0,604), e não similaridade, e distância menor é melhor. Esse é um detalhe que costuma causar erro quando se troca de banco: sempre confira se o número que volta é distância ou similaridade (e, no pgvector, o operador <=> devolve distância de cosseno, por isso o código abaixo usa 1 - (embedding <=> q)). Note também que o Chroma configura a métrica na criação da coleção, com metadata={"hnsw:space": "cosine"}; sem isso, o padrão do Chroma é outro (distância euclidiana ao quadrado, pelo que conheço, mas não consultei a página de configuração de hoje).
12. pgvector: o banco que você já tem
O pgvector é uma extensão do Postgres que acrescenta o tipo vector, operadores de distância e dois tipos de índice aproximado (HNSW e IVFFlat). A diferença de filosofia em relação aos outros é que aqui o vetor é só mais uma coluna: a tabela tem o id, o texto, as etiquetas como colunas comuns e a coluna do embedding. O código abaixo cria a tabela, o índice HNSW, insere os registros (com ON CONFLICT ... DO UPDATE, que é o upsert do SQL), faz a busca simples e a busca com filtro em SQL puro.
import numpy as np, psycopg
from pgvector.psycopg import register_vector
from corpus import DOCS
X = np.load("vetores.npy"); q = np.load("consulta.npy")
with psycopg.connect("host=localhost port=5433 user=aula dbname=rag", autocommit=True) as c:
c.execute("CREATE EXTENSION IF NOT EXISTS vector")
register_vector(c)
c.execute("DROP TABLE IF EXISTS faq")
c.execute("""
CREATE TABLE faq (
id integer PRIMARY KEY,
texto text NOT NULL,
categoria text NOT NULL,
ano integer NOT NULL,
loja text NOT NULL,
embedding vector(384) NOT NULL
)""")
c.execute("CREATE INDEX ON faq USING hnsw (embedding vector_cosine_ops)") # m=16, ef_construction=64
with c.cursor() as cur:
cur.executemany(
"INSERT INTO faq VALUES (%s,%s,%s,%s,%s,%s) "
"ON CONFLICT (id) DO UPDATE SET texto=EXCLUDED.texto, embedding=EXCLUDED.embedding",
[(d[0], d[1], d[2], d[3], d[4], X[i]) for i, d in enumerate(DOCS)])
print("-- busca simples")
for r in c.execute("SELECT id, 1 - (embedding <=> %s) AS sim, texto FROM faq "
"ORDER BY embedding <=> %s LIMIT 3", (q, q)):
print(r[0], round(float(r[1]), 3), "|", r[2])
print("\n-- com filtro loja=b e ano>=2026 (SQL comum no WHERE)")
for r in c.execute("SELECT id, 1 - (embedding <=> %s) AS sim, texto FROM faq "
"WHERE loja = 'b' AND ano >= 2026 ORDER BY embedding <=> %s LIMIT 3", (q, q)):
print(r[0], round(float(r[1]), 3), "|", r[2])
A saída:
-- busca simples
10 0.396 | O estorno de uma compra no cartão aparece em até duas faturas.
13 0.305 | Para redefinir a senha, clique em esqueci minha senha na tela de login.
9 0.272 | Para trocar um produto, abra uma solicitação em até 7 dias após o recebimento.
-- com filtro loja=b e ano>=2026 (SQL comum no WHERE)
12 0.266 | A devolução do valor pago por Pix acontece em até 2 dias úteis.
15 0.241 | Para excluir sua conta, envie um pedido ao suporte; os dados somem em 30 dias.
16 0.22 | Ative a verificação em duas etapas para proteger o acesso à conta.
Os mesmos três ids, as mesmas similaridades do Qdrant. A vantagem do pgvector aparece no que vem de graça, porque o dado vetorial agora vive num banco relacional completo. O próximo script mostra duas dessas vantagens: uma transação que falha no meio e desfaz tudo (o vetor vai e volta junto com a linha), e um JOIN com outra tabela na mesma consulta que ordena por distância.
import numpy as np, psycopg
from pgvector.psycopg import register_vector
q = np.load("consulta.npy")
with psycopg.connect("host=localhost port=5433 user=aula dbname=rag") as c:
register_vector(c)
# 1. transação: apaga, falha no meio, nada muda
try:
with c.transaction():
c.execute("DELETE FROM faq WHERE ano = 2024")
print("dentro:", c.execute("SELECT count(*) FROM faq").fetchone()[0])
raise RuntimeError("falhou antes do commit")
except RuntimeError:
pass
print("depois do rollback:", c.execute("SELECT count(*) FROM faq").fetchone()[0])
# 2. JOIN com outra tabela, na mesma consulta
c.execute("CREATE TABLE IF NOT EXISTS lojas (loja text PRIMARY KEY, nome text)")
c.execute("INSERT INTO lojas VALUES ('a','Loja Centro'),('b','Loja Online') ON CONFLICT DO NOTHING")
for r in c.execute("SELECT l.nome, f.texto FROM faq f JOIN lojas l USING (loja) "
"ORDER BY f.embedding <=> %s LIMIT 2", (q,)):
print(r)
A saída foi:
dentro: 13
depois do rollback: 16
('Loja Centro', 'O estorno de uma compra no cartão aparece em até duas faturas.')
('Loja Centro', 'Para redefinir a senha, clique em esqueci minha senha na tela de login.')
Dentro da transação o DELETE já tinha removido os três trechos de 2024 (13 linhas), mas a exceção desfez a operação, e depois do rollback as 16 linhas estavam de volta. Nos bancos dedicados, um conjunto de operações “apaga o documento no banco relacional e remove os vetores no banco vetorial” não tem uma transação que cubra os dois sistemas, e o que acontece quando o segundo passo falha vira problema seu. O JOIN com a tabela lojas devolveu o nome da loja ao lado do texto, sem uma segunda consulta.
A figura reúne o que o pgvector herda. No grupo “Mesmo banco”, a aplicação laranja envia um só SELECT ... JOIN lojas ... ORDER BY embedding <=> q à tabela faq (seta grossa, com a resposta verde voltando). A tabela faq tem as colunas comuns e a coluna embedding vector(384), e se relaciona com a tabela lojas por uma chave estrangeira (a linha verde sólida). O índice HNSW azul (vector_cosine_ops, m = 16, ef_construction = 64) atende o ORDER BY. No grupo “Operação que você já tem”, o WAL cuida da durabilidade e alimenta uma réplica de leitura (seta cinza lenta), o backup com recuperação num ponto do tempo (PITR) é o que o time já opera, e a transação leva o vetor junto no rollback. Essa é a razão central de escolher uma extensão: não é preciso aprender, monitorar e proteger um sistema novo.
O mesmo banco foi usado para um teste de escala. Com 100.000 vetores sintéticos de 384 números (os de Busca vetorial), a tabela ocupou 157 MB e o índice HNSW 195 MB, e a criação do índice levou cerca de 42 s (41,3 e 41,9 s em duas execuções). O código do experimento de filtro da seção 7 é este:
import time, numpy as np, psycopg
from pgvector.psycopg import register_vector
rng = np.random.default_rng(42)
N, D, C, NQ, K = 100_000, 384, 300, 100, 10
centros = rng.standard_normal((C, D)).astype(np.float32)
def gerar(n):
v = centros[rng.integers(0, C, n)] + 1.6 * rng.standard_normal((n, D)).astype(np.float32)
return v / np.linalg.norm(v, axis=1, keepdims=True)
X, Q = gerar(N), gerar(NQ)
cat = rng.integers(0, 10, N) # 10 categorias, 10% das linhas cada
with psycopg.connect("host=localhost port=5433 user=aula dbname=rag", autocommit=True) as c:
register_vector(c)
c.execute("DROP TABLE IF EXISTS itens")
c.execute("CREATE TABLE itens (id int PRIMARY KEY, cat int, embedding vector(384))")
t = time.perf_counter()
with c.cursor() as cur, cur.copy("COPY itens (id, cat, embedding) FROM STDIN WITH (FORMAT BINARY)") as cp:
cp.set_types(["int4", "int4", "vector"])
for i in range(N):
cp.write_row((i, int(cat[i]), X[i]))
print(f"carga: {time.perf_counter() - t:.1f} s")
t = time.perf_counter()
c.execute("CREATE INDEX ON itens USING hnsw (embedding vector_cosine_ops)")
print(f"índice HNSW (m=16, ef_construction=64): {time.perf_counter() - t:.1f} s")
print("tabela:", c.execute("select pg_size_pretty(pg_table_size('itens'))").fetchone()[0],
"| índice HNSW:", c.execute("select pg_size_pretty(pg_relation_size('itens_embedding_idx'))").fetchone()[0])
def media_linhas(setup=()):
for s in setup: c.execute(s)
n = []; t = time.perf_counter()
for q in Q:
r = c.execute(f"SELECT id FROM itens WHERE cat = 3 ORDER BY embedding <=> %s LIMIT {K}", (q,)).fetchall()
n.append(len(r))
return np.mean(n), (time.perf_counter() - t) / NQ * 1000
print("\nfiltro cat = 3 (10% das linhas), LIMIT 10, 100 consultas")
for nome, setup in [
("ef_search = 40 (padrão), sem varredura iterativa", ["SET hnsw.ef_search = 40", "SET hnsw.iterative_scan = off"]),
("ef_search = 200", ["SET hnsw.ef_search = 200"]),
("ef_search = 40 + iterative_scan = relaxed_order", ["SET hnsw.ef_search = 40", "SET hnsw.iterative_scan = relaxed_order"]),
("ef_search = 40 + iterative_scan = strict_order", ["SET hnsw.ef_search = 40", "SET hnsw.iterative_scan = strict_order"]),
]:
n, ms = media_linhas(setup)
print(f"{nome:55s} linhas devolvidas (média) = {n:4.1f} {ms:.2f} ms")
c.execute("SET hnsw.iterative_scan = off"); c.execute("SET hnsw.ef_search = 40")
t = time.perf_counter()
c.execute("CREATE INDEX itens_cat3 ON itens USING hnsw (embedding vector_cosine_ops) WHERE cat = 3")
print(f"\níndice parcial (WHERE cat = 3): construído em {time.perf_counter() - t:.1f} s")
n, ms = media_linhas()
print(f"com índice parcial: linhas devolvidas = {n:4.1f} {ms:.2f} ms")
E a saída (uma execução; na outra, os tempos variaram em torno de 10 a 20%, e os números de linhas devolvidas foram idênticos):
carga: 1.1 s
índice HNSW (m=16, ef_construction=64): 41.9 s
tabela: 157 MB | índice HNSW: 195 MB
filtro cat = 3 (10% das linhas), LIMIT 10, 100 consultas
ef_search = 40 (padrão), sem varredura iterativa linhas devolvidas (média) = 4.0 2.49 ms
ef_search = 200 linhas devolvidas (média) = 10.0 2.49 ms
ef_search = 40 + iterative_scan = relaxed_order linhas devolvidas (média) = 10.0 2.25 ms
ef_search = 40 + iterative_scan = strict_order linhas devolvidas (média) = 10.0 3.57 ms
índice parcial (WHERE cat = 3): construído em 1.2 s
com índice parcial: linhas devolvidas = 10.0 0.78 ms
Para medir o recall do HNSW sem filtro e o custo da varredura exata, usei o script a seguir. Ele tem uma armadilha que me pegou na primeira tentativa e que merece registro. A primeira versão desligou o uso do índice (SET enable_indexscan = off) para obter a “verdade” exata, religou, e mediu o índice: o recall deu 1,000 com ef_search = 10, 40 e 100, e os tempos ficaram em 26 a 33 ms, que são tempos de varredura, não de índice. O índice não estava sendo usado, e a causa provável (não a confirmei nas estatísticas do servidor) é que o driver passa a usar comandos preparados depois de algumas execuções iguais, e o servidor reaproveitou o plano da varredura. Com prepare_threshold=None na conexão, que desliga os comandos preparados, o resultado saiu como esperado. O código final é este:
import time, numpy as np, psycopg
from pgvector.psycopg import register_vector
rng = np.random.default_rng(42)
N, D, C, NQ, K = 100_000, 384, 300, 100, 10
centros = rng.standard_normal((C, D)).astype(np.float32)
def gerar(n):
v = centros[rng.integers(0, C, n)] + 1.6 * rng.standard_normal((n, D)).astype(np.float32)
return v / np.linalg.norm(v, axis=1, keepdims=True)
X, Q = gerar(N), gerar(NQ)
with psycopg.connect("host=localhost port=5433 user=aula dbname=rag", autocommit=True, prepare_threshold=None) as c:
register_vector(c)
sql = f"SELECT id FROM itens ORDER BY embedding <=> %s LIMIT {K}"
def rodar():
t = time.perf_counter(); out = [ [r[0] for r in c.execute(sql, (q,))] for q in Q ]
return out, (time.perf_counter() - t) / NQ * 1000
c.execute("SET enable_indexscan = off"); c.execute("SET max_parallel_workers_per_gather = 0")
exato, ms = rodar(); print(f"sem índice (varredura): {ms:.1f} ms")
c.execute("RESET enable_indexscan")
for ef in (10, 40, 100):
c.execute(f"SET hnsw.ef_search = {ef}")
got, ms = rodar()
rec = np.mean([len(set(g) & set(e)) / K for g, e in zip(got, exato)])
print(f"HNSW ef_search={ef:3d}: recall@10 = {rec:.3f} {ms:.2f} ms")
sem índice (varredura): 25.8 ms
HNSW ef_search= 10: recall@10 = 0.629 1.20 ms
HNSW ef_search= 40: recall@10 = 0.968 1.37 ms
HNSW ef_search=100: recall@10 = 0.990 1.62 ms
Dois aprendizados saem daí. O primeiro: o índice reduziu a consulta de 25,8 ms para cerca de 1,4 ms, com recall de 0,968 no ef_search padrão (40), e o recall visto em Busca vetorial reaparece: mais ef_search, mais recall, mais tempo. O segundo: desconfie de um recall perfeito acompanhado de um tempo alto, porque os dois juntos indicam que a medição não é a que você pensa. O EXPLAIN mostra o plano escolhido: no teste da seção 7, a consulta com WHERE cat = 3 usou o índice parcial (Index Scan using itens_cat3 on itens).
Existem limites que o README do pgvector informa e que importam para quem escolhe embeddings: o tipo vector aceita até 16.000 dimensões, mas o índice (HNSW ou IVFFlat) só suporta até 2.000, e o tipo halfvec (meia precisão) permite indexar até 4.000. Um modelo de embedding de 3.072 dimensões, por exemplo, exige halfvec para ter índice, ou outra estratégia. Esse tipo de limite muda com as versões, então confira o README da versão que você usa.
13. Biblioteca em memória: FAISS e hnswlib
O quarto degrau da escada é o mais barato e o mais exigente. FAISS e hnswlib entregam o índice, e o resto fica com você. O código abaixo mostra o que isso significa. Os metadados ficam num dicionário seu; o filtro é um pós-filtro escrito à mão (no FAISS) ou uma função passada à busca (na hnswlib, que aceita um filter sobre o id); salvar é gravar um arquivo; e remover depende do tipo de índice.
import json, numpy as np, faiss, hnswlib
from corpus import DOCS
X = np.load("vetores.npy"); q = np.load("consulta.npy")
ids = np.array([d[0] for d in DOCS], dtype=np.int64)
meta = {d[0]: {"texto": d[1], "categoria": d[2], "ano": d[3], "loja": d[4]} for d in DOCS} # fica por sua conta
# FAISS: só vetor e id. Metadados, texto e filtro são com você.
indice = faiss.IndexIDMap(faiss.IndexFlatIP(384))
indice.add_with_ids(X, ids)
sim, found = indice.search(q[None, :], 16)
bons = [(int(i), float(s)) for i, s in zip(found[0], sim[0]) if meta[int(i)]["loja"] == "b"][:3] # pós-filtro manual
print("FAISS com pós-filtro:", [(i, round(s, 3)) for i, s in bons])
faiss.write_index(indice, "faq.faiss") # persistência = salvar o arquivo inteiro
json.dump(meta, open("faq_meta.json", "w"))
de_novo = faiss.read_index("faq.faiss")
print("FAISS recarregado:", de_novo.ntotal, "vetores")
de_novo.remove_ids(np.array([3, 8, 11], dtype=np.int64)) # IndexIDMap(Flat) aceita remoção
print("depois de remover 3 ids:", de_novo.ntotal)
# hnswlib: a busca aceita uma função de filtro sobre o id
h = hnswlib.Index(space="cosine", dim=384)
h.init_index(max_elements=100, ef_construction=200, M=16)
h.add_items(X, ids)
h.set_ef(50)
ids_b = {i for i, m in meta.items() if m["loja"] == "b"}
lab, dist = h.knn_query(q, k=3, filter=lambda i: i in ids_b)
print("hnswlib com filtro:", [(int(i), round(1 - float(d), 3)) for i, d in zip(lab[0], dist[0])])
h.mark_deleted(12) # marca; o nó continua no grafo
lab, _ = h.knn_query(q, k=3, filter=lambda i: i in ids_b)
print("depois de mark_deleted(12):", [int(i) for i in lab[0]])
A saída:
FAISS com pós-filtro: [(12, 0.266), (15, 0.241), (8, 0.24)]
FAISS recarregado: 16 vetores
depois de remover 3 ids: 13
hnswlib com filtro: [(12, 0.266), (15, 0.241), (8, 0.24)]
depois de mark_deleted(12): [15, 8, 16]
Repare no que o código precisou fazer por conta própria. O FAISS recebe só vetores e ids (IndexIDMap permite usar os seus ids). O texto e as etiquetas estão no dicionário meta, salvo à parte num JSON, e se o arquivo do índice e o JSON saírem de sincronia, ninguém avisa. A busca com filtro no FAISS foi um pós-filtro: pedi os 16 vizinhos e escolhi os da loja b, o que funciona num acervo de 16 itens e é o mesmo problema dos “4 linhas em vez de 10” num acervo grande. O remove_ids funcionou no IndexIDMap sobre um índice exato (IndexFlatIP); nem todos os tipos de índice do FAISS aceitam remoção, e ela precisa ser testada para o tipo que você usar. Na hnswlib, a função de filtro é aplicada durante a navegação, e mark_deleted(12) fez o id 12 sumir dos resultados (o 16 entrou no lugar). Pelo que conheço da biblioteca (sem consulta à documentação de hoje), o nó marcado continua no grafo e só deixa de ser devolvido.
Quando isso é o certo? Quando o acervo cabe na RAM, é construído em lote e muda pouco, e quando o time quer o controle total e a menor latência: um índice de FAQ com 50 mil trechos, regenerado toda noite, que roda dentro do mesmo processo do serviço, é um bom caso.
14. A mesma pergunta, três respostas
Os três bancos fizeram o mesmo trabalho com a mesma pergunta e os mesmos filtros, e a comparação vale uma figura.
A pergunta laranja (“quero meu dinheiro de volta”, loja b, ano 2026 ou mais) vira um vetor de 384 números e é enviada, em setas azuis defasadas, aos três bancos, cada um com a sua sintaxe: query_points com Filter(must=[...]) no Qdrant, query com where {"$and": [...]} no Chroma e SELECT ... WHERE ... ORDER BY embedding <=> q no pgvector. As três saídas convergem no mesmo documento verde, com os mesmos três ids (12, 15 e 16, nessa ordem). Os rótulos mostram o detalhe de unidade: similaridade 0,266 no Qdrant e no pgvector, distância 0,734 no Chroma (1 − 0,266). Resultado igual com sintaxes diferentes é o que se espera de um conjunto pequeno e exato. Com milhões de vetores e índices aproximados, os três divergiriam um pouco, conforme os parâmetros de cada índice.
Parte 4: Decidir e seguir adiante
15. Quatro lugares para guardar vetores
Para decidir, ajuda separar o que cada opção entrega e o que cobra. São quatro famílias, e cada uma aparece no código da Parte 3.
A biblioteca em memória (FAISS, hnswlib) ganha latência mínima, nenhum servidor e controle total do índice, e assume tudo o que o banco faria: texto, filtro, salvamento e concorrência. A extensão no banco (pgvector no Postgres) ganha SQL, JOIN, transação, backup e permissões que já existem, e assume a memória do índice, os limites do HNSW dentro do Postgres e o cuidado com o filtro e o ef_search. O banco embarcado em pasta (Chroma persistente, Qdrant local) ganha a API de coleção pronta e um comando para começar, e assume o limite de um processo por pasta (e, no Qdrant local, a ausência de índice de payload). O servidor dedicado (Qdrant em servidor ou em cluster) ganha shards, réplicas, índice de payload e quantização, e assume mais um sistema para operar, monitorar e atualizar. Há outros bancos vetoriais no ecossistema (Milvus, Weaviate, Pinecone, o Elasticsearch com vetores e outros), que não consultei para esta aula e que caberiam, conforme o desenho, na família do servidor dedicado ou do serviço gerenciado.
16. Um critério de decisão
O critério abaixo é uma árvore de perguntas, na ordem em que cada uma elimina opções. Os limiares (“poucos milhões”, por exemplo) são um julgamento meu, uma ordem de grandeza para começar a conversa, e não um número oficial de nenhum projeto.
A primeira pergunta é se o acervo cabe na RAM de um só processo e muda pouco. Se sim, uma biblioteca com um arquivo ao lado (FAISS ou hnswlib) é a opção mais simples e rápida. Se não, a segunda pergunta é se você já opera Postgres e se o acervo é de poucos milhões de vetores: nesse caso, o pgvector é a escolha de menor atrito, porque o vetor entra na mesma transação, no mesmo backup e nas mesmas permissões. Se não, a terceira pergunta é se é um protótipo ou um aplicativo local, de um usuário numa máquina: o Chroma persistente ou o Qdrant local resolvem. E se nenhuma das três se aplica (volume grande, filtros pesados, muitos locatários), o caminho é um servidor dedicado, com shards e réplicas. As setas “não” levam adiante e a seta grossa final destaca o caminho que mais exige operação.
Para a escolha entre pgvector e um servidor dedicado, que é a mais comum no mundo real, a tabela abaixo reúne os fatores. O que os dois lados escrevem de si mesmos fica sujeito a mudança, então trate a tabela como roteiro de perguntas a fazer, e não como veredito.
| Fator | Pesa a favor do pgvector | Pesa a favor de um servidor dedicado |
|---|---|---|
| Time e operação | Já há Postgres, backup e monitoramento | Há gente para operar mais um sistema, ou usa-se um serviço gerenciado |
| Consistência com os dados relacionais | Vetor e linha na mesma transação, JOIN na consulta | Os dados já vivem fora do banco relacional |
| Volume | Até alguns milhões de vetores, com RAM para o índice | Dezenas de milhões ou mais, com sharding e quantização |
| Filtros | Poucos e simples, ou índice parcial por valor | Muitos, seletivos, com índice de payload e filtro dentro do grafo |
| Dimensão do embedding | Até 2.000 (ou 4.000 com halfvec) para ter índice | Sem esse limite de índice do pgvector (confira o seu motor) |
| Carga de escrita | Moderada; a criação do índice HNSW leva dezenas de segundos para 100 mil vetores | Alta, com escrita distribuída entre shards |
Três cenários ajudam a fixar. Um suporte com 50 mil trechos de FAQ, já num Postgres que guarda os tickets, pede pgvector (ou até a busca exata, que Busca vetorial mostrou custar décimos de milissegundo nessa escala). Um assistente de pesquisa sobre 200 milhões de trechos, com filtros por muitos clientes e picos de escrita, aponta para um servidor dedicado em cluster. Um protótipo de fim de semana, ou um aplicativo de desktop que indexa as notas de uma pessoa, pede um banco embarcado, ou uma biblioteca com um arquivo. E vale lembrar que a escolha não é irreversível: como o registro tem a forma id, vetor, payload e texto em qualquer sistema, migrar é reler os registros e gravá-los no destino, e o pipeline de Ingestão de documentos deve ser capaz de refazer o índice do zero.
17. Erros comuns
O primeiro erro é misturar modelos de embedding numa mesma coleção, ou trocar o modelo sem reindexar. A dimensão diferente gera erro, mas dimensão igual com modelos diferentes passa em silêncio e devolve ruído. A correção é nomear a coleção com a versão do modelo e migrar com uma coleção nova.
O segundo é guardar só o vetor e deixar o texto em outro lugar sem uma regra de sincronização. O trecho é corrigido, o vetor não, e a busca devolve texto que não corresponde ao que foi indexado. Guarde o texto no registro ou, no mínimo, uma versão ou um hash dele, para detectar a diferença.
O terceiro é usar ids aleatórios ou contadores na ingestão. Uma ingestão repetida duplica os registros. Ids derivados da origem (arquivo mais número do trecho) tornam o upsert idempotente.
O quarto é esperar que o filtro seja gratuito. Na medição da seção 7, o padrão do pgvector devolveu 4 linhas de 10 pedidas. Teste os filtros reais, os mais seletivos em especial, e confira se o motor usa varredura iterativa, índice parcial ou filtro dentro do grafo.
O quinto é criar o índice de payload depois de ingerir os dados (no Qdrant), o que, segundo a documentação, obriga a reconstruir o HNSW para que o grafo tenha as arestas do filtro. Crie primeiro, ingira depois. No pgvector, o mesmo cuidado vale para o IVFFlat, que deve ser criado depois de haver dados.
O sexto é levar um banco embarcado para produção. O Qdrant local recusa um segundo processo na mesma pasta e ignora índices de payload. É uma ferramenta de protótipo e de teste.
O sétimo é confiar numa medição sem desconfiar dela. O recall 1,000 com tempo de varredura, que vi no pgvector, era sinal de que o índice não estava em uso. Meça recall e tempo juntos, e confira o plano com EXPLAIN.
O oitavo é esquecer que distância e similaridade são lados opostos: o Chroma devolve 0,604 onde o Qdrant devolve 0,396 para o mesmo par. Ordenar ao contrário é um erro silencioso, e comparar limiares entre bancos sem converter também.
O nono é tratar o recall do índice como a qualidade do RAG, tema que Busca vetorial já avisou e que Avaliação de RAG trata: o banco devolve os vizinhos mais próximos, e se os trechos foram mal cortados ou o modelo é inadequado, o vizinho mais próximo ainda pode ser inútil.
18. Para ir além
Neste ponto o RAG já tem um lugar para os vetores: um banco com coleções, filtros e persistência, ao qual se envia um trecho com id, vetor, payload e texto. Falta a pergunta que está antes de tudo isso. O que é um “trecho”? Até aqui os 16 registros da aula eram frases curtas e prontas, mas documentos reais são PDFs de 80 páginas, páginas web com menus e rodapés, atas de reunião de duas horas. Se você guarda o documento inteiro como um registro, o vetor vira uma média borrada de assuntos e o trecho entregue ao modelo estoura a janela de contexto (LLMs). Se corta em pedaços pequenos demais, cada pedaço perde o contexto que o tornava compreensível. O tamanho e o corte do trecho decidem o que o banco vai guardar e, portanto, o que a busca vai achar.
A figura mostra onde o banco entra no pipeline. Na linha de cima (escrita), os documentos seguem para o chunking, em rosa tracejado e pulsando porque é a peça que ainda falta, depois para o embedding e, por fim, para o banco com upsert. Na linha de baixo (leitura), a pergunta do usuário passa pelo mesmo modelo de embedding, chega ao banco como search + filtro, e o banco devolve ids, texto e payload, que formam o top-k que vai ao prompt do LLM. Chunking ensina a peça rosa: como cortar documentos em trechos que cabem no contexto e continuam fazendo sentido sozinhos.
19. Exercícios
Os exercícios 1 a 3 são contas e raciocínio. Os demais rodam em CPU, sem chave de API: do 4 em diante, você precisa do qdrant-client, do chromadb e, para o 7, de um Postgres com a extensão pgvector.
-
Um acervo tem 5 milhões de vetores de 768 números em
float32. Calcule a memória só dos vetores e some a estimativa da hnswlib para o grafo HNSW (M× 8 a 10 bytes por vetor, comM= 16). (Resposta: 5.000.000 × 768 × 4 = 15,36 GB para os vetores; o grafo soma 5.000.000 × 128 a 160 bytes, de 0,64 a 0,8 GB. O total passa de 16 GB só para o índice e os vetores, sem o texto e o payload.) -
Um filtro vale para 5% das linhas e o pgvector usa
hnsw.ef_search= 40, sem varredura iterativa. Quantas linhas, em média, você espera receber de umLIMIT 10? E se o filtro valesse 50%? (Resposta: 40 × 0,05 = 2 linhas; 40 × 0,5 = 20 candidatos que passam, e oLIMIT 10se completa. Essa é a conta do README do pgvector, e o experimento da seção 7 a confirmou para 10%.) -
Para cada caso, escolha entre biblioteca em memória, pgvector, banco embarcado e servidor dedicado, e justifique em duas frases: (a) um chatbot interno com 30 mil trechos e um Postgres de tickets já em produção; (b) um buscador com 300 milhões de vetores e 10 mil clientes, cada um vendo só os seus dados; (c) um script que indexa as suas notas pessoais; (d) um serviço que atende 2.000 consultas por segundo sobre um índice de 2 milhões de vetores que é refeito toda madrugada. (Há mais de uma resposta defensável para (d); o ponto é o argumento.)
-
Rode os scripts
dados.pyeqd1.pynum ambiente seu e confira se a busca com filtro devolve os ids 12, 15 e 16. Depois acrescente um 17º trecho e verifique se uma segunda execução doupsertnão duplica o registro. -
Tente abrir a mesma pasta do Qdrant local com dois clientes ao mesmo tempo e leia a mensagem de erro. Em seguida, repita com o Chroma persistente e relate o que acontece (não verifiquei o comportamento do Chroma com dois processos, então a resposta é sua).
-
No Chroma, crie a coleção sem
metadata={"hnsw:space": "cosine"}e compare as distâncias da mesma consulta com as da saída da seção 11. Explique a diferença usando o que Similaridade entre vetores ensina sobre as métricas. -
Reproduza o experimento da seção 7 com 100.000 vetores no seu Postgres. Meça quantas linhas voltam com
ef_search= 40, depois comhnsw.iterative_scan = relaxed_order. Repita com um filtro mais seletivo (1% das linhas) e veja o que acontece com a varredura iterativa e com o limitehnsw.max_scan_tuples. -
Provoque a armadilha do plano em cache: meça a “verdade” com
SET enable_indexscan = off, religue e meça o índice semprepare_threshold=None. UseEXPLAINpara ver qual plano a consulta está usando e explique o que viu. -
Com o
lib1.py, tente remover um id de umIndexHNSWFlatdo FAISS (em vez doIndexIDMapsobre umIndexFlatIP) e descreva o resultado. Relacione com a observação sobre remoção em grafos HNSW. -
Projete, no papel, o registro de um RAG sobre contratos: que campos de payload você criaria para as perguntas “só contratos vigentes”, “só do cliente X” e “só a cláusula de rescisão”? Quais desses campos seriam indexados e por quê? (Relaciona-se com Chunking e Ingestão de documentos.)
20. Glossário
| Termo | Significado |
|---|---|
| Banco de dados vetorial | Sistema que guarda registros com vetores e responde a buscas por similaridade, com persistência, filtros e operações de escrita |
| Registro (ponto) | Unidade guardada: id, vetor, payload e, em geral, o texto original |
| Payload (metadados) | Pares chave e valor de um registro, usados para filtrar e para devolver informação junto ao resultado |
| Coleção | Conjunto de registros com a mesma dimensão de vetor e a mesma métrica, e o índice construído sobre eles |
| Upsert | Gravar um registro: insere se o id não existe, substitui se existe |
| Idempotente | Operação que, repetida, dá o mesmo resultado que feita uma vez |
| WAL | Log de escrita: arquivo em que cada operação é anotada antes de ser aplicada, usado para recuperar após uma queda |
| Durabilidade | Garantia de que um dado confirmado sobrevive a uma falha |
| Pós-filtro | Filtro aplicado depois que o índice devolve os candidatos; pode deixar menos resultados que o pedido |
| Pré-filtro | Filtro aplicado antes da comparação: acha as linhas válidas e compara só com elas |
| Varredura iterativa | Continuar percorrendo o índice até completar o número de resultados que passam no filtro (pgvector 0.8) |
| Índice parcial | Índice construído só sobre as linhas que satisfazem uma condição (WHERE cat = 3) |
| Índice de payload | Índice sobre um campo de metadados que acelera o filtro (Qdrant) |
| Shard | Pedaço de uma coleção, guardado e indexado num nó |
| Réplica | Cópia de um shard em outro nó, para tolerar falhas e distribuir leituras |
write_consistency_factor | No Qdrant, quantas réplicas precisam confirmar uma escrita (padrão 1) |
| Banco embarcado | Banco que roda dentro do processo da aplicação e grava numa pasta (Chroma persistente, Qdrant local) |
| Extensão | Módulo que acrescenta um recurso a um banco existente (pgvector no Postgres) |
Fontes
Consultadas em 15 de outubro de 2026.
- pgvector (README): o tipo
vectoraceita até 16.000 dimensões e o índice até 2.000 (halfvecindexa até 4.000); “ACID, recuperação em um ponto do tempo, JOINs” herdados do Postgres; uso do WAL, que permite replicação e recuperação; escala “com réplicas, ou Citus, PgDog ou outra abordagem para sharding”; com índices aproximados “o filtro é aplicado depois da varredura do índice”; particionamento para muitos valores distintos de filtro; varredura iterativa desde a versão 0.8.0. Os valores padrão dem,ef_construction,ef_searchemax_scan_tuplesvêm da consulta de Busca vetorial, na mesma data. - Qdrant: coleções: definição de coleção, mesma dimensionalidade e métrica para todos os pontos, vetores nomeados, métricas de distância (produto escalar, cosseno, euclidiana, Manhattan).
- Qdrant: pontos: ponto como vetor mais payload opcional, ids como inteiro sem sinal de 64 bits ou UUID, upsert que insere ou atualiza, remoção por filtro, escrita no WAL antes de aplicar. Li só os primeiros 100.000 dos cerca de 115.000 caracteres.
- Qdrant: indexação: índice de payload, tipos de campo, criação antes da ingestão para que o HNSW tenha as arestas do filtro (senão, é preciso reconstruir). Li só os primeiros 100.000 dos cerca de 113.200 caracteres.
- Qdrant: escalabilidade: escalar na vertical primeiro, horizontal quando os dados não cabem num nó mesmo com quantização, em torno de 100 milhões de vetores por nó (por resumo da página de escala vertical), cluster mínimo tolerante a falhas de três nós com fator de replicação 2 ou mais.
- Qdrant: garantias de consistência:
write_consistency_factorcom padrão 1 e faixa de 1 ao número de réplicas, consistência de leitura padrão 1 (um nó aleatório), opçõesall,majorityequorum. A página não informa padrões deshard_numbernem dereplication_factor. - Chroma: clientes: cliente em memória (só Python, sem persistência),
PersistentClient(grava empath, padrão.chroma), clientes de servidor e nuvem; a página não trata de segurança entre processos nem de recomendação para produção. - Busca vetorial, Similaridade entre vetores e Embeddings: índices HNSW e IVF, recall, métricas, valores padrão de
efeme o comportamento do filtro depois do índice.
As citações entre aspas nas seções 5, 7 e 8 são traduções minhas dos trechos em inglês.
Medido por mim (ambiente descartável no scratchpad da sessão, macOS, Python 3.12, qdrant-client 1.19.1, chromadb 1.5.9, psycopg 3.3.6, pgvector Python 0.5.1, faiss-cpu 1.15.1, hnswlib 0.8.0, Postgres 18.1 já existente na máquina, extensão pgvector 0.8.7 compilada da fonte para uma pasta temporária, servidor de teste em diretório descartável): todos os trechos de código e saídas das seções 9 a 13, o teste de queda do Postgres com pg_ctl stop -m immediate, o experimento de filtro com 100.000 vetores (4,0 contra 10,0 linhas, tempos e índice parcial), os tamanhos da tabela e do índice, o recall do HNSW sem filtro, o erro de pasta em uso do Qdrant local e o aviso de que o índice de payload não tem efeito no modo local. Os tempos de consulta incluem a viagem ao servidor local e variaram em torno de 10 a 20% entre execuções (a de strict_order, de 2,5 a 3,6 ms, variou mais), então leia-os como ordem de grandeza; os números de linhas devolvidas foram idênticos.
Sem execução ou sem fonte: não rodei o Qdrant em modo servidor nem em cluster (exigiria Docker ou instalação, que não fiz), então não verifiquei o filtro com índice de payload, a quantização, o sharding, a replicação nem a consistência na prática; o que a aula diz deles vem só da documentação. Não repeti o teste de queda no Qdrant ou no Chroma. Não verifiquei o comportamento do Chroma com dois processos na mesma pasta, nem o padrão de métrica do Chroma (a frase sobre a distância euclidiana ao quadrado é conhecimento geral meu). A causa do plano em cache no teste da seção 12 é uma hipótese: mudou o resultado com prepare_threshold=None, mas não consultei pg_stat_statements nem o plano durante a falha. A frase sobre mark_deleted manter o nó no grafo da hnswlib e a de que nem todo índice do FAISS aceita remoção são conhecimento geral, sem consulta hoje. Os embeddings vêm de um modelo pequeno que erra algumas perguntas simples, e os 16 trechos são inventados para a aula. Os limiares da árvore de decisão (poucos milhões, dezenas de milhões) e a tabela de fatores são julgamento meu. Não consultei Milvus, Weaviate, Pinecone nem Elasticsearch; o que escrevi sobre eles é só a lista de nomes. Os 13 diagramas foram renderizados e revisados em pelo menos uma rodada, e os principais (01, 02, 08, 13) em mais de uma.