Embeddings: Teoria e Pratica
Ogni sistema di ricerca semantica, ogni pipeline RAG e ogni applicazione AI che lavora con il linguaggio naturale ha un componente fondamentale in comune: gli embeddings. Sono la traduzione del significato in numeri, il ponte tra il mondo del testo e quello della matematica. Senza embeddings, un database non potrebbe distinguere "cane" da "automobile" - con gli embeddings, sa che "cane" e più vicino a "gatto" di quanto lo sia a "tostapane".
Nel primo articolo di questa serie abbiamo configurato pgvector e imparato a salvare e interrogare vettori in PostgreSQL. Ma da dove vengono quei vettori? Come si genera un embedding di qualità? E soprattutto, quale modello scegliere tra le decine disponibili? In questo articolo rispondiamo a tutte queste domande, dalla teoria matematica alla pratica con Python e PostgreSQL.
Panoramica della Serie
| # | Articolo | Focus |
|---|---|---|
| 1 | pgvector | Installazione, operatori, indexing |
| 2 | Sei qui - Embeddings | Modelli, distanze, generazione |
| 3 | RAG con PostgreSQL | Pipeline RAG end-to-end |
| 4 | Similarity Search Avanzata | Hybrid search, filtering |
| 5 | Indexing e Performance | HNSW, IVFFlat, tuning |
| 6 | RAG in Produzione | Monitoring, scaling, CI/CD |
Cosa Imparerai
- Cos'è un embedding e perchè e fondamentale per l'AI moderna
- L'evoluzione storica: da one-hot encoding a Word2Vec, GloVe, BERT e Sentence Transformers
- Le proprietà matematiche degli embeddings: analogie vettoriali e clustering semantico
- Le quattro metriche di distanza con formule e casi d'uso
- Come generare embeddings con Python: locale e via API
- Come salvare e interrogare embeddings in PostgreSQL con pgvector
- Embeddings multimodali: testo, immagini, audio e codice
- Come valutare la qualità di un modello di embedding (MTEB)
- Costi e strategie di scaling per milioni di documenti
1. Cosa Sono gli Embeddings
Un embedding e una rappresentazione vettoriale densa di un oggetto (parola, frase, documento, immagine) in uno spazio continuo a dimensionalità ridotta. In termini pratici, e un array di numeri floating-point che cattura il "significato" di quell'oggetto.
# L'embedding della frase "Il gatto dorme sul divano"
# generato con text-embedding-3-small di OpenAI (1536 dimensioni)
embedding = [
0.0231, -0.0456, 0.0891, -0.0123, 0.0567, -0.0234,
0.0789, -0.0345, 0.0123, -0.0678, 0.0456, -0.0891,
# ... altri 1524 valori ...
]
print(f"Tipo: {type(embedding)}") # <class 'list'>
print(f"Dimensioni: {len(embedding)}") # 1536
L'intuizione chiave e questa: in uno spazio vettoriale ben addestrato, la distanza geometrica tra due vettori riflette la similarità semantica tra i concetti che rappresentano. Frasi con significato simile avranno vettori vicini, frasi con significati diversi saranno lontane.
Caratteristiche degli Embeddings
| Proprietà | Descrizione | Esempio |
|---|---|---|
| Densi | Ogni dimensione ha un valore non-zero | [0.023, -0.045, 0.089, ...] |
| Continui | Valori reali, non discreti | Ogni componente e un float32/float16 |
| A dimensionalità fissa | Lo stesso modello produce sempre vettori della stessa lunghezza | 384, 768, 1536 o 3072 dimensioni |
| Semanticamente significativi | Le distanze tra vettori riflettono relazioni di significato | sim("gatto", "felino") > sim("gatto", "auto") |
Se pensiamo allo spazio degli embeddings come una mappa, i concetti simili formano "quartieri": gli animali in una zona, i veicoli in un'altra, le emozioni in un'altra ancora. Ma la bellezza sta nel fatto che queste relazioni emergono automaticamente dall'addestramento, non vengono programmate manualmente.
2. Da Parole a Vettori: Evoluzione Storica
La storia degli embeddings e una progressione di idee sempre più sofisticate, ognuna che risolve i limiti della precedente. Capire questa evoluzione aiuta a comprendere perchè i modelli moderni funzionano cosi bene.
2.1 One-Hot Encoding (Anni '90)
L'approccio più semplice: ogni parola e rappresentata da un vettore con un solo 1 e tutti gli altri 0. Se il vocabolario ha V parole, ogni vettore ha V dimensioni.
# Vocabolario: ["gatto", "cane", "pesce", "auto", "moto"]
# Dimensione vettore = dimensione vocabolario = 5
gatto = [1, 0, 0, 0, 0]
cane = [0, 1, 0, 0, 0]
pesce = [0, 0, 1, 0, 0]
auto = [0, 0, 0, 1, 0]
moto = [0, 0, 0, 0, 1]
# Problema 1: la distanza tra "gatto" e "cane" e uguale
# alla distanza tra "gatto" e "auto"
import numpy as np
dist_gatto_cane = np.linalg.norm(
np.array(gatto) - np.array(cane)
) # sqrt(2) = 1.414
dist_gatto_auto = np.linalg.norm(
np.array(gatto) - np.array(auto)
) # sqrt(2) = 1.414 -- identica!
# Problema 2: con un vocabolario di 100.000 parole,
# ogni vettore ha 100.000 dimensioni (sparso, inefficiente)
Limiti del One-Hot Encoding
Dimensionalità esplosiva: per un vocabolario di 100K parole, ogni vettore ha 100K dimensioni, quasi tutte a zero. Nessuna informazione semantica: tutti i vettori sono equidistanti tra loro. "Gatto" e altrettanto distante da "felino" quanto da "terremoto". Questo approccio non cattura alcuna relazione di significato tra le parole.
2.2 TF-IDF (Term Frequency - Inverse Document Frequency)
Un passo avanti: invece di 0/1, i componenti del vettore indicano quanto una parola e importante in un documento rispetto all'intero corpus. Ma ogni documento diventa un vettore sparso nella dimensionalità del vocabolario.
from sklearn.feature_extraction.text import TfidfVectorizer
documenti = [
"il gatto dorme sul divano",
"il cane gioca nel giardino",
"l'automobile corre sulla strada",
"il felino riposa sulla poltrona",
]
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform(documenti)
# Risultato: matrice sparsa (4 documenti x N termini)
print(f"Shape: {tfidf_matrix.shape}") # (4, 14)
print(f"Termini: {vectorizer.get_feature_names_out()}")
# Problema: "gatto dorme" e "felino riposa" sono lontani
# perchè usano parole diverse, anche se il significato e simile
from sklearn.metrics.pairwise import cosine_similarity
sim = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[3:4])
print(f"Similarità gatto-felino: {sim[0][0]:.3f}") # ~0.07 (bassa!)
TF-IDF migliora il one-hot encoding pesando le parole per importanza, ma soffre dello stesso problema fondamentale: non capisce che "gatto" e "felino" sono sinonimi, perchè ragiona solo su corrispondenze lessicali esatte.
2.3 Word2Vec: La Rivoluzione (2013)
Nel 2013, Tomas Mikolov e il suo team a Google pubblicarono Word2Vec, che cambio tutto. L'idea geniale: una parola e definita dal contesto in cui appare. Parole che compaiono in contesti simili avranno rappresentazioni simili.
Word2Vec usa reti neurali shallow per apprendere vettori densi (tipicamente 100-300 dimensioni) da grandi corpora di testo. Due architetture:
Architetture Word2Vec
| Architettura | Input | Output | Descrizione |
|---|---|---|---|
| CBOW | Parole di contesto | Parola target | Predice la parola centrale dato il contesto circostante |
| Skip-gram | Parola target | Parole di contesto | Predice le parole circostanti data la parola centrale |
from gensim.models import Word2Vec
# Corpus di esempio (in produzione: milioni di frasi)
frasi = [
["il", "gatto", "dorme", "sul", "divano"],
["il", "cane", "gioca", "nel", "giardino"],
["il", "felino", "riposa", "sulla", "poltrona"],
["il", "cane", "corre", "nel", "parco"],
]
# Addestramento Word2Vec (Skip-gram)
model = Word2Vec(
sentences=frasi,
vector_size=100, # dimensionalità embedding
window=5, # contesto: 5 parole prima e dopo
min_count=1, # includi parole con almeno 1 occorrenza
sg=1, # 1 = Skip-gram, 0 = CBOW
epochs=100
)
# Ora "gatto" e "felino" sono vicini!
print(model.wv.most_similar("gatto", topn=3))
# [('felino', 0.92), ('cane', 0.85), ('dorme', 0.71)]
# Accesso al vettore
vettore_gatto = model.wv["gatto"]
print(f"Dimensioni: {vettore_gatto.shape}") # (100,)
print(f"Primi 5: {vettore_gatto[:5]}")
2.4 GloVe: Global Vectors (2014)
Stanford sviluppo GloVe (Global Vectors for Word Representation) con un approccio diverso: invece di una rete neurale, GloVe fattorizza la matrice di co-occorrenza globale del corpus. Combina i vantaggi dei metodi statistici globali (come LSA) con quelli del contesto locale di Word2Vec.
GloVe minimizza una funzione di costo che assicura che il prodotto scalare tra due vettori di parole sia proporzionale al logaritmo della loro probabilità di co-occorrenza:
2.5 FastText: Subword Embeddings (2016)
Facebook AI Research (FAIR) estese Word2Vec con FastText, che rappresenta ogni parola come un insieme di n-grammi di caratteri. Questo risolve due problemi critici:
- Parole rare o fuori vocabolario (OOV): FastText può generare embeddings per parole mai viste, componendo i vettori dei sotto-segmenti
- Morfologia: parole correlate morfologicamente (es. "correre", "correva", "corridore") condividono n-grammi e quindi hanno vettori simili
Evoluzione: Da Rappresentazioni Sparse a Dense
| Metodo | Anno | Tipo | Dimensioni Tipiche | Semantica |
|---|---|---|---|---|
| One-hot | - | Sparso | V (vocabolario) | Nessuna |
| TF-IDF | 1972 | Sparso | V (vocabolario) | Statistica |
| Word2Vec | 2013 | Denso | 100-300 | Contestuale locale |
| GloVe | 2014 | Denso | 50-300 | Globale + locale |
| FastText | 2016 | Denso | 100-300 | Subword + contesto |
| BERT | 2018 | Denso | 768 | Contestuale dinamica |
| Sentence Transformers | 2019 | Denso | 384-1024 | Frasi intere |
3. Proprietà Matematiche degli Embeddings
Una delle scoperte più affascinanti di Word2Vec e che lo spazio vettoriale apprende relazioni algebriche tra concetti. Le operazioni aritmetiche sui vettori producono risultati semanticamente coerenti.
3.1 Analogie Vettoriali
La famosa analogia: king - man + woman = queen. In termini vettoriali, la differenza tra "king" e "man" cattura il concetto di "regalita", e aggiungendola a "woman" si ottiene "queen". Formalmente:
import gensim.downloader as api
# Carica embeddings GloVe pre-addestrati
model = api.load("glove-wiki-gigaword-100")
# king - man + woman = ?
result = model.most_similar(
positive=["king", "woman"],
negative=["man"],
topn=3
)
print(result)
# [('queen', 0.7698), ('princess', 0.6450), ('monarch', 0.6345)]
# Altre analogie che funzionano:
# Parigi - Francia + Italia = Roma
result2 = model.most_similar(
positive=["paris", "italy"],
negative=["france"],
topn=1
)
print(result2) # [('rome', 0.8722)]
# buono - cattivo + triste = ?
result3 = model.most_similar(
positive=["good", "sad"],
negative=["bad"],
topn=1
)
print(result3) # [('happy', 0.6891)]
3.2 Clustering Semantico
Gli embeddings formano naturalmente cluster nello spazio vettoriale. Se proiettiamo i vettori in 2D (usando t-SNE o UMAP), osserviamo che parole della stessa categoria si raggruppano: animali vicino ad animali, paesi vicino a paesi, professioni vicino a professioni.
Questa proprietà e fondamentale per le applicazioni pratiche: la similarity search funziona proprio perchè documenti su argomenti simili hanno embeddings vicini nello spazio vettoriale.
4. Embedding Moderni: Contextual Embeddings
Word2Vec e GloVe generano un singolo vettore per parola, indipendente dal contesto. Ma "banco" ha significati diversi in "banco di scuola" e "banco di pesci". I contextual embeddings, introdotti con BERT nel 2018, risolvono questo problema: la stessa parola ha vettori diversi a seconda del contesto.
4.1 BERT Embeddings
BERT (Bidirectional Encoder Representations from Transformers) processa l'intera frase e produce un vettore per ogni token. Per ottenere un embedding dell'intera frase, si usa tipicamente:
- CLS token: il primo token speciale [CLS] contiene una rappresentazione aggregata della frase
- Mean pooling: media di tutti i token vectors - generalmente produce risultati migliori per similarity search
BERT non e ottimale per similarity search
BERT originale non e stato addestrato per produrre embeddings di frase di qualità. Il CLS token e ottimizzato per la classificazione, non per la similarità semantica. Per similarity search, servono modelli specializzati come Sentence Transformers.
4.2 Sentence Transformers (SBERT)
Nel 2019, Reimers e Gurevych introdussero Sentence-BERT, fine-tuning BERT con una struttura siamese per produrre embeddings di frase significativi. Questo ha rivoluzionato la similarity search: per la prima volta, era possibile confrontare frasi con una semplice distanza coseno, ottenendo risultati di alta qualità.
4.3 Modelli di Embedding: Confronto Completo
Modelli di Embedding a Confronto (2026)
| Modello | Provider | Dimensioni | MTEB Score | Costo / 1M token | Note |
|---|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536 | 62.3 | $0.02 | Buon rapporto qualità/prezzo |
| text-embedding-3-large | OpenAI | 3072 | 64.6 | $0.13 | Massima qualità OpenAI |
| embed-v3 | Cohere | 1024 | 64.5 | $0.10 | Supporta 100+ lingue |
| voyage-3 | Voyage AI | 1024 | 67.1 | $0.06 | Top per retrieval |
| all-MiniLM-L6-v2 | HuggingFace | 384 | 56.3 | Gratuito | Veloce, locale, compatto |
| all-mpnet-base-v2 | HuggingFace | 768 | 57.8 | Gratuito | Miglior modello open-source base |
| gte-large-en-v1.5 | Alibaba (HF) | 1024 | 65.4 | Gratuito | Competitivo con modelli commerciali |
| bge-large-en-v1.5 | BAAI (HF) | 1024 | 64.2 | Gratuito | Ottimo per RAG |
Come Scegliere il Modello
- Prototipo / budget limitato: all-MiniLM-L6-v2 (gratuito, veloce, 384 dim)
- Produzione, costi contenuti: text-embedding-3-small (OpenAI, $0.02/1M token)
- Massima qualità retrieval: voyage-3 o gte-large-en-v1.5
- Multilingue: Cohere embed-v3 (100+ lingue)
- Self-hosted / privacy: bge-large-en-v1.5 o gte-large-en-v1.5
5. Misure di Distanza tra Vettori
La scelta della metrica di distanza influenza direttamente la qualità della similarity search. Vediamo le quattro metriche principali con le loro formule matematiche, i loro punti di forza e quando usarle.
5.1 Cosine Similarity
La metrica più usata per gli embeddings di testo. Misura l'angolo tra due vettori, ignorando la loro magnitudine (lunghezza). Due vettori che puntano nella stessa direzione hanno cosine similarity 1, ortogonali 0, opposti -1.
In pgvector, l'operatore <=> calcola la cosine distance
(= 1 - cosine similarity), dove 0 significa identici e 2 significa opposti.
5.2 Distanza Euclidea (L2)
La distanza "in linea d'aria" tra due punti nello spazio. Tiene conto sia della direzione che della magnitudine dei vettori.
In pgvector, l'operatore <-> calcola la distanza L2.
5.3 Dot Product (Prodotto Scalare)
Il prodotto scalare misura sia la direzione che la magnitudine. Per vettori normalizzati (norma = 1), il dot product e equivalente alla cosine similarity.
In pgvector, l'operatore <#> calcola il negative inner product (per compatibilità
con ORDER BY ASC).
5.4 Distanza di Manhattan (L1)
Somma delle differenze assolute componente per componente. Meno sensibile agli outlier rispetto alla distanza euclidea.
import numpy as np
from scipy.spatial.distance import cosine, euclidean, cityblock
# Due vettori di esempio (normalizzati)
a = np.array([0.5, 0.3, 0.8, 0.1, 0.6])
b = np.array([0.4, 0.35, 0.75, 0.15, 0.55])
# Normalizzazione L2
a_norm = a / np.linalg.norm(a)
b_norm = b / np.linalg.norm(b)
# 1. Cosine Similarity (1 - cosine distance)
cos_sim = 1 - cosine(a_norm, b_norm)
print(f"Cosine similarity: {cos_sim:.6f}") # ~0.999
# 2. Distanza Euclidea (L2)
l2_dist = euclidean(a_norm, b_norm)
print(f"Distanza L2: {l2_dist:.6f}") # ~0.042
# 3. Dot Product (per vettori normalizzati = cosine similarity)
dot = np.dot(a_norm, b_norm)
print(f"Dot product: {dot:.6f}") # ~0.999
# 4. Distanza Manhattan (L1)
l1_dist = cityblock(a_norm, b_norm)
print(f"Distanza L1: {l1_dist:.6f}") # ~0.072
# Relazione L2-Cosine per vettori normalizzati:
# d_L2^2 = 2 * (1 - cos_sim)
print(f"\nVerifica: L2^2 = {l2_dist**2:.6f}")
print(f"2*(1-cos) = {2*(1-cos_sim):.6f}") # uguale!
Quando Usare Quale Metrica
| Metrica | Operatore pgvector | Usa Quando | Evita Quando |
|---|---|---|---|
| Cosine | <=> |
Embeddings di testo, quando la magnitudine non conta | Dati spaziali dove la magnitudine e significativa |
| L2 (Euclidea) | <-> |
Immagini, dati numerici, quando la magnitudine conta | Vettori con scale diverse tra le componenti |
| Dot Product | <#> |
Vettori già normalizzati (performance leggermente migliore) | Vettori non normalizzati (risultati distorti dalla magnitudine) |
| Manhattan (L1) | Non nativo in pgvector | Dati sparsi, robustezza agli outlier | Uso generale con embeddings densi |
Regola Pratica
Per il 95% dei casi con embeddings di testo, usa cosine distance
(<=> in pgvector). I modelli di embedding moderni producono vettori
già normalizzati, il che rende cosine e dot product praticamente equivalenti. La distanza
euclidea ha senso per dati spaziali o quando la magnitudine del vettore trasporta informazione.
6. Generare Embeddings in Python
Vediamo ora come generare embeddings con tre approcci diversi: modelli locali con Sentence Transformers, API OpenAI e HuggingFace Inference API. Ogni approccio ha vantaggi e trade-off specifici.
6.1 Sentence Transformers (Locale)
L'approccio più flessibile e privato: il modello gira sulla tua macchina, nessun dato esce dalla rete, nessun costo per API call.
# pip install sentence-transformers
from sentence_transformers import SentenceTransformer
import numpy as np
# Carica il modello (scaricato automaticamente al primo uso)
model = SentenceTransformer("all-MiniLM-L6-v2")
# Embedding di una singola frase
frase = "PostgreSQL e un database relazionale open-source"
embedding = model.encode(frase)
print(f"Tipo: {type(embedding)}") # numpy.ndarray
print(f"Dimensioni: {embedding.shape}") # (384,)
# Embedding di più frasi (batch - molto più efficiente)
frasi = [
"PostgreSQL e un database relazionale open-source",
"pgvector aggiunge il supporto per vettori a PostgreSQL",
"Il machine learning richiede grandi quantità di dati",
"La pizza margherita e un piatto tipico napoletano",
]
embeddings = model.encode(
frasi,
batch_size=32, # processa 32 frasi alla volta
show_progress_bar=True, # mostra progresso per batch grandi
normalize_embeddings=True # normalizza a norma L2 = 1
)
print(f"Shape: {embeddings.shape}") # (4, 384)
# Calcola similarità tra tutte le coppie
from sentence_transformers.util import cos_sim
similarities = cos_sim(embeddings, embeddings)
print(f"\nMatrice di similarità:\n{similarities}")
# Le prime 2 frasi (su PostgreSQL) avranno alta similarità
# La frase sulla pizza sarà distante dalle altre
6.2 OpenAI Embedding API
L'API di OpenAI offre modelli di alta qualità senza gestione infrastrutturale. Ideale per produzione con volumi moderati.
# pip install openai
from openai import OpenAI
import os
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def get_embeddings(
texts: list[str],
model: str = "text-embedding-3-small"
) -> list[list[float]]:
"""Genera embeddings per una lista di testi."""
response = client.embeddings.create(
input=texts,
model=model,
)
return [item.embedding for item in response.data]
# Singolo embedding
testo = "PostgreSQL come vector database per AI"
embedding = get_embeddings([testo])[0]
print(f"Dimensioni: {len(embedding)}") # 1536
# Batch di embeddings (fino a 2048 testi per chiamata)
testi = [
"Come installare pgvector su Docker",
"Tutorial per similarity search in PostgreSQL",
"Guida alla cottura della pasta al forno",
]
embeddings = get_embeddings(testi)
print(f"Embeddings generati: {len(embeddings)}") # 3
# Dimensione ridotta con text-embedding-3-small
# Puoi specificare dimensioni inferiori per risparmiare spazio
response = client.embeddings.create(
input=["Testo di esempio"],
model="text-embedding-3-small",
dimensions=512 # ridotto da 1536 a 512
)
emb_ridotto = response.data[0].embedding
print(f"Dimensioni ridotte: {len(emb_ridotto)}") # 512
6.3 HuggingFace Inference API
Un compromesso tra modelli locali e API commerciali: accesso a migliaia di modelli open-source tramite API, con un piano gratuito generoso.
# pip install huggingface_hub
from huggingface_hub import InferenceClient
import os
client = InferenceClient(
token=os.getenv("HF_TOKEN")
)
def get_hf_embeddings(
texts: list[str],
model: str = "BAAI/bge-large-en-v1.5"
) -> list[list[float]]:
"""Genera embeddings usando HuggingFace Inference API."""
result = client.feature_extraction(
text=texts,
model=model,
)
return result
# Genera embeddings
testi = [
"Vector search con PostgreSQL e pgvector",
"Come creare indici HNSW per ricerca veloce",
]
embeddings = get_hf_embeddings(testi)
print(f"Embeddings: {len(embeddings)}") # 2
print(f"Dimensioni: {len(embeddings[0])}") # 1024 (bge-large)
6.4 Batch Processing Efficiente
Quando devi generare embeddings per migliaia o milioni di documenti, l'efficienza del batch processing diventa critica.
import time
from typing import Generator
from sentence_transformers import SentenceTransformer
import numpy as np
def chunk_list(
lst: list, chunk_size: int
) -> Generator[list, None, None]:
"""Divide una lista in chunk di dimensione fissa."""
for i in range(0, len(lst), chunk_size):
yield lst[i:i + chunk_size]
def generate_embeddings_batch(
texts: list[str],
model_name: str = "all-MiniLM-L6-v2",
batch_size: int = 256,
device: str = "cpu" # "cuda" per GPU
) -> np.ndarray:
"""Genera embeddings in batch con progress tracking."""
model = SentenceTransformer(model_name, device=device)
all_embeddings = []
total_batches = (len(texts) + batch_size - 1) // batch_size
start = time.time()
for i, batch in enumerate(chunk_list(texts, batch_size)):
batch_emb = model.encode(
batch,
batch_size=batch_size,
normalize_embeddings=True,
show_progress_bar=False
)
all_embeddings.append(batch_emb)
elapsed = time.time() - start
rate = (i + 1) * batch_size / elapsed
print(
f"Batch {i+1}/{total_batches} - "
f"{rate:.0f} testi/sec"
)
return np.vstack(all_embeddings)
# Utilizzo
texts = [f"Documento numero {i}" for i in range(10_000)]
embeddings = generate_embeddings_batch(
texts,
batch_size=256,
device="cuda" # usa GPU se disponibile
)
print(f"Shape finale: {embeddings.shape}") # (10000, 384)
7. Storicizzare Embeddings in PostgreSQL
Ora che sappiamo generare embeddings, vediamo come salvarli in PostgreSQL con pgvector ed eseguire query di similarity search. Questo e il collegamento pratico con l'articolo 1 della serie.
7.1 Schema della Tabella
-- Abilita pgvector
CREATE EXTENSION IF NOT EXISTS vector;
-- Tabella documenti con embedding
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
source VARCHAR(255),
category VARCHAR(100),
embedding vector(384), -- dimensione del modello scelto
created_at TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB DEFAULT '{}'::jsonb
);
-- Indice HNSW per ricerca veloce (cosine distance)
CREATE INDEX idx_documents_embedding
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- Indice su categoria per filtri combinati
CREATE INDEX idx_documents_category
ON documents (category);
7.2 Inserimento da Python
import psycopg2
from psycopg2.extras import execute_values
from sentence_transformers import SentenceTransformer
import numpy as np
# Configurazione
DB_CONFIG = {
"host": "localhost",
"port": 5432,
"dbname": "vectordb",
"user": "admin",
"password": "secret_password",
}
# 1. Genera embeddings
model = SentenceTransformer("all-MiniLM-L6-v2")
documenti = [
{
"title": "Introduzione a pgvector",
"content": "pgvector e un'estensione PostgreSQL per vettori...",
"source": "blog",
"category": "database"
},
{
"title": "RAG con LangChain",
"content": "Retrieval Augmented Generation combina retrieval...",
"source": "tutorial",
"category": "ai"
},
{
"title": "Python per Data Science",
"content": "Python e il linguaggio più usato per data science...",
"source": "guide",
"category": "programming"
},
]
# Genera embeddings per i contenuti
testi = [d["content"] for d in documenti]
embeddings = model.encode(testi, normalize_embeddings=True)
# 2. Salva in PostgreSQL
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Prepara i dati per batch insert
values = []
for doc, emb in zip(documenti, embeddings):
values.append((
doc["title"],
doc["content"],
doc["source"],
doc["category"],
emb.tolist() # converti numpy array in lista Python
))
# Inserimento batch efficiente
execute_values(
cur,
"""INSERT INTO documents
(title, content, source, category, embedding)
VALUES %s""",
values,
template="(%s, %s, %s, %s, %s::vector)"
)
conn.commit()
print(f"Inseriti {len(values)} documenti con embeddings")
cur.close()
conn.close()
7.3 Similarity Search da Python
def similarity_search(
query: str,
top_k: int = 5,
category: str = None,
threshold: float = 0.3
) -> list[dict]:
"""Cerca documenti simili alla query."""
# Genera embedding della query
query_embedding = model.encode(
query, normalize_embeddings=True
).tolist()
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Query con filtro opzionale
if category:
cur.execute("""
SELECT id, title, content, category,
1 - (embedding <=> %s::vector) AS similarity
FROM documents
WHERE category = %s
AND 1 - (embedding <=> %s::vector) > %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (
query_embedding, category,
query_embedding, threshold,
query_embedding, top_k
))
else:
cur.execute("""
SELECT id, title, content, category,
1 - (embedding <=> %s::vector) AS similarity
FROM documents
WHERE 1 - (embedding <=> %s::vector) > %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (
query_embedding,
query_embedding, threshold,
query_embedding, top_k
))
results = []
for row in cur.fetchall():
results.append({
"id": row[0],
"title": row[1],
"content": row[2][:200], # troncato
"category": row[3],
"similarity": round(row[4], 4),
})
cur.close()
conn.close()
return results
# Esempio d'uso
risultati = similarity_search(
"come usare i vettori in un database",
top_k=3,
category="database"
)
for r in risultati:
print(f"[{r['similarity']}] {r['title']}")
7.4 Indexing: HNSW vs IVFFlat
Per dataset con più di qualche migliaio di documenti, un indice e essenziale per performance accettabili. pgvector offre due tipi di indice:
HNSW vs IVFFlat
| Caratteristica | HNSW | IVFFlat |
|---|---|---|
| Velocita query | Molto veloce | Veloce |
| Recall | 95-99% | 85-95% |
| Tempo di build | Lento (minuti) | Veloce (secondi) |
| Memoria | Alta (grafo in RAM) | Bassa (centroidi) |
| Insert/Update | Buono (aggiornamento incrementale) | Richiede ricostruzione periodica |
| Raccomandato per | Produzione, alta qualità | Prototipazione, dataset statici |
-- HNSW (raccomandato per produzione)
-- m: connessioni per nodo (16-64, default 16)
-- ef_construction: qualità costruzione (64-512, default 64)
CREATE INDEX idx_hnsw_cosine
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- Per L2 distance
CREATE INDEX idx_hnsw_l2
ON documents
USING hnsw (embedding vector_l2_ops)
WITH (m = 16, ef_construction = 200);
-- IVFFlat (più veloce da costruire)
-- lists: numero di cluster (sqrt(N) come regola base)
CREATE INDEX idx_ivfflat_cosine
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100); -- per ~10K documenti
-- Parametri di query per controllare recall vs velocità
SET hnsw.ef_search = 100; -- default 40, alza per più recall
SET ivfflat.probes = 10; -- default 1, alza per più recall
8. Embeddings per Diversi Tipi di Dati
Gli embeddings non sono limitati al testo. I modelli moderni possono generare rappresentazioni vettoriali per immagini, audio, codice sorgente e persino dati multimodali.
Embeddings Multimodali: Modelli per Tipo di Dato
| Tipo di Dato | Modello | Dimensioni | Caso d'Uso |
|---|---|---|---|
| Testo | all-MiniLM-L6-v2, text-embedding-3-small | 384-3072 | Ricerca semantica, RAG, classificazione |
| Immagini | CLIP (OpenAI), SigLIP (Google) | 512-768 | Ricerca immagini, classificazione visiva |
| Audio | Whisper, CLAP | 512-1280 | Ricerca audio, classificazione musicale |
| Codice | CodeBERT, StarCoder embeddings | 768 | Code search, duplicate detection |
| Multimodale | CLIP, ImageBind (Meta) | 512-1024 | Ricerca cross-modale (testo per immagine) |
# pip install transformers pillow
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import torch
import numpy as np
# Carica CLIP
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
# Embedding di un'immagine
image = Image.open("foto_gatto.jpg")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
image_embedding = model.get_image_features(**inputs)
image_emb = image_embedding[0].numpy()
print(f"Image embedding: {image_emb.shape}") # (512,)
# Embedding di testo (nello STESSO spazio!)
text_inputs = processor(
text=["un gatto che dorme", "un cane che gioca"],
return_tensors="pt",
padding=True
)
with torch.no_grad():
text_embeddings = model.get_text_features(**text_inputs)
text_embs = text_embeddings.numpy()
# Calcola similarità cross-modale
from numpy.linalg import norm
for i, text in enumerate(["un gatto che dorme", "un cane che gioca"]):
sim = np.dot(image_emb, text_embs[i]) / (
norm(image_emb) * norm(text_embs[i])
)
print(f"Similarità '{text}': {sim:.4f}")
# "un gatto che dorme" avra similarità più alta con foto_gatto.jpg
La potenza di CLIP e che testo e immagini vivono nello stesso spazio vettoriale. Puoi cercare immagini con una query testuale o trovare testi correlati a un'immagine. Questo apre scenari come la ricerca multimodale in PostgreSQL: salvi embeddings CLIP nella stessa tabella pgvector e cerchi con query testuali.
9. Valutare la qualità degli Embeddings
Come fai a sapere se un modello di embedding e "buono"? La risposta dipende dal task specifico, ma esistono benchmark standardizzati e metriche oggettive.
9.1 MTEB: Massive Text Embedding Benchmark
MTEB e il benchmark di riferimento per valutare i modelli di embedding. Misura le performance su 58+ task raggruppati in 8 categorie:
- Retrieval: trovare documenti rilevanti data una query
- Semantic Textual Similarity (STS): quanto due frasi sono simili
- Classification: classificare testi in categorie
- Clustering: raggruppare testi simili
- Pair Classification: stabilire se due testi sono correlati
- Reranking: ri-ordinare risultati per rilevanza
- Summarization: qualità dei riassunti
- BitextMining: trovare traduzioni parallele
from sentence_transformers import SentenceTransformer
from sentence_transformers.evaluation import (
InformationRetrievalEvaluator
)
# Prepara dataset di valutazione
queries = {
"q1": "come installare pgvector",
"q2": "cos'è la similarity search",
"q3": "embedding di immagini con CLIP",
}
corpus = {
"d1": "Guida installazione pgvector su Ubuntu",
"d2": "pgvector per PostgreSQL: setup Docker",
"d3": "Ricerca per similarità vettoriale",
"d4": "CLIP: modello multimodale per immagini e testo",
"d5": "Ricetta pasta alla carbonara",
}
# Mappatura query -> documenti rilevanti
relevant = {
"q1": {"d1": 1, "d2": 1}, # d1 e d2 rilevanti per q1
"q2": {"d3": 1},
"q3": {"d4": 1},
}
# Valuta il modello
model = SentenceTransformer("all-MiniLM-L6-v2")
evaluator = InformationRetrievalEvaluator(
queries=queries,
corpus=corpus,
relevant_docs=relevant,
name="custom-eval"
)
results = evaluator(model)
print(f"NDCG@10: {results['custom-eval_ndcg@10']:.4f}")
print(f"MAP@10: {results['custom-eval_map@10']:.4f}")
9.2 Valutazione Intrinseca vs Estrinseca
Due Approcci alla Valutazione
| Tipo | Cosa Misura | Esempio | Quando Usare |
|---|---|---|---|
| Intrinseca | Proprietà dei vettori stessi | Analogie, clustering, STS | Confronto rapido tra modelli |
| Estrinseca | Performance sul task finale | qualità RAG, precisione ricerca | Decisione finale in produzione |
Consiglio Pratico
Non fidarti solo del punteggio MTEB. Un modello può avere punteggio MTEB alto ma funzionare male sul tuo dominio specifico. Valuta sempre sul tuo dataset: crea un piccolo set di query e documenti rilevanti dal tuo dominio, e misura nDCG e MAP. Questo ti dara una stima molto più affidabile delle performance reali.
10. Riduzione della Dimensionalità
I vettori ad alta dimensionalità sono difficili da visualizzare e possono essere costosi in termini di storage e computazione. Le tecniche di riduzione della dimensionalità aiutano sia per la visualizzazione che per l'ottimizzazione.
10.1 Tecniche di Visualizzazione
Tecniche di Riduzione Dimensionale
| Tecnica | Preserva | Velocita | Uso Tipico |
|---|---|---|---|
| PCA | Varianza globale | Molto veloce | Riduzione dimensioni per storage, pre-processing |
| t-SNE | Struttura locale | Lenta | Visualizzazione 2D di cluster |
| UMAP | Struttura locale + globale | Media | Visualizzazione 2D, anche per riduzione pre-indexing |
# pip install umap-learn matplotlib
import umap
import matplotlib.pyplot as plt
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
# Testi con categorie diverse
testi = [
# Database
"PostgreSQL e un database relazionale",
"MongoDB e un database NoSQL",
"Redis e un database in-memory",
# AI/ML
"Il deep learning usa reti neurali profonde",
"GPT e un modello di linguaggio",
"La regressione lineare e un algoritmo semplice",
# Cucina
"La pizza si cuoce nel forno a legna",
"Il tiramisu e un dolce italiano",
"La pasta alla carbonara usa uova e guanciale",
]
categorie = ["DB"]*3 + ["AI"]*3 + ["Cucina"]*3
colori = ["blue"]*3 + ["red"]*3 + ["green"]*3
# Genera embeddings (384 dimensioni)
embeddings = model.encode(testi, normalize_embeddings=True)
# Riduci a 2D con UMAP
reducer = umap.UMAP(n_components=2, random_state=42)
emb_2d = reducer.fit_transform(embeddings)
# Visualizza
plt.figure(figsize=(10, 8))
for i, (x, y) in enumerate(emb_2d):
plt.scatter(x, y, c=colori[i], s=100, zorder=5)
plt.annotate(testi[i][:30], (x, y),
fontsize=8, ha='left')
plt.title("Embeddings in 2D (UMAP)")
plt.savefig("embeddings_umap.png", dpi=150)
plt.show()
10.2 Matryoshka Embeddings
Una tecnica recente e innovativa: i Matryoshka Representation Learning (MRL) embeddings sono addestrati in modo che i primi N componenti del vettore siano già un embedding valido. Puoi troncare il vettore da 1536 a 512 o 256 dimensioni mantenendo buona qualità.
OpenAI text-embedding-3-small e text-embedding-3-large supportano
questa tecnica: puoi specificare il parametro dimensions per ottenere vettori
più compatti senza ricalcolare gli embeddings.
from openai import OpenAI
import numpy as np
import os
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
testo = "PostgreSQL come vector database per applicazioni AI"
# Genera lo stesso embedding a dimensioni diverse
for dim in [256, 512, 1024, 1536]:
response = client.embeddings.create(
input=[testo],
model="text-embedding-3-small",
dimensions=dim
)
emb = response.data[0].embedding
print(f"Dimensioni: {dim}, norma: {np.linalg.norm(emb):.4f}")
# In PostgreSQL: usa colonne con dimensione appropriata
# CREATE TABLE docs_compact (
# id BIGSERIAL PRIMARY KEY,
# content TEXT,
# embedding vector(256) -- più compatto, 6x meno storage
# );
11. Costi e Strategie di Scaling
Quando si passa dal prototipo alla produzione, i costi di generazione e storage degli embeddings diventano un fattore critico. Vediamo un'analisi dettagliata.
11.1 Costi per 1 Milione di Documenti
Stima Costi: 1M Documenti (media 500 token/doc)
| Modello | Costo Generazione | Dim. Vettore | Storage (float32) | Totale Iniziale |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | $0 (locale) | 384 | ~1.4 GB | Solo GPU/CPU time |
| text-embedding-3-small | ~$10 | 1536 | ~5.7 GB | ~$10 + storage |
| text-embedding-3-large | ~$65 | 3072 | ~11.4 GB | ~$65 + storage |
| voyage-3 | ~$30 | 1024 | ~3.8 GB | ~$30 + storage |
Formula storage: N documenti * dimensioni * 4 byte (float32). Esempio: 1M * 1536 * 4 = 5.7 GB solo per i vettori.
11.2 Self-Hosted vs API: Trade-off
Confronto Self-Hosted vs API
| Aspetto | Self-Hosted | API (OpenAI, Cohere) |
|---|---|---|
| Costo iniziale | Alto (GPU ~$1-3/ora) | Basso (pay-per-use) |
| Costo a volume | Più economico oltre ~10M doc | Lineare, scala con il volume |
| Latenza | Bassa (no rete) | 50-200ms per chiamata |
| Privacy | Dati restano on-premise | Dati inviati a terze parti |
| Manutenzione | Gestione GPU, aggiornamenti, monitoring | Zero |
| qualità | Dipende dal modello scelto | Generalmente alta e consistente |
11.3 Strategie di Caching
Il caching degli embeddings e fondamentale per ridurre costi e latenza. Se lo stesso testo viene richiesto più volte, non ha senso rigenerare l'embedding.
import hashlib
import json
import psycopg2
from typing import Optional
import numpy as np
class EmbeddingCache:
"""Cache embeddings in PostgreSQL per evitare ricalcoli."""
def __init__(self, db_config: dict):
self.db_config = db_config
self._init_table()
def _init_table(self):
conn = psycopg2.connect(**self.db_config)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS embedding_cache (
content_hash VARCHAR(64) PRIMARY KEY,
model_name VARCHAR(100) NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
conn.commit()
cur.close()
conn.close()
def _hash(self, text: str, model: str) -> str:
"""Hash deterministico del contenuto + modello."""
key = f"{model}::{text}"
return hashlib.sha256(key.encode()).hexdigest()
def get(
self, text: str, model: str
) -> Optional[list[float]]:
"""Cerca embedding in cache."""
h = self._hash(text, model)
conn = psycopg2.connect(**self.db_config)
cur = conn.cursor()
cur.execute(
"SELECT embedding FROM embedding_cache "
"WHERE content_hash = %s",
(h,)
)
row = cur.fetchone()
cur.close()
conn.close()
return row[0] if row else None
def put(
self, text: str, model: str, embedding: list[float]
):
"""Salva embedding in cache."""
h = self._hash(text, model)
conn = psycopg2.connect(**self.db_config)
cur = conn.cursor()
cur.execute(
"INSERT INTO embedding_cache "
"(content_hash, model_name, embedding) "
"VALUES (%s, %s, %s::vector) "
"ON CONFLICT (content_hash) DO NOTHING",
(h, model, embedding)
)
conn.commit()
cur.close()
conn.close()
12. Collegamento con la Serie PostgreSQL AI
Ricapitoliamo come gli embeddings si integrano nell'ecosistema che stiamo costruendo in questa serie:
Il Flusso Completo: Dall'Articolo 1 all'Articolo 3
| Step | Articolo | Azione |
|---|---|---|
| 1 | pgvector (Art. 1) | Configura PostgreSQL con pgvector, crea tabelle con colonne vector |
| 2 | Embeddings (Art. 2 - questo) | Scegli il modello, genera embeddings, salvali in pgvector |
| 3 | RAG con PostgreSQL (Art. 3) | Combina retrieval via pgvector con LLM per rispondere alle domande |
Nell'articolo 1 abbiamo preparato l'infrastruttura: PostgreSQL con pgvector installato, tabelle con colonne vettoriali e indici HNSW configurati. In questo articolo abbiamo colmato il gap fondamentale: da dove vengono quei vettori, come scegliere il modello giusto e come generarli efficientemente. Nel prossimo articolo costruiremo una pipeline RAG completa che usa tutto questo stack: documenti indicizzati in pgvector, embeddings generati on-the-fly per le query, e un LLM che genera risposte basate sul contesto recuperato.
13. Conclusioni e Checklist
Gli embeddings sono il componente fondamentale che connette il linguaggio naturale con la matematica dei database vettoriali. Scegliere il modello giusto, la metrica di distanza appropriata e una strategia di scaling efficiente sono decisioni che impattano direttamente la qualità e i costi del tuo sistema AI.
Checklist: Scegliere il Modello di Embedding Giusto
- Definisci il task: retrieval, classificazione, clustering, STS?
- Identifica la lingua: solo inglese, multilingue, o dominio specifico?
- Valuta i vincoli: budget, privacy, latenza, infrastruttura disponibile
- Scegli 2-3 candidati dalla tabella dei modelli (sezione 4.3)
- Crea un evaluation set dal tuo dominio (50-100 query con documenti rilevanti)
- Misura nDCG e MAP sul tuo dataset per ogni candidato
- Calcola i costi a regime per il volume previsto (sezione 11)
- Testa le dimensioni ridotte: 512 dim spesso bastano per molti use case
- Implementa il caching per ridurre costi di ri-generazione
- Monitora la qualità nel tempo con il tuo evaluation set
Prossimo Articolo: RAG con PostgreSQL
Nel prossimo articolo della serie costruiremo una pipeline RAG completa (Retrieval Augmented Generation) usando PostgreSQL + pgvector come knowledge base. Vedremo come combinare similarity search con chunking intelligente, come integrare LLM come GPT-4 e Claude, e come misurare la qualità delle risposte generate.







