SSD Nodes Learn 🎉 VPS da $5.50/mese
Guide Matt ConnorDi Matt Connor

Pipeline RAG self-hosted su un VPS: guida pratica

Configura una pipeline RAG su un solo VPS: schema pgvector, dimensionamento HNSW, modello locale Ollama e SQL per verificare che il retrieval funzioni.

Come si presenta una pipeline RAG self-hosted

Una pipeline RAG (retrieval augmented generation) prevede cinque fasi: suddividere i documenti in chunk, generare gli embedding dei chunk, memorizzare i vettori, recuperare quelli più vicini per una domanda e inviare questi chunk a un modello linguistico che scrive la risposta. Su un VPS che già noleggi, le prime quattro fasi vengono eseguite sul server. PostgreSQL con l'estensione pgvector memorizza i vettori, mentre un piccolo modello di embedding fornito da Ollama converte il testo in vettori. Solo l'ultima fase deve uscire dal server.

Questa separazione è il punto centrale della guida. La suddivisione in chunk richiede soltanto lavoro sulla CPU. La generazione degli embedding utilizza un modello da 137 milioni di parametri, che occupa poche centinaia di megabyte di RAM. L'archiviazione consiste in una tabella PostgreSQL la cui dimensione può essere calcolata prima di inserire una sola riga. Per un corpus composto da centinaia di migliaia di chunk, tutto questo può essere eseguito su un normale VPS. La generazione è diversa, perché ha un costo per ogni domanda, per sempre.

Quali parti di una pipeline RAG comportano effettivamente un costo

Il tutorial RAG end-to-end di DigitalOcean utilizza un database vettoriale gestito e un modello di embedding ospitato. La sezione sui costi resta qualitativa: memorizzare nella cache le query ripetute, mantenere ridotto il numero di chunk recuperati e applicare il reranking prima della generazione. Sono indicazioni corrette. Tuttavia, non considera l'opzione che modifica il calcolo: eseguire il modello di embedding sul server che si sta già pagando.

È più utile contare i token invece dei dollari, perché il numero di token non diventa obsoleto quando cambia un listino prezzi. Consideriamo un corpus di 100,000 chunk da 400 token ciascuno, 10,000 domande, 8 chunk inviati al modello per ogni risposta, una domanda e un blocco di istruzioni da 100 token e risposte da 400 token.

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"
  }
]

L'embedding dell'intero corpus richiede 40 milioni di token e viene eseguito una sola volta. Distribuito su 10,000 domande, equivale a 4,000 token per domanda. Con centomila domande, il valore scende a 400. La generazione non diminuisce mai. Per ogni domanda a cui si risponde, richiede 3,300 token in ingresso e 400 token in uscita.

Il costo segue quindi la fase che si ripete. Conviene eseguire internamente l'embedding, perché lo si paga una sola volta e il VPS è comunque già in esecuzione. Conviene acquistare la generazione, perché è in questa fase che un modello migliore può giustificare un costo reale. La cache è importante per lo stesso motivo: un cache hit evita l'unica fase il cui costo non viene mai ammortizzato. La differenza tra una KV cache e una prompt cache determina quale metà del processo è possibile riutilizzare. Un prompt RAG contiene un blocco di istruzioni stabile seguito da un blocco di chunk variabile: è la struttura che trae il massimo vantaggio dalla cache.

Chunking: perché la dimensione fissa con sovrapposizione è l’impostazione predefinita corretta

Un chunk è l’unità che viene recuperata, quindi la sua dimensione determina tutto ciò che segue. Deve essere abbastanza piccolo da avere un embedding incentrato su un solo argomento: un embedding è infatti un singolo punto nello spazio, quindi un chunk che tratta quattro argomenti si colloca tra questi e risulta vicino a nessuno di essi. Deve però essere abbastanza grande da consentire di rispondere autonomamente a una domanda, perché il modello linguistico vede il chunk, non il documento nel suo insieme.

Inizia con 300 parole e 50 parole di sovrapposizione. L’inglese usa circa 1.3 token per parola, quindi 300 parole corrispondono a circa 400 token. La sovrapposizione serve perché, in caso contrario, una frase che cade sul confine viene divisa a metà e nessuna delle due parti consente di rispondere alla domanda.

