pgvector in produzione: indici HNSW, IVFFlat e tuning per applicazioni AI con dataset medi

pgvector in produzione: indici HNSW, IVFFlat e tuning per applicazioni AI con dataset medi

Ho iniziato questa sessione di benchmarking il 23 febbraio 2026 nella mia sandbox di laboratorio su un Hetzner CX42 (8 vCPU Intel Xeon Gold 5412U, 16 GB RAM DDR4, 160 GB NVMe), Debian 12, PostgreSQL 16.2 con l'estensione pgvector 0.7.4, Laravel 12 su PHP 8.3 per l'applicazione di ricerca semantica. Il dataset di partenza era un corpus di 180.432 chunk di documentazione tecnica sintetica (manuali, articoli, specifiche), ognuno trasformato in embedding a 768 dimensioni con il modello Nomic Embed Text v1.5 (precisamente nomic-ai/nomic-embed-text-v1.5@a9d9ec1c, pinnato per digest come ho descritto nell'articolo sulla supply chain security di applicazioni AI). La prima versione dell'applicazione faceva ricerca con cosine distance brutale su tutta la tabella - niente indice vettoriale, SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 20. Ogni query rispondeva in 3,8-4,2 secondi in un P50 su cold cache, 2,9 secondi su warm cache. Era insostenibile per un chatbot interattivo. Da quella baseline ho lavorato in tre tappe - IVFFlat, HNSW con parametri di default, HNSW con tuning - e il risultato finale sono query P95 sotto i 40 millisecondi su quello stesso dataset. Quello che segue è il racconto di come ci sono arrivato, quali numeri ho misurato a ciascun passaggio, e quali trade-off sono diventati visibili solo mettendo davvero in carico il sistema.

Qual è la scelta di indice corretta per pgvector in produzione?

La risposta breve è che non c'è una scelta corretta: ci sono due indici con caratteristiche diverse che si chiamano IVFFlat e HNSW, e la scelta dipende da tre variabili concrete - volume del dataset, tasso di scrittura, budget di memoria. IVFFlat (Inverted File with Flat lists) è più leggero in memoria, costruisce più rapidamente, ma richiede retraining quando il dataset cambia significativamente e ha un recall più basso su query ai bordi delle list. HNSW (Hierarchical Navigable Small World) ha recall molto alto, supporta insert incrementali senza retraining, ma consuma più memoria e tempo di costruzione. Nel 2026, per applicazioni RAG di PMI italiane con dataset tra 50k e 2M di chunk e budget di RAM ragionevole, il mio consiglio by default è HNSW - la convenienza operativa di poter aggiungere chunk senza ricostruire supera largamente il costo di memoria, e il recall più alto produce risultati RAG qualitativamente migliori. IVFFlat resta sensato su dataset molto grandi (10M+) dove il costo RAM di HNSW diventa proibitivo e gli update sono rari.

Il paper originale di Malkov e Yashunin che ha introdotto HNSW (arxiv 1603.09320) è lettura consigliata per chi vuole capire perché questo algoritmo regge così bene: costruisce un grafo gerarchico in cui il navigable small-world property permette di raggiungere qualsiasi nodo in O(log N) hop medi. La documentazione del repository ufficiale pgvector su GitHub è invece lettura obbligatoria per chi lo mette in produzione - copre i parametri, i caveat di maintenance_work_mem, le compatibilità di versione.

Se vuoi vedere come integro vector database in pipeline AI di produzione - dall'embedding sincrono alla ricerca semantica sotto carico - nel mio hub sull'integrazione AI per aziende trovi articoli tecnici che partono dal disegno dello stack e arrivano fino ai numeri di benchmark reali, con criterio comune di misurare prima di scegliere.

Step 1: la baseline brute force e il dolore misurato

Il primo setup era una tabella document_chunks con colonna embedding vector(768), zero indici. La query standard era questa:

SELECT id, chunk_text, embedding <=> $1 AS distance
FROM document_chunks
ORDER BY embedding <=> $1
LIMIT 20;

Su 180k righe PostgreSQL fa un sequential scan su tutti i tuple, calcola la cosine distance verso il vettore di query, ordina, tronca. Con 180k vettori a 768 dimensioni (ogni vettore è 3 KB circa, la tabella embedding occupa da sola circa 540 MB), la scansione completa non entra mai nel buffer pool del mio CX42 e il tempo di risposta è dominato dall'I/O su NVMe. I numeri medi su 500 query di benchmark erano: P50 3.806 ms, P95 4.240 ms, P99 5.110 ms. Il throughput massimo era 1,2 query al secondo - sotto carico concorrente, la CPU andava al 100% e le query cominciavano a impilarsi. Inutilizzabile per qualunque chatbot interattivo, inutilizzabile anche per RAG batch con volumi seri.

Questa baseline serve a due cose. Serve a motivare agli stakeholder perché è necessaria l'indicizzazione - senza numeri misurati, la frase "lento" è soggettiva. Serve a verificare la correttezza semantica del sistema - la brute force è garantita esatta, quindi i risultati sono il ground truth rispetto a cui misurare il recall degli indici approssimati che vengono dopo.

Step 2: IVFFlat, il primo tentativo economico

L'indice IVFFlat divide lo spazio vettoriale in list (cluster) tramite k-means al momento della costruzione. Al query time, pgvector calcola la distanza del vettore di query verso i centroid di tutte le list, sceglie le probes più vicine (configurabile via ivfflat.probes), e fa la scansione fine solo all'interno di quelle. La velocità sale perché scanni solo una frazione del dataset; il recall scende perché vettori di query ai bordi fra list possono avere vicini reali in list non scannerizzate.

SET maintenance_work_mem = '2GB';

CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 425);  -- sqrt(180432) ≈ 425, regola di pollice

VACUUM ANALYZE document_chunks;
SET ivfflat.probes = 10;

Il parametro lists = 425 deriva dalla rule of thumb pgvector: sqrt(N) per dataset sotto il milione, N/1000 sopra. Il probes = 10 indica a pgvector di guardare nelle 10 list più vicine al query vector invece che in tutte - trade-off centrale dell'indice.

I numeri dopo la costruzione dell'indice (22 secondi su quel dataset): P50 94 ms, P95 180 ms, P99 280 ms. Throughput 38 query/secondo. Recall rispetto alla brute force: 91% (calcolato confrontando gli ID dei top-20 returnati dall'indice rispetto ai top-20 veri). Fattibile per un prototipo, non convincente per produzione - un recall del 9% di miss significa che quasi una query su dieci si perde il documento migliore e restituisce qualcosa di peggiore.

Alzare probes a 30 sale il recall al 97%, ma la latenza sale a P50 230 ms - dimezza la velocità. La correlazione inversa tra probes, recall e speed è strutturale all'algoritmo: non esiste la configurazione magica.

Step 3: HNSW con parametri di default

Il passaggio a HNSW è stato il salto qualitativo. L'indice costruisce un grafo a più livelli in cui ogni nodo ha un numero fissato di connessioni (m, default 16), e un parametro di beam search determina la profondità di esplorazione in costruzione (ef_construction, default 64) e in query (ef_search, default 40). Il recall è tipicamente nell'ordine del 98-99% anche con parametri di default.

DROP INDEX IF EXISTS document_chunks_embedding_idx;

SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 4;

CREATE INDEX document_chunks_embedding_idx
ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

La costruzione su 180k vettori ha richiesto 6 minuti e 38 secondi con 4 worker paralleli e 4 GB di maintenance_work_mem. Il max_parallel_maintenance_workers = 4 è cruciale: di default PostgreSQL usa un solo worker per index build, e su 180k vettori questo significa attendere 20+ minuti invece di 7. Il maintenance_work_mem alto evita che la costruzione debba spillover su disco - se il working set eccede la RAM configurata, la costruzione rallenta di un ordine di grandezza.

I numeri dopo la build: P50 28 ms, P95 56 ms, P99 95 ms. Throughput 120 query/secondo. Recall rispetto alla brute force: 98,7%. La tabella+indice consuma ora 920 MB di disco (erano 540 MB di sola tabella per la baseline e 610 MB con IVFFlat - HNSW ha sovraccarico).

