Baza danych wektorowych: wybór i optymalizacja dla inżynierii AI
Budując rurociąg RAG w produkcji, wybór bazy danych wektorowych nie jest szczegółem wdrożenie: jest to decyzja architektoniczna, która wpływa na opóźnienia, koszty operacyjne i dokładność przypominania i skalowalność systemu. W 2025 roku rynek wektorowych baz danych będzie wart więcej 2,65 miliarda dolarów wzrosła także liczba dostępnych rozwiązań drastycznie, co sprawia, że wybór staje się coraz bardziej złożony.
Ten artykuł nie jest przeglądem marketingowym funkcji komercyjnych. To techniczne głębokie nurkowanie jak wewnętrznie działają wektorowe bazy danych, jakich algorytmów indeksowania używają, jak są skonfigurowane i zoptymalizowane pod kątem rzeczywistych obciążeń. Przeanalizujemy Qdrant, Pinecone, Milvus i Weaviate porównują je w konkretnych wymiarach: architektura HNSW, strategie kwantyzacji, wyszukiwanie filtrowane, DiskANN vs in-memory i dostrajanie parametrów, aby osiągnąć cel przywołanie/opóźnienie zdefiniowane przez aplikację.
Jeśli budujesz system RAG, który musi obsłużyć miliony dokumentów z opóźnieniami poniżej 50 ms i przywołaj powyżej 95% lub jeśli optymalizujesz istniejący system, który zużywa zbyt dużo pamięci, W tym artykule przedstawiono koncepcyjne i praktyczne narzędzia umożliwiające podejmowanie świadomych decyzji.
Czego się nauczysz
- Wewnętrzna architektura wektorowych baz danych: jak działa HNSW na poziomie algorytmicznym
- Porównanie IVF vs HNSW vs DiskANN: kiedy którego użyć i dlaczego
- Kwantyzacja skalarna, iloczynowa i binarna: kompromis pamięć/dokładność
- Filtrowane wyszukiwanie wektorów: filtracja wstępna, filtracja końcowa i problem wąskich filtrów
- Praktyczna konfiguracja Qdrant, Milvus i Pinecone z przykładami kodu
- Benchmarking i dostrajanie w produkcji: jak mierzyć i ulepszać QPS oraz wycofanie
Architektura wewnętrzna: jak działa baza danych wektorowych
Baza wektorowa różni się od bazy relacyjnej nie tylko danymi, którymi zarządza, ale ze względu na rodzaj podstawowej operacji należy zoptymalizować: zamiast dokładnych wyszukiwań na dyskretnych kluczach, wykonuje Wyszukiwanie przybliżonego najbliższego sąsiada (ANN). w przestrzeniach wielowymiarowych, zazwyczaj o wymiarach 768–4096 dla nowoczesnych osadzań LLM.
Dokładne wyszukiwanie k najbliższych sąsiadów (kNN) ma złożoność O(n*d), gdzie n jest liczbą wektorów i d wymiarów. Z 10 milionami wektorów w 1536 wymiarach (rozmiar standardowy OpenAI ada-002), dokładne zapytanie wymagałoby ~15 miliardów operacji zmiennoprzecinkowych: całkowicie nie do przyjęcia w przypadku systemu czasu rzeczywistego. Używają wszystkich współczesnych wektorowych baz danych dlatego algorytmy SSN, które poświęcają część przypomnienia, aby zyskać rzędy wielkości z prędkością.
Wewnętrzny stos bazy danych wektorowych jest podzielony na kilka poziomów:
- Warstwy przechowywania: zarządzanie skompresowanymi wektorami na dysku lub w pamięci, z obsługą mmap dla wydajnego dostępu
- Warstwy indeksowe: Struktura danych ANN (HNSW, IVF, DiskANN) do nawigacji w przestrzeni wektorowej
- Warstwa ładunku/metadanych: atrybuty skalarne powiązane z wektorami do filtrowania
- Planista zapytań: decyduje o optymalnej strategii, łącząc wyszukiwanie wektorów i filtrowanie ładunku
- Warstwa replikacji/shardingu: dla systemów rozproszonych, takich jak Milvus lub Pinecone
HNSW: Algorytmiczne głębokie nurkowanie
Hierarchiczny żeglowny mały świat (HNSW) jest dominującym algorytmem SSN w 2025 r., używany domyślnie przez Qdrant, Weaviate jest dostępny w Milvusie. Zrozum, jak to działa internal jest niezbędny do jego prawidłowego skonfigurowania.
HNSW konstruuje wielopoziomowy graf hierarchiczny. Na najwyższym poziomie znajduje się kilka węzłów silnie ze sobą powiązane („węzły”), przy schodzeniu w dół zagęszczenie wzrasta na poziomie 0, który zawiera wszystkie wektory. Podczas wyszukiwania algorytm zaczyna się od góry i schodzi po kolejnych poziomach, stopniowo udoskonalając kandydaturę najbardziej podobnych sąsiadów. Podejście to inspirowane jest zjawiskiem „małego świata” grafów społecznościowych: z dowolnego węzła, do każdego innego można dotrzeć w ciągu kilku przeskoków dzięki połączeniom dalekobieżnym.
Trzy podstawowe parametry HNSW to:
- M (domyślnie 16): maksymalna liczba dwukierunkowych krawędzi na węzeł. Typowe wartości: 8-64. Zwiększanie M poprawia zapamiętywanie, ale zwiększa pamięć i czas budowania. Dla wielowymiarowych zbiorów danych (1536+) dobre wyniki daje M=32-64.
- efKonstrukcja (domyślnie 100-200): wielkość listy kandydatów podczas tworzenia indeksu. Nie ma to wpływu na ostateczną wielkość indeksu, ale to decyduje o jakości połączeń. Wyższe wartości = lepszy indeks, ale wolniejsza kompilacja. Zalecany zakres: 200-400 dla wysokiej jakości.
- ef (lub efSearch, konfigurowalny w czasie wykonywania): rozmiar listy kandydatów podczas zapytania. Musi wynosić >= k (liczba żądanych wyników). Zwiększenie ef poprawia przypomnienia, ale zwiększa opóźnienie. Zwykle 50-500.
Podstawowy kompromis: M i efConstruction określić jakość indeksu (droga jednorazowa operacja), podczas gdy ef Przywołanie salda a opóźnienie w czasie zapytania (można zmieniać dynamicznie).
# 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: jaki algorytm wybrać
HNSW nie jest jedynym dostępnym algorytmem indeksowania. Wybór zależy w dużej mierze według ograniczeń pamięci, rozmiaru zbioru danych i wzorca aktualizacji.
IVF (odwrócony indeks plików)
IVF dzieli przestrzeń wektorową na nlista grupowanie za pomocą k-średnich.
Kiedy pytasz, szukaj tylko nieprawda klastry najbliżej wektora zapytania.
Kluczowe parametry to nlist (numer klastra) e nprobe
(ile klastrów należy sprawdzić). Zalecany wzór empiryczny: nlist = 4 * sqrt(n_vectors).
Profesjonalne zapłodnienie in vitro: Szybka kompilacja, umiarkowana pamięć, dobra dla statycznych zestawów danych. Przeciw zapłodnieniu in vitro: Przypomnijmy, że wymaga ponownego grupowania, jeśli dane często się zmieniają niższy niż HNSW przy tym samym budżecie obliczeniowym, zimny start w przypadku nowych kolekcji.
HNSW (Hierarchiczny Żeglowny Mały Świat)
Jak opisano powyżej, konstruuje graf wielowarstwowy. Jest to najbardziej wszechstronny algorytm i używany domyślnie w większości wektorowych baz danych.
Pro HNSW: doskonały kompromis w zakresie szybkości przywracania, natywna obsługa aktualizacji przyrostowe, parametry konfigurowalne w czasie zapytania. Wady HNSW: wymaga, aby cały indeks zmieścił się w pamięci RAM, staje się to wygórowane powyżej 50-100 mln nośników na standardowym sprzęcie.
DyskANN
Opracowany przez Microsoft Research, DiskANN jest przeznaczony dla zestawów danych, które nie mieszczą się w pamięci RAM. Zachowuje w pamięci jedynie zwartą strukturę wykresu (wykres skompresowany), natomiast wektory kompletne znajdują się na dysku SSD NVMe. Dzięki sprzętowi PCIe Gen5 utrzymuje przypomnienie > 95% i opóźnienie 10 ms na miliardach nośników, przy koszcie pamięci DRAM 10–20 razy niższym niż równoważny HNSW.
Dysk ProANN: skalowanie do miliardów operatorów na sprzęcie towarowym, koszt zmniejszona eksploatacja. Wady dyskuANN: wymaga szybkich dysków SSD NVMe, większych opóźnień niż HNSW w pamięci, podstawowa implementacja jest niezmienna (FreshDiskANN obsługuje aktualizacje). Dostępne w Milvus, Azure PostgreSQL z rozszerzeniem pgvector oraz w wersji zapoznawczej na innych systemach.
Uważaj na antywzorzec „HNSW na wszystko”.
Wiele zespołów konfiguruje HNSW w pamięci dla ponad 50 milionów wektorowych zestawów danych, a następnie znajduje się w takiej sytuacji z instancjami 256 GB RAM po niezrównoważonych kosztach. Ogólna zasada: jeśli Twój zbiór danych przekracza 10–20 milionów nośnych i nie masz wymagań dotyczących bardzo niskich opóźnień (<5 ms), oceń poważnie DiskANN lub agresywna kwantyzacja przed skalowaniem sprzętu.
# 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')}")
Kwantyzacja wektorowa: kompresja bez utraty pamięci
Kwantyzacja to najpotężniejsza technika zmniejszania zużycia pamięci wektorowych baz danych, z kontrolowanym wpływem na jakość badań. 1536-wymiarowy wektor float32 zajmuje 6144 bajtów (6KB). Dzięki kwantyzacji możemy zmniejszyć go do 384 bajtów lub mniej.
Kwantyzacja skalarna (SQ)
Mapuje każdą wartość float32 (4 bajty) na int8 (1 bajt), co daje kompresję 4x. Algorytm analizuje rozkład wartości w każdym wymiarze i wyznacza zakres optymalne dla kwantyzacji. Odległości wyliczane są bezpośrednio na int8, bardziej wydajne obliczeniowo. Typowa utrata pamięci wynosi 1-3% w porównaniu do float32.
Kiedy go używać: zalecany punkt wyjścia dla każdego wdrożenia, 4-krotna redukcja przy minimalnym wpływie na jakość. Obsługiwane przez Qdrant, Milvus (IVF_SQ8), Weaviate (PQ z rezerwą skalarną).
Kwantyzacja produktu (PQ)
Dzieli wektor na m podwektorów i kwantyzować każdy z nich w książce kodów z 2^nbitów wejście. Typowa kompresja: 16-64x. Z PQ każdego przewoźnika jest reprezentowany jako sekwencja indeksów w książce kodowej. Przybliżone odległości są one obliczane za pomocą wstępnego obliczenia tabeli przeglądowej (ADC – asymetryczne obliczanie odległości).
Kompromisy PQ: agresywna kompresja (10-50MB zamiast 10GB) ale znaczna utrata pamięci (5-15%). Wymaga szkolenia z zakresu książki kodowej w zestawie danych. Dobre w przypadku dużych zbiorów danych, w których głównym ograniczeniem jest pamięć.
Kwantyzacja binarna (BQ)
Zmniejsza każdy wymiar do 1 bitu: najbardziej skompresowanej wartości. Przewoźnik w 1536 roku size wynosi 192 bajty (kompresja 32x w porównaniu do float32). Obliczana jest odległość z odległością Hamminga (XOR + popcount), niezwykle szybka praca na nowoczesnych procesorach. Qdrant raportuje przyspieszenie do 40x w operacjach obliczania odległości.
Jednak kwantyzacja binarna działa dobrze tylko w przypadku osadzania o określonych właściwościach: wartości muszą być rozłożone symetrycznie wokół zera (właściwość spełniona z modeli OpenAI ada-002, Cohere embed-v3, e-5). W przypadku osadzania z rozkładami asymetrycznymi, spadek liczby przypomnień może być poważny (15–30%).
W 2025 roku Qdrant wprowadził także kwantyzacje pośrednie 1,5-bitowy i 2-bitowy, oferując punkt równowagi pomiędzy skalarem (4x) i binarnym (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)
Zasada praktyczna: wybierz kwantyzację
- Zbiór danych <10M wektorów, przypomnienie krytyczne: natywny float32 (bez kwantyzacji)
- Wektory zbioru danych 10-100M: Kwantyzacja skalarna INT8, optymalny punkt jakości/pamięci
- Zbiór danych > 100M wektorów, ograniczona pamięć: Kwantyzacja produktu z rescore
- Bardzo niskie opóźnienia dzięki osadzaniu OpenAI/Cohere: Kwantyzacja binarna + ponowna ocena
Filtrowane wyszukiwanie wektorów: problem wąskich filtrów
W praktyce większość zapytań RAG nie jest wyszukiwaniem czysto wektorowym: chcesz znaleźć semantycznie podobne dokumenty e należący do pewnego użytkownik, zakres danych, kategoria lub dzierżawca. Jednym z problemów jest filtrowane wyszukiwanie wektorów algorytmicznie trudniejsze w wektorowych bazach danych.
Podstawowy problem: przy bardzo selektywnych filtrach (np. „tylko dokumenty z ostatniego miesiąca” które pasują do 0,1% zbioru danych), k najbliższych sąsiadów w przestrzeni wektorowej mogłoby wszystkie zostaną wykluczone z filtra, co zmusi do wyszukiwania bardzo dużą część wykresu HNSW przed znalezieniem k prawidłowych wyników. Może to zwiększyć opóźnienie o 10–100 razy w porównaniu z wyszukiwaniem niefiltrowanym.
Strategie filtrowania
Filtrowanie końcowe: uruchom wyszukiwanie ANN w normalny sposób, a następnie przefiltruj wyniki. Działa to, jeśli filtr nie jest zbyt selektywny (wyklucza mniej niż 50% wyników). Problem: Jeżeli filtr wykluczy 99% wektorów, trzeba wyszukać 100 razy więcej kandydatów.
Filtrowanie wstępne: najpierw zidentyfikuj punkty spełniające filtr, następnie wykonaj wyszukiwanie ANN tylko w tym zestawie. Wymaga wydajnego indeksu skalarnego na filtrowanym polu. Działa dobrze z filtrami o wysokiej selektywności, ale wymaga indeksowania ładunku.
Filtrowalny HNSW (Qdrant): Qdrant implementuje wyrafinowane rozszerzenie HNSW, który dodaje dodatkowe krawędzie do wykresu w oparciu o wartości indeksowanego ładunku. Planista zapytań szacuje liczność filtra i dynamicznie wybiera strategię: jeśli filtr jest bardzo selektywny, użyj indeksu ładunku, w przeciwnym razie użyj filtrowalnego HNSW.
W przypadkach, w których występuje wiele wąskich kombinacji filtrów, Qdrant zaleca użycie algorytmu ACORN (adaptacyjna sieć routingu z nakładaniem się komponentów), który lepiej radzi sobie z rozłączonymi wykresami spowodowanymi agresywnym filtrowaniem.
# 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
)
Porównanie baz danych: Qdrant vs Pinecone vs Milvus vs Weaviate
Każda baza danych ma inny profil wytrzymałości. Nie ma uniwersalnie optymalnego wyboru: decyzja zależy od ograniczeń wdrożenia, możliwości zespołu i konkretnych wymagań.
Qdrant
Napisana w języku Rust, jest to najbardziej wartościowa baza danych wydajność/złożoność operacyjna w 2025 r. Obsługuje zaawansowane filtrowanie za pomocą indeksów ładunku i filtrowalnych HNSW, skalarnych/produktowych/binarnych kwantyzacja, wielowektor dla nazwanych wektorów, wektory rzadkie dla natywnego wyszukiwania hybrydowego. Najprostsze wdrożenie: zarządzanie pojedynczym plikiem binarnym, Dockerem lub chmurą. Świetne dla takich zespołów chcą kontroli bez ogromnych kosztów operacyjnych.
Idealny dla: RAG Enterprise, systemy wielodostępne, wdrożenia lokalne, zespół z doświadczeniem w Pythonie, ale bez skomplikowanej infrastruktury Kubernetes.
Szyszka
W pełni zarządzane, bezserwerowe, zero operacji. Cena jest wyższa niż w przypadku alternatywnych rozwiązań hostowanych samodzielnie ale całkowicie eliminuje koszty operacyjne infrastruktury. Doskonały dla zespołów, które wolą skupić się na produkcie bez zarządzania klastrami. Obsługuje bezserwerowe kapsuły z przejrzyste autoskalowanie i replikacja w wielu regionach. Opóźnienie jest stale niskie dzięki zoptymalizowanej infrastrukturze.
Idealny dla: startupy na wczesnym etapie, małe zespoły, zmienne obciążenie pracą, dowody koncepcji, które stają się produkcją bez przeróbek.
Chmura Milvus/Zilliz
Najbardziej dojrzały i kompletny system rozproszony. Obsługuje wszystkie typy indeksów (HNSW, IVF, DiskANN, ScaNN, akceleracja GPU), automatyczne sharding na Kubernetes, separacja obliczeń/pamięci. Wersja chmurowa (Zilliz) wygrywa w testach wydajności w zbiorach danych > 100M wektorów. Znaczące obciążenie operacyjne Kubernetes.
Idealny dla: zbiór danych > 50M wektorów, zespół z infrastrukturą Kubernetes istniejące, maksymalne wymagania dotyczące przepustowości, przyspieszenie GPU.
Tkać
Umieszczony pomiędzy czystą wektorową bazą danych a wykresem wiedzy. Obsługuje wbudowane moduły dla automatyczne generowanie osadzania (text2vec-openai, text2vec-cohere), zapytanie GraphQL interface, and hybridization with native BM25. Wymaga więcej pamięci niż inne dla tego samego zbioru danych. Idealne dla zespołów, które chcą zintegrować wykres wyszukiwania i wiedzy.
Idealny dla: wyszukiwanie semantyczne z wykresem wiedzy, zespoły korzystające z GraphQL, bezpośrednia integracja z dostawcami modeli bez zarządzania potokami osadzania.
Matryca decyzyjna: jak wybrać
- Mały zespół, szybkość rozwoju: Pinecone (zero operacji) lub Qdrant (prostota)
- Zbiór danych > 50M wektorów, wysoka przepustowość: Milvus z indeksem DiskANN lub GPU
- RAG dla wielu dzierżawców ze złożonymi filtrami: Qdrant (filtrowalny HNSW)
- Wykres wiedzy + wyszukiwanie semantyczne: Tkać
- Już w PostgreSQL, umiarkowana głośność: pgvector (unika dodatkowej infrastruktury)
- Natywne wyszukiwanie hybrydowe bez narzutu: Qdrant rzadkie wektory lub Weaviate BM25
Pinecone: konfiguracja i optymalizacja
Pinecone w latach 2024–2025 jeszcze bardziej uprościł swój pakiet SDK dzięki architekturze bezserwerowej. Nie konfigurujesz już jawnie algorytmu indeksowania: obsługuje go Pinecone wewnętrznie wybór indeksu na podstawie rozmiaru zbioru danych.
# 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"
)
Optymalizacja pamięci i dostrajanie produkcji
Produkcyjna baza danych wektorowych wymaga uwagi w wielu wymiarach: nie tylko przypominania i opóźnienia, ale także wykorzystanie pamięci, przepustowość, zachowanie pod obciążeniem, i długoterminowe koszty operacyjne.
Oszacowanie śladu pamięci
Podstawowy wzór na oszacowanie wymaganej pamięci (float32, bez kwantyzacji):
- Surowe wektory: n_vectors * dim * 4 bajty
- Wykres HNSW: n_vectors * M * 2 * 8 bajtów (w przybliżeniu, zależy od implementacji)
- Ładunek/metadane: zmienna, zazwyczaj 100-500 bajtów na wektor
- Narzut systemu: ~20-30% całości
Przykład: 5 milionów wektorów przy 1536 przyciemnieniu z HNSW M=32: Wektory: 5M * 1536 * 4 = ~29 GB. Wykres HNSW: 5M * 32 * 2 * 8 = ~2,5 GB. Szacunkowa suma z obciążeniem: ~38 GB RAM. Z SQ8: ~11 GB. Wersja binarna: ~1,5 GB.
Dostrajanie wydajności Qdrant w produkcji
# 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
Analiza porównawcza i pomiar wycofania
Żadna optymalizacja nie jest skuteczna bez rygorystycznych pomiarów. Standardowe ramy ocena wektorowych baz danych opiera się na trzech podstawowych metrykach:
- Przypomnij@k: procent prawdziwych k najbliższych sąsiadów znalezionych wśród k wyników wrócił. Jest to najważniejszy miernik jakości. Wzór: |pobrano ∩ prawda| /k
- QPS (zapytania na sekundę): przepustowość systemu pod obciążeniem. Zwykle mierzy się go za pomocą ustalonego celu przypomnienia (np. „QPS @ wycofanie = 0,95”).
- Percentyle opóźnienia (p50, p95, p99): średnie opóźnienie jest mylące. W środowisku produkcyjnym liczy się p99: 99% zapytań musi zostać zrealizowanych w ramach umowy SLA.
Punktem odniesienia dla wektorowych baz danych jest ann-benchmarks.com, który mierzy wszystkie główne systemy na standardowych zbiorach danych (SIFT1M, GIST1M, GloVe-100-kątowa). Wyniki za lata 2024-2025 plasują wśród liderów Qdrant i Milvus dla kompromisu w zakresie przepustowości przywoływania, z Pinecone doskonałym pod względem spójności opóźnień p99.
# 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}")
Wyszukiwanie hybrydowe: Vector + BM25 w bazie wektorów
Nowoczesne wektorowe bazy danych nie są już systemami czysto wektorowymi: wiele z nich jest obsługiwanych teraz wyszukiwanie hybrydowe, które łączy gęste wektory z rzadkimi wektorami (BM25/TF-IDF). Temat ten został szczegółowo omówiony w artykule poświęconym pobieraniu hybryd, ale jest on ważny zrozumieć, w jaki sposób integruje się na poziomie bazy danych wektorów.
Qdrant obsługuje natywnie rzadkie wektory: możesz zapisać oba gęste wektory (osadzanie semantyczne) wektor rzadki (wagi BM25) dla każdego dokumentu i uruchom zapytania hybrydowe w jednym żądaniu z RRF (Reciprocal Rank Fusion) lub niestandardową fuzją wyników.
Tkać ma wyszukiwanie hybrydowe zintegrowane ze swoim schematem GraphQL: określa alfa (0=czysty BM25, 1=czysty wektor), aby kontrolować względną wagę. Milvus 2.4+ wprowadzono fuzję rzadką i gęstą. Szyszka obsługuje rzadką gęstość z enkoderem Pinecone Sparse lub niestandardowym modelem BM25.
Aby dowiedzieć się więcej o implementacjach wyszukiwania hybrydowego i metodach fuzji (RRF, suma ważona, wyuczona fuzja), patrz artykuł Odzyskiwanie hybrydowe: BM25 + wyszukiwanie wektorowe tej serii.
Łącze krzyżowe: powiązane artykuły
- RAG: Wyjaśnienie generacji rozszerzonej odzyskiwania - Podstawy RAG umożliwiające kontekstualizację roli bazy danych wektorów
- Osadzanie i wyszukiwanie wektorów: BERT kontra transformatory zdań - Jak wybrać model osadzania dla swojego potoku
- Odzyskiwanie hybrydowe: BM25 + wyszukiwanie wektorowe - Połącz wyszukiwanie wektorów z wyszukiwaniem słów kluczowych, aby lepiej je zapamiętać
- PostgreSQL z pgvectorem - Wyszukiwanie wektorów w PostgreSQL bez dodatkowej infrastruktury
Lista kontrolna produkcji
Przed wprowadzeniem wektorowej bazy danych do produkcji sprawdź następujące krytyczne punkty:
- Test porównawczy na Twoim prawdziwym zbiorze danych: wyniki ogólne nie są przenoszone automatycznie do Twojego przypadku użycia. Mierz przypominanie i opóźnienia za pomocą prawdziwych zapytań.
- Skonfigurowane indeksy ładunku: każde pole, po którym filtrujesz, musi mieć indeks, w przeciwnym razie filtrowanie skanuje wszystkie punkty.
- Odpowiednia kwantyzacja: oceń SQ8 jako domyślny, zmierz utratę pamięci. Jeśli jest to akceptowalne, złóż wniosek teraz — oszczędność pamięci jest znacząca.
- Kopie zapasowe i migawki: skonfiguruj automatyczne migawki. Bazy wektorowe nie zawsze mają transakcje ACID; awaria podczas przetwarzania może spowodować uszkodzenie indeksu.
- Monitorowanie: wykres Indexed_vectors_count vs wektors_count dla wykryć opóźnienie w indeksowaniu, które pogarsza wydajność zapytań.
- Rozmiar pamięci: obliczyć rzeczywistą powierzchnię przed wdrożeniem. Serwer z niewystarczającą ilością pamięci powoduje zamianę, która niszczy opóźnienia.
- Test z wąskimi filtrami: jeśli Twoja aplikacja korzysta z bardzo selektywnych filtrów, wyraźnie przetestuj te scenariusze. Opóźnienie w przypadku wąskich filtrów jest bardzo różne od tego w przypadku niefiltrowanych wyszukiwań.
Typowe anty-wzorce, których należy unikać
- Próg indeksowania zbyt niski: z indeksowaniem_threshold=0 lub bardzo niskim, każde wstawienie powoduje ponowne indeksowanie, przez co wchłanianie jest bardzo powolne. W przypadku wstawiania zbiorczego użyj progów 10–100 tys., a następnie zoptymalizuj.
- M za wysokie bez pomiaru: M=128 nie zawsze jest lepsze niż M=32. Powyżej pewnego punktu przypominanie poprawia się nieznacznie, ale pamięć rośnie liniowo. Mierz za pomocą swojego zbioru danych.
- Brak indeksu ładunku w filtrowanych polach: bez indeksu, dowolny warunek filtra i O(n). W przypadku wektorów 10M filtr nieindeksowany powoduje różnicę między 5 ms a 5000 ms.
- Wymiar nieznormalizowanych wektorów o podobieństwie cosinusowym: jeśli używasz cosinus podobieństwa, wektory muszą zostać znormalizowane. Niektóre modele ich nie normalizują domyślnie. Wektory nieznormalizowane za pomocą cosinusów dają semantycznie nieprawidłowe wyniki.
Wnioski i dalsze kroki
Wybór jest taki, że optymalizacja wektorowych baz danych jest jednym z najbardziej technicznych i wpływowych aspektów inżynierii AI. Nie ma uniwersalnych odpowiedzi: każdy system ma profil wytrzymałościowy różne, a optymalna konfiguracja zależy od konkretnego obciążenia.
Zalecana ścieżka dla nowego projektu: Zacznij od Qdrant z SQ8 dla prostoty obsługi i dobrej wydajności, następnie zmierz przywołanie i opóźnienie w prawdziwym zestawie danych. Jeśli wydajność nie jest wystarczająca, zapoznaj się z tuningiem M i ef. Jeśli problemem jest pamięć, rozważ to kwantyzacja produktu lub DiskANN. Jeśli masz już PostgreSQL i umiarkowany wolumen (wektory <5M), rozważ pgvector przed dodaniem nowej infrastruktury.
Na tych fundamentach opierają się kolejne artykuły z tej serii: w artykule na Odzyskiwanie hybrydowe zobaczymy, jak połączyć wyszukiwanie wektorowe z BM25, aby poprawić zapamiętywanie przy precyzyjnych zapytaniach, podczas gdy w artykule nt RAG w produkcji zobaczymy, jak mierzyć kompleksowy wpływ wyborów wektorowych baz danych na jakość odpowiedzi RAG.
Przedstawione tu koncepcje osadzania i modele semantyczne łączą się bezpośrednio do serialu Nowoczesne NLP i do serialu Sztuczna inteligencja PostgreSQL dla tych, którzy chcą wdrożyć wyszukiwanie wektorowe na istniejącej infrastrukturze.







