Embedding e Vector Search: BERT vs Sentence Transformers
Nel primo articolo di questa serie abbiamo esplorato l'architettura RAG e il suo ruolo nel risolvere le allucinazioni degli LLM. Il cuore pulsante di ogni sistema RAG è il retrieval: la capacità di trovare, in una base di conoscenza potenzialmente enorme, i documenti più rilevanti rispetto a una domanda. Questa capacità si basa interamente sugli embeddings e sulla ricerca vettoriale.
Un embedding è una rappresentazione numerica del significato di un testo: una sequenza di numeri (un vettore) che cattura le relazioni semantiche tra parole, frasi e documenti. La qualità degli embeddings determina direttamente la qualità del retrieval, e quindi la qualità dell'intero sistema RAG. Scegliere il modello di embedding sbagliato significa costruire le fondamenta della casa sulla sabbia.
In questo secondo articolo della serie AI Engineering e RAG Avanzato, faremo un percorso completo: dalle origini degli embeddings con Word2Vec, attraverso la rivoluzione BERT, fino ai moderni Sentence Transformers. Vedremo come generare embeddings, come confrontare testi nello spazio vettoriale, come costruire un motore di ricerca semantica con FAISS e come scegliere il modello giusto per il proprio caso d'uso.
Panoramica della Serie
| # | Articolo | Focus |
|---|---|---|
| 1 | RAG Spiegato | Fondamenti e architettura completa |
| 2 | Sei qui - Embeddings e Ricerca Semantica | Come i testi diventano vettori |
| 3 | Vector Database in Profondità | Storage, indexing, similarity search |
| 4 | RAG con LangChain e Python | Implementazione pratica end-to-end |
| 5 | Hybrid Retrieval e Reranking | Ricerca ibrida keyword + semantica |
| 6 | Context Window e Prompt Engineering | Ottimizzare il contesto per l'LLM |
| 7 | RAG in Produzione | Scaling, monitoring, evaluation |
| 8 | Knowledge Graphs e RAG | Grafi di conoscenza + retrieval |
| 9 | Multi-Agent Systems | Agenti AI collaborativi |
| 10 | Il Futuro del RAG | Trend, ricerca e next steps |
Cosa Imparerai
- Cos'è un embedding e come rappresenta il significato in forma numerica
- L'evoluzione da Word2Vec a BERT a Sentence Transformers
- perchè BERT vanilla non funziona per la similarity e come SBERT risolve il problema
- Come scegliere il modello di embedding giusto tra decine di opzioni
- Implementare ricerca semantica con sentence-transformers e FAISS in Python
- Le metriche di similarità vettoriale e quando usare ciascuna
- Confronto architetturale tra i principali vector search engine
- Come fare fine-tuning degli embeddings per domini specifici
1. Cos'è un Embedding
Un embedding è una funzione matematica che trasforma un oggetto discreto (una parola, una frase, un documento, un'immagine) in un vettore di numeri reali in uno spazio continuo a dimensionalità fissa. In pratica, converte testo leggibile dall'uomo in una lista di numeri comprensibile dalla macchina, preservando le relazioni semantiche tra i testi originali.
L'idea fondamentale è che testi con significato simile devono avere vettori vicini nello spazio, mentre testi con significato diverso devono avere vettori distanti. Questa proprietà si chiama isomorfismo semantico: la struttura delle relazioni semantiche tra parole viene preservata nella geometria dello spazio vettoriale.
1.1 Da One-Hot Encoding a Dense Vectors
Per capire perchè gli embeddings sono necessari, consideriamo l'alternativa più semplice: il one-hot encoding. Con un vocabolario di 50.000 parole, ogni parola viene rappresentata da un vettore di 50.000 dimensioni con un solo 1 e tutto il resto zeri.
ONE-HOT ENCODING (vocabolario di 50.000 parole):
"gatto" = [0, 0, ..., 1, ..., 0, 0] (50.000 dimensioni, un solo 1)
"cane" = [0, 0, ..., 0, ..., 1, 0] (50.000 dimensioni, un solo 1)
Distanza tra "gatto" e "cane" = stessa di "gatto" e "frigorifero"
Nessuna informazione semantica!
DENSE EMBEDDING (es. 384 dimensioni):
"gatto" = [0.23, -0.45, 0.89, ..., 0.12] (384 dimensioni, tutti numeri reali)
"cane" = [0.21, -0.42, 0.91, ..., 0.15] (384 dimensioni, tutti numeri reali)
Distanza tra "gatto" e "cane" = PICCOLA (animali domestici)
Distanza tra "gatto" e "frigorifero" = GRANDE (concetti diversi)
Il significato è catturato nella geometria!
I problemi del one-hot encoding sono evidenti: i vettori sono enormi (dimensionalità pari al vocabolario), sparsi (quasi tutti zeri) e, soprattutto, tutti ortogonali tra loro. Due parole qualsiasi hanno la stessa distanza, indipendentemente dal significato. Non c'e modo di distinguere "gatto" da "felino" rispetto a "gatto" da "economia".
I dense embeddings risolvono tutti e tre i problemi: i vettori sono compatti (poche centinaia di dimensioni), densi (tutti i valori sono significativi) e catturano relazioni semantiche nella loro geometria. Parole simili hanno vettori vicini, e le direzioni nello spazio corrispondono a concetti linguistici.
1.2 Lo Spazio Semantico
Una proprietà affascinante degli embeddings è che le relazioni semantiche si trasformano in relazioni geometriche. L'esempio classico è l'aritmetica vettoriale: il vettore "re" meno "uomo" più "donna" produce un vettore molto vicino a "regina". Questa non è magia: significa che lo spazio ha catturato il concetto di "genere" come una direzione e il concetto di "regalità" come un'altra direzione.
Relazioni semantiche come operazioni vettoriali:
vec("re") - vec("uomo") + vec("donna") ~ vec("regina")
vec("Parigi") - vec("Francia") + vec("Italia") ~ vec("Roma")
vec("buono") - vec("migliore") + vec("grande") ~ vec("più grande")
Cluster nello spazio:
[gatto, cane, cavallo, pesce] --> vicini (animali)
[Python, Java, C++, Rust] --> vicini (linguaggi di programmazione)
[felice, contento, gioioso] --> vicinissimi (sinonimi)
Intuizione Fondamentale
Un embedding è essenzialmente un compressore di significato. Prende il significato di un testo, con tutte le sue sfumature, e lo comprende in un punto nello spazio multidimensionale. La posizione di quel punto rispetto a tutti gli altri punti cattura tutte le relazioni semantiche che il modello ha imparato. Questa è la base su cui si fonda l'intera ricerca semantica.
2. Word Embeddings Classici: Word2Vec, GloVe, FastText
La storia moderna degli embeddings inizia nel 2013 con Word2Vec, pubblicato da Tomas Mikolov e colleghi di Google. L'idea rivoluzionaria era semplice: puoi imparare il significato di una parola dal contesto in cui appare. Come disse il linguista John Firth nel 1957: "Conoscerai una parola dalla compagnia che tiene".
2.1 Word2Vec: CBOW e Skip-gram
Word2Vec propone due architetture neurali per apprendere embeddings:
- CBOW (Continuous Bag of Words): Data una finestra di parole di contesto, predici la parola centrale. Esempio: dato "il ___ miagola forte", predici "gatto"
- Skip-gram: Data una parola centrale, predici le parole di contesto. Esempio: dato "gatto", predici "il", "miagola", "forte"
CBOW (Continuous Bag of Words):
Input: parole di contesto ["il", "___", "miagola", "forte"]
Output: parola target "gatto"
Contesto ──> [Embedding Layer] ──> Media vettori ──> [Softmax] ──> "gatto"
Veloce, buono per parole frequenti
SKIP-GRAM:
Input: parola target "gatto"
Output: parole di contesto ["il", "miagola", "forte"]
"gatto" ──> [Embedding Layer] ──> [Softmax] ──> parole contesto
Più lento, migliore per parole rare
Parametri tipici:
- Dimensioni embedding: 100-300
- Finestra di contesto: 5-10 parole
- Vocabolario: 100k-1M parole
- Training: miliardi di parole (Wikipedia, Common Crawl)
2.2 GloVe e FastText
GloVe (Global Vectors for Word Representation, Stanford 2014) adotta un approccio diverso: costruisce una matrice di co-occorrenza globale e la fattorizza per ottenere gli embeddings. Cattura relazioni globali che Word2Vec, con la sua finestra locale, potrebbe perdere.
FastText (Facebook 2016) estende Word2Vec lavorando a livello di sotto-parole (n-grammi di caratteri). La parola "embedding" viene rappresentata anche dai suoi componenti: "emb", "mbe", "bed", "edd", ecc. Questo permette di generare embeddings anche per parole mai viste durante l'addestramento (parole out-of-vocabulary), un vantaggio cruciale per lingue con ricca morfologia come l'italiano.
Confronto Word Embeddings Classici
| Modello | Anno | Approccio | Forza | Limite |
|---|---|---|---|---|
| Word2Vec | 2013 | Predizione contesto locale | Veloce, efficace | No OOV, no contesto |
| GloVe | 2014 | Co-occorrenza globale | Relazioni globali | No OOV, no contesto |
| FastText | 2016 | N-grammi di caratteri | Gestisce OOV | Un vettore per parola |
2.3 Il Limite Fondamentale: Un Vettore per Parola
Tutti i word embeddings classici condividono un limite strutturale: producono un singolo vettore per ogni parola, indipendentemente dal contesto. La parola "banco" ha lo stesso embedding sia in "il banco di scuola" che in "il banco d'Italia". Questo è un problema serio perchè il significato di una parola dipende quasi sempre dal contesto in cui appare.
Inoltre, questi modelli operano a livello di singole parole: non possono produrre un embedding per una frase o un paragrafo. Per rappresentare una frase, bisogna ricorrere a strategie rudimentali come la media dei vettori delle parole che la compongono, perdendo informazioni sull'ordine e sulla struttura sintattica.
perchè Non Usare Word2Vec per RAG
I word embeddings classici sono inadeguati per la ricerca semantica moderna perchè: (1) non catturano il contesto, (2) non producono embeddings a livello di frase, (3) la media dei vettori perde informazione critica. La frase "il cane morde l'uomo" e "l'uomo morde il cane" avrebbero lo stesso embedding. Per RAG servono modelli che comprendano il significato dell'intera frase nel suo contesto.
3. Contextual Embeddings: La Rivoluzione BERT
Nel 2018, Google pubblica BERT (Bidirectional Encoder Representations from Transformers) e cambia radicalmente il panorama. BERT produce embeddings contestuali: la rappresentazione di ogni parola dipende dall'intero contesto della frase in cui appare. La parola "banco" in "il banco di scuola" avra un embedding diverso da "il banco d'Italia".
3.1 Come Funziona BERT
BERT è basato sull'architettura Transformer encoder. Prende in input una sequenza di token e produce, per ogni token, un vettore contestualizzato di 768 dimensioni (BERT-base) o 1024 dimensioni (BERT-large). La potenza di BERT risiede nel meccanismo di self-attention: ogni token "guarda" tutti gli altri token nella sequenza, sia a sinistra che a destra (bidirezionale), per calcolare la propria rappresentazione.
Input: [CLS] il banco di scuola [SEP]
Tokenizzazione:
[CLS] il ban ##co di scuo ##la [SEP]
12 layer di Transformer Encoder:
Layer 1: Self-attention + Feed-forward
Layer 2: Self-attention + Feed-forward
...
Layer 12: Self-attention + Feed-forward
Output: un vettore 768-dim per OGNI token
[CLS] --> [0.23, -0.45, ..., 0.12] (vettore "riassunto")
il --> [0.11, -0.32, ..., 0.08]
ban --> [0.56, 0.12, ..., 0.34] (contestualizzato!)
##co --> [0.45, 0.09, ..., 0.29]
di --> [0.03, -0.21, ..., 0.05]
scuo --> [0.67, 0.33, ..., 0.41]
##la --> [0.59, 0.28, ..., 0.38]
[SEP] --> [0.02, -0.05, ..., 0.01]
3.2 Pooling Strategies: Da Token a Frase
BERT produce un vettore per ogni token, ma per la ricerca semantica ci serve un singolo vettore per l'intera frase. Come ricavarlo? Esistono diverse strategie di pooling:
Strategie di Pooling per BERT
| Strategia | Descrizione | qualità |
|---|---|---|
| [CLS] Token | Usa il vettore del token speciale [CLS] | Mediocre per similarity |
| Mean Pooling | Media di tutti i vettori dei token | Buona in generale |
| Max Pooling | Valore massimo per ogni dimensione | Cattura feature salienti |
| Weighted Mean | Media pesata con attention weights | Buona, più complessa |
3.3 perchè BERT Vanilla Non Funziona per la Similarity
Nonostante la potenza di BERT, usarlo direttamente per calcolare la similarità tra frasi è estremamente inefficiente e produce risultati scadenti. Il motivo è duplice:
- Inefficienza computazionale: Per confrontare N frasi tra loro, bisogna passare ogni coppia di frasi attraverso BERT. Con 10.000 frasi, servono 10.000 x 10.000 / 2 = 50 milioni di forward pass. Con un tempo di circa 65ms per coppia, occorrerebbero circa 65 ore
- Spazio embedding degenerato: Gli embeddings prodotti dal pooling su BERT non sono ottimizzati per la similarità coseno. Tutte le frasi tendono a finire in una regione ristretta dello spazio, rendendo difficile distinguere frasi simili da frasi diverse. Questo fenomeno si chiama anisotropy
Il Problema della Cross-Encoding
BERT è progettato come cross-encoder: prende DUE frasi in input e produce un punteggio di similarità. Per ogni coppia di frasi bisogna ricalcolare tutto da zero. Con un corpus di 10.000 documenti e una query, servono 10.000 forward pass. Per costruire un indice di tutti i confronti possibili, il costo cresce quadraticamente. Questo rende BERT impraticabile per il retrieval real-time.
4. Sentence Transformers: La Soluzione
Nel 2019, Nils Reimers e Iryna Gurevych pubblicano Sentence-BERT (SBERT), che risolve elegantemente entrambi i problemi. L'idea chiave è trasformare BERT da cross-encoder a bi-encoder: invece di passare coppie di frasi, ogni frase viene codificata indipendentemente in un vettore denso. La similarità si calcola poi con una semplice operazione vettoriale (cosine similarity).
4.1 Architettura Siamese e Triplet Networks
SBERT viene addestrato con un'architettura a rete siamese: due copie identiche di BERT (con pesi condivisi) processano due frasi separatamente. I vettori risultanti vengono poi confrontati con una funzione di loss che "avvicina" vettori di frasi simili e "allontana" vettori di frasi diverse.
CROSS-ENCODER (BERT vanilla):
["frase A", "frase B"] ──> [BERT] ──> punteggio similarità
Deve processare OGNI coppia. O(n^2) per n frasi.
BI-ENCODER (SBERT):
"frase A" ──> [BERT + Pooling] ──> vettore_A (384-dim)
"frase B" ──> [BERT + Pooling] ──> vettore_B (384-dim)
similarità = cosine(vettore_A, vettore_B)
Ogni frase viene codificata UNA VOLTA. O(n) per n frasi.
I vettori possono essere pre-calcolati e indicizzati!
TRAINING con Siamese Network:
Frase 1 ──> [BERT] ──> pool ──> u
├──> loss(u, v, label)
Frase 2 ──> [BERT] ──> pool ──> v
Label = 1 se frasi simili, 0 se diverse
Loss: Contrastive Loss o Cosine Similarity Loss
Le triplet networks estendono l'approccio con tre input: un'ancora, un esempio positivo (simile) e un esempio negativo (diverso). La loss (triplet margin loss) spinge il modello ad avere una distanza anchor-positivo inferiore alla distanza anchor-negativo di almeno un margine predefinito.
4.2 Vantaggi di SBERT Rispetto a BERT
Confronto Prestazioni
| Metrica | BERT Cross-Encoder | SBERT Bi-Encoder |
|---|---|---|
| Similarità 10.000 frasi | ~65 ore | ~5 secondi |
| Pre-calcolo vettori | Non possibile | Si, una volta sola |
| Indicizzazione | Non applicabile | FAISS, HNSW, etc. |
| qualità STS Benchmark | ~87 (Spearman) | ~85 (Spearman) |
| Uso in produzione | Solo re-ranking | Retrieval + ranking |
SBERT sacrifica una piccola percentuale di accuratezza (circa 2 punti su STS Benchmark) ma guadagna una velocità 10.000 volte superiore. In pratica, la scelta è obbligata: il cross-encoder può essere usato come re-ranker sui top-k risultati di SBERT, combinando il meglio di entrambi gli approcci (pattern chiamato retrieve and re-rank).
5. Modelli di Embedding a Confronto
L'ecosistema dei modelli di embedding è vasto e in rapida evoluzione. La scelta del modello giusto dipende dal caso d'uso: ricerca semantica, clustering, classificazione, RAG multilingue, budget e latenza. Ecco un confronto dettagliato dei modelli più rilevanti.
Confronto Modelli di Embedding (2024-2025)
| Modello | Provider | Dimensioni | Max Token | MTEB Avg | Note |
|---|---|---|---|---|---|
| all-MiniLM-L6-v2 | SBERT | 384 | 256 | 56.26 | Velocissimo, ideale per prototipazione |
| all-mpnet-base-v2 | SBERT | 768 | 384 | 57.78 | Miglior rapporto qualità/velocità open-source |
| e5-large-v2 | Microsoft | 1024 | 512 | 62.20 | Alta qualità, richiede prefix "query:" / "passage:" |
| BGE-M3 | BAAI | 1024 | 8192 | 63.55 | Multilingual, dense+sparse+colbert |
| text-embedding-3-small | OpenAI | 1536 | 8191 | 62.26 | API cloud, economico ($0.02/1M token) |
| text-embedding-3-large | OpenAI | 3072 | 8191 | 64.59 | API cloud, top quality ($0.13/1M token) |
| embed-v3 (english) | Cohere | 1024 | 512 | 64.47 | API cloud, ottimo per ricerca |
| nomic-embed-text-v1.5 | Nomic | 768 | 8192 | 62.28 | Open-source, long context, Matryoshka |
Come Leggere i Benchmark MTEB
MTEB (Massive Text Embedding Benchmark) è lo standard de facto per valutare modelli di embedding. Include 56+ dataset in 8 categorie (classification, clustering, pair classification, reranking, retrieval, STS, summarization). Il punteggio medio è la media su tutte le categorie, ma per RAG il sotto-punteggio Retrieval è il più rilevante. Controlla sempre il punteggio specifico per il tuo caso d'uso.
5.1 Criteri di Scelta
- Prototipazione rapida: all-MiniLM-L6-v2 - veloce, leggero, gratuito
- Produzione open-source: all-mpnet-base-v2 o e5-large-v2
- Massima qualità cloud: text-embedding-3-large (OpenAI) o embed-v3 (Cohere)
- Multilingue / Italiano: BGE-M3 o multilingual-e5-large
- Long context (documenti lunghi): BGE-M3 (8192 token) o nomic-embed-text-v1.5
- Budget limitato: text-embedding-3-small (OpenAI) o modelli open-source self-hosted
6. Metriche di Similarità Vettoriale
Una volta ottenuti i vettori, serve una funzione per misurare quanto due vettori sono "simili". La scelta della metrica influisce sia sui risultati che sulle prestazioni.
6.1 Cosine Similarity
La cosine similarity misura il coseno dell'angolo tra due vettori. Varia da -1 (vettori opposti) a +1 (vettori identici), con 0 per vettori ortogonali. Ignora la magnitudine dei vettori, concentrandosi solo sulla direzione: è la metrica più usata per gli embeddings di testo.
COSINE SIMILARITY:
A . B sum(a_i * b_i)
cos(A,B) = ─────── = ──────────────────────────────
|A| |B| sqrt(sum(a_i^2)) * sqrt(sum(b_i^2))
Range: [-1, +1]
+1 = identici, 0 = ortogonali, -1 = opposti
Invariante alla scala (normalizzata)
DOT PRODUCT (Prodotto Scalare):
dot(A,B) = sum(a_i * b_i)
Range: (-inf, +inf)
Sensibile alla magnitudine dei vettori
Più veloce (nessuna normalizzazione)
Equivalente a cosine se i vettori sono già normalizzati (norma L2 = 1)
EUCLIDEAN DISTANCE (L2):
d(A,B) = sqrt(sum((a_i - b_i)^2))
Range: [0, +inf)
0 = identici, valori alti = molto diversi
Sensibile alla magnitudine
Nota: DISTANZA, non similarità (valori bassi = più simili)
Quando Usare Quale Metrica
| Metrica | Quando Usarla | Performance |
|---|---|---|
| Cosine Similarity | Default per text embeddings, modelli non normalizzati | Media (richiede normalizzazione) |
| Dot Product | Embeddings già normalizzati (Sentence Transformers), quando serve velocità | Alta (operazione più semplice) |
| Euclidean (L2) | Quando la magnitudine è significativa, clustering | Media |
Un consiglio pratico: se usi Sentence Transformers, i vettori possono essere normalizzati a norma unitaria. In questo caso cosine similarity e dot product sono equivalenti, e il dot product è più veloce. La maggior parte dei vector database supporta tutte e tre le metriche e permette di scegliere in fase di creazione dell'indice.
7. Implementazione Pratica con Python
Passiamo al codice. Implementeremo un sistema di ricerca semantica completo usando
sentence-transformers per gli embeddings e FAISS per
l'indicizzazione e la ricerca vettoriale.
7.1 Setup e Installazione
# Crea ambiente virtuale
python -m venv embedding-env
source embedding-env/bin/activate
# Installa le dipendenze principali
pip install sentence-transformers==3.3.1
pip install faiss-cpu==1.9.0 # Per CPU (usa faiss-gpu per GPU)
pip install numpy==2.1.3
pip install pandas==2.2.3
pip install tqdm==4.67.1
# Opzionale: per visualizzazione
pip install matplotlib==3.9.3
pip install scikit-learn==1.6.0
7.2 Generare Embeddings con Sentence Transformers
from sentence_transformers import SentenceTransformer
import numpy as np
# Carica il modello (download automatico al primo utilizzo)
model = SentenceTransformer("all-MiniLM-L6-v2")
# Frasi di esempio
sentences = [
"Il gatto dorme sul divano",
"Il felino riposa sul sofa",
"Python è un linguaggio di programmazione",
"Java è usato per lo sviluppo enterprise",
"La ricerca semantica usa gli embeddings",
]
# Genera embeddings
embeddings = model.encode(sentences, show_progress_bar=True)
# Ogni embedding è un vettore numpy
print(f"Tipo: {type(embeddings)}")
print(f"Shape: {embeddings.shape}") # (5, 384) - 5 frasi, 384 dimensioni
print(f"Norma L2 del primo vettore: {np.linalg.norm(embeddings[0]):.4f}")
# Visualizza i primi 10 valori del primo embedding
print(f"\nPrimi 10 valori: {embeddings[0][:10]}")
7.3 Calcolare la Similarità tra Frasi
from sentence_transformers import SentenceTransformer, util
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2")
# Frasi da confrontare
sentences = [
"Il gatto dorme sul divano",
"Il felino riposa sul sofa",
"Python è un linguaggio di programmazione",
"La pioggia cade sulla citta",
]
embeddings = model.encode(sentences, convert_to_tensor=True)
# Matrice di similarità completa
cosine_scores = util.cos_sim(embeddings, embeddings)
print("Matrice di Similarità Coseno:")
print("-" * 60)
for i in range(len(sentences)):
for j in range(len(sentences)):
score = cosine_scores[i][j].item()
if i != j:
print(f" {sentences[i][:30]:<30} vs {sentences[j][:30]:<30}")
print(f" Similarità: {score:.4f}")
# Calcolo manuale della cosine similarity per verifica
def cosine_similarity_manual(vec_a, vec_b):
"""Calcola cosine similarity manualmente."""
dot_product = np.dot(vec_a, vec_b)
norm_a = np.linalg.norm(vec_a)
norm_b = np.linalg.norm(vec_b)
return dot_product / (norm_a * norm_b)
emb_np = model.encode(sentences)
manual_score = cosine_similarity_manual(emb_np[0], emb_np[1])
print(f"\nVerifica manuale (frase 0 vs 1): {manual_score:.4f}")
Output Atteso
Le frasi "Il gatto dorme sul divano" e "Il felino riposa sul sofa" avranno una similarità alta (~0.85), perchè esprimono lo stesso concetto con parole diverse. La similarità con "Python è un linguaggio di programmazione" sarà bassa (~0.10), perchè i concetti sono completamente diversi. Questa è la potenza della ricerca semantica rispetto alla ricerca per parole chiave.
7.4 Ricerca Semantica con FAISS
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
class SemanticSearchEngine:
"""Motore di ricerca semantica basato su FAISS."""
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
self.index = None
self.documents = []
def build_index(self, documents: list[str], use_gpu: bool = False):
"""Costruisce l'indice FAISS dai documenti."""
self.documents = documents
# Genera embeddings per tutti i documenti
embeddings = self.model.encode(
documents,
show_progress_bar=True,
normalize_embeddings=True, # Normalizza per cosine similarity
batch_size=64,
)
embeddings = embeddings.astype("float32")
# Crea indice FAISS (Inner Product = Cosine per vettori normalizzati)
self.index = faiss.IndexFlatIP(self.dimension)
if use_gpu:
# Trasferisci indice su GPU per ricerca più veloce
res = faiss.StandardGpuResources()
self.index = faiss.index_cpu_to_gpu(res, 0, self.index)
self.index.add(embeddings)
print(f"Indice costruito: {self.index.ntotal} documenti indicizzati")
def search(self, query: str, top_k: int = 5) -> list[dict]:
"""Cerca i documenti più simili alla query."""
# Genera embedding della query
query_embedding = self.model.encode(
[query],
normalize_embeddings=True,
).astype("float32")
# Ricerca nell'indice FAISS
scores, indices = self.index.search(query_embedding, top_k)
results = []
for score, idx in zip(scores[0], indices[0]):
if idx != -1: # -1 indica nessun risultato
results.append({
"document": self.documents[idx],
"score": float(score),
"index": int(idx),
})
return results
def save_index(self, path: str):
"""Salva l'indice su disco."""
faiss.write_index(self.index, path)
def load_index(self, path: str):
"""Carica l'indice da disco."""
self.index = faiss.read_index(path)
# Utilizzo
if __name__ == "__main__":
# Corpus di documenti
documents = [
"Python è un linguaggio di programmazione ad alto livello, interpretato e multiparadigma",
"Java è un linguaggio orientato agli oggetti progettato per essere portabile",
"Il machine learning è un sottoinsieme dell'intelligenza artificiale",
"I database relazionali usano SQL per le query sui dati strutturati",
"Docker permette di containerizzare le applicazioni per il deployment",
"Kubernetes orchestra i container in ambienti di produzione distribuiti",
"React è una libreria JavaScript per costruire interfacce utente",
"Angular è un framework TypeScript per applicazioni web enterprise",
"I transformer sono l'architettura alla base dei modelli linguistici moderni",
"FAISS è una libreria per la ricerca efficiente di similarità tra vettori",
"La sicurezza informatica protegge i sistemi da accessi non autorizzati",
"Il cloud computing fornisce risorse computazionali on-demand via internet",
]
engine = SemanticSearchEngine()
engine.build_index(documents)
# Ricerca semantica
queries = [
"Come funziona l'intelligenza artificiale?",
"Framework per sviluppo web frontend",
"Come distribuire applicazioni in modo scalabile?",
]
for query in queries:
print(f"\nQuery: '{query}'")
print("-" * 50)
results = engine.search(query, top_k=3)
for i, result in enumerate(results, 1):
print(f" {i}. [{result['score']:.4f}] {result['document']}")
7.5 Batch Processing per Grandi Dataset
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
from tqdm import tqdm
import json
from pathlib import Path
class LargeScaleEmbedder:
"""Gestisce embedding e indicizzazione per grandi dataset."""
def __init__(self, model_name: str = "all-mpnet-base-v2"):
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
def encode_in_batches(
self,
texts: list[str],
batch_size: int = 256,
output_path: str | None = None,
) -> np.ndarray:
"""Genera embeddings in batch con salvataggio incrementale."""
all_embeddings = []
for start_idx in tqdm(range(0, len(texts), batch_size)):
batch = texts[start_idx : start_idx + batch_size]
batch_embeddings = self.model.encode(
batch,
normalize_embeddings=True,
batch_size=batch_size,
show_progress_bar=False,
)
all_embeddings.append(batch_embeddings)
# Salvataggio incrementale (checkpoint)
if output_path and (start_idx % (batch_size * 10) == 0):
partial = np.vstack(all_embeddings)
np.save(f"{output_path}_partial.npy", partial)
result = np.vstack(all_embeddings).astype("float32")
if output_path:
np.save(f"{output_path}.npy", result)
print(f"Embeddings salvati: {result.shape}")
return result
def build_ivf_index(
self,
embeddings: np.ndarray,
n_clusters: int = 100,
n_probe: int = 10,
) -> faiss.Index:
"""Costruisce un indice IVF per ricerca approssimata veloce."""
# Quantizzatore per assegnare vettori ai cluster
quantizer = faiss.IndexFlatIP(self.dimension)
# Indice IVF con Inner Product
index = faiss.IndexIVFFlat(
quantizer, self.dimension, n_clusters, faiss.METRIC_INNER_PRODUCT
)
# Training: impara la struttura dei cluster
print(f"Training indice IVF con {n_clusters} cluster...")
index.train(embeddings)
# Aggiungi i vettori
index.add(embeddings)
index.nprobe = n_probe # Numero di cluster da esplorare in ricerca
print(f"Indice IVF costruito: {index.ntotal} vettori, {n_clusters} cluster")
return index
# Esempio di utilizzo con un grande corpus
if __name__ == "__main__":
embedder = LargeScaleEmbedder(model_name="all-MiniLM-L6-v2")
# Simula un grande corpus (in produzione: carica da file/database)
corpus = [f"Documento di esempio numero {i} sul tema tecnologia" for i in range(100_000)]
# Genera embeddings in batch
embeddings = embedder.encode_in_batches(
corpus,
batch_size=512,
output_path="corpus_embeddings",
)
# Costruisci indice IVF (più veloce di Flat per grandi corpus)
index = embedder.build_ivf_index(embeddings, n_clusters=256, n_probe=16)
# Ricerca
query_embedding = embedder.model.encode(
["intelligenza artificiale e machine learning"],
normalize_embeddings=True,
).astype("float32")
scores, indices = index.search(query_embedding, 5)
for score, idx in zip(scores[0], indices[0]):
print(f" [{score:.4f}] {corpus[idx]}")
7.6 Embedding con Modelli E5 (Prefix-Based)
from sentence_transformers import SentenceTransformer
import numpy as np
# I modelli E5 richiedono prefix specifici
model = SentenceTransformer("intfloat/e5-large-v2")
# Per i documenti nel corpus: prefix "passage: "
documents = [
"passage: Python è un linguaggio di programmazione versatile e potente",
"passage: I database NoSQL sono progettati per dati non strutturati",
"passage: Kubernetes gestisce il deployment di applicazioni containerizzate",
"passage: Il deep learning usa reti neurali con molti layer",
]
# Per le query di ricerca: prefix "query: "
queries = [
"query: Come funziona l'apprendimento automatico?",
"query: Strumenti per gestire i container",
]
# Genera embeddings
doc_embeddings = model.encode(documents, normalize_embeddings=True)
query_embeddings = model.encode(queries, normalize_embeddings=True)
# Calcola similarità
for i, query in enumerate(queries):
scores = np.dot(query_embeddings[i], doc_embeddings.T)
best_idx = np.argmax(scores)
print(f"Query: {query}")
print(f" Miglior match: {documents[best_idx]}")
print(f" Score: {scores[best_idx]:.4f}\n")
Attenzione ai Prefix
I modelli E5 e BGE richiedono prefix specifici per distinguere query da documenti.
Per E5: query: e passage: . Per BGE:
Represent this sentence: per le query. Dimenticare il prefix degrada
significativamente le performance. Controlla sempre la documentazione del modello.
8. Vector Search Engines: Confronto Architetturale
FAISS è eccellente per l'implementazione a basso livello, ma in produzione servono soluzioni che gestiscono persistenza, filtraggio per metadati, scalabilità orizzontale e aggiornamenti in tempo reale. Ecco i principali vector database e le loro caratteristiche.
Confronto Vector Database
| Database | Tipo | Linguaggio | Indici | Caso d'Uso Ideale |
|---|---|---|---|---|
| FAISS | Libreria | C++/Python | Flat, IVF, HNSW, PQ | Ricerca pura, embedding, prototipazione |
| Qdrant | Database | Rust | HNSW | Produzione, filtri complessi, alta performance |
| Pinecone | Cloud managed | Proprietario | Proprietario | Zero-ops, startup, scalabilità automatica |
| Milvus | Database | Go/C++ | Flat, IVF, HNSW, DiskANN | Scala enterprise, miliardi di vettori |
| pgvector | Estensione | C | IVFFlat, HNSW | Gia usi PostgreSQL, dataset moderati |
| ChromaDB | Database | Python/Rust | HNSW | Prototipazione, integrazione LangChain |
| Weaviate | Database | Go | HNSW | Multi-modal, GraphQL API |
8.1 Esempio con Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
VectorParams,
PointStruct,
Filter,
FieldCondition,
MatchValue,
)
from sentence_transformers import SentenceTransformer
# Inizializza client (in-memory per test, o URL per produzione)
client = QdrantClient(":memory:") # oppure QdrantClient("http://localhost:6333")
model = SentenceTransformer("all-MiniLM-L6-v2")
# Crea collection
client.create_collection(
collection_name="articles",
vectors_config=VectorParams(
size=384, # Dimensione vettore all-MiniLM-L6-v2
distance=Distance.COSINE,
),
)
# Documenti con metadati
documents = [
{"text": "Python è ottimo per il data science", "category": "programming", "year": 2024},
{"text": "React è il framework frontend più popolare", "category": "web", "year": 2024},
{"text": "Kubernetes gestisce i container in produzione", "category": "devops", "year": 2023},
{"text": "TensorFlow è usato per il deep learning", "category": "ai", "year": 2024},
{"text": "PostgreSQL è un database relazionale avanzato", "category": "database", "year": 2023},
]
# Indicizzazione
points = []
for i, doc in enumerate(documents):
embedding = model.encode(doc["text"]).tolist()
points.append(
PointStruct(
id=i,
vector=embedding,
payload={"text": doc["text"], "category": doc["category"], "year": doc["year"]},
)
)
client.upsert(collection_name="articles", points=points)
# Ricerca semantica semplice
query = "machine learning e intelligenza artificiale"
query_vector = model.encode(query).tolist()
results = client.search(
collection_name="articles",
query_vector=query_vector,
limit=3,
)
print(f"Query: '{query}'")
for result in results:
print(f" [{result.score:.4f}] {result.payload['text']} ({result.payload['category']})")
# Ricerca con filtro per metadati
filtered_results = client.search(
collection_name="articles",
query_vector=query_vector,
query_filter=Filter(
must=[FieldCondition(key="year", match=MatchValue(value=2024))]
),
limit=3,
)
print(f"\nRicerca filtrata (solo 2024):")
for result in filtered_results:
print(f" [{result.score:.4f}] {result.payload['text']}")
9. Algoritmi di Indicizzazione: Trade-off Velocita/Recall
La ricerca esatta (brute-force) su milioni di vettori è troppo lenta. Gli algoritmi di Approximate Nearest Neighbor (ANN) sacrificano una piccola percentuale di precisione per guadagnare ordini di grandezza in velocità. Comprendere questi algoritmi è fondamentale per configurare correttamente il vector database.
9.1 Flat Index (Ricerca Esatta)
L'indice Flat confronta la query con ogni singolo vettore nel database. Recall del 100% garantito, ma costo O(n*d) dove n è il numero di vettori e d la dimensionalità. Praticabile solo per dataset piccoli (fino a ~100k vettori).
9.2 IVF (Inverted File Index)
IVF partiziona lo spazio vettoriale in cluster usando k-means. Al momento
della ricerca, solo i cluster più vicini alla query vengono esplorati. Il parametro
nprobe controlla quanti cluster esplorare: valori alti aumentano il recall
ma rallentano la ricerca.
9.3 HNSW (Hierarchical Navigable Small World)
HNSW è l'algoritmo più popolare nei vector database moderni. Costruisce un grafo navigabile multi-livello dove ogni nodo è un vettore collegato ai suoi vicini più prossimi. La ricerca parte dal livello superiore (pochi nodi, connessioni lunghe) e scende fino al livello base (tutti i nodi, connessioni corte), restringendo progressivamente l'area di ricerca.
FLAT (Brute Force):
Costruzione: O(n) | Ricerca: O(n*d) | Recall: 100%
Memoria: n*d*4 bytes | Aggiornamento: O(1)
Ideale per: <100k vettori, quando il recall perfetto e necessario
IVF (Inverted File):
Costruzione: O(n*k) | Ricerca: O(nprobe*n/k*d) | Recall: 90-99%
Memoria: n*d*4 bytes | Aggiornamento: richiede re-training
Parametri: nlist (cluster), nprobe (cluster esplorati)
Ideale per: 100k-10M vettori, quando serve controllo fine
HNSW (Hierarchical Navigable Small World):
Costruzione: O(n*log(n)) | Ricerca: O(log(n)*d) | Recall: 95-99.9%
Memoria: n*d*4 + grafo | Aggiornamento: O(log(n))
Parametri: M (connessioni), ef_construction, ef_search
Ideale per: qualsiasi scala, migliore latenza, più memoria
Product Quantization (PQ):
Comprime vettori 768-dim in ~64 bytes (12x compressione)
Recall inferiore ma enorme risparmio memoria
Combinabile con IVF: IVF-PQ per miliardi di vettori
Guida Pratica alla Scelta
| Scala Dataset | Algoritmo Consigliato | Recall Tipico |
|---|---|---|
| <100k vettori | Flat (brute force) | 100% |
| 100k - 1M | HNSW | 98-99% |
| 1M - 100M | HNSW o IVF-HNSW | 95-99% |
| 100M - 1B | IVF-PQ o DiskANN | 90-95% |
| >1B | IVF-PQ distribuito (Milvus) | 85-95% |
10. Fine-Tuning degli Embeddings
I modelli pre-addestrati funzionano bene per casi d'uso generali, ma per domini specialistici (medicina, legale, finanza, codice) il fine-tuning può migliorare significativamente la qualità del retrieval. Il principio è semplice: adattare il modello ai dati e al vocabolario del proprio dominio.
10.1 Quando Fare Fine-Tuning
- Vocabolario specialistico: Il tuo dominio usa terminologia tecnica non presente nei dati di training generici
- Relazioni semantiche diverse: Nel tuo dominio, parole che normalmente non sono correlate hanno relazioni strette
- Lingue sotto-rappresentate: Il modello base non ha visto abbastanza testo nella tua lingua
- Retrieval scadente: I risultati della ricerca semantica non sono soddisfacenti nonostante buoni chunk
10.2 Implementazione con Contrastive Learning
from sentence_transformers import (
SentenceTransformer,
InputExample,
losses,
evaluation,
)
from torch.utils.data import DataLoader
# Carica modello base
model = SentenceTransformer("all-MiniLM-L6-v2")
# Prepara i dati di training: coppie (frase_a, frase_b, similarità)
# similarità: 1.0 = semanticamente identiche, 0.0 = completamente diverse
train_examples = [
# Coppie positive (dominio medico)
InputExample(texts=["infarto del miocardio", "attacco cardiaco acuto"], label=0.95),
InputExample(texts=["ipertensione arteriosa", "pressione alta cronica"], label=0.90),
InputExample(texts=["cefalea tensiva", "mal di testa da stress"], label=0.85),
InputExample(texts=["diabete mellito tipo 2", "diabete dell'adulto"], label=0.90),
# Coppie negative
InputExample(texts=["infarto del miocardio", "frattura del femore"], label=0.10),
InputExample(texts=["ipertensione arteriosa", "gastrite cronica"], label=0.05),
# Aggiungi migliaia di coppie per risultati ottimali...
]
# DataLoader
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
# Loss function: Cosine Similarity Loss
train_loss = losses.CosineSimilarityLoss(model)
# Valutazione (opzionale ma consigliata)
eval_examples = [
InputExample(texts=["tachicardia sinusale", "battito cardiaco accelerato"], label=0.85),
InputExample(texts=["polmonite batterica", "infezione polmonare"], label=0.80),
]
evaluator = evaluation.EmbeddingSimilarityEvaluator.from_input_examples(
eval_examples, name="medical-eval"
)
# Fine-tuning
model.fit(
train_objectives=[(train_dataloader, train_loss)],
evaluator=evaluator,
epochs=3,
warmup_steps=100,
evaluation_steps=500,
output_path="models/medical-embedding-model",
)
print("Fine-tuning completato! Modello salvato.")
# Test del modello fine-tuned
finetuned_model = SentenceTransformer("models/medical-embedding-model")
emb1 = finetuned_model.encode("infarto miocardico acuto")
emb2 = finetuned_model.encode("attacco di cuore")
from sentence_transformers import util
score = util.cos_sim([emb1], [emb2])
print(f"Similarità post fine-tuning: {score.item():.4f}")
Quanti Dati Servono per il Fine-Tuning?
Come regola generale: minimo 1.000 coppie per vedere un miglioramento, 5.000-10.000 coppie per risultati solidi, 50.000+ coppie per risultati ottimali. La qualità dei dati è più importante della quantità: coppie rumorose o etichettate male degradano il modello. Se non hai abbastanza dati annotati, considera tecniche come il hard negative mining o la generazione sintetica con un LLM.
11. Embeddings per la Lingua Italiana
La maggior parte dei modelli di embedding è addestrata prevalentemente su testo inglese. Per applicazioni in italiano, la scelta del modello è particolarmente critica. Vediamo le opzioni disponibili e le loro performance.
11.1 Modelli Multilingual
I modelli multilingual sono addestrati su decine di lingue simultaneamente. La qualità per l'italiano è generalmente buona ma inferiore rispetto alla performance sull'inglese. I modelli consigliati per l'italiano sono:
- multilingual-e5-large: Ottima qualità multilingue, richiede prefix "query:"/"passage:"
- BGE-M3: Supporta 100+ lingue con qualità uniforme, supporta dense+sparse+colbert
- paraphrase-multilingual-MiniLM-L12-v2: Buon compromesso velocità/qualità per 50+ lingue
- text-embedding-3-small/large (OpenAI): Buone performance multilingue via API
11.2 Strategie per Migliorare la qualità
- Fine-tuning su dati italiani: Anche poche migliaia di coppie annotate in italiano possono migliorare sensibilmente le performance
- Data augmentation: Usa un LLM per generare parafrasi in italiano dei tuoi documenti
- Hybrid retrieval: Combina ricerca semantica con BM25 (keyword) per compensare le debolezze del modello sulla lingua
- Traduzione query: Per modelli inglesi di alta qualità, traduci la query in inglese prima dell'embedding (workaround pragmatico)
Test Pratico per l'Italiano
Prima di scegliere un modello per un progetto in italiano, esegui sempre un test manuale con le tue query e documenti reali. Crea un piccolo dataset di valutazione (50-100 query con risposte attese) e misura precision@k e recall@k. Un modello con MTEB score alto sull'inglese potrebbe performare significativamente peggio sull'italiano. Il benchmark MTEB include sotto-task multilingue: controlla specificamente quelli.
12. Embeddings nel Contesto RAG
Gli embeddings sono il fondamento di ogni sistema RAG. La qualità del retrieval - e quindi dell'intera pipeline - dipende direttamente dalla qualità delle rappresentazioni vettoriali. Ecco come gli embeddings si integrano nell'architettura RAG completa.
FASE DI INDICIZZAZIONE (offline):
Documenti ──> Chunking ──> [EMBEDDING MODEL] ──> Vettori ──> Vector DB
|
Stesso modello per query!
FASE DI QUERY (online):
Query utente ──> [EMBEDDING MODEL] ──> Vettore query
|
v
[VECTOR DB: similarity search]
|
v
Top-k chunk rilevanti
|
v
[LLM + Contesto] ──> Risposta con citazioni
REGOLE D'ORO:
1. Usare SEMPRE lo stesso modello per indicizzazione e query
2. La qualità degli embeddings determina la qualità del retrieval
3. Scegliere il modello in base al dominio e alla lingua
4. Normalizzare i vettori per cosine similarity
5. Monitorare retrieval precision e recall in produzione
L'errore più comune nei sistemi RAG è sottovalutare l'importanza della scelta del modello di embedding. Teams che investono settimane sull'ottimizzazione del prompt dell'LLM spesso non hanno mai testato modelli di embedding diversi. Nella pratica, passare da all-MiniLM-L6-v2 a un modello come e5-large-v2 o BGE-M3 può migliorare la qualità del retrieval del 15-25%, con un impatto diretto sulla qualità delle risposte finali.
Checklist Embeddings per RAG
- Hai testato almeno 3 modelli diversi sul tuo dataset?
- Hai un evaluation set con query e risposte attese?
- Il modello supporta la tua lingua con qualità sufficiente?
- La dimensionalità è compatibile con il tuo budget di storage?
- La lunghezza massima del contesto (max token) è adeguata ai tuoi chunk?
- Hai considerato il fine-tuning per il tuo dominio?
- Stai monitorando la qualità del retrieval in produzione?
13. Costi e Scaling in Produzione
La scelta tra embedding self-hosted e API cloud ha un impatto significativo sui costi, sulla latenza e sulla complessità operativa. Analizziamo i fattori chiave per prendere una decisione informata.
Confronto Costi Embedding API (2024-2025)
| Provider | Modello | Prezzo per 1M Token | Dimensioni |
|---|---|---|---|
| OpenAI | text-embedding-3-small | $0.02 | 1536 |
| OpenAI | text-embedding-3-large | $0.13 | 3072 |
| Cohere | embed-v3 | $0.10 | 1024 |
| Self-hosted | all-MiniLM-L6-v2 | Costo GPU | 384 |
| Self-hosted | BGE-M3 | Costo GPU | 1024 |
13.1 Self-Hosted vs Cloud API
- Cloud API (OpenAI, Cohere): Zero infrastruttura, pay-per-use, latenza di rete, dependency su servizio esterno, privacy dei dati da valutare
- Self-hosted (GPU dedicata): Costo iniziale alto (GPU ~$1-3/ora), nessun costo per token, latenza minima, pieno controllo dei dati, complessità operativa
Regola pratica: Per meno di 10 milioni di token/mese, le API cloud sono più economiche. Sopra i 100 milioni di token/mese, il self-hosting diventa conveniente. Tra 10M e 100M, dipende dalla latenza richiesta e dalle esigenze di privacy.
13.2 Costo dello Storage Vettoriale
Ogni vettore occupa dimensioni * 4 bytes (float32). Con un modello a 768
dimensioni e 1 milione di documenti: 768 * 4 * 1M = ~3 GB di sola memoria vettoriale,
a cui vanno aggiunti metadati, indici e overhead. Con Product Quantization si può
comprimere fino a 8-16x, ma con una perdita di recall.
Stima Rapida dei Costi
Per un sistema RAG con 1 milione di documenti, chunking a 500 token per chunk, modello 768-dim: servono circa 3 GB di RAM per i vettori + 2-4 GB per l'indice HNSW. Un'istanza cloud con 8 GB di RAM (circa $50-100/mese) è sufficiente. Per la generazione iniziale degli embeddings con API OpenAI (text-embedding-3-small): circa 500M token = $10. Con modello self-hosted e una GPU T4: circa 2-3 ore di calcolo = $3-5.
Conclusioni e Prossimi Passi
In questo articolo abbiamo percorso l'intera evoluzione degli embeddings testuali: dai word embeddings statici di Word2Vec e GloVe, attraverso i contextual embeddings di BERT, fino ai moderni Sentence Transformers ottimizzati per la ricerca semantica. Abbiamo visto perchè BERT vanilla è inadatto per il retrieval e come SBERT risolve il problema con l'architettura bi-encoder.
Abbiamo implementato un motore di ricerca semantica completo con Python, sentence-transformers e FAISS, esplorando strategie per batch processing e grandi dataset. Abbiamo confrontato i principali vector database e gli algoritmi di indicizzazione, analizzando i trade-off tra velocità, recall e memoria.
I punti chiave da ricordare:
- La scelta del modello di embedding è la decisione più importante in un sistema RAG - più importante del modello LLM
- Sentence Transformers (bi-encoder) sono lo standard per il retrieval; BERT cross-encoder per il re-ranking
- Testa sempre più modelli sul tuo dataset reale prima di scegliere
- FAISS e HNSW rendono la ricerca su milioni di vettori efficiente e praticabile
- Il fine-tuning può migliorare drasticamente la qualità per domini specialistici
- Per l'italiano, usa modelli multilingual (BGE-M3, multilingual-e5) e considera il fine-tuning
Nel Prossimo Articolo
Nel terzo articolo della serie approfondiremo i Vector Database: architettura interna, configurazione avanzata degli indici, strategie di sharding e replicazione, filtraggio per metadati, e come scegliere il database giusto per il tuo caso d'uso. Vedremo implementazioni complete con Qdrant, ChromaDB e pgvector.