Un dato operativo importante: dopo un INSERT di 5.000 nuovi chunk, l'indice HNSW è utilizzabile subito - pgvector aggiorna la struttura incrementalmente. Con IVFFlat, inserimenti massivi degradano il recall e richiedono REINDEX periodici. Per un'applicazione RAG dove i documenti arrivano continuamente, la differenza è decisiva.

Step 4: il tuning fine dei parametri HNSW

I parametri default di HNSW sono conservative. Alzando m (connettività del grafo) e ef_construction (qualità della costruzione) si ottiene un indice più grosso e più lento da costruire, ma con recall più alto e latenze di query più basse. Il tuning ha senso solo se hai misurato che i default non bastano - altrimenti stai pagando complessità senza motivo. Sul mio dataset ho misurato queste tre configurazioni.

-- Default (quello che già avevamo)
WITH (m = 16, ef_construction = 64)
-- Dimensione indice: 380 MB, build: 6m38s, P50 query: 28 ms, recall 98.7%

-- Balanced (punto di ottimo nei miei benchmark)
WITH (m = 24, ef_construction = 128)
-- Dimensione indice: 530 MB, build: 14m05s, P50 query: 22 ms, recall 99.3%

-- Quality-first
WITH (m = 32, ef_construction = 256)
-- Dimensione indice: 720 MB, build: 34m20s, P50 query: 20 ms, recall 99.7%

La configurazione balanced è quella che ho portato in produzione nella mia sandbox: il costo di build si fa una volta ogni migrazione importante, e l'indice resta incrementale da quel momento in poi. Il recall a 99,3% è indistinguibile dal brute force su query reali di utenti.

Oltre ai parametri di costruzione, c'è ef_search che è runtime e modificabile senza rebuild. Il default 40 è conservativo; alzarlo a 100 sale il recall di qualche decimale ma raddoppia la latenza di query. Sul mio sistema lo tengo a 60 per P95 sotto i 50 ms.

SET hnsw.ef_search = 60;  -- impostato in pg_hba per sessione app

Step 5: l'integrazione Laravel e il pattern di ingestione incrementale

Sulla parte applicativa uso Eloquent con il pacchetto pgvector-php per avere il tipo Vector nativo nelle query builder. Ogni documento caricato dall'utente attraversa una pipeline di quattro step. Chunk del testo in segmenti da 500 token con 50 di overlap (via textsplitter custom in PHP). Embedding di ogni chunk via chiamata sincrona al container del modello Nomic che gira sul GEX44 con GPU descritto nel mio articolo su LLM self-hosted su VPS con GPU. Batch INSERT via prepared statement con ?::vector per ogni vettore. Trigger automatico di VACUUM ANALYZE se il numero di nuovi chunk supera l'1% del totale.

<?php
use Illuminate\Support\Facades\DB;
use Pgvector\Laravel\Vector;

final class ChunkIngestionService
{
    public function ingestBatch(array $chunks): void
    {
        DB::transaction(function () use ($chunks) {
            $rows = [];
            foreach ($chunks as $chunk) {
                $embedding = $this->embeddingService->embed($chunk['text']);
                $rows[] = [
                    'document_id' => $chunk['document_id'],
                    'chunk_text' => $chunk['text'],
                    'embedding' => new Vector($embedding),
                    'metadata' => json_encode($chunk['metadata']),
                    'created_at' => now(),
                ];
            }
            DB::table('document_chunks')->insert($rows);
        });

        if ($this->shouldTriggerVacuum($chunks)) {
            DB::statement('VACUUM ANALYZE document_chunks');
        }
    }
}

La query di ricerca sul lato applicativo è semplice come una chiamata Eloquent estesa:

<?php
public function findSimilar(array $queryEmbedding, int $limit = 20): Collection
{
    return DocumentChunk::query()
        ->selectRaw('id, chunk_text, metadata, embedding <=> ? AS distance', [
            new Vector($queryEmbedding)
        ])
        ->orderByRaw('embedding <=> ?', [new Vector($queryEmbedding)])
        ->limit($limit)
        ->get();
}

