Salta al contenuto principale

02 - Embeddings: Teoria e Pratica

Guida completa a Embeddings: Teoria e Pratica: architettura, implementazione pratica e best practices per developer e team tecnici.

Cosa ne pensi di questo articolo?

02 - Embeddings: Teoria e Pratica
PostgreSQL AIFoto:Brett SaylesPexels

In Breve

Guida completa a Embeddings: Teoria e Pratica: architettura, implementazione pratica e best practices per developer e team tecnici.

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
1pgvectorInstallazione, operatori, indexing
2Sei qui - EmbeddingsModelli, distanze, generazione
3RAG con PostgreSQLPipeline RAG end-to-end
4Similarity Search AvanzataHybrid search, filtering
5Indexing e PerformanceHNSW, IVFFlat, tuning
6RAG in ProduzioneMonitoring, 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.

Un embedding in pratica

# 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.

One-hot encoding: semplice ma limitato

# 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.

TF-IDF: importanza statistica ma nessuna semantica

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
Word2Vec: parole come vettori densi

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:

J = \sum_{i,j=1}^{V} f(X_{ij}) \left( \mathbf{w}_i^T \tilde{\mathbf{w}}_j + b_i + \tilde{b}_j - \log X_{ij} \right)^2

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-SparsoV (vocabolario)Nessuna
TF-IDF1972SparsoV (vocabolario)Statistica
Word2Vec2013Denso100-300Contestuale locale
GloVe2014Denso50-300Globale + locale
FastText2016Denso100-300Subword + contesto
BERT2018Denso768Contestuale dinamica
Sentence Transformers2019Denso384-1024Frasi 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:

\vec{\text{king}} - \vec{\text{man}} + \vec{\text{woman}} \approx \vec{\text{queen}}
Analogie vettoriali con Gensim

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.

\cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \cdot \|\mathbf{B}\|} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \cdot \sqrt{\sum_{i=1}^{n} B_i^2}}

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.

d(\mathbf{A}, \mathbf{B}) = \sqrt{\sum_{i=1}^{n} (A_i - B_i)^2} = \|\mathbf{A} - \mathbf{B}\|_2

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.

\mathbf{A} \cdot \mathbf{B} = \sum_{i=1}^{n} A_i B_i = \|\mathbf{A}\| \cdot \|\mathbf{B}\| \cdot \cos(\theta)

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.

d(\mathbf{A}, \mathbf{B}) = \sum_{i=1}^{n} |A_i - B_i| = \|\mathbf{A} - \mathbf{B}\|_1
Confronto metriche di distanza in Python

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.

Generazione embeddings con Sentence Transformers

# 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.

Generazione embeddings con OpenAI API

# 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.

Generazione embeddings con HuggingFace Inference API

# 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.

Pipeline di batch processing ottimizzata

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

Schema per salvare embeddings con metadati

-- 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

Pipeline completa: genera e salva embeddings

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

Query di similarity search con pgvector

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
Creazione indici in pgvector

-- 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)
CLIP: ricerca immagini con testo

# 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
Valutazione embeddings su un task custom

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
Visualizzazione embeddings con UMAP

# 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.

Matryoshka: dimensioni variabili con lo stesso modello

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.

Caching embeddings con hash del contenuto

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

  1. Definisci il task: retrieval, classificazione, clustering, STS?
  2. Identifica la lingua: solo inglese, multilingue, o dominio specifico?
  3. Valuta i vincoli: budget, privacy, latenza, infrastruttura disponibile
  4. Scegli 2-3 candidati dalla tabella dei modelli (sezione 4.3)
  5. Crea un evaluation set dal tuo dominio (50-100 query con documenti rilevanti)
  6. Misura nDCG e MAP sul tuo dataset per ogni candidato
  7. Calcola i costi a regime per il volume previsto (sezione 11)
  8. Testa le dimensioni ridotte: 512 dim spesso bastano per molti use case
  9. Implementa il caching per ridurre costi di ri-generazione
  10. 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.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti