~/ / Artigos sobre IA / LLMs locais em uma VPS / Guarde e pesquise embeddings do Ollama no PostgreSQL com o pgvector
Guarde e pesquise embeddings do Ollama no PostgreSQL com o pgvector
Os embeddings do /api/embed do Ollama só servem para buscas depois de guardados em algum lugar. O pgvector é uma extensão de código aberto que adiciona ao PostgreSQL a busca por similaridade de vetores, de modo que os vetores podem ficar no mesmo banco de dados que os textos e o restante dos seus dados, no mesmo servidor. Este artigo explica como instalá-lo, criar a tabela, escolher a distância e o índice, e as configurações que mudam os resultados, com base no README do pgvector para a versão 0.8.7. Em seguida, ele passa os resultados da busca para o /api/chat do Ollama para que a resposta indique suas fontes, e verifica essas citações. Executamos o SQL no pgvector 0.8.7 com PostgreSQL 16, usando vetores reais do Ollama, e as requisições de chat no Ollama 0.40.11 com qwen2.5:7b. Quatro ferramentas desta página escrevem o SQL e a requisição e verificam a resposta no seu navegador.
Publicado: · 11 min de leitura · Fatos verificados no README e no código-fonte do pgvector na versão 0.8.7 e na documentação e no código-fonte do Ollama (0.40.11) em 2026-11-05. Consulte a documentação atual antes de confiar neles.
O que o pgvector adiciona ao PostgreSQL
O README do pgvector o descreve como busca por similaridade de vetores de código aberto para o Postgres. Ele oferece suporte a busca exata e aproximada de vizinhos mais próximos; vetores de precisão simples, meia precisão, binários e esparsos; e distância L2, produto interno, distância de cosseno, distância L1, distância de Hamming e distância de Jaccard. Como os vetores são colunas comuns do PostgreSQL, eles também contam com conformidade ACID, recuperação em um ponto no tempo e JOINs.
Para uma busca nos seus documentos, você guarda uma linha por trecho com o texto e o vetor, gera o embedding da pergunta com o mesmo modelo e ordena as linhas pela distância até o vetor da pergunta.
Instalação
O pgvector é compatível com o PostgreSQL 13 e posteriores. O README lista várias formas de instalá-lo, entre elas estas:
| Método | O que o README diz |
|---|---|
| Compilar a partir do código-fonte | git clone --branch v0.8.7, depois make e make install (pode ser necessário sudo) |
| Docker | A imagem pgvector/pgvector adiciona o pgvector à imagem do Postgres. Tags como pg18-trixie ou 0.8.7-pg16 escolhem a versão do Postgres. |
| APT (Debian, Ubuntu) | sudo apt install postgresql-18-pgvector a partir do repositório APT do PostgreSQL, trocando 18 pela sua versão do Postgres |
| Homebrew | brew install pgvector, que só o adiciona às fórmulas postgresql@18 e postgresql@17 |
Depois, ative-o uma vez em cada banco de dados em que quiser usá-lo, com CREATE EXTENSION vector;. Para este artigo usamos a imagem do Docker pgvector/pgvector:0.8.7-pg16.
Criar a tabela
Uma coluna do tipo vector(n) guarda vetores com exatamente n dimensões, e n deve ser o comprimento dos vetores do seu modelo de embeddings. Um vetor com outro comprimento é rejeitado: quando inserimos um vetor com 2 dimensões em uma coluna vector(3), o PostgreSQL retornou "expected 3 dimensions, not 2". O all-minilm retorna 384 dimensões, então sua coluna é vector(384).
O README informa que cada vetor ocupa 4 × dimensões + 8 bytes, com cada elemento sendo um número de ponto flutuante de precisão simples, e permite até 16,000 dimensões em uma coluna. Um índice aceita menos dimensões, como mostrado abaixo. O tipo halfvec guarda valores de meia precisão em 2 × dimensões + 8 bytes.
| Dimensões | vector | halfvec |
|---|---|---|
| 384 | 1,544 bytes | 776 bytes |
| 768 | 3,080 bytes | 1,544 bytes |
| 1024 | 4,104 bytes | 2,056 bytes |
Na nossa tabela de teste, pg_column_size deu 1,544 bytes para vector(384) e 776 bytes para halfvec(384), de acordo com a fórmula. Isso é apenas o vetor; a coluna de texto e os índices ocupam espaço adicional.
Carregar os vetores do Ollama
O pgvector lê um vetor como texto entre colchetes, como '[1,2,3]', e o README diz que todos os elementos devem ser finitos (sem NaN, Infinity nem -Infinity). Cada array do campo embeddings de uma resposta de /api/embed já tem essa forma, e a resposta mantém a ordem da entrada, então o primeiro texto corresponde ao primeiro vetor.
O README mostra INSERT para poucas linhas, INSERT … ON CONFLICT para upserts e COPY para carga em massa. O conversor abaixo emparelha seus textos com a resposta e escreve instruções INSERT com as aspas escapadas. Executamos a saída dele com textos contendo aspas, barras invertidas, quebras de linha e emoji, e eles voltaram sem alterações.
Experimente: Embeddings to pgvector INSERT
O exemplo usa vetores inventados, de comprimento unitário e com 4 dimensões para que dê para lê-los. Embeddings reais têm centenas ou milhares de dimensões.
3 linhas com 4 dimensões. Textos lidos como o corpo da requisição do /api/embed, vetores como uma resposta do Ollama /api/embed.
Instruções INSERT
Execute no psql ou em outro cliente. As linhas são inseridas em uma única transação.
BEGIN;
INSERT INTO documents (content, embedding) VALUES
('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');
COMMIT;Formato de vetor do README do pgvector, verificado com o pgvector 0.8.7 em 2026-11-04. README do pgvector
Tudo é feito no seu navegador. A ferramenta escreve SQL e não se conecta a nada.
Escolher a distância
A documentação do Ollama recomenda a similaridade de cosseno para a maioria dos casos de busca semântica e diz que o /api/embed retorna vetores com comprimento 1. Para esses vetores, a distância de cosseno, a distância L2 e o produto interno colocam as linhas na mesma ordem, porque a distância L2 ao quadrado é 2 menos 2 vezes a similaridade de cosseno. No nosso teste com o all-minilm, as três primeiras linhas foram as mesmas com as três, e a distância L2 de 0.931 coincidiu com a similaridade de cosseno de 0.566.
Cada distância tem o seu próprio operador. Uma busca ordena pelo operador e pega as primeiras linhas com LIMIT, e um índice é criado para uma classe de operador.
| Operador | Retorna | Classe de operador do índice |
|---|---|---|
| <=> | Distância de cosseno. A similaridade de cosseno é 1 - (a <=> b). | vector_cosine_ops |
| <-> | Distância L2 (euclidiana) | vector_l2_ops |
| <#> | O produto interno negativo. Multiplique por -1 para obter o produto interno. | vector_ip_ops |
| <+> | Distância L1 | vector_l1_ops |
O README explica que <#> retorna o produto interno negativo porque o Postgres só oferece suporte a varreduras de índice em ordem ASC para operadores. Para halfvec, as classes de operador são halfvec_cosine_ops, halfvec_l2_ops e assim por diante.
Busca exata ou com índice
Sem índice, o pgvector faz uma busca exata de vizinhos mais próximos, que segundo o README oferece recall perfeito. Um índice aproximado troca um pouco de recall por velocidade, e o README avisa que as consultas podem retornar resultados diferentes depois que você adiciona um.
Um índice só é usado quando a consulta tem ORDER BY e LIMIT e o ORDER BY é o próprio operador de distância em ordem crescente. ORDER BY embedding <=> $1 LIMIT 5 pode usar o índice; ORDER BY 1 - (embedding <=> $1) DESC não pode. O README também observa que uma varredura da tabela pode ser mais rápida quando a tabela é pequena.
| Ponto | HNSW | IVFFlat |
|---|---|---|
| Como funciona | Um grafo de várias camadas | Divide os vetores em listas e pesquisa as listas mais próximas da consulta |
| Em comparação com o outro | Melhor equilíbrio entre velocidade e recall, mas mais lento para construir e usa mais memória | Mais rápido para construir e usa menos memória, mas com um equilíbrio pior entre velocidade e recall |
| Quando criar | Pode ser criado em uma tabela vazia (sem etapa de treinamento) | Depois que a tabela tiver alguns dados |
| Opções de construção | m (16 por padrão) e ef_construction (64 por padrão) | lists: comece com rows / 1000 até 1M linhas, e com sqrt(rows) acima disso |
| Opção de busca | hnsw.ef_search, 40 por padrão | ivfflat.probes, 1 por padrão; comece com sqrt(lists) |
| Dimensões | vector até 2,000, halfvec até 4,000 | vector até 2,000, halfvec até 4,000 |
No pgvector 0.8.7 também confirmamos que uma coluna vector(2001) não pode ter um índice HNSW, que o IVFFlat não tem classe de operador para a distância L1 e que ef_construction deve ser pelo menos 2 × m. Para mais dimensões, o FAQ do README lista halfvec, a quantização binária e outras opções.
Experimente: pgvector SQL Builder
Cada vetor ocupa 1,544 bytes, então 10,000 linhas ocupam cerca de 15.4 MB só com os vetores (sem contar índices e outras colunas).
1. Extensão e tabela
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE documents ( id bigserial PRIMARY KEY, content text NOT NULL, embedding vector(384) NOT NULL );
2. Índice
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
3. Busca
PREPARE recebe como $1 o vetor da pergunta obtido de /api/embed. Passe-o como texto, por exemplo '[0.01,-0.02,…]'.
PREPARE search_documents(vector(384)) AS
SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
-- EXECUTE search_documents('[0.0123, -0.0456, ...]');Limites e padrões do README e do código-fonte do pgvector 0.8.7, verificados em 2026-11-04. README do pgvector
Tudo é feito no seu navegador. A ferramenta escreve SQL e não se conecta a nada.
Por que uma busca pode devolver menos linhas que LIMIT
Com HNSW, os resultados são limitados por hnsw.ef_search, que é 40 por padrão. Em uma tabela de teste de 200 linhas com um índice HNSW, LIMIT 100 devolveu 40 linhas; depois de SET hnsw.ef_search = 100, devolveu 100. Defina hnsw.ef_search com pelo menos o número de linhas de que você precisa. SET LOCAL dentro de uma transação o altera apenas para uma consulta.
A filtragem também reduz os resultados. Com índices aproximados, o README diz que a condição WHERE é aplicada depois que o índice é percorrido: se uma condição corresponde a 10% das linhas, o HNSW com o hnsw.ef_search padrão de 40 devolve apenas 4 linhas em média. Desde a versão 0.8.0, as varreduras iterativas do índice (SET hnsw.iterative_scan = strict_order, ou relaxed_order) percorrem mais do índice até encontrar linhas suficientes, até hnsw.max_scan_tuples (20,000 por padrão).
O README acrescenta que vetores NULL não são indexados, nem os vetores zero para a distância de cosseno.
Passar os resultados ao /api/chat com fontes numeradas
Quando a busca devolve as linhas mais próximas, o modelo precisa delas no prompt. O /api/chat do Ollama recebe uma lista de mensagens, cada uma com um papel (system, user, assistant ou tool) e o seu content. Uma organização simples é uma mensagem system com as regras e uma mensagem user que lista as linhas, cada uma com um número, como [1] e [2], seguida da pergunta. Com os números, a resposta pode dizer quais linhas usou, e você pode associar cada número à sua linha.
Para obter as citações em um formato fixo, dê ao campo format um JSON Schema com a resposta e uma lista de citações, cada uma com o número da fonte e um trecho citado curto. A página de saídas estruturadas do Ollama recomenda escrever o esquema também no prompt e baixar a temperatura (temperature), por exemplo para 0, para obter uma saída mais determinística. No psql, a função json_agg reúne as linhas de uma consulta em um array JSON, que a ferramenta abaixo lê diretamente.
Experimente: RAG Prompt Builder
As fontes do exemplo foram escritas à mão no formato que o json_agg devolve.
3 fontes, lidas como um array JSON de objetos.
Comando curl
Envia a requisição ao Ollama nesta máquina (localhost:11434).
curl http://localhost:11434/api/chat -d '{
"model": "qwen2.5:7b",
"messages": [
{
"role": "system",
"content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
},
{
"role": "user",
"content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "integer"
},
"quote": {
"type": "string"
}
},
"required": [
"source",
"quote"
]
}
}
},
"required": [
"answer",
"citations"
]
},
"options": {
"temperature": 0
},
"truncate": false
}'Corpo da requisição (JSON)
{
"model": "qwen2.5:7b",
"messages": [
{
"role": "system",
"content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
},
{
"role": "user",
"content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "integer"
},
"quote": {
"type": "string"
}
},
"required": [
"source",
"quote"
]
}
}
},
"required": [
"answer",
"citations"
]
},
"options": {
"temperature": 0
},
"truncate": false
}Na resposta, o texto está em message.content. Se prompt_eval_count for muito menor do que você espera, o prompt pode ter sido cortado.
Campos da requisição conforme a referência da API e o código-fonte do Ollama, conferidos no Ollama 0.40.11 em 2026-11-05. Ollama API: /api/chat
Tudo roda no seu navegador. A ferramenta escreve a requisição e não se conecta a nada.
Quando as fontes não cabem, o Ollama corta o início do prompt
As regras, as linhas e a pergunta precisam caber no tamanho do contexto. A página sobre o tamanho do contexto do Ollama indica um padrão de 4k tokens com menos de 24 GiB de VRAM, e o FAQ indica 4096 tokens; ollama ps na nossa máquina de teste mostrou 4096. num_ctx em options ou a variável de ambiente OLLAMA_CONTEXT_LENGTH o altera.
Quando o prompt é mais longo, o /api/chat não devolve um erro por padrão. No código-fonte do Ollama (0.40.11), o servidor primeiro descarta mensagens anteriores inteiras, mantendo as mensagens system e a última mensagem. Se o prompt restante ainda for longo demais, ele mantém os primeiros tokens (num_keep, 4 por padrão) e o final do prompt e descarta a parte do meio. No nosso teste com qwen2.5:7b e num_ctx 1024, um prompt de 2,068 tokens foi cortado para 514. A mensagem system e as primeiras fontes foram perdidas: com a resposta na fonte [1], o modelo respondeu com uma palavra-código que não estava em nenhuma fonte, e com a resposta na última fonte, respondeu corretamente, mas sem o número da fonte que a mensagem system pedia. A resposta não continha nenhum erro; o log do servidor tinha o aviso "truncating input prompt".
O código-fonte do Ollama também tem um campo truncate para requisições de chat, que não consta na página do /api/chat. Com "truncate": false, a mesma requisição devolveu HTTP 400 com o erro "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it". O campo prompt_eval_count de uma resposta é o número de tokens do prompt; no teste com o corte, foi 514, não 2,068.
Como o final do prompt é mantido, coloque a pergunta depois das fontes. Para saber quando as fontes não cabem, envie "truncate": false e depois aumente num_ctx ou envie menos linhas. Um num_ctx maior precisa de mais memória.
Verificar as citações
Uma citação pode ser verificada de forma mecânica: o número deve se referir a uma fonte que foi enviada, e o trecho citado deveria aparecer nessa fonte. No nosso teste com quatro fontes e qwen2.5:7b, os trechos citados nas respostas em JSON coincidiam palavra por palavra com as fontes. Isso não prova que a resposta esteja certa: ao perguntar o tamanho de um vetor de 384 dimensões, o modelo citou a regra correta (4 vezes as dimensões mais 8 bytes) e mesmo assim respondeu 1552 bytes em vez de 1,544.
Olhe também as respostas sem citações. Quando as fontes não continham a resposta, o modelo uma vez respondeu com o próprio conhecimento e sem citações, e outra vez disse que não sabia, mas ainda assim citou uma fonte sem relação. Se done_reason na resposta for "length", a resposta foi cortada por num_predict e pode estar sem as citações.
O verificador abaixo compara apenas texto. Se uma fonte realmente sustenta uma frase ainda precisa ser julgado por uma pessoa.
Experimente: RAG Citation Checker
A resposta do exemplo foi escrita à mão para mostrar um trecho citado que não está na sua fonte.
4 fontes (um array JSON de strings). Resposta lida como uma resposta do /api/chat do Ollama, na forma de JSON com citações.
Tokens do prompt (prompt_eval_count): 306
- O trecho citado da citação #2 não está na fonte [4].
Citações
| Fonte | Trecho citado | Resultado |
|---|---|---|
| [2] | hnsw.ef_search is 40 by default | Encontrado exatamente |
| [4] | 4 bytes per dimension plus 8 | Não está nesta fonte |
Isto só verifica se os números e os trechos coincidem com as fontes. Não consegue dizer se a resposta está correta nem se uma fonte realmente sustenta uma frase.
Formato da resposta conforme a referência da API e o código-fonte do Ollama, conferido no Ollama 0.40.11 em 2026-11-05. Ollama API: /api/chat
Tudo roda no seu navegador. A ferramenta não se conecta a nada.
Memória em um servidor pequeno
O README diz que um índice HNSW é construído de forma significativamente mais rápida quando o grafo cabe em maintenance_work_mem, mostra um aviso quando ele deixa de caber e alerta para não definir maintenance_work_mem tão alto a ponto de esgotar a memória do servidor. Como com outros índices, é mais rápido criar o índice depois de carregar os dados iniciais. Os índices não precisam caber na memória, mas provavelmente você verá um desempenho melhor se couberem; SELECT pg_size_pretty(pg_relation_size('index_name')) mostra o tamanho de um índice.
Para um conjunto de trabalho menor e índices menores, o README sugere halfvec, indexação de meia precisão ou quantização binária. Se o Ollama roda no mesmo servidor, o PostgreSQL e os modelos compartilham a mesma memória; nosso artigo sobre como acompanhar a memória do Ollama explica o que determina quanto os modelos usam.
Fontes
- pgvector README (v0.8.7)
- pgvector source: HNSW limits (src/hnsw.h)
- pgvector source: IVFFlat limits (src/ivfflat.h)
- Ollama: Embeddings
- Ollama API: Generate embeddings (/api/embed)
- PostgreSQL documentation: Lexical Structure (string constants)
- Ollama API: Generate a chat message (/api/chat)
- Ollama: Structured Outputs
- Ollama: Context length
- Ollama FAQ (context window size)
- Ollama source v0.40.11: server/prompt.go (dropping earlier messages)
- Ollama source v0.40.11: llm/llama_server.go (truncating the prompt)
- Ollama source v0.40.11: api/types.go (truncate, num_keep)
- PostgreSQL documentation: Aggregate Functions (json_agg)
Leia a seguir
- Embeddings com o Ollama: o básico para busca e RAGCrie embeddings com o /api/embed do Ollama: os modelos e seus tamanhos, truncate e dimensions, vetores de comprimento unitário e similaridade de cosseno, segundo a documentação oficial.
- Acompanhe a memória do Ollama em uma VPS pequenaVeja o que o Ollama mantém na memória, por quanto tempo e quais configurações mudam isso: ollama ps, keep_alive e os limites, a partir do FAQ oficial.
- Conectar o Dify ao Ollama no seu próprio VPSExecute o Dify no seu próprio VPS e use o Ollama como modelo: instalação, campos do plugin, o problema do endereço no Docker e embeddings, segundo a documentação oficial.
- Rodar um LLM local sem um PC caro: guia para iniciantes sobre como usar uma VPSSem um PC potente? Alugue uma VPS e rode um pequeno LLM local pelo navegador: seis passos, da estimativa de memória até uma tela de chat protegida, com os limites ditos com honestidade.