Due sottigliezze che emergono solo in produzione. Il VACUUM ANALYZE non è opzionale: senza di esso, pgvector usa statistiche stale e l'ottimizzatore può preferire un sequential scan all'indice - latenze che passano da 30 ms a 3 secondi improvvisamente, senza apparente motivo. L'ho incontrato dopo un import massiccio il 28 febbraio e mi ha fatto perdere un'ora prima di ricordarmi la lezione. La seconda sottigliezza è che ef_search deve essere impostato alla sessione, non al server globale: se diversi client applicativi hanno esigenze di recall diverse (p.es. ricerca interattiva veloce vs. reportistica accurata), ciascuno può settare il suo valore via SET LOCAL all'apertura della sessione.

Step 6: monitoring e caveat operativi

Le tre metriche che monitoro con Prometheus su un postgres_exporter sono query-per-secondo sulla tabella vector, latenza P95 della query, ratio index hit vs sequential scan. L'ultima è la più critica: se il ratio index scan scende sotto il 95%, qualcosa non va - di solito statistiche stale o un query planner che sceglie male perché il dataset è cresciuto oltre la soglia stimata.

# Prometheus alert
- alert: PgvectorSequentialScan
  expr: rate(pg_stat_user_tables_seq_scan{relname="document_chunks"}[5m]) > 0.5
  for: 10m
  annotations:
    description: "pgvector table going through sequential scans - check VACUUM ANALYZE and index health"

Altri due caveat che è sicuro incontrare. Primo: il parametro shared_buffers di PostgreSQL va dimensionato per contenere almeno 50% dell'indice HNSW in RAM - sul mio CX42 con 16 GB di RAM totali, shared_buffers = 6GB tiene comodo l'indice da 530 MB più altre tabelle del dominio. Sotto quella soglia le query cominciano a fare I/O disco e la P95 impenna. Secondo: backup e restore dell'indice HNSW tramite pg_dump non è l'opzione di default - pg_dump dumpa solo i dati, al restore l'indice viene ricostruito da zero (altri 14 minuti). Per disaster recovery real time conviene usare streaming replication verso una replica che mantiene indice e dati sincronizzati.

Quando pgvector NON è la scelta giusta

Se il tuo dataset è sopra i 10 milioni di chunk (20-30 GB di soli embedding), pgvector con HNSW comincia a essere costoso in RAM e in tempo di build. Valuta Qdrant, Weaviate, o Milvus che sono vector database specializzati con engineering dedicato a scale più grandi. Se fai ricerca semantica su un corpus statico senza update (p.es. documentazione di un prodotto versionato) e vuoi il minimo footprint operativo, un indice FAISS offline serializzato su disco e caricato in memoria è più semplice di un PostgreSQL dedicato. Se il tuo stack applicativo è già basato su Elasticsearch o OpenSearch, guarda al loro supporto nativo per vector search - evita di duplicare infrastruttura.

Il punto di ottimo di pgvector - dataset fra 50k e 2M di chunk, aggiornamenti incrementali regolari, stack già basato su PostgreSQL per il dominio, requirement di latenza P95 sotto i 100 ms - copre la stragrande maggioranza dei casi RAG di PMI italiane che vedo ogni mese. La tentazione di partire con Qdrant o Pinecone per un progetto da 80k chunk è esattamente il over-engineering che produce quei progetti AI che il mercato enterprise cita come cancellati prima di andare in produzione: complessità non giustificata che nessuno ha tempo di manutenere.

Un'applicazione RAG che risponde in 30 millisecondi non si distingue da una che risponde in 3 secondi solo per l'UX: si distingue perché la prima può essere usata conversazionalmente mentre la seconda viene inevitabilmente abbandonata dagli utenti dopo due o tre query. La differenza fra le due non è la grandezza del database né la potenza della GPU: è la scelta dell'indice e la disciplina dei parametri. HNSW con m=24, ef_construction=128, ef_search=60 su un VPS da 16 GB non è magia - è il punto di ottimo che emerge misurando, e che la documentazione pgvector suggerisce ma che troppi team saltano perché "i default vanno bene". I default vanno bene per dire "funziona"; il tuning va fatto per dire "funziona sotto carico reale".

Se stai progettando o già stai gestendo un vector store pgvector in produzione e vuoi capire se la tua configurazione è ottimizzata per il tuo volume e pattern di query, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: