Vector Database: Scelta e Ottimizzazione per AI Engineering
Quando si costruisce una pipeline RAG in produzione, la scelta del vector database non è un dettaglio implementativo: è una decisione architetturale che influenza latenza, costi operativi, recall accuracy e scalabilità del sistema. Nel 2025 il mercato dei vector database vale oltre 2.65 miliardi di dollari e il numero di soluzioni disponibili è cresciuto drasticamente, rendendo la selezione sempre più complessa.
Questo articolo non è una panoramica marketing delle feature commerciali. È un deep dive tecnico su come funzionano internamente i vector database, quali algoritmi di indicizzazione usano, come si configurano e si ottimizzano per workload reali. Analizzeremo Qdrant, Pinecone, Milvus e Weaviate confrontandoli su dimensioni concrete: architettura HNSW, strategie di quantizzazione, filtered search, DiskANN vs in-memory, e tuning dei parametri per raggiungere il target di recall/latency definito dalla propria applicazione.
Se stai costruendo un sistema RAG che deve gestire milioni di documenti con latenza sotto i 50ms e recall superiore al 95%, o se stai ottimizzando un sistema esistente che consuma troppa memoria, questo articolo ti fornisce gli strumenti concettuali e pratici per prendere decisioni informate.
Cosa Imparerai
- Architettura interna dei vector database: come funziona HNSW a livello algoritmico
- Confronto IVF vs HNSW vs DiskANN: quando usare quale e perchè
- Quantizzazione scalar, product e binary: tradeoff memoria/accuratezza
- Filtered vector search: pre-filtering, post-filtering e il problema dei filtri stretti
- Configurazione pratica di Qdrant, Milvus e Pinecone con code examples
- Benchmark e tuning in produzione: come misurare e migliorare QPS e recall
Architettura Interna: Come Funziona un Vector Database
Un vector database differisce da un database relazionale non solo per i dati che gestisce, ma per il tipo di operazione fondamentale che deve ottimizzare: invece di ricerche esatte su chiavi discrete, esegue Approximate Nearest Neighbor (ANN) search su spazi ad alta dimensionalità, tipicamente 768-4096 dimensioni per gli embedding LLM moderni.
La ricerca esatta dei k nearest neighbors (kNN) ha complessità O(n*d) dove n è il numero di vettori e d le dimensioni. Con 10 milioni di vettori a 1536 dimensioni (dimensione standard OpenAI ada-002), una query esatta richiederebbe ~15 miliardi di operazioni floating point: completamente inaccettabile per un sistema real-time. Tutti i vector database moderni usano quindi algoritmi ANN che sacrificano un po' di recall per guadagnare ordini di grandezza in velocità.
Lo stack interno di un vector database si articola su più livelli:
- Storage layer: gestione dei vettori compressi su disco o in memoria, con supporto a mmap per accesso efficiente
- Index layer: struttura dati ANN (HNSW, IVF, DiskANN) per navigare lo spazio vettoriale
- Payload/metadata layer: attributi scalari associati ai vettori per il filtering
- Query planner: decide la strategia ottimale combinando vector search e payload filtering
- Replication/sharding layer: per sistemi distribuiti come Milvus o Pinecone
HNSW: Deep Dive Algoritmico
Hierarchical Navigable Small World (HNSW) è l'algoritmo ANN dominante nel 2025, usato di default da Qdrant, Weaviate è disponibile in Milvus. Capire il suo funzionamento interno è essenziale per configurarlo correttamente.
HNSW costruisce un grafo a più livelli gerarchici. Al livello più alto ci sono pochi nodi fortemente connessi tra loro (i "hub"), mentre scendendo di livello la densita aumenta fino al livello 0 che contiene tutti i vettori. Durante la ricerca, l'algoritmo parte dall'alto e scende attraverso i livelli, raffinando progressivamente la candidatura dei vicini più simili. Questo approccio è ispirato al fenomeno "small world" dei grafi sociali: da qualsiasi nodo, si può raggiungere qualsiasi altro in pochi salti grazie ai collegamenti di lungo raggio.
I tre parametri fondamentali di HNSW sono:
- M (default 16): numero massimo di edges bidirezionali per nodo. Valori tipici: 8-64. Aumentando M migliora il recall ma cresce la memoria e il tempo di build. Per dataset ad alta dimensionalità (1536+), M=32-64 da buoni risultati.
- efConstruction (default 100-200): dimensione della lista di candidati durante la costruzione dell'indice. Non influenza la dimensione finale dell'indice, ma determina la qualità delle connessioni. Valori più alti = indice migliore ma build più lento. Range consigliato: 200-400 per alta qualità.
- ef (o efSearch, configurabile a runtime): dimensione della lista di candidati durante la query. Deve essere >= k (numero di risultati richiesti). Aumentare ef migliora il recall ma aumenta la latenza. Tipicamente 50-500.
Il tradeoff fondamentale: M e efConstruction determinano la qualità dell'indice (operazione one-time costosa), mentre ef bilancia recall vs latency a query time (può essere cambiato dinamicamente).
# Configurazione HNSW in Qdrant - esempio pratico con tradeoff espliciti
from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance,
HnswConfigDiff, OptimizersConfigDiff,
CollectionConfig
)
client = QdrantClient(url="http://localhost:6333")
# --- Configurazione HIGH-RECALL per RAG critico ---
# Target: recall >= 0.98, latency accettabile fino a 50ms
# Costo: ~4x memoria rispetto a configurazione base
client.recreate_collection(
collection_name="rag_high_recall",
vectors_config=VectorParams(
size=1536, # OpenAI text-embedding-3-small
distance=Distance.COSINE
),
hnsw_config=HnswConfigDiff(
m=64, # Alta connettivita: migliore recall ma +memoria
ef_construct=400, # Build lento ma indice di alta qualità
full_scan_threshold=10000, # Sotto 10k vettori usa brute force
on_disk=False # In-memory per latenza minima
),
optimizers_config=OptimizersConfigDiff(
default_segment_number=4,
indexing_threshold=20000
)
)
# --- Configurazione BALANCED per produzione tipica ---
# Target: recall >= 0.95, latency < 20ms, memoria ottimizzata
client.recreate_collection(
collection_name="rag_balanced",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE
),
hnsw_config=HnswConfigDiff(
m=32, # Buon bilanciamento recall/memoria
ef_construct=200, # Build ragionevole
full_scan_threshold=5000,
on_disk=False
)
)
# --- Configurazione LOW-LATENCY per real-time ---
# Target: latency < 5ms, recall accettabile >= 0.90
client.recreate_collection(
collection_name="rag_fast",
vectors_config=VectorParams(
size=768, # Embeddings compatti (all-MiniLM-L6-v2)
distance=Distance.COSINE
),
hnsw_config=HnswConfigDiff(
m=16, # Meno connessioni = query più veloci
ef_construct=128,
full_scan_threshold=1000,
on_disk=False
)
)
# Configurare ef a query time (più flessibile)
results = client.search(
collection_name="rag_balanced",
query_vector=query_embedding,
limit=10,
search_params={
"hnsw_ef": 128, # Aumenta recall senza rebuild indice
"exact": False
}
)
print(f"Trovati {len(results)} risultati")
for hit in results:
print(f"Score: {hit.score:.4f} | ID: {hit.id}")
IVF vs HNSW vs DiskANN: Quale Algoritmo Scegliere
HNSW non è l'unico algoritmo di indicizzazione disponibile. La scelta dipende fortemente da vincoli di memoria, dimensione del dataset e pattern di update.
IVF (Inverted File Index)
IVF suddivide lo spazio vettoriale in nlist cluster tramite k-means.
Durante la query, cerca solo nei nprobe cluster più vicini al query vector.
I parametri chiave sono nlist (numero cluster) e nprobe
(quanti cluster ispezionare). Formula empirica consigliata: nlist = 4 * sqrt(n_vectors).
Pro IVF: build veloce, memoria moderata, buono per dataset statici. Contro IVF: richiede re-clustering se i dati cambiano molto, recall inferiore a HNSW per stesso budget computazionale, cold start su nuove collezioni.
HNSW (Hierarchical Navigable Small World)
Come descritto sopra, costruisce un grafo multi-layer. È l'algoritmo più versatile e usato di default nella maggior parte dei vector database.
Pro HNSW: eccellente recall-speed tradeoff, supporto nativo agli update incrementali, parametri configurabili a query time. Contro HNSW: richiede che l'intero indice stia in RAM, diventa proibitivo sopra i 50-100M vettori su hardware standard.
DiskANN
Sviluppato da Microsoft Research, DiskANN è progettato per dataset che non entrano in RAM. Mantiene in memoria solo una struttura grafo compatta (compressed graph), mentre i vettori completi sono su SSD NVMe. Con hardware PCIe Gen5, mantiene recall >95% e latency 10ms su miliardi di vettori, con costo DRAM 10-20x inferiore a HNSW equivalente.
Pro DiskANN: scala a miliardi di vettori su hardware commodity, costo operativo ridotto. Contro DiskANN: richiede SSD NVMe veloci, latency più alta di HNSW in-memory, l'implementazione base è immutabile (FreshDiskANN gestisce gli update). Disponibile in Milvus, Azure PostgreSQL con pgvector estesa, e in anteprima in altri sistemi.
Attenzione al "HNSW for everything" Anti-Pattern
Molti team configurano HNSW in-memory per dataset da 50M+ vettori, poi si trovano con istanze da 256GB RAM a costi insostenibili. La regola pratica: se il tuo dataset supera i 10-20M vettori e non hai requisiti di latency ultra-bassa (<5ms), valuta seriamente DiskANN o quantizzazione aggressiva prima di aumentare l'hardware.
# Configurazione IVF_FLAT e HNSW in Milvus - confronto pratico
from pymilvus import (
connections, Collection, CollectionSchema,
FieldSchema, DataType, utility
)
connections.connect("default", host="localhost", port="19530")
# Schema comune per entrambi i test
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=100),
FieldSchema(name="timestamp", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536)
]
schema = CollectionSchema(fields, description="RAG document collection")
# Crea collezione
collection = Collection("rag_docs", schema)
# --- Indice IVF_FLAT: per dataset statici o quasi-statici ---
# nlist = 4 * sqrt(n_vectors) regola empirica
# Per 1M vettori: nlist = 4000
ivf_index_params = {
"metric_type": "COSINE",
"index_type": "IVF_FLAT",
"params": {
"nlist": 4096 # Numero di cluster Voronoi
}
}
# --- Indice HNSW: per dataset con update frequenti ---
hnsw_index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {
"M": 32,
"efConstruction": 256
}
}
# --- Indice DiskANN: per dataset >50M vettori ---
diskann_index_params = {
"metric_type": "L2",
"index_type": "DISKANN",
"params": {
"search_list": 100 # Candidati durante la ricerca
}
}
# Build dell'indice prescelto
collection.create_index(
field_name="embedding",
index_params=hnsw_index_params # Scegliere in base al caso d'uso
)
collection.load()
# Query con parametri specifici per HNSW
search_params_hnsw = {
"metric_type": "COSINE",
"params": {
"ef": 256 # Aumentare per più recall, diminuire per più velocità
}
}
# Query con parametri per IVF
search_params_ivf = {
"metric_type": "COSINE",
"params": {
"nprobe": 64 # nprobe/nlist = fraction di cluster ispezionati
} # 64/4096 = 1.5% - bilancio recall/speed
}
results = collection.search(
data=[query_embedding],
anns_field="embedding",
param=search_params_hnsw,
limit=10,
output_fields=["content", "category", "timestamp"]
)
for hit in results[0]:
print(f"Distance: {hit.distance:.4f} | Category: {hit.entity.get('category')}")
Quantizzazione Vettoriale: Comprimere senza Perdere Recall
La quantizzazione è la tecnica più potente per ridurre l'uso di memoria dei vector database, con impatto controllabile sulla qualità della ricerca. Un vettore float32 a 1536 dimensioni occupa 6144 byte (6KB). Con quantizzazione, possiamo ridurlo a 384 byte o meno.
Scalar Quantization (SQ)
Mappa ogni valore float32 (4 byte) in un int8 (1 byte), ottenendo una compressione 4x. L'algoritmo analizza la distribuzione dei valori in ogni dimensione è determina un range ottimale per la quantizzazione. Le distanze vengono calcolate direttamente su int8, più efficienti computazionalmente. Il recall loss tipico è del 1-3% rispetto a float32.
Quando usarla: punto di partenza consigliato per qualsiasi deployment, riduzione 4x con impatto minimo sulla qualità. Supportata da Qdrant, Milvus (IVF_SQ8), Weaviate (PQ con scalar fallback).
Product Quantization (PQ)
Divide il vettore in m sottovettori e quantizza ognuno in un codebook di 2^nbits entry. Compressione tipica: 16-64x. Con PQ ogni vettore viene rappresentato come una sequenza di indici nel codebook. Le distanze approssimate si calcolano tramite lookup table precompute (ADC - Asymmetric Distance Computation).
Tradeoff PQ: compressione aggressiva (10-50MB invece di 10GB) ma recall loss significativo (5-15%). Richiede training del codebook sul dataset. Buono per dataset enormi dove la memoria è il vincolo primario.
Binary Quantization (BQ)
Riduce ogni dimensione a 1 bit: il valore più compresso possibile. Un vettore a 1536 dimensioni diventa 192 byte (32x compressione rispetto a float32). La distanza si calcola con Hamming distance (XOR + popcount), operazione estremamente veloce sulle CPU moderne. Qdrant riporta speedup fino a 40x sulle operazioni di distance calculation.
Tuttavia Binary Quantization funziona bene solo su embedding con proprietà specifiche: i valori devono essere distribuiti simmetricamente attorno a zero (proprietà soddisfatta da OpenAI ada-002, Cohere embed-v3, e-5 models). Per embedding con distribuzioni asimmetriche, il recall drop può essere severo (15-30%).
Qdrant ha introdotto nel 2025 anche quantizzazioni intermedie a 1.5-bit e 2-bit, offrendo un punto di equilibrio tra scalar (4x) e binary (32x).
# Configurazione quantizzazione in Qdrant - tutti i tipi
from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance,
ScalarQuantizationConfig, ScalarType,
ProductQuantizationConfig, CompressionRatio,
BinaryQuantizationConfig,
QuantizationConfig
)
client = QdrantClient(url="http://localhost:6333")
# --- 1. Scalar Quantization (SQ8) ---
# Compressione 4x, recall loss ~1-3%
# CONSIGLIATO: miglior punto di partenza
client.recreate_collection(
collection_name="rag_sq8",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE
),
quantization_config=ScalarQuantizationConfig(
scalar=QuantizationConfig(
type=ScalarType.INT8,
quantile=0.99, # Usa il 99° percentile per definire il range
always_ram=True # Tieni quantized vectors in RAM (+ velocità)
)
)
)
# --- 2. Product Quantization (PQ) ---
# Compressione 16-64x, recall loss 5-15%
# PER dataset enormi (>100M vettori) con vincoli di memoria severi
client.recreate_collection(
collection_name="rag_pq",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE
),
quantization_config=ProductQuantizationConfig(
product=QuantizationConfig(
compression=CompressionRatio.X16, # 16x compressione
always_ram=True
)
)
)
# --- 3. Binary Quantization (BQ) ---
# Compressione 32x, speedup 40x, recall loss variabile
# SOLO per embedding con distribuzione simmetrica (OpenAI, Cohere)
client.recreate_collection(
collection_name="rag_binary",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE
),
quantization_config=BinaryQuantizationConfig(
binary=QuantizationConfig(
always_ram=True
)
)
)
# Verifica dell'impatto sulla qualità a query time
# Con rescore=True, i candidati BQ vengono rirankinati con float32
def search_with_rescore(client, collection_name, query_vector, limit=10):
"""
rescore=True: usa BQ per candidate generation, poi
ricalcola distanze esatte con float32 sui top-k candidati.
Bilancia la velocità di BQ con la precisione di float32.
"""
return client.search(
collection_name=collection_name,
query_vector=query_vector,
limit=limit,
search_params={
"quantization": {
"ignore": False, # Usa quantizzazione
"rescore": True, # Rescore finale con float32
"oversampling": 3.0 # Preleva 3x candidati per il rescore
}
}
)
# Benchmark comparativo: misura recall vs latency
import time
import numpy as np
def benchmark_collection(client, collection_name, test_queries, ground_truth, k=10):
recalls = []
latencies = []
for query, gt in zip(test_queries, ground_truth):
start = time.perf_counter()
results = client.search(
collection_name=collection_name,
query_vector=query.tolist(),
limit=k
)
elapsed_ms = (time.perf_counter() - start) * 1000
latencies.append(elapsed_ms)
# Calcola recall@k
retrieved_ids = {hit.id for hit in results}
true_ids = set(gt[:k])
recall = len(retrieved_ids & true_ids) / k
recalls.append(recall)
return {
"mean_recall": np.mean(recalls),
"p95_latency_ms": np.percentile(latencies, 95),
"p99_latency_ms": np.percentile(latencies, 99)
}
# Confronto risultati tipici (su hardware commodity, 1M vettori 1536-dim)
# float32: recall=1.00, p95_latency=45ms, memory=6.1GB
# SQ8: recall=0.98, p95_latency=18ms, memory=1.6GB ← sweet spot
# PQ16: recall=0.91, p95_latency=8ms, memory=0.4GB
# Binary: recall=0.93, p95_latency=3ms, memory=0.2GB (con rescore)
Regola Pratica: Scegliere la Quantizzazione
- Dataset <10M vettori, recall critico: float32 nativo (nessuna quantizzazione)
- Dataset 10-100M vettori: Scalar Quantization INT8, sweet spot qualità/memoria
- Dataset >100M vettori, memoria limitata: Product Quantization con rescore
- Latency ultra-bassa con OpenAI/Cohere embeddings: Binary Quantization + rescore
Filtered Vector Search: Il Problema dei Filtri Stretti
Nella pratica, la maggior parte delle query RAG non è una ricerca vettoriale pura: si vuole trovare i documenti semanticamente simili e appartenenti a un certo utente, data range, categoria o tenant. Il filtered vector search è uno dei problemi algoritmicamente più difficili nei vector database.
Il problema fondamentale: con filtri molto selettivi (es. "solo documenti dell'ultimo mese" che matchano 0.1% del dataset), i k vicini più prossimi nello spazio vettoriale potrebbero essere tutti esclusi dal filtro, forzando la ricerca a esplorare una porzione molto ampia del grafo HNSW prima di trovare k risultati validi. Questo può aumentare la latency di 10-100x rispetto a una ricerca non filtrata.
Strategie di Filtering
Post-filtering: esegui la ricerca ANN normalmente, poi filtra i risultati. Funziona se il filtro è poco selettivo (esclude meno del 50% dei risultati). Problema: se il filtro esclude il 99% dei vettori, devi recuperare 100x più candidati.
Pre-filtering: prima identifica i punti che soddisfano il filtro, poi esegui la ricerca ANN solo su quell'insieme. Richiede un indice scalare efficiente sul campo filtrato. Funziona bene con filtri ad alta selettivita ma richiede payload indexing.
Filterable HNSW (Qdrant): Qdrant implementa un'estensione sofisticata di HNSW che aggiunge edges aggiuntivi al grafo basati sui valori del payload indicizzato. Il query planner stima la cardinalita del filtro e sceglie dinamicamente la strategia: se il filtro è molto selettivo usa l'indice payload, altrimenti usa il filterable HNSW.
Per casi con combinazioni di filtri multipli e stretti, Qdrant raccomanda l'uso dell'algoritmo ACORN (Adaptive Component-Overlap Routing Network), che gestisce meglio i grafi disconnessi causati da filtri aggressivi.
# Filtered vector search in Qdrant - best practices
from qdrant_client import QdrantClient
from qdrant_client.models import (
Filter, FieldCondition, MatchValue, Range,
MatchAny, SearchRequest
)
import datetime
client = QdrantClient(url="http://localhost:6333")
# STEP 1: Crea indici payload per i campi filtrati frequentemente
# CRITICO: senza payload index, il filtering scansiona tutti i punti
# Indice per filtri di uguaglianza (tenant_id, category)
client.create_payload_index(
collection_name="rag_docs",
field_name="tenant_id",
field_schema="keyword" # Per valori categorici
)
client.create_payload_index(
collection_name="rag_docs",
field_name="category",
field_schema="keyword"
)
# Indice per filtri di range (timestamp, score)
client.create_payload_index(
collection_name="rag_docs",
field_name="created_at",
field_schema="integer" # UNIX timestamp per range queries
)
client.create_payload_index(
collection_name="rag_docs",
field_name="relevance_score",
field_schema="float"
)
# STEP 2: Query con filtri - da semplice a complesso
# Filtro singolo (alta cardinalita): efficiente con keyword index
def search_by_tenant(query_vector, tenant_id, limit=10):
return client.search(
collection_name="rag_docs",
query_vector=query_vector,
query_filter=Filter(
must=[
FieldCondition(
key="tenant_id",
match=MatchValue(value=tenant_id)
)
]
),
limit=limit
)
# Filtro combinato (filtro stretto): usa HNSW filterable
def search_recent_high_quality(query_vector, tenant_id, days_back=7, limit=10):
cutoff = int((datetime.datetime.now() -
datetime.timedelta(days=days_back)).timestamp())
return client.search(
collection_name="rag_docs",
query_vector=query_vector,
query_filter=Filter(
must=[
FieldCondition(
key="tenant_id",
match=MatchValue(value=tenant_id)
),
FieldCondition(
key="created_at",
range=Range(gte=cutoff) # >= cutoff timestamp
),
FieldCondition(
key="relevance_score",
range=Range(gte=0.7) # Solo documenti di qualità
)
]
),
limit=limit,
search_params={
"hnsw_ef": 256, # Aumenta ef per filtri stretti
"exact": False
}
)
# Filtro con OR (MatchAny): utile per multi-category search
def search_multi_category(query_vector, categories, limit=10):
return client.search(
collection_name="rag_docs",
query_vector=query_vector,
query_filter=Filter(
must=[
FieldCondition(
key="category",
match=MatchAny(any=categories) # OR sui valori
)
]
),
limit=limit
)
# STEP 3: Batch search per performance (evita N query singole)
def batch_search(query_vectors, tenant_id, limit=10):
"""
Usa search_batch per ridurre overhead di N query indipendenti.
Throughput tipico: 3-5x rispetto a query sequenziali.
"""
requests = [
SearchRequest(
vector=qv,
filter=Filter(
must=[FieldCondition(
key="tenant_id",
match=MatchValue(value=tenant_id)
)]
),
limit=limit
)
for qv in query_vectors
]
return client.search_batch(
collection_name="rag_docs",
requests=requests
)
Confronto Database: Qdrant vs Pinecone vs Milvus vs Weaviate
Ogni database ha un profilo di forza diverso. Non esiste una scelta universalmente ottima: la decisione dipende da vincoli di deployment, team capability, e requisiti specifici.
Qdrant
Scritto in Rust, è il database con il miglior rapporto performance/complessità operativa nel 2025. Supporta filtering sofisticato con payload indexes e filterable HNSW, scalar/product/binary quantization, multi-vector per named vectors, sparse vectors per hybrid search nativo. Il deployment più semplice: single binary, Docker, o cloud managed. Ottimo per team che vogliono controllo senza overhead operativo massiccio.
Ideale per: RAG enterprise, multi-tenant systems, deployment on-premise, team con esperienza Python ma non infrastrutture Kubernetes complesse.
Pinecone
Fully managed, serverless, zero ops. Il prezzo è più alto delle alternative self-hosted ma elimina completamente il costo operativo di infrastruttura. Eccellente per team che preferiscono focus sul prodotto senza gestire cluster. Supporta serverless pods con autoscaling trasparente e multi-region replication. La latency è consistentemente bassa grazie all'infrastruttura ottimizzata.
Ideale per: startup early-stage, team piccoli, workload variabile, proof of concept che diventano produzione senza rework.
Milvus / Zilliz Cloud
Il sistema distribuito più maturo e feature-completo. Supporta tutti gli index types (HNSW, IVF, DiskANN, ScaNN, GPU-accelerated), sharding automatico su Kubernetes, separazione compute/storage. La versione cloud (Zilliz) vince i benchmark di throughput su dataset >100M vettori. Overhead operativo significativo su Kubernetes.
Ideale per: dataset >50M vettori, team con infrastruttura Kubernetes esistente, requisiti di throughput massimo, GPU acceleration.
Weaviate
Posizionato tra vector database puro e knowledge graph. Supporta moduli integrati per generazione automatica di embedding (text2vec-openai, text2vec-cohere), GraphQL query interface, e ibridazione con BM25 nativa. Richiede più memoria rispetto agli altri a parita di dataset. Ottimo per team che vogliono integrare retrieval e knowledge graph.
Ideale per: semantic search con knowledge graph, team che usano GraphQL, integrazione diretta con model providers senza gestire embedding pipeline.
Decision Matrix: Come Scegliere
- Team piccolo, velocità di sviluppo: Pinecone (zero ops) o Qdrant (semplicità)
- Dataset >50M vettori, high throughput: Milvus con DiskANN o GPU index
- Multi-tenant RAG con filtri complessi: Qdrant (filterable HNSW)
- Knowledge graph + semantic search: Weaviate
- Gia su PostgreSQL, volume moderato: pgvector (evita infrastruttura aggiuntiva)
- Hybrid search nativo senza overhead: Qdrant sparse vectors o Weaviate BM25
Pinecone: Configurazione e Ottimizzazione
Pinecone ha semplificato ulteriormente il suo SDK con l'architettura serverless nel 2024-2025. Non si configura più esplicitamente l'algoritmo di indicizzazione: Pinecone gestisce internamente la scelta dell'indice in base alla dimensione del dataset.
# Pinecone - setup e ottimizzazione con SDK v3+
from pinecone import Pinecone, ServerlessSpec, PodSpec
import os
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
# --- Serverless Index (consigliato per la maggior parte dei casi) ---
# Autoscaling trasparente, pay-per-query
pc.create_index(
name="rag-serverless",
dimension=1536, # Deve matchare il modello di embedding
metric="cosine", # oppure "dotproduct" per modelli ottimizzati per IP
spec=ServerlessSpec(
cloud="aws",
region="us-east-1"
)
)
# --- Pod Index (per latency garantita e throughput alto) ---
# Scegliere il tipo di pod in base al profilo workload
pc.create_index(
name="rag-pod-optimized",
dimension=1536,
metric="cosine",
spec=PodSpec(
environment="us-east1-gcp",
pod_type="p2.x1", # p1=storage, p2=speed, s1=storage-optimized
pods=1,
replicas=2, # 2 replicas per HA
shards=1
)
)
index = pc.Index("rag-serverless")
# Upsert con metadata ricchi per filtering
def upsert_documents(documents, embeddings):
vectors = [
{
"id": doc["id"],
"values": emb.tolist(),
"metadata": {
"text": doc["text"][:1000], # Pinecone limit: 40KB per vector
"source": doc["source"],
"tenant_id": doc["tenant_id"],
"created_at": doc["created_at"], # ISO string o epoch int
"category": doc["category"],
"language": doc.get("language", "it")
}
}
for doc, emb in zip(documents, embeddings)
]
# Batch upsert: max 100 vectors per request, max 2MB
batch_size = 100
for i in range(0, len(vectors), batch_size):
batch = vectors[i:i + batch_size]
index.upsert(vectors=batch)
# Query con metadata filtering
def query_pinecone(query_embedding, tenant_id, limit=10, category=None):
filter_dict = {"tenant_id": {"$eq": tenant_id}}
if category:
filter_dict["category"] = {"$in": category if isinstance(category, list) else [category]}
return index.query(
vector=query_embedding.tolist(),
top_k=limit,
filter=filter_dict,
include_metadata=True
)
# Fetch statistiche indice per monitoring
stats = index.describe_index_stats()
print(f"Totale vettori: {stats['total_vector_count']}")
print(f"Dimensione: {stats['dimension']}")
print(f"Namespace breakdown: {stats.get('namespaces', {})}")
# Pinecone Namespaces: isolamento logico multi-tenant senza costi extra
# Inserimento in namespace specifico
index.upsert(
vectors=[{"id": "doc1", "values": embedding}],
namespace="tenant-acme-corp"
)
# Query nel namespace
index.query(
vector=query_embedding,
top_k=10,
namespace="tenant-acme-corp"
)
Memory Optimization e Production Tuning
Un vector database in produzione richiede attenzione a più dimensioni: non solo recall e latency, ma anche utilizzo di memoria, throughput, comportamento sotto carico, e costi operativi a lungo termine.
Memory Footprint Estimation
Formula base per stimare la memoria richiesta (float32, no quantizzazione):
- Vettori raw: n_vectors * dim * 4 bytes
- HNSW graph: n_vectors * M * 2 * 8 bytes (circa, dipende dall'implementazione)
- Payload/metadata: variabile, tipicamente 100-500 bytes per vettore
- Overhead sistema: ~20-30% del totale
Esempio: 5 milioni di vettori a 1536 dim con HNSW M=32: Vettori: 5M * 1536 * 4 = ~29GB. HNSW graph: 5M * 32 * 2 * 8 = ~2.5GB. Totale stimato con overhead: ~38GB RAM. Con SQ8: ~11GB. Con Binary: ~1.5GB.
Qdrant Performance Tuning in Produzione
# Qdrant - ottimizzazioni avanzate per produzione
from qdrant_client import QdrantClient
from qdrant_client.models import (
OptimizersConfigDiff, WalConfigDiff,
HnswConfigDiff, QuantizationConfig,
ScalarQuantizationConfig, ScalarType
)
client = QdrantClient(
url="http://localhost:6333",
# Connection pool per high-throughput
timeout=30
)
# Configurazione ottimizers per bulk ingestion
# Durante l'ingestion massiva, disabilita temporaneamente il reindexing
client.update_collection(
collection_name="rag_docs",
optimizers_config=OptimizersConfigDiff(
# Aumenta la soglia per ritardare il reindexing
# durante bulk insert (es. 200k invece di default 20k)
indexing_threshold=200000,
# Numero di segmenti ottimali per la collection
# più segmenti = parallelismo migliore in lettura
default_segment_number=8,
# Max dimensione segmento prima di merge
max_segment_size=500000,
# Delay prima di ottimizzare (evita ottimizzazioni inutili su dati transienti)
flush_interval_sec=5,
)
)
# Dopo il bulk insert, forza l'ottimizzazione
# e ripristina configurazione normale
client.update_collection(
collection_name="rag_docs",
optimizers_config=OptimizersConfigDiff(
indexing_threshold=20000, # Ripristina default
default_segment_number=4
)
)
# Monitoring della collection: verifica stato di ottimizzazione
collection_info = client.get_collection("rag_docs")
print(f"Stato: {collection_info.status}")
print(f"Vettori totali: {collection_info.vectors_count}")
print(f"Segmenti: {collection_info.segments_count}")
print(f"Dimensione disco: {collection_info.disk_data_size} bytes")
print(f"Dimensione RAM: {collection_info.ram_data_size} bytes")
# Check se l'indice è aggiornato (indexed_vectors_count == vectors_count)
if collection_info.indexed_vectors_count < collection_info.vectors_count:
not_indexed = collection_info.vectors_count - collection_info.indexed_vectors_count
print(f"ATTENZIONE: {not_indexed} vettori non ancora indicizzati (query meno efficienti)")
# Configurazione WAL per durability vs performance
# Per ambienti in cui un crash è accettabile (risincronizzazione possibile)
client.update_collection(
collection_name="rag_docs",
wal_config=WalConfigDiff(
wal_capacity_mb=256,
wal_segments_ahead=0 # 0 = massima velocità, meno durability
)
)
# Snapshot per backup
snapshot_info = client.create_snapshot(collection_name="rag_docs")
print(f"Snapshot creato: {snapshot_info.name}")
# Scroll per esportare o processare tutti i vettori
# (evita di usare search per questo scopo)
def export_all_vectors(client, collection_name, batch_size=1000):
offset = None
all_points = []
while True:
batch, next_offset = client.scroll(
collection_name=collection_name,
offset=offset,
limit=batch_size,
with_vectors=True,
with_payload=True
)
all_points.extend(batch)
if next_offset is None:
break
offset = next_offset
return all_points
Benchmarking e Misurazione del Recall
Nessuna ottimizzazione è valida senza una misurazione rigorosa. Il framework standard per valutare i vector database è basato su tre metriche fondamentali:
- Recall@k: percentuale dei veri k nearest neighbors trovati tra i k risultati restituiti. È la metrica più importante per la qualità. Formula: |retrieved ∩ true| / k
- QPS (Queries Per Second): throughput del sistema sotto carico. Si misura tipicamente a recall target fissato (es. "QPS @ recall=0.95").
- Latency percentili (p50, p95, p99): la latency media è fuorviante. In produzione conta il p99: il 99% delle query deve completarsi entro il SLA.
Il benchmark di riferimento per i vector database è ann-benchmarks.com, che misura tutti i principali sistemi su dataset standardizzati (SIFT1M, GIST1M, GloVe-100-angular). I risultati 2024-2025 mostrano Qdrant e Milvus tra i leader per recall-throughput tradeoff, con Pinecone eccellente per latency p99 consistenza.
# Framework di benchmarking per vector database
import time
import numpy as np
from typing import List, Tuple, Dict
from dataclasses import dataclass
@dataclass
class BenchmarkResult:
mean_recall: float
p50_latency_ms: float
p95_latency_ms: float
p99_latency_ms: float
qps: float
total_queries: int
class VectorDBBenchmark:
"""
Framework per benchmarkare un vector database.
Genera ground truth con brute force e confronta con ANN.
"""
def __init__(self, collection_size: int, dim: int, n_test_queries: int = 1000):
self.collection_size = collection_size
self.dim = dim
self.n_test_queries = n_test_queries
def generate_test_data(self) -> Tuple[np.ndarray, np.ndarray]:
"""Genera dataset e query vectors normalizzati."""
# Simula embedding realistici (distribuzione gaussiana normalizzata)
data = np.random.randn(self.collection_size, self.dim).astype(np.float32)
data = data / np.linalg.norm(data, axis=1, keepdims=True)
queries = np.random.randn(self.n_test_queries, self.dim).astype(np.float32)
queries = queries / np.linalg.norm(queries, axis=1, keepdims=True)
return data, queries
def compute_ground_truth(self, data: np.ndarray, queries: np.ndarray, k: int = 10) -> np.ndarray:
"""
Calcola i veri k nearest neighbors con brute force.
LENTO ma necessario come reference per calcolare il recall.
"""
ground_truth = np.zeros((len(queries), k), dtype=np.int64)
for i, query in enumerate(queries):
# Cosine similarity = dot product su vettori normalizzati
similarities = data @ query
top_k_indices = np.argsort(similarities)[::-1][:k]
ground_truth[i] = top_k_indices
return ground_truth
def run_benchmark(
self,
search_fn, # Funzione di ricerca: (query_vector, k) -> List[int]
queries: np.ndarray,
ground_truth: np.ndarray,
k: int = 10
) -> BenchmarkResult:
"""Esegue il benchmark completo."""
recalls = []
latencies = []
# Warmup (i primi risultati possono essere penalizzati da cold start)
for _ in range(10):
search_fn(queries[0], k)
# Benchmark effettivo
for i, query in enumerate(queries):
start = time.perf_counter()
results = search_fn(query, k)
elapsed_ms = (time.perf_counter() - start) * 1000
latencies.append(elapsed_ms)
# Calcola recall@k
retrieved = set(results[:k])
true_set = set(ground_truth[i].tolist())
recall = len(retrieved & true_set) / k
recalls.append(recall)
total_time = sum(latencies) / 1000 # in secondi
qps = len(queries) / total_time
return BenchmarkResult(
mean_recall=float(np.mean(recalls)),
p50_latency_ms=float(np.percentile(latencies, 50)),
p95_latency_ms=float(np.percentile(latencies, 95)),
p99_latency_ms=float(np.percentile(latencies, 99)),
qps=qps,
total_queries=len(queries)
)
# Esempio di utilizzo con Qdrant
def qdrant_search_fn(client, collection_name, ef=128):
def search(query_vector: np.ndarray, k: int) -> List[int]:
results = client.search(
collection_name=collection_name,
query_vector=query_vector.tolist(),
limit=k,
search_params={"hnsw_ef": ef}
)
return [hit.id for hit in results]
return search
# Esegui il benchmark per diversi valori di ef
benchmark = VectorDBBenchmark(collection_size=1_000_000, dim=1536)
data, queries = benchmark.generate_test_data()
gt = benchmark.compute_ground_truth(data, queries[:100], k=10) # Subset per ground truth
for ef_value in [32, 64, 128, 256, 512]:
search_fn = qdrant_search_fn(client, "rag_docs", ef=ef_value)
result = benchmark.run_benchmark(search_fn, queries[:100], gt, k=10)
print(f"ef={ef_value:3d} | "
f"Recall: {result.mean_recall:.3f} | "
f"P95: {result.p95_latency_ms:.1f}ms | "
f"QPS: {result.qps:.0f}")
Hybrid Search: Vector + BM25 nel Vector Database
I vector database moderni non sono più sistemi puramente vettoriali: molti supportano ormai la ricerca ibrida che combina dense vectors con sparse vectors (BM25/TF-IDF). Questo argomento è approfondito nell'articolo dedicato al Hybrid Retrieval, ma è importante capire come si integra a livello di vector database.
Qdrant supporta sparse vectors nativamente: puoi salvare sia un dense vector (embedding semantico) che uno sparse vector (BM25 weights) per ogni documento, ed eseguire query ibride in una singola richiesta con RRF (Reciprocal Rank Fusion) o score fusion custom.
Weaviate ha la hybrid search integrata nel suo schema GraphQL: specifica un alpha (0=pure BM25, 1=pure vector) per controllare il peso relativo. Milvus 2.4+ ha introdotto sparse-dense fusion. Pinecone supporta sparse-dense con il modello Pinecone Sparse encoder o BM25 custom.
Per approfondire le implementazioni di hybrid retrieval e i metodi di fusion (RRF, weighted sum, learned fusion), consulta l'articolo Hybrid Retrieval: BM25 + Vector Search di questa serie.
Cross-Link: Articoli Correlati
- RAG: Retrieval-Augmented Generation Spiegato - Fondamenti RAG per contestualizzare il ruolo del vector database
- Embedding e Vector Search: BERT vs Sentence Transformers - Come scegliere il modello di embedding per la tua pipeline
- Hybrid Retrieval: BM25 + Vector Search - Combina vector search con ricerca keyword per recall migliore
- PostgreSQL con pgvector - Vector search su PostgreSQL senza infrastruttura aggiuntiva
Checklist di Produzione
Prima di portare un vector database in produzione, verifica questi punti critici:
- Benchmark sul tuo dataset reale: i risultati generici non si trasferiscono automaticamente al tuo caso d'uso. Misura recall e latency con query reali.
- Payload indexes configurati: ogni campo su cui filtri deve avere un indice, altrimenti il filtering scansiona tutti i punti.
- Quantizzazione appropriata: valuta SQ8 come default, misura il recall loss. Se accettabile, applica subito: il risparmio di memoria è significativo.
- Backup e snapshot: configura snapshot automatici. I vector database non hanno sempre transazioni ACID; un crash durante l'ingestion può corrompere l'indice.
- Monitoring: traccia indexed_vectors_count vs vectors_count per detectare lag nell'indicizzazione che degrada le query performance.
- Dimensionamento memoria: calcola il footprint effettivo prima del deployment. Un server con memoria insufficiente causa swapping che distrugge la latency.
- Test con filtri stretti: se la tua applicazione usa filtri molto selettivi, testa esplicitamente questi scenari. La latency sotto filtri stretti è molto diversa da quella su ricerche non filtrate.
Anti-pattern Comuni da Evitare
- Indexing threshold troppo basso: con indexing_threshold=0 o molto basso, ogni inserimento triggerisce un reindexing, rendendo l'ingestion lentissima. Usa soglie di 10k-100k per bulk insert, poi ottimizza.
- M troppo alto senza misurare: M=128 non è sempre migliore di M=32. Sopra un certo punto il recall migliora marginalmente ma la memoria cresce linearmente. Misura con il tuo dataset.
- Nessun payload index sui campi filtrati: senza index, ogni filter condition e O(n). Con 10M vettori, un filtro non indicizzato fa la differenza tra 5ms e 5000ms.
- Dimensione vettori non normalizzati con cosine similarity: se usi cosine similarity, i vettori devono essere normalizzati. Alcuni modelli non li normalizzano di default. Vettori non normalizzati con cosine danno risultati semanticamente errati.
Conclusioni e Next Steps
La scelta è ottimizzazione del vector database è uno degli aspetti più tecnici e impattanti dell'AI engineering. Non esistono risposte universali: ogni sistema ha un profilo di forza diverso e la configurazione ottimale dipende dal tuo specifico workload.
Il percorso consigliato per un nuovo progetto: inizia con Qdrant con SQ8 per semplicità operativa e buone performance, poi misura recall e latency sul tuo dataset reale. Se le performance non bastano, esplora M e ef tuning. Se la memoria è un problema, valuta product quantization o DiskANN. Se hai già PostgreSQL e volume moderato (<5M vettori), considera pgvector prima di aggiungere nuova infrastruttura.
I prossimi articoli di questa serie costruiscono su questi fondamenti: nell'articolo sul Hybrid Retrieval vedremo come combinare vector search con BM25 per migliorare il recall su query precise, mentre nell'articolo su RAG in Production vedremo come misurare l'impatto end-to-end delle scelte di vector database sulla qualità delle risposte RAG.
I concetti di embedding e modelli semantici presentati qui si collegano direttamente alla serie NLP Moderno e alla serie PostgreSQL AI per chi vuole implementare vector search su infrastruttura esistente.