Suddividi prima in base alla struttura, quando i documenti la prevedono. Dividi in base ai titoli, poi in base ai paragrafi, e applica la regola della dimensione fissa solo all’interno di una sezione ancora troppo lunga. Un chunk che inizia a metà di una frase produce una risposta finale poco leggibile, perché il modello riprende il testo che gli hai fornito.

Non ottimizzare il chunking prima di poterlo misurare. La dimensione fissa con sovrapposizione è deterministica e può essere ricalcolata rapidamente, quindi costituisce una baseline con cui confrontare le modifiche. Prima costruisci la query di valutazione più avanti, poi modifica un solo elemento alla volta.

Integrazione sulla stessa macchina e relativo costo in RAM e latenza

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

nomic-embed-text ha 137 million parameters e un download da 274 MB ad agosto 2026. Verificate cosa restituisce prima di progettare una tabella basata su quel valore.

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]))'

Il comando stampa 768. Il tipo della colonna deve corrispondere esattamente a quel numero.

Su questo modello ci sono due impostazioni che spesso causano problemi.

Il prefisso dell'attività è obbligatorio. La model card di Nomic specifica che l'input "must include a task instruction prefix". I documenti vengono incorporati anteponendo search_document: , mentre alle domande va anteposto search_query: . Se li omettete, non si verifica alcun errore: vengono restituiti i vettori, la qualità del retrieval diminuisce e nessuna riga di log spiega il motivo.

L'input lungo viene troncato senza avvisi. L'endpoint /api/embed accetta un campo truncate, il cui valore predefinito è true, mentre il modello distribuito da Ollama dichiara un contesto di 2K. Un chunk più lungo viene tagliato a quel limite e incorporato comunque, quindi la parte finale non sarà ricercabile. Durante i test inviate "truncate": false, così un chunk troppo grande genera un errore invece di essere accettato.

Raggruppate le richieste e mantenete il modello residente in memoria.

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 accetta un elenco. Una richiesta contenente 32 chunk è più efficiente di 32 richieste separate, perché il round trip HTTP e la ricerca del modello avvengono una sola volta anziché 32 volte. keep_alive controlla per quanto tempo il modello resta in memoria dopo una richiesta; il valore predefinito è 5 minutes. Quando scade, la richiesta successiva deve sostenere nuovamente il tempo di caricamento.

Misurate sulla vostra macchina i due valori importanti. Dipendono dal numero di vCPU, quindi nessun dato pubblicato corrisponderà esattamente.

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 stampa la dimensione residente del modello caricato, cioè la RAM impegnata per tutto il tempo in cui keep_alive lo mantiene in memoria. Dividendo l'output di time per la dimensione del batch ottenete i secondi per chunk. Moltiplicate il risultato per il numero di chunk per ottenere il costo di indicizzazione di una sola esecuzione. Con un piano basato esclusivamente sulla CPU, prevedete che un corpus di 100,000 chunk richieda ore anziché minuti. Non è un problema, perché l'operazione viene eseguita una sola volta e può essere eseguita durante la notte con nice -n 19. Se le ore non sono accettabili, la domanda reale è se il noleggio di una GPU si ripaga, e questa è un'analisi del punto di pareggio rispetto ai token API, non una preferenza.

Se la macchina serve già un modello per chat, il modello di embedding è un secondo modello residente e la RAM necessaria si somma. Eseguire Ollama su un VPS tratta il dimensionamento del lato di generazione, mentre il comportamento di un modello self-hosted con utenti simultanei spiega cosa accade quando più persone inviano richieste contemporaneamente. Il modello di embedding è abbastanza piccolo da poter essere eseguito accanto a entrambi.

Lo script di indicizzazione, dall'inizio alla fine

Su Ubuntu 24.04, un semplice pip install eseguito fuori da un ambiente virtuale si interrompe con error: externally-managed-environment, perché il Python di sistema appartiene ad 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() è la parte che dovete implementare: qualunque componente attraversi i vostri file o le vostre righe e produca un ID del documento insieme al relativo testo. Tutto il resto costituisce la pipeline.

Storage: lo schema di pgvector e quanto spazio occupa

Ubuntu 24.04 include postgresql-16-pgvector alla versione 0.6.0, precedente al tipo halfvec. Usa il repository del progetto PostgreSQL per installare una build aggiornata.

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

Il numero nel nome del pacchetto deve corrispondere alla versione major del server. Crea quindi il ruolo, il database e l'estensione.

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) deve corrispondere all'output del modello. Se inserisci in quella colonna un vettore con 1024 dimensioni, Postgres lo rifiuta con expected 768 dimensions, not 1024, che è il messaggio di errore più chiaro dell'intera procedura. La colonna fts generata non richiede attività di manutenzione e consente di aggiungere in seguito la ricerca per parole chiave.

Lo spazio occupato si calcola con una semplice operazione aritmetica. La documentazione di pgvector indica vector come 4 * dimensions + 8 byte e halfvec come 2 * dimensions + 8. Le dimensioni riportate di seguito corrispondono all'output pubblicato per ciascun modello.

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
  }
]

Con 768 dimensioni, ogni vettore occupa 3,080 byte. Pertanto, 100,000 chunk occupano 294 MiB di dati vettoriali. Lo stesso corpus, elaborato da un modello hosted con 1536 dimensioni, richiede 587 MiB; anche l'indice cresce in proporzione. La precisione dimezzata dimezza entrambi i valori: halfvec(768) archivia quel corpus in 147 MiB. La query di scoring riportata di seguito verifica in un'unica esecuzione se questo comporta una perdita di recall.

Queste cifre riguardano soltanto la colonna vettoriale. Testo, overhead delle righe e indici si aggiungono a questo valore, quindi misura la tabella reale.

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 preferisci la stessa estensione con un'API e account utente, uno stack Supabase self-hosted usa PostgreSQL con pgvector già abilitato e tutte le query di questa guida funzionano senza modifiche.

Indicizzazione: le impostazioni HNSW più importanti

Al di sotto di qualche migliaio di righe, non usare l'indice. La ricerca esatta legge ogni riga, ma a queste dimensioni è sufficientemente veloce e il recall è perfetto. Aggiungi l'indice quando la scansione sequenziale non è più abbastanza veloce e considera il compromesso: un indice approssimato restituisce vicini approssimativamente corretti.

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 sono i valori predefiniti di pgvector. Aumentarli migliora il recall, ma aumenta anche il tempo di compilazione e le dimensioni dell'indice. Usa vector_cosine_ops con l'operatore <=>, a meno che tu sappia che il modello produce vettori di lunghezza unitaria, perché la distanza coseno ignora la lunghezza del vettore, mentre il prodotto interno no.

Monitora la compilazione. Quando il grafo supera maintenance_work_mem, pgvector lo segnala:

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

Non è un errore e la compilazione termina comunque, ma passa a un percorso molto più lento. Aumenta maintenance_work_mem nella sessione che compila l'indice e lascia invariato il valore predefinito del server, perché questa impostazione si applica a ogni operazione di manutenzione e un valore globale elevato può esaurire la memoria del server. Monitora una compilazione lunga da una seconda sessione.

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

Poi confronta l'indice completato con la memoria disponibile sul server.

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

Una ricerca HNSW attraversa un grafo, quindi accede a pagine distribuite nell'indice invece di leggere un intervallo contiguo. Se l'indice non entra in memoria, ogni query genera letture dal disco e gli utenti percepiscono soprattutto la latenza nella coda. Questa è la regola principale per dimensionare il server: l'indice, insieme alle righe effettivamente servite, deve entrare nella RAM. free -m e la dimensione indicata sopra sono i due valori da confrontare.

Al momento della query, hnsw.ef_search controlla il recall e il suo valore predefinito è 40.

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

Un valore più alto ricerca una porzione maggiore del grafo, trova vicini migliori e aumenta la latenza. È un'impostazione di sessione, quindi puoi aumentarla per una singola query senza modificare l'indice.

Se una query non usa affatto l'indice, il piano lo mostra.

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

In questo caso, una scansione sequenziale è spesso dovuta allo storage. Un vettore con 768 dimensioni occupa 3,080 byte, una dimensione superiore a quella che Postgres mantiene inline; di conseguenza, il valore viene spostato nella tabella TOAST, lo storage per i valori di dimensioni eccessive conservati fuori riga. La nota di pgvector specifica che il planner non considera lo storage fuori riga nelle stime dei costi; per questo una scansione sequenziale può sembrare meno costosa di quanto sia realmente. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mantiene i vettori inline. Si applica alle righe scritte dopo la modifica, quindi per le righe esistenti è necessaria una riscrittura della tabella.

Recupero: una query, due segnali

La ricerca vettoriale trova il testo che esprime lo stesso significato della domanda. È meno efficace con le stringhe esatte: un codice prodotto, un codice di errore, un cognome. La ricerca per parole chiave si comporta all’opposto e Postgres la supporta già. Combinale in una sola query invece di eseguire un secondo sistema.

La reciprocal rank fusion è il combinatore più semplice che funziona. Ogni risultato riceve 1 / (60 + rank) da ciascun elenco in cui compare e i due punteggi vengono sommati. Non richiede la normalizzazione dei punteggi, perché usa le posizioni invece delle distanze.

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 è l’embedding della domanda, generato dallo stesso modello con il prefisso search_query: . $2 è la domanda in formato testuale. Entrambi vengono associati dall’applicazione. websearch_to_tsquery accetta una domanda reale dell’utente senza problemi con la punteggiatura, mentre to_tsquery non lo fa. Va considerato anche un altro aspetto: aggiungendo un filtro WHERE a una scansione HNSW, è possibile ottenere meno righe di quelle richieste, perché l’indice viene cercato per primo e il filtro viene applicato dopo. SET hnsw.iterative_scan = relaxed_order; fa in modo che pgvector continui la scansione finché non ha trovato un numero sufficiente di righe.

Come capire se il retrieval funziona bene?

Questo è il passaggio che quasi tutte le guide su RAG saltano, ma è l'unico che permette di capire se le altre scelte hanno prodotto un miglioramento. Non serve un framework di valutazione. Servono 30 domande e l'id del chunk che risponde a ciascuna.

Scrivile manualmente. Scegli domande che gli utenti pongono realmente su questo corpus, esegui ogni ricerca, leggi il risultato e annota l'id del chunk che avrebbe dovuto essere classificato al primo posto. Trenta domande non permettono di distinguere differenze minime. Rilevano però le differenze importanti, perché queste sono ampie.

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

Genera l'embedding di ogni domanda con il prefisso search_query: , salvalo, quindi assegna i punteggi all'intero insieme con una sola 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 diviso per questions è il recall@10: indica con quale frequenza la risposta si trovava nella finestra inviata al modello. L'MRR (mean reciprocal rank) calcola la media di 1 diviso la posizione del chunk corretto e considera una mancata corrispondenza come zero. In questo modo premia il posizionamento della risposta al primo posto anziché all'ottavo. Entrambi i valori cambiano quando modifichi la dimensione dei chunk, sostituisci il modello di embedding o aggiungi la ricerca per parole chiave. Ora puoi vedere in quale direzione cambiano.

Dai priorità al recall@10, perché il generatore non può usare un chunk che non ha mai ricevuto. Quando il recall@10 è 0.9 e le risposte sono ancora errate, il problema è nel prompt o nel modello, non nel retrieval. Questa distinzione evita giorni di tentativi casuali.

Controlla separatamente l'indice. La ricerca approssimata riduce il recall e pgvector mostra di quanto: esegui la stessa query con la ricerca esatta e confronta gli id.

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

Nove id su dieci in comune indicano che ef_search è configurato correttamente. Quattro su dieci indicano che devi aumentarlo.

Reranking e generazione: dove un'API giustifica il costo

Un reranker è un modello di tipo diverso. Legge insieme la domanda e un chunk e assegna un punteggio a quella coppia. Questo è più efficace del confronto tra due embedding calcolati separatamente. Inoltre, è troppo lento per essere eseguito sull'intero corpus. Per questo viene usato in questo punto della pipeline. Analizza i 40 candidati restituiti dal retrieval, non i 100,000 chunk presenti nella tabella. Un'API di reranking gestita addebita quindi 40 coppie brevi per domanda ed elimina i falsi positivi peggiori prima che arrivino alla fase più costosa.

La generazione è il costo ricorrente, e due leve permettono di ridurlo. Invia meno chunk, usando recall@10 per determinare quanti ne puoi inviare senza perdere le risposte. Mantieni invariata byte per byte la parte iniziale del prompt, in modo che il prompt cache del provider possa utilizzarla, e inserisci i chunk recuperati dopo quella parte stabile. Memorizza nella cache anche le risposte completate in base alla domanda, perché il token generato meno costoso è quello generato la settimana scorsa.

Dimensionamento del server e quando questa soluzione non è più sufficiente

Ogni regola di dimensionamento riportata qui si basa su misurazioni, non su stime.

  • La RAM è il vincolo principale: la dimensione del modello residente indicata da ollama ps, più la dimensione dell'indice HNSW, più shared_buffers, lasciando margine per le connessioni e la page cache.
  • Il disco deve avere una capacità pari al doppio di pg_total_relation_size('chunks'), perché durante la ricostruzione di un indice entrambe le copie restano presenti contemporaneamente.
  • La CPU determina la durata della reindicizzazione, calcolata moltiplicando i secondi misurati per chunk per il numero di chunk.
  • La reindicizzazione avviene più spesso di quanto si possa prevedere, perché la modifica del modello di embedding invalida tutti i vettori già memorizzati.

Questa architettura diventa insufficiente in condizioni prevedibili. Quando l'indice HNSW non entra più nella RAM disponibile acquistando hardware aggiuntivo, la latenza delle query si trasforma in attese per accessi al disco e nessuna impostazione può risolvere il problema. Quando una tabella serve molti tenant e ogni query applica un filtro per tenant, la soluzione consiste nel partizionare la tabella, ma si tratta di un intervento significativo. Quando le scritture del processo di indicizzazione entrano in competizione con le query degli utenti sullo stesso server, spostare il worker di embedding su un secondo server è preferibile allo spostamento del database. Finché nessuna di queste condizioni si verifica, Postgres con pgvector sul VPS già disponibile è una soluzione adatta alla produzione e i numeri riportati sopra indicano quanto manca al raggiungimento del limite.

FAQ

Posso eseguire una pipeline RAG su un singolo VPS o mi serve un database vettoriale?

Un singolo VPS è sufficiente per corpora composti da centinaia di migliaia di chunk. Con 768 dimensioni, 100,000 chunk occupano 294 MiB di dati vettoriali, oltre al testo e all'indice HNSW; il totale rientra nella RAM di un piano ordinario. Il limite è la memoria, non il numero di righe, perché una ricerca HNSW accede a posizioni distribuite nell'indice. Quando l'indice non entra più nella RAM, la latenza peggiora. Confronta pg_relation_size sull'indice con free -m per capire qual è la situazione.

Mi serve una GPU per creare gli embedding dei documenti?

No, se crei gli embedding una sola volta e poi esegui le query. Un modello con 137 milioni di parametri come nomic-embed-text funziona su CPU, e un'elaborazione completa di un corpus di grandi dimensioni richiede ore, che puoi sfruttare durante la notte. Una GPU diventa utile quando i documenti arrivano continuamente o quando vuoi eseguire la generazione sulla stessa macchina. Misura il tempo di un batch con /api/embed sul tuo server e moltiplicalo per il numero di chunk, perché il numero di vCPU varia troppo tra i piani per rendere utile un valore pubblicato.

Perché la query vettoriale usa una scansione sequenziale invece dell'indice HNSW?

Leggi il piano con EXPLAIN (ANALYZE, BUFFERS). La causa più comune è lo storage: pgvector indica che il planner non considera lo storage fuori riga nelle stime dei costi. Di conseguenza, una scansione sequenziale può sembrare più economica di quanto sia realmente. Inoltre, un vettore con 768 dimensioni occupa 3,080 byte e, per impostazione predefinita, viene memorizzato nella tabella TOAST. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; mantiene le nuove righe inline. Le altre due cause sono un operatore non compatibile con l'indice, perché un indice creato con vector_cosine_ops viene usato solo da <=>, e una query senza ORDER BY ... LIMIT, perché un indice approssimato gestisce soltanto query ordinate per ricerca dei vicini più prossimi.

Come posso capire se il recupero dei documenti funziona bene?

Crea un gold set di 30 domande, ciascuna associata all'id del chunk che contiene la risposta, e memorizza insieme anche gli embedding delle domande. Misura quindi il recall@10, cioè la frequenza con cui il chunk corretto compare nei primi 10 risultati, e l'MRR, che assegna un valore maggiore quando il chunk corretto è al primo posto. Questi due valori indicano se una modifica alla dimensione dei chunk, al modello di embedding o alla fusione del ranking ha prodotto un miglioramento. Senza queste misure, modifichi le impostazioni basandoti soltanto sulla tua impressione dopo aver esaminato una manciata di risposte.