SSD Nodes Learn 🎉 VPS desde $5.50/mês
Guias Matt ConnorPor Matt Connor

Banco vetorial na VPS: pgvector, Qdrant ou Chroma?

Compare pgvector, Qdrant, Chroma e busca bruta na mesma VPS. Calcule RAM, custo do índice e o impacto real dos embeddings antes de escolher.

O custo real de um banco de dados vetorial numa VPS

Executar um banco de dados vetorial numa VPS (servidor privado virtual) elimina o problema para o qual todos os fornecedores geridos vendem uma solução. A sua aplicação e o seu índice ficam na mesma máquina, por isso um pedido de pesquisa atravessa um socket de loopback em vez da rede. O custo que sempre foi o principal continua a existir: transformar texto em vetores. A este custo somam-se outros dois: o tempo necessário para criar o índice e a RAM que o índice ocupa enquanto está a servir pedidos.

Isto altera as decisões relevantes. A região e o tempo de ida e volta até ao endpoint deixam de ser uma preocupação. O produto do número de vetores pelas dimensões e por quatro bytes passa a ser o fator importante, porque determina se o índice cabe na memória que aluga todos os meses.

Onde os milissegundos são realmente gastos num único servidor

Acompanhe uma consulta de similaridade ao longo de uma stack autoalojada.

  1. O texto da consulta é convertido num vetor por um modelo de embeddings. Num CPU, isto demora dezenas a centenas de milissegundos para uma cadeia curta. Numa GPU, demora apenas alguns milissegundos.
  2. O vetor é enviado para o armazenamento. Através de TCP em loopback ou de um socket de domínio Unix, isto demora uma fração de milissegundo.
  3. O armazenamento percorre o seu índice e devolve as linhas mais próximas.
  4. O seu código lê o texto correspondente e monta um prompt.

O passo 1 é normalmente o maior valor dessa lista. O passo 2 é aquele em que os fornecedores alojados competem e, num único servidor, quase não tem impacto. Não faça suposições sobre essa divisão. Cronometre ambas as extremidades no seu próprio servidor.

curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
  -w 'embed: %{time_total}s\n' \
  -d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'

Em seguida, execute \timing on em psql antes da consulta de pesquisa. Se o primeiro comando imprimir embed: 0.184312s e psql responder Time: 4.201 ms, ajustar o índice é a tarefa errada: a sua latência está no modelo de embeddings. Executar o modelo de embeddings localmente com Ollama coloca o passo 1 no mesmo CPU que os passos 2 e 3, pelo que ambas as partes competem pelos mesmos núcleos e pela mesma RAM. O ciclo de ingestão e recuperação que envolve este armazenamento é abordado em o guia do pipeline RAG autoalojado. RAG significa geração aumentada por recuperação: pesquisa os seus próprios documentos e cola as melhores correspondências num prompt.

Com menos de cerca de cem mil vetores, percorra todos

Uma pesquisa exaustiva compara a consulta com todos os vetores armazenados. O recall é perfeito por definição. Não precisa de índice nem de uma etapa de compilação, e não pode ficar desatualizada em relação aos seus dados.

A aritmética indica quando esta abordagem deixa de ser adequada. Uma pesquisa lê n * d * 4 bytes por consulta, em que n é o número de vetores e d é a dimensão. Com 100,000 vetores de 768 dimensões, isso corresponde a 307 MB por consulta, uma quantidade que uma CPU moderna percorre em algumas dezenas de milissegundos. Com 5 milhões de vetores, são 15 GB por consulta. Nesse caso, já não se trata de uma consulta viável.

Por isso, armazene os vetores no SQLite e faça a comparação no NumPy.

import sqlite3, numpy as np

db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")

def add(body, vec):
    v = np.asarray(vec, dtype=np.float32)
    v /= np.linalg.norm(v)
    db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
    db.commit()

def search(query_vec, k=5):
    rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
    mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
    q = np.asarray(query_vec, dtype=np.float32)
    q /= np.linalg.norm(q)
    scores = mat @ q
    return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]

Ambos os lados são normalizados para um comprimento unitário. Assim, o produto escalar é a similaridade de cosseno, e uma pontuação mais alta indica uma correspondência mais próxima. Carregue mat uma vez no arranque, em vez de uma vez por consulta. Dessa forma, a leitura do SQLite deixa completamente o caminho crítico.

Meça o desempenho na sua própria máquina antes de rejeitar esta abordagem.

import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")

Os limites reais são estes: um único processo mantém a matriz completa na RAM, e não há filtragem por metadados nem uma estratégia para escritores concorrentes. Quando um desses pontos for o motivo da sua insatisfação, mude de abordagem. O SQLite é, por si só, um armazenamento sério para o lado do servidor, como explica o guia do SQLite em produção. Se a sua carga de trabalho real consistir em percorrer colunas em vez de servir linhas, a comparação entre DuckDB e SQLite será uma leitura mais útil.

pgvector quando já utiliza Postgres

Se a aplicação já tiver uma base de dados Postgres, pgvector acrescenta a menor superfície nova. É uma extensão, não um serviço. Os vetores ficam numa tabela normal, junto à linha que descrevem, por isso uma pesquisa filtrada é uma cláusula WHERE em vez de um segundo sistema que seja necessário manter sincronizado.

O Ubuntu 24.04 inclui-a no componente universe.

sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'

Esse pacote é o pgvector 0.6.0 em agosto de 2026, uma versão bastante atrás da versão upstream. Em particular, as pesquisas iterativas com índices precisam da versão 0.8, por isso obtenha-a do repositório próprio do projeto PostgreSQL.

sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvector

Substitua 17 pela versão principal do seu servidor, que sudo -u postgres psql -tAc 'SHOW server_version' apresenta. Se instalar o pacote da extensão criado para a versão principal errada, CREATE EXTENSION falha, porque o Postgres só procura no diretório share da versão que está em execução:

ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory

O esquema é SQL normal com um novo tipo.

CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    bigint NOT NULL,
  body      text   NOT NULL,
  embedding vector(768)
);

SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;

<=> é a distância de cosseno, <-> é a distância L2 (euclidiana) e <#> é o produto interno negativo. Utilize a métrica para a qual o seu modelo de embeddings foi treinado. Se escolher a métrica errada, não ocorre nenhum erro; os resultados ficam apenas silenciosamente piores.

Sem um índice, essa consulta é uma pesquisa exata em todas as linhas. É a versão do Postgres da pesquisa por força bruta apresentada acima e tem a mesma recuperação perfeita. Aumentar max_parallel_workers_per_gather disponibiliza mais núcleos para a operação. Faça isto primeiro e crie o índice depois, porque assim terá uma referência de recuperação contra a qual pode medir o índice.

Qdrant, quando o índice cresce além da base de dados

Qdrant é um armazenamento de vetores dedicado, escrito em Rust. Faz sentido quando o índice é suficientemente grande para não querer que a sua compilação concorra com o Postgres da aplicação, ou quando precisa de filtragem de payload e quantização que o pgvector não oferece.

docker run -d --name qdrant \
  -p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
  -e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
  -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
  qdrant/qdrant

A porta 6333 disponibiliza a API REST (representational state transfer) e um painel em /dashboard, enquanto a porta 6334 disponibiliza gRPC. Há dois pontos importantes num VPS público. A documentação do Qdrant indica que o serviço é executado por predefinição "sem encriptação nem autenticação", e o -p 6333:6333 do início rápido faz bind a todas as interfaces. O Docker publica essa porta para além de uma regra do ufw porque cria as suas próprias regras de encaminhamento. Faça bind a 127.0.0.1 e defina uma chave de API. Uma instância do Qdrant acessível através de um IP público sem chave é uma cópia pública dos seus documentos.

curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"

Uma resposta correta tem o formato {"result":{"collections":[]},"status":"ok","time":0.00002}. Receber {"status":{"error":"Unauthorized"}} significa que o nome do cabeçalho ou a chave está errado. Não receber resposta significa que o contentor não está em execução ou está associado a outro endereço. Saber se esse contentor tem a configuração adequada para o seu servidor é a questão habitual dos serviços com estado. Por isso, a comparação entre a base de dados Docker e a base de dados no host aplica-se aqui sem alterações.

Chroma e para que serve

Chroma e o caminho mais curto entre o zero e uma demonstracao funcional de retrieval.

pip install chromadb
chroma run --path /srv/chroma

Isso disponibiliza o servico na porta 8000, e chromadb.HttpClient(host="localhost", port=8000) liga-se a ele. Chroma inclui uma funcao de embeddings predefinida, por isso um primeiro prototipo nao precisa de um servidor de modelos separado.

Seja claro sobre a troca. Chroma e pratico porque oculta as decisoes abordadas neste guia: qual metrica de distancia usar e quanta RAM o resultado vai ocupar. Isso e adequado para um prototipo, mas nao para o sistema sobre o qual recebera um alerta. Se os seus dados ja estiverem no Postgres, move-los para o Chroma adiciona um processo e um problema de sincronizacao para resolver um problema que o pgvector nao tem.

Quanta RAM o índice vai precisar

Comece pelos vetores brutos, porque eles definem o limite mínimo e nenhum ajuste os altera.

bytes = number_of_vectors * dimensions * 4

São 4 bytes por dimensão, correspondentes a um float de 32 bits. A documentação de planeamento de capacidade do Qdrant acrescenta um multiplicador de 1.5 para os metadados e para os segmentos temporários criados durante a otimização:

memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5

A fórmula aplicada a 1 milhão de vetores, com as dimensões produzidas pelos modelos de embeddings reais, fica assim:

ChartRAM for 1 million vectors, by embedding dimension
The data behind this chart
[
  {
    "label": "384 dims",
    "raw_gib": 1.43,
    "with_overhead_gib": 2.15
  },
  {
    "label": "768 dims",
    "raw_gib": 2.86,
    "with_overhead_gib": 4.29
  },
  {
    "label": "1024 dims",
    "raw_gib": 3.81,
    "with_overhead_gib": 5.72
  },
  {
    "label": "1536 dims",
    "raw_gib": 5.72,
    "with_overhead_gib": 8.58
  },
  {
    "label": "3072 dims",
    "raw_gib": 11.44,
    "with_overhead_gib": 17.17
  }
]

Estes valores são o resultado da fórmula em gibibytes (GiB), não uma medição. Interprete-os como o espaço que tem de reservar na memória. Um modelo de 768 dimensões, como nomic-embed-text, aplicado a 1 milhão de chunks, precisa de cerca de 4.29 GiB. Isto cabe num plano de 8 GB, deixando espaço para o Postgres. O mesmo corpus com 3072 dimensões precisa de 17.17 GiB e não cabe.

A variável mais importante é a primeira coluna do gráfico, não a última. Reduzir a dimensão para metade reduz para metade todos os bytes dependentes dela. Isto mantém-se em todas as etapas seguintes. Num VPS, um modelo de 768 dimensões que obtém uma pontuação ligeiramente inferior numa tabela de classificação pública é muitas vezes a melhor escolha de engenharia. O tipo halfvec do pgvector armazena então floats de 16 bits, reduzindo novamente os bytes para metade, e o índice é criado através de uma expressão:

CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);

Há um limite que deve conhecer antes de escolher um modelo. O tipo vector do pgvector aceita até 16,000 dimensões, mas os índices HNSW e IVFFlat suportam apenas 2,000. Acima desse valor, crie o índice sobre um cast para halfvec, que suporta até 4,000 dimensões, ou não crie qualquer índice.

O custo de m e ef_construction durante a criação

HNSW (hierarchical navigable small world) é o índice que tanto o pgvector como o Qdrant utilizam. É um grafo em camadas. Cada vetor é um nó com ligações para nós próximos, e a pesquisa percorre essas ligações em direção à consulta em vez de ler tudo.

m indica quantas ligações cada nó mantém. A documentação do Faiss calcula a memória do HNSW como (d * 4 + m * 2 * 4) bytes por vetor e recomenda manter m entre 4 e 64. Considere esse valor com 768 dimensões e um milhão de vetores.

ChartCost of raising m at 768 dimensions, 1 million vectors
The data behind this chart
[
  {
    "label": "m = 8",
    "link_bytes_per_vector": 64,
    "total_gib": 2.92
  },
  {
    "label": "m = 16 (default)",
    "link_bytes_per_vector": 128,
    "total_gib": 2.98
  },
  {
    "label": "m = 32",
    "link_bytes_per_vector": 256,
    "total_gib": 3.1
  },
  {
    "label": "m = 64",
    "link_bytes_per_vector": 512,
    "total_gib": 3.34
  }
]

A diferença de tamanho é significativa. Passar do valor predefinido m = 16 para m = 64 adiciona 512 bytes de ligações por vetor, face aos 3072 bytes dos dados do vetor. Assim, o total passa de 2.98 GiB para 3.34 GiB. Isso representa cerca de 12 por cento. Com estas dimensões, m não é o principal consumidor de memória. Os vetores são.

O custo real de m está no tempo de criação e de inserção, porque colocar um nó exige encontrar e ligar esse número de vizinhos. ef_construction define o tamanho da lista de candidatos que o criador considera ao colocar cada nó. Aumentar esse valor produz um grafo melhor e uma criação mais lenta. O tamanho final do índice não é alterado.

SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

maintenance_work_mem é a definição que determina se a criação demora minutos ou horas, porque o pgvector monta o grafo na memória quando ele cabe. Quando deixa de caber, o pgvector informa:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.
HINT:  Increase maintenance_work_mem to speed up builds.

Essa mensagem é a linha mais útil apresentada pelo pgvector. Significa que a criação passou para um caminho muito mais lento. Cancele-a, aumente a definição para um valor acima da quantidade de RAM calculada anteriormente e inicie novamente. Monitorize o progresso a partir de outra sessão:

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;

O HNSW apresenta initializing e depois loading tuples. Uma criação que permanece numa percentagem baixa durante muito tempo, num disco que não está ocupado, indica o problema maintenance_work_mem, não uma consulta bloqueada.

Há dois factos importantes para o planeamento. O README do pgvector afirma que o HNSW "tem tempos de criação mais lentos e utiliza mais memória" do que o IVFFlat. Em contrapartida, oferece um compromisso melhor entre velocidade e recall. Além disso, pode criar um HNSW numa tabela vazia. O IVFFlat precisa de executar k-means sobre dados representativos antes da criação, pelo que criá-lo numa tabela vazia produz um recall fraco. Num schema novo, o HNSW é o índice que pode criar antecipadamente.

ef_search: o parâmetro ajustado depois da compilação

m e ef_construction ficam fixos no índice. ef_search não. Ele define quantos candidatos a pesquisa mantém enquanto percorre o grafo. Pode alterá-lo por sessão ou por consulta.

SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;

O valor predefinido é 40. Se o aumentar, o recall aumenta e a latência também. Se o reduzir, ambos diminuem. Este é o único controlo de recall que pode alterar sem recompilar o índice. Por isso, ajuste-o usando um conjunto fixo de consultas cujas respostas corretas já conhece. Pare quando o recall deixar de melhorar.

Há uma armadilha. ef_search interage mal com uma cláusula WHERE seletiva, porque o índice devolve um número fixo de candidatos e o filtro é aplicado depois. Um filtro que rejeita a maioria das linhas pode deixar menos de LIMIT resultados, mesmo quando existem linhas correspondentes na tabela. O pgvector 0.8 resolve este problema com pesquisas iterativas:

SET hnsw.iterative_scan = relaxed_order;

O índice é então pesquisado novamente para obter mais candidatos até o limite ser atingido, no máximo até hnsw.max_scan_tuples, cujo valor predefinido é 20000. strict_order mantém a ordenação exata por distância e tem um custo maior. Esta funcionalidade não existe no pacote Ubuntu 0.6.0. A ausência de linhas ao usar um filtro é a forma de detetar essa limitação.

Por que o índice precisa de caber na RAM

Uma pesquisa HNSW é uma travessia num grafo. Cada salto lê um nó armazenado numa localização sem relação com a anterior, pelo que o padrão de acesso é quase aleatório e a leitura antecipada não ajuda. Enquanto o grafo está na RAM, cada salto é uma referência à memória. Quando deixa de estar, um salto pode transformar-se numa leitura do disco, e uma pesquisa que acede a algumas centenas de nós pode gerar algumas centenas de leituras.

A documentação do Qdrant resume isto numa relação concreta: "se armazenar metade dos vetores na RAM, a latência da pesquisa aproximadamente duplica." Faça o planeamento com base nessa frase.

Quando o índice realmente não couber, cada opção envolve uma cedência que deve ser escolhida de forma consciente.

  • Faça o memory-map dos vetores para que o sistema operativo coloque as páginas mais utilizadas em cache e mantenha as restantes no disco. Para que isto seja tolerável, precisa de armazenamento NVMe rápido.
  • Quantize os vetores, armazenando cada dimensão num byte em vez de quatro. Isto reduz os bytes dos vetores para um quarto, com um custo de recall pequeno, mas mensurável.
  • Converta para halfvec no pgvector, reduzindo os bytes para metade com uma perda de recall menor do que na quantização para um byte.
  • Gere embeddings com um modelo mais pequeno. Esta é a correção mais barata e a que costuma ser ignorada, porque implica gerar novamente os embeddings do corpus.

Modos de falha e as mensagens que verá

could not open extension control file. O pacote pgvector correspondente à versão principal em execução do Postgres não está instalado. Mostre a versão com sudo -u postgres psql -tAc 'SHOW server_version' e instale o postgresql-NN-pgvector correspondente.

ERROR: expected 768 dimensions, not 1536. O tipo da coluna e o modelo não correspondem. Mudou os modelos de embeddings e não voltou a gerar os embeddings. Não existe uma correção parcial neste caso, porque os vetores de dois modelos diferentes não são comparáveis, pelo que todas as linhas têm de ser regeneradas.

A consulta é lenta e EXPLAIN mostra uma sequential scan. A operator class do índice e o operador da consulta não correspondem. vector_cosine_ops só funciona com <=>. Execute EXPLAIN ANALYZE sobre a consulta e procure Index Scan using ... on chunks. Se vir Seq Scan on chunks, recrie o índice com a operator class correspondente ao operador que realmente utiliza na consulta.

Menos linhas do que LIMIT e existe uma cláusula WHERE. Essa é a armadilha de filtragem descrita acima. Aumente hnsw.ef_search ou atualize para pgvector 0.8 e defina hnsw.iterative_scan.

A criação do índice termina com o processo encerrado e sem erro em psql. Definir maintenance_work_mem para a maior parte da memória da máquina, enquanto shared_buffers e a aplicação também precisam de memória, faz o processo terminar no out-of-memory killer do kernel. sudo dmesg -T | grep -i 'killed process' mostra a linha que identifica postgres. Reduza essa definição ou crie o índice num plano maior e restaure o dump.

Escolha

Se já utiliza Postgres e tem menos de alguns milhões de vetores, use pgvector. O índice fica junto dos dados, a filtragem é uma cláusula WHERE e as cópias de segurança existentes já o incluem. Se o índice for suficientemente grande para justificar um limite de memória próprio, ou se precisar de filtragem intensa dos payloads, execute o Qdrant ao lado e aceite a necessidade de operar um segundo serviço.

Abaixo de aproximadamente cem mil vetores, meça a pesquisa por força bruta antes de instalar qualquer coisa. Uma pesquisa exaustiva, com recall perfeito e sem etapa de criação do índice, não é um compromisso nessa escala. É a opção correta. Escolher um índice aproximado significa assumir afinação e pressão sobre a RAM em troca de milissegundos que não estavam a ser gastos.

FAQ

Preciso de uma base de dados vetorial dedicada ou o Postgres é suficiente?

Se os seus dados já estão no Postgres, o pgvector é suficiente durante muito mais tempo do que a maioria das comparações sugere. Os vetores ficam armazenados numa coluna normal, por isso uma pesquisa filtrada usa uma cláusula WHERE e as cópias de segurança existentes também abrangem o índice. Migre para um armazenamento dedicado, como o Qdrant, quando a carga vetorial precisar do seu próprio limite de memória ou quando precisar de filtragem de payload e quantização que o pgvector não disponibiliza.

Quantos vetores cabem num VPS?

Calcule em vez de adivinhar, usando number_of_vectors * dimensions * 4 bytes * 1.5. Um milhão de vetores com 768 dimensões ocupa aproximadamente 4.3 GiB, por isso um plano de 8 GB acomoda essa carga e ainda deixa espaço para o Postgres. Um milhão de vetores com 3072 dimensões ocupa aproximadamente 17 GiB e requer um plano muito maior. O fator que mais influencia este valor é a dimensão do seu modelo de embeddings. Escolha esse modelo tendo em conta o consumo de memória.

Porque é que a minha pesquisa vetorial é lenta quando o índice está na mesma máquina?

A rede não é o problema quando tudo está no mesmo servidor. Analise os dois fatores relevantes. Primeiro, cronometre a chamada de embeddings isoladamente. Gerar o vetor da consulta na CPU demora muitas vezes muito mais do que a própria pesquisa. Segundo, confirme que o índice está na RAM. Uma pesquisa HNSW percorre aleatoriamente um grafo. Quando o grafo passa a ocupar disco, cada salto pode tornar-se uma leitura do disco. As orientações do próprio Qdrant indicam que reduzir para metade os vetores mantidos na RAM aproximadamente duplica a latência da pesquisa.

Devo criar um índice HNSW?

Não com menos de aproximadamente cem mil vetores. Uma pesquisa exaustiva lê n * d * 4 bytes por consulta. Isso corresponde a 307 MB com 100,000 vetores de 768 dimensões. Uma CPU moderna processa esses dados em dezenas de milissegundos, com recall perfeito e sem etapa de criação do índice. Meça primeiro a pesquisa no seu próprio hardware. Crie o índice quando o tempo medido da pesquisa for realmente demasiado elevado, não apenas porque um artigo de benchmark o recomendou.

Qual é o custo real de aumentar m?

O custo recai sobretudo no tempo de criação e de inserção, muito mais do que na memória. Com 768 dimensões, passar do valor predefinido m = 16 para m = 64 acrescenta 512 bytes de ligações do grafo por vetor, face a 3072 bytes de dados vetoriais. Assim, a memória total aumenta aproximadamente 12 por cento. Cada inserção, contudo, tem de encontrar e ligar quatro vezes mais vizinhos. Ajuste ef_search primeiro, porque pode alterá-lo sem custo e sem recriar o índice.

#vector-database#rag#pgvector#qdrant#self-hosting