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

Como criar um pipeline RAG no seu próprio VPS

Aprenda a dividir, gerar embeddings e buscar documentos no PostgreSQL com pgvector, HNSW e Ollama, usando SQL para validar a recuperação no próprio VPS.

Como é um pipeline RAG autoalojado

Um pipeline RAG (geração aumentada por recuperação) tem cinco etapas: dividir os documentos em segmentos, gerar embeddings dos segmentos, armazenar os vetores, recuperar os mais próximos para uma pergunta e enviar esses segmentos para um modelo de linguagem que escreve a resposta. Num VPS que já arrenda, as primeiras quatro etapas executam-se no próprio servidor. O PostgreSQL com a extensão pgvector armazena os vetores, e um pequeno modelo de embeddings servido pelo Ollama transforma o texto em vetores. Apenas a última etapa tem de sair do servidor.

Essa separação é o argumento deste guia. A divisão em segmentos é uma operação normal de CPU. A geração de embeddings usa um modelo com 137 milhões de parâmetros, que ocupa algumas centenas de megabytes de RAM. O armazenamento é uma tabela do Postgres cujo tamanho pode calcular antes de inserir uma única linha. Para um corpus com centenas de milhares de segmentos, tudo isso funciona num VPS normal. A geração é diferente, porque tem um custo em cada pergunta, para sempre.

Quais partes de um pipeline RAG custam dinheiro

O tutorial completo de RAG da DigitalOcean utiliza um banco de dados vetorial gerido e um modelo de embeddings alojado. A secção sobre custos é qualitativa: coloque as consultas repetidas em cache, mantenha pequeno o número de chunks recuperados e faça reranking antes da geração. Esse conselho está correto. Também ignora a opção que altera a aritmética: executar o modelo de embeddings no servidor pelo qual já está a pagar.

Conte tokens em vez de dólares, porque as contagens de tokens não ficam desatualizadas quando uma tabela de preços muda. Considere um corpus com 100,000 chunks de 400 tokens cada, 10,000 perguntas feitas ao corpus, 8 chunks enviados ao modelo por resposta, uma pergunta e um bloco de instruções com 100 tokens e respostas com 400 tokens.

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

A geração de embeddings para todo o corpus corresponde a 40 milhões de tokens e ocorre uma vez. Distribuído pelas 10,000 perguntas, isso corresponde a 4,000 tokens por pergunta. Se fizer cem mil perguntas, o valor baixa para 400. A geração nunca baixa. Em todas as perguntas que responder, o custo será de 3,300 tokens de entrada e 400 tokens de saída.

Por isso, o custo acompanha a etapa que se repete. Execute internamente a geração de embeddings, porque paga por ela uma vez e o VPS já está em execução. Compre a geração, porque é nessa etapa que um modelo melhor justifica um custo real. O armazenamento em cache é importante pelo mesmo motivo: um acerto no cache ignora a única etapa cujo custo nunca é amortizado. A diferença entre um cache KV e um cache de prompt determina que metade desse processamento pode reutilizar. Um prompt RAG tem um bloco de instruções estável seguido por um bloco de chunks variável, que é a estrutura que mais beneficia do cache.

Chunking: por que o tamanho fixo com sobreposição é a opção padrão correta

Um chunk é a unidade que você recupera, portanto o tamanho dele determina tudo o que vem depois. Ele precisa ser pequeno o suficiente para que o embedding represente aproximadamente uma coisa, porque um embedding é um único ponto no espaço. Assim, um chunk que abrange quatro tópicos fica entre eles e próximo de nenhum deles. Ele também precisa ser grande o suficiente para responder sozinho a uma pergunta, porque o modelo de linguagem vê o chunk, e não o documento ao redor dele.

Comece com 300 palavras e uma sobreposição de 50 palavras. O inglês usa aproximadamente 1.3 tokens por palavra, portanto 300 palavras correspondem a cerca de 400 tokens. A sobreposição existe porque, caso contrário, uma frase que caia numa fronteira será dividida ao meio, e nenhuma das metades responderá à pergunta.

Divida primeiro pela estrutura quando os documentos tiverem estrutura. Separe pelos headings e depois pelos parágrafos. Aplique a regra de tamanho fixo apenas dentro de uma seção que ainda seja longa demais. Um chunk que começa no meio de uma frase fica ruim na resposta final, porque o modelo repete o conteúdo que recebeu.

Não ajuste o chunking antes de conseguir medi-lo. O tamanho fixo com sobreposição é determinístico e barato de executar novamente, o que faz dele uma baseline que você pode superar. Primeiro crie a scoring query mais abaixo. Depois altere uma coisa de cada vez.

Integração no mesmo servidor e o custo em RAM e latência

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text tem 137 milhões de parâmetros e ocupa 274 MB para descarregar em agosto de 2026. Verifique o que devolve antes de definir uma tabela com base nisso.

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

Isto imprime 768. O tipo da coluna tem de corresponder exatamente a esse número.

Duas definições deste modelo costumam causar problemas.

O prefixo da tarefa é obrigatório. O cartão do modelo da Nomic indica que a entrada "tem de incluir um prefixo de instrução da tarefa". Os documentos são incorporados com search_document: à frente; as perguntas, com search_query: . Se os omitir, nada falha: recebe os vetores, a qualidade da recuperação diminui e nenhuma linha de log explica o motivo.

As entradas longas são truncadas silenciosamente. O endpoint /api/embed recebe um campo truncate, cujo valor predefinido é true, e o modelo fornecido pelo Ollama anuncia um contexto de 2K. Um bloco maior do que esse limite é cortado e incorporado na mesma, pelo que o final deixa de poder ser pesquisado. Envie "truncate": false durante os testes, para que um bloco demasiado grande falhe em vez de passar.

Agrupe os pedidos e mantenha o modelo residente.

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input aceita uma lista, e um pedido com 32 blocos é mais rápido do que 32 pedidos, porque a ida e volta HTTP e a procura do modelo acontecem uma vez, e não 32 vezes. keep_alive controla durante quanto tempo o modelo permanece na memória depois de um pedido; o valor predefinido é 5 minutos. Quando esse período termina, o pedido seguinte volta a pagar o tempo de carregamento.

Meça os dois valores relevantes no seu próprio servidor. Eles dependem do número de vCPUs, por isso nenhum valor publicado será igual.

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps mostra o tamanho residente do modelo carregado. Essa é a RAM comprometida enquanto keep_alive o mantiver na memória. Divida o valor de time pelo tamanho do lote para obter os segundos por bloco. Multiplique pelo número de blocos para obter o custo total de indexação. Num plano apenas com CPU, espere que um corpus com 100,000 blocos demore horas, e não minutos. Isso é aceitável porque acontece uma vez e pode ser executado durante a noite com nice -n 19. Se várias horas não forem aceitáveis, a questão é saber se alugar uma GPU compensa, o que exige um cálculo de ponto de equilíbrio face aos tokens da API, e não uma preferência.

Se o servidor já disponibiliza um modelo de chat, o modelo de incorporação será um segundo modelo residente e a RAM será somada. Executar o Ollama numa VPS explica como dimensionar o lado da geração, e o comportamento de um modelo autoalojado com utilizadores concorrentes explica o que acontece quando várias pessoas fazem pedidos em simultâneo. O modelo de incorporação é suficientemente pequeno para funcionar ao lado de qualquer um deles.

O script de indexação, do início ao fim

No Ubuntu 24.04, um pip install simples fora de um ambiente virtual termina com error: externally-managed-environment, porque o Python do sistema pertence ao apt.

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() é da sua responsabilidade: qualquer código que percorra os seus ficheiros ou linhas e produza um ID de documento e o respetivo texto. Tudo o resto é o pipeline.

Armazenamento: o esquema do pgvector e o seu crescimento

Ubuntu 24.04 disponibiliza postgresql-16-pgvector na versão 0.6.0, que é mais antiga do que o tipo halfvec. Use o repositório do próprio projeto PostgreSQL para obter uma versão atual.

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

O número no nome do pacote tem de corresponder à versão principal do seu servidor. Em seguida, crie a role, a base de dados e a extensão.

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768) tem de corresponder à saída do modelo. Se inserir um vetor de dimensão 1024 nessa coluna, o Postgres rejeita-o com expected 768 dimensions, not 1024. Esta é a mensagem de erro mais clara de todo o pipeline. A coluna fts gerada não tem custo de manutenção e permite adicionar pesquisa por palavras-chave mais tarde.

O armazenamento é uma questão de aritmética. A documentação do pgvector indica que um vector ocupa 4 * dimensions + 8 bytes e que um halfvec ocupa 2 * dimensions + 8. As dimensões abaixo correspondem ao tamanho de saída publicado para cada modelo.

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

Com 768 dimensões, cada vetor ocupa 3,080 bytes. Assim, 100,000 chunks ocupam 294 MiB de dados vetoriais. O mesmo corpus, incorporado por um modelo alojado de 1536 dimensões, precisa de 587 MiB, e o índice correspondente cresce proporcionalmente. A precisão intermédia reduz ambos para metade: halfvec(768) armazena esse corpus em 147 MiB. A perda de recall, se existir, é determinada pela query de pontuação abaixo numa única execução.

Estes valores abrangem apenas a coluna de vetores. O texto, o overhead das linhas e os índices são adicionais. Por isso, meça a tabela real.

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

Se preferir a mesma extensão com uma API e contas de utilizador, uma stack Supabase self-hosted é PostgreSQL com pgvector já ativado, e todas as queries deste guia funcionam aí sem alterações.

Indexação: as definições de HNSW que importam

Abaixo de alguns milhares de linhas, não use o índice. A pesquisa exata lê todas as linhas, é suficientemente rápida nesse tamanho e tem recall perfeito. Adicione o índice quando a leitura sequencial deixar de ser suficientemente rápida e compreenda o compromisso: um índice aproximado devolve aproximadamente os vizinhos corretos.

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

m = 16 e ef_construction = 64 são os valores predefinidos do pgvector. Aumentá-los melhora o recall, mas aumenta o tempo de compilação e o tamanho do índice. Use vector_cosine_ops com o operador <=>, a menos que saiba que o seu modelo produz vetores de comprimento unitário, porque a distância de cosseno ignora o comprimento do vetor, enquanto o produto interno não.

Monitorize a compilação. Quando o grafo ultrapassa maintenance_work_mem, o pgvector informa:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

Isto não é um erro e a compilação termina na mesma, mas passa para um caminho muito mais lento. Aumente maintenance_work_mem na sessão que compila o índice e mantenha o valor predefinido do servidor, porque esta definição aplica-se a cada operação de manutenção e um valor global elevado pode esgotar a memória do servidor. Acompanhe uma compilação longa a partir de uma segunda sessão.

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

Depois, compare o índice concluído com a memória disponível no servidor.

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

Uma pesquisa HNSW percorre um grafo. Por isso, acede a páginas espalhadas pelo índice em vez de ler um intervalo. Um índice que não cabe na memória transforma cada consulta em leituras do disco, e é essa latência elevada que os utilizadores notam. Essa é a regra de dimensionamento do servidor: o índice e as linhas que efetivamente serve devem caber na RAM. free -m e o tamanho acima são os dois números a comparar.

No momento da consulta, hnsw.ef_search controla o recall e o valor predefinido é 40.

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

Um valor mais elevado pesquisa uma parte maior do grafo, encontra vizinhos melhores e aumenta a latência. É uma definição da sessão, por isso pode aumentá-la para uma consulta sem alterar o índice.

Se uma consulta não estiver a usar o índice, o plano mostra isso.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

Uma leitura sequencial neste caso deve-se frequentemente ao armazenamento. Um vetor com dimensão 768 ocupa 3,080 bytes, o que é superior ao tamanho que o Postgres mantém inline. Por isso, o valor é movido para a tabela TOAST, o armazenamento externo para valores demasiado grandes. A própria nota do pgvector indica que o planeador não contabiliza o armazenamento externo nas estimativas de custo. Isso pode fazer uma leitura sequencial parecer mais barata do que realmente é. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mantém os vetores inline. A definição aplica-se às linhas gravadas depois da alteração, por isso as linhas existentes precisam de uma reescrita da tabela.

Recuperação: uma consulta, dois sinais

A pesquisa vetorial encontra texto com o mesmo significado da pergunta. É fraca com cadeias exatas: um número de peça, um código de erro ou um apelido. A pesquisa por palavras-chave funciona ao contrário, e o Postgres já a suporta. Combine as duas na mesma consulta em vez de executar um segundo sistema.

A fusão recíproca de posições é o combinador mais simples que funciona. Cada resultado recebe 1 / (60 + rank) de cada lista em que aparece, e as duas pontuações são somadas. Não precisa de normalizar as pontuações, porque usa posições e não distâncias.

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 é o embedding da pergunta gerado pelo mesmo modelo, com o prefixo search_query: . $2 é a pergunta em texto. Ambos são associados pela aplicação. websearch_to_tsquery aceita uma pergunta real do utilizador sem falhar por causa da pontuação, ao contrário de to_tsquery. Há mais um aspeto importante: adicionar um filtro WHERE a uma pesquisa HNSW pode devolver menos linhas do que as pedidas, porque o índice é pesquisado primeiro e o filtro é aplicado depois. SET hnsw.iterative_scan = relaxed_order; faz o pgvector continuar a pesquisa até obter linhas suficientes.

Como saber se a recuperação está a funcionar bem?

Esta é a etapa que quase todos os guias de RAG omitem. É a única que mostra se as outras escolhas ajudaram. Não precisa de um framework de avaliação. Precisa de 30 perguntas e do id do chunk que responde a cada uma.

Escreva as perguntas manualmente. Use perguntas que as pessoas realmente fazem sobre este corpus, execute cada uma, leia o resultado e registe o id do chunk que deveria ter ficado em primeiro lugar. Trinta perguntas não resolvem diferenças pequenas. Vão detetar as diferenças importantes, porque essas são grandes.

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

Faça o embedding de cada pergunta com o prefixo search_query: , armazene-o e depois calcule a pontuação de todo o conjunto numa única query.

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10 dividido por questions é o recall at 10: a frequência com que a resposta estava dentro da janela enviada ao modelo. O MRR (mean reciprocal rank) calcula a média de 1 dividido pela posição do chunk correto e conta uma falha como zero. Assim, dá mais valor a colocar a resposta em primeiro lugar do que em oitavo. Ambos os valores mudam quando altera o tamanho dos chunks, troca o modelo de embeddings ou adiciona pesquisa por palavras-chave. Agora pode ver em que direção mudaram.

Dê prioridade ao recall at 10 acima de tudo, porque o gerador não pode usar um chunk que nunca recebeu. Quando o recall at 10 é 0.9 e as respostas continuam erradas, o problema está no prompt ou no modelo, não na recuperação. Esta separação poupa dias de tentativas.

Verifique o índice separadamente. A pesquisa aproximada reduz o recall, e o pgvector mostra em que medida: execute a mesma query com pesquisa exata e compare os ids.

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

Nove ids em dez em comum significa que ef_search está adequado. Quatro em dez significa que deve aumentá-lo.

Reranking e geração: onde uma API justifica o custo

Um reranker é um tipo diferente de modelo. Lê a pergunta e um chunk em conjunto e atribui uma pontuação a esse par. Isso é melhor do que comparar dois embeddings calculados de forma independente. Além disso, é demasiado lento para ser executado em todo o corpus. É precisamente por isso que deve ser usado nesta etapa. Analisa os 40 candidatos devolvidos pela recuperação, e não os 100,000 chunks da tabela. Assim, uma API de reranking alojada cobra por 40 pares curtos por pergunta e elimina os falsos positivos menos relevantes antes de chegarem à etapa mais dispendiosa.

A geração é a despesa recorrente, e há duas formas de a controlar. Envie menos chunks. Use recall at 10 para descobrir quantos pode enviar sem perder respostas. Mantenha o início do prompt estável, byte a byte, para que a prompt cache do fornecedor possa ser utilizada. Coloque os chunks recuperados depois dessa parte estável. Também deve colocar em cache as respostas concluídas por pergunta, porque o token gerado mais barato é aquele que foi gerado na semana passada.

Dimensionamento do servidor e quando isto deixa de ser suficiente

Todas as regras de dimensionamento apresentadas aqui devem basear-se em medições, não em estimativas.

  • A RAM é a principal limitação: o tamanho residente do modelo indicado em ollama ps, mais o tamanho do índice HNSW, mais shared_buffers, mantendo margem para as ligações e a cache de páginas.
  • O disco precisa de duas vezes pg_total_relation_size('chunks'), porque a reconstrução de um índice mantém as duas cópias em simultâneo.
  • A CPU determina o tempo de reindexação: os segundos medidos por chunk multiplicados pelo número de chunks.
  • A reindexação acontece mais vezes do que espera, porque mudar o modelo de embeddings invalida todos os vetores já armazenados.

Este desenho deixa de ser suficiente num ponto que é possível antecipar. Quando o índice HNSW já não cabe na RAM que consegue adquirir, a latência das consultas passa a depender de operações de leitura no disco, e nenhuma configuração recupera o desempenho. Quando uma tabela serve muitos tenants e todas as consultas filtram pelo tenant, particionar a tabela passa a ser a solução, mas isso exige trabalho real. Quando as escritas da indexação competem com as consultas dos utilizadores no mesmo servidor, mova o worker de embeddings para um segundo servidor antes de mover a base de dados. Até uma dessas situações ocorrer, Postgres com pgvector no VPS que já aluga é uma solução de produção, e os números acima mostram a distância até ao limite.

FAQ

Posso executar um pipeline RAG num único VPS ou preciso de uma base de dados vetorial?

Um único VPS é suficiente para corpora com centenas de milhares de chunks. Com 768 dimensões, 100,000 chunks correspondem a 294 MiB de dados vetoriais, além do texto e do índice HNSW, o que cabe na RAM de um plano comum. O limite é a memória, não o número de linhas, porque uma pesquisa HNSW percorre o índice de forma não sequencial. A latência degrada-se quando o índice deixa de caber na RAM. Compare pg_relation_size no índice com free -m para saber em que situação está.

Preciso de uma GPU para gerar embeddings dos meus documentos?

Não, se gerar os embeddings uma vez e fizer as consultas depois. Um modelo com 137 milhões de parâmetros, como nomic-embed-text, funciona em CPU, e uma passagem completa por um corpus grande pode demorar horas que pode deixar a executar durante a noite. Uma GPU começa a ser importante quando os documentos chegam continuamente ou quando pretende executar a geração na mesma máquina. Meça o tempo de um lote com /api/embed no seu próprio servidor e multiplique-o pelo número de chunks, porque o número de vCPUs varia demasiado para que um valor publicado seja útil.

Porque é que a minha consulta vetorial usa uma varredura sequencial em vez do índice HNSW?

Leia o plano com EXPLAIN (ANALYZE, BUFFERS). A causa comum é o armazenamento: o pgvector indica que o planner não inclui o armazenamento fora da linha nas estimativas de custo. Isso faz com que uma varredura serial pareça mais barata do que realmente é. Além disso, um vetor com 768 dimensões tem 3,080 bytes, por isso é armazenado na tabela TOAST por predefinição. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mantém as linhas novas dentro da linha. As outras duas causas são um operador que não corresponde ao índice, porque um índice criado com vector_cosine_ops só é usado por <=>, e uma consulta sem ORDER BY ... LIMIT, porque um índice aproximado só serve consultas ordenadas de vizinhos mais próximos.

Como sei se a minha recuperação é boa?

Crie um conjunto de referência com 30 perguntas, cada uma associada ao id do chunk que contém a resposta, e armazene os embeddings das perguntas juntamente com esses dados. Depois, meça o recall@10, que indica com que frequência o chunk correto aparece nos 10 primeiros resultados, e o MRR, que atribui mais valor quando ele aparece em primeiro lugar. Esses dois valores mostram se uma alteração no tamanho dos chunks, no modelo de embeddings ou na fusão de rankings produziu melhorias. Sem essas métricas, estará apenas a alterar definições e a confiar na sua impressão de algumas respostas.