Semantic Similarity e Sentence Embeddings: Confrontare Testi
Quanto sono simili due frasi? Non nel senso lessicale (stesse parole), ma nel senso semantico (stesso significato). "Il cane insegue il gatto" e "Il felino viene rincorso dal canino" sono semanticamente quasi identiche ma lessicalmente molto diverse. Rispondere a questa domanda e la sfida della Semantic Similarity.
Le applicazioni sono ovunque: motori di ricerca semantica, sistemi di raccomandazione, deduplicazione di contenuti, question answering, RAG (Retrieval-Augmented Generation), chatbot e FAQ matching. In questo articolo costruiremo sistemi di semantic similarity da zero: dalla cosine similarity alle sentence embeddings con Sentence-BERT, fino alla ricerca vettoriale veloce con FAISS.
Questo e il nono articolo della serie NLP Moderno: da BERT ai LLM. Questo topic si collega direttamente con la serie AI Engineering/RAG dove gli embeddings semantici sono il cuore del dense retrieval.
Cosa Imparerai
- Cosine similarity e dot product: formule e quando usarle
- Limiti di BERT per la semantic similarity e perchè serve Sentence-BERT
- Sentence-BERT (SBERT): architettura siamese e training con triplet loss
- Modelli sentence-transformers su HuggingFace: quale scegliere
- Ricerca semantica su corpus grandi con FAISS
- Sentence embeddings per l'italiano
- Benchmarking: STS-B, SICK e metriche di valutazione
- Cross-encoder vs bi-encoder: trade-off qualità/velocità
- Fine-tuning di un sentence transformer sul proprio dominio
- Implementazione completa di un sistema di FAQ matching
- Pipeline production-ready con caching e ottimizzazione
1. Il Problema della Similarità Semantica
Consideriamo questi tre gruppi di frasi e le loro sfide:
Esempi di Similarità Semantica
- Alta similarità: "La banca ha alzato i tassi" / "I tassi di interesse sono aumentati dall'istituto bancario"
- Bassa similarità: "La banca ha alzato i tassi" / "Il gatto dorme sul divano"
- Ingannevole (stesse parole, senso diverso): "Il banco di scuola" / "Il banco del pesce al mercato"
- Cross-linguistica: "The dog runs fast" / "Il cane corre veloce" (stessa semantica, lingue diverse)
Le metriche tradizionali come la Jaccard similarity o la BM25 si basano sulla sovrapposizione lessicale e falliscono completamente con i sinonimi e le parafrasi. Anche il semplice TF-IDF non cattura il significato. La soluzione risiede negli embedding semantici: rappresentazioni vettoriali dense dove la vicinanza geometrica riflette la vicinanza semantica.
1.1 Cosine Similarity: La Metrica Fondamentale
La cosine similarity misura l'angolo tra due vettori nello spazio degli embeddings. Varia da -1 (opposti) a 1 (identici), con 0 per vettori ortogonali. La formula matematica e:
cos(A, B) = (A · B) / (||A|| · ||B||)
Quando i vettori sono normalizzati a norma unitaria, la cosine similarity coincide con il dot product, il che rende il calcolo molto più efficiente su hardware GPU.
import numpy as np
import torch
from torch.nn import functional as F
def cosine_similarity(vec1, vec2):
"""Cosine similarity tra due vettori numpy."""
dot_product = np.dot(vec1, vec2)
norm1 = np.linalg.norm(vec1)
norm2 = np.linalg.norm(vec2)
return dot_product / (norm1 * norm2)
# Versione PyTorch (batch-friendly)
def cosine_similarity_batch(emb1, emb2):
"""Cosine similarity tra batch di embedding (normalizzato)."""
# Normalizza a norma unitaria
emb1_norm = F.normalize(emb1, p=2, dim=1)
emb2_norm = F.normalize(emb2, p=2, dim=1)
return (emb1_norm * emb2_norm).sum(dim=1)
# Esempio con vettori semplici
vec_a = np.array([1.0, 0.5, 0.3, 0.8])
vec_b = np.array([0.9, 0.4, 0.4, 0.7]) # simile ad a
vec_c = np.array([-0.2, 0.8, -0.5, 0.1]) # diverso da a
print(f"sim(a, b) = {cosine_similarity(vec_a, vec_b):.4f}") # alta
print(f"sim(a, c) = {cosine_similarity(vec_a, vec_c):.4f}") # bassa
# Matrice di similarità per corpus di frasi
def similarity_matrix(embeddings):
"""Matrice di similarità N x N per un set di embedding."""
# Normalizza
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
normalized = embeddings / norms
# Prodotto matriciale per tutte le coppie
return normalized @ normalized.T
# Output: matrice (N, N) dove [i,j] = sim(frase_i, frase_j)
1.2 Altre Metriche di Distanza
Confronto tra Metriche di Similarità/Distanza
| Metrica | Formula | Range | Caso d'uso |
|---|---|---|---|
| Cosine Similarity | cos(A, B) | [-1, 1] | Semantic similarity standard |
| Euclidean Distance | ||A - B|| | [0, +inf) | Clustering, k-NN |
| Dot Product | A · B | (-inf, +inf) | Con vettori normalizzati = cosine |
| Manhattan Distance | sum(|A-B|) | [0, +inf) | Robustezza agli outlier |
| Pearson Correlation | cov(A,B)/sigma | [-1, 1] | Valutazione su STS benchmark |
2. perchè BERT Standard non Funziona per la Similarità
Intuitivamente, potremmo usare BERT per estrarre embedding di frasi e confrontarli. Ma la ricerca di Reimers & Gurevych (2019) ha mostrato che questo approccio e sorprendentemente inefficace.
Il problema principale e che BERT e pre-trainato con Masked Language Modeling (MLM) e
Next Sentence Prediction (NSP). Il token [CLS] codifica informazioni
utili per la classificazione di coppie di frasi (NSP), ma non e ottimizzato per
produrre embedding che riflettano la similarità semantica quando comparati
con cosine similarity.
Inoltre, il mean pooling su tutti i token produce uno spazio embedding anisotropo: le direzioni non sono distribuite uniformemente, e cluster di frasi semanticamente diverse si sovrappongono.
from transformers import BertModel, BertTokenizer
import torch
import numpy as np
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
def bert_mean_pooling(text):
"""Embedding di frase con mean pooling su BERT."""
inputs = tokenizer(text, return_tensors='pt',
truncation=True, max_length=128, padding=True)
with torch.no_grad():
outputs = model(**inputs)
# Mean pooling (esclude padding)
mask = inputs['attention_mask'].unsqueeze(-1)
embeddings = (outputs.last_hidden_state * mask).sum(1) / mask.sum(1)
return embeddings[0].numpy()
# Test: frasi semanticamente simili vs diverse
sent1 = "The weather is lovely today."
sent2 = "It's so beautiful today outside." # simile
sent3 = "My dog bit the mailman." # diversa
emb1 = bert_mean_pooling(sent1)
emb2 = bert_mean_pooling(sent2)
emb3 = bert_mean_pooling(sent3)
sim_1_2 = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
sim_1_3 = np.dot(emb1, emb3) / (np.linalg.norm(emb1) * np.linalg.norm(emb3))
print(f"sim(sent1, sent2) = {sim_1_2:.4f}") # ~0.93 - ok
print(f"sim(sent1, sent3) = {sim_1_3:.4f}") # ~0.87 - troppo alto!
# Problema: BERT tende a produrre embedding simili per tutte le frasi
# perchè il token [CLS] e trainato su NSP, non su similarità semantica
# La soluzione e Sentence-BERT
Performance di BERT su STS-B (Benchmark)
Sulla task STS-B (Semantic Textual Similarity Benchmark), BERT con mean pooling raggiunge solo Pearson r = 0.54, ben al di sotto di approcci supervised come SBERT (0.87). Anche il [CLS] token da solo raggiunge solo 0.20. Per la semantic similarity, SBERT e la scelta corretta.
3. Sentence-BERT (SBERT): La Soluzione
Sentence-BERT (Reimers & Gurevych, EMNLP 2019) risolve il problema con un'architettura siamese: due istanze di BERT condividono i pesi, processano due frasi separatamente, e la loss funzione forza le rappresentazioni semanticamente simili ad essere vicine nello spazio vettoriale.
3.1 Architettura Siamese
L'idea chiave e che le due "reti" condividono esattamente gli stessi pesi. Non si tratta di due modelli separati ma dello stesso modello chiamato due volte. La loss viene calcolata sulla coppia di output:
- Regression objective: MSE tra cosine similarity predetta e score umano (per STS)
- Classification objective: Cross-entropy su [u, v, |u-v|] (per NLI)
- Triplet loss: margin loss su anchor/positive/negative (per paraphrase mining)
from sentence_transformers import SentenceTransformer, util
import torch
# Carica un modello sentence-transformers
# Modello multilingua (include italiano!)
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
# Modello ottimizzato per inglese (più accurato)
# model = SentenceTransformer('all-MiniLM-L6-v2')
# Encoding di frasi (batch-optimized)
sentences = [
"The weather is lovely today.",
"It's so beautiful today outside.",
"He drove to the stadium.",
"La giornata e bellissima oggi.", # italiano
"Il tempo e meraviglioso questa mattina.", # italiano simile
]
# Encode tutto in una volta (molto più efficiente del loop)
embeddings = model.encode(sentences, batch_size=32, show_progress_bar=False)
print(f"Embedding shape: {embeddings.shape}") # (5, 384)
# Calcola similarità
cos_scores = util.cos_sim(embeddings, embeddings)
print("\nMatrice di similarità:")
for i in range(len(sentences)):
for j in range(i+1, len(sentences)):
score = cos_scores[i][j].item()
if score > 0.6: # mostra solo coppie simili
print(f" {i+1} vs {j+1}: {score:.4f}")
print(f" '{sentences[i][:50]}'")
print(f" '{sentences[j][:50]}'")
# Pairwise similarity per coppie specifiche
sim = util.cos_sim(embeddings[0], embeddings[1]).item()
print(f"\nsim(EN1, EN2) = {sim:.4f}") # ~0.85 (frasi simili)
sim_cross = util.cos_sim(embeddings[0], embeddings[3]).item()
print(f"sim(EN1, IT1) = {sim_cross:.4f}") # ~0.75 (cross-lingual!)
4. Modelli sentence-transformers: Quale Scegliere
Principali Modelli sentence-transformers (2024-2025)
| Modello | Lingue | Dim. | Velocita | STS-B Pearson |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | EN | 384 | Molto veloce | 0.834 |
| all-mpnet-base-v2 | EN | 768 | Media | 0.869 |
| paraphrase-multilingual-MiniLM-L12-v2 | 50+ lingue | 384 | Veloce | 0.821 |
| paraphrase-multilingual-mpnet-base-v2 | 50+ lingue | 768 | Media | 0.853 |
| intfloat/multilingual-e5-large | 100+ lingue | 1024 | Lenta | 0.892 |
| text-embedding-3-small (OpenAI) | Multilingua | 1536 | API only | ~0.90 |
4.1 Scelta del Modello: Guida Pratica
La scelta dipende da tre fattori principali: lingua, velocità e qualità necessaria.
from sentence_transformers import SentenceTransformer
import time
import numpy as np
def benchmark_model(model_name, sentences, n_runs=3):
"""Benchmark velocità e qualità di un modello sentence-transformer."""
model = SentenceTransformer(model_name)
# Warmup
model.encode(sentences[:2])
# Misura velocità
times = []
for _ in range(n_runs):
start = time.time()
embs = model.encode(sentences)
times.append(time.time() - start)
avg_time = np.mean(times)
dim = embs.shape[1]
print(f"Model: {model_name}")
print(f" Embedding dim: {dim}")
print(f" Avg encoding time ({len(sentences)} sentences): {avg_time*1000:.1f}ms")
print(f" Throughput: {len(sentences)/avg_time:.0f} sentences/sec")
sentences_test = [
"Il sole splende oggi a Milano.",
"Oggi e una bella giornata soleggiata.",
"Roma e la capitale dell'Italia.",
"La Juventus ha vinto il campionato.",
"L'intelligenza artificiale sta cambiando il mondo.",
] * 20 # 100 frasi
# Benchmark modelli multilingua
for model_name in [
'paraphrase-multilingual-MiniLM-L12-v2',
'paraphrase-multilingual-mpnet-base-v2',
'intfloat/multilingual-e5-small',
]:
benchmark_model(model_name, sentences_test)
print()
5. Ricerca Semantica con FAISS
Per corpus di grandi dimensioni (milioni di documenti), la ricerca a forza bruta (calcola la similarità con tutti i documenti) e troppo lenta. FAISS (Facebook AI Similarity Search) permette la ricerca approssimata del nearest neighbor in tempo sub-lineare con diversi tipi di indice.
5.1 Tipi di Indice FAISS
Indici FAISS: Trade-off Velocita/Accuratezza
| Indice | Tipo | Caso d'uso | Recall (%) | Velocita |
|---|---|---|---|---|
| IndexFlatL2 | Esatto | < 100K docs | 100% | Lenta |
| IndexFlatIP | Esatto (cosine) | < 100K docs | 100% | Lenta |
| IndexIVFFlat | Approssimato | 100K - 10M | ~95% | Veloce |
| IndexHNSW | Approssimato | 1M+ | ~99% | Molto veloce |
| IndexIVFPQ | Compresso | 10M+, RAM limitata | ~85% | Molto veloce |
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
import time
model = SentenceTransformer('all-MiniLM-L6-v2')
# Corpus di esempio: articoli Wikipedia
corpus = [
"The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris.",
"Apple Inc. is an American multinational technology company founded by Steve Jobs.",
"Python is a high-level, general-purpose programming language.",
"The Mediterranean diet is based on traditional foods from countries bordering the sea.",
"Quantum computing uses quantum-mechanical phenomena such as superposition.",
"The Amazon River is the largest river by discharge volume in the world.",
"Artificial neural networks are computing systems inspired by biological neural networks.",
"The Sistine Chapel ceiling was painted by Michelangelo between 1508 and 1512.",
"Machine learning is a subset of artificial intelligence focused on algorithms.",
"The Colosseum is an oval amphitheatre in the centre of Rome, Italy.",
]
# Encode il corpus (offline, una volta sola)
print("Encoding corpus...")
start = time.time()
corpus_embeddings = model.encode(corpus, convert_to_numpy=True, show_progress_bar=False)
print(f"Encoded {len(corpus)} docs in {time.time()-start:.2f}s")
print(f"Embeddings shape: {corpus_embeddings.shape}") # (10, 384)
# Costruisci indice FAISS
dim = corpus_embeddings.shape[1] # 384
# IndexFlatIP: esatta, cosine similarity su vettori normalizzati
index_ip = faiss.IndexFlatIP(dim)
# Normalizza per usare cosine similarity (dot product su vettori unit-norm)
faiss.normalize_L2(corpus_embeddings)
index_ip.add(corpus_embeddings)
print(f"Index size: {index_ip.ntotal} vettori")
# IndexHNSW: approssimato ma molto veloce, buona per produzione
# M = numero di connessioni per nodo (16-64 in produzione)
index_hnsw = faiss.IndexHNSWFlat(dim, 32, faiss.METRIC_INNER_PRODUCT)
index_hnsw.hnsw.efConstruction = 200 # più alto = migliore recall in build
index_hnsw.hnsw.efSearch = 128 # più alto = migliore recall in search
# Ricerca semantica
def semantic_search(query, index, corpus, model, k=3):
"""Ricerca semantica: restituisce i k documenti più simili alla query."""
query_emb = model.encode([query], convert_to_numpy=True)
faiss.normalize_L2(query_emb)
start = time.time()
distances, indices = index.search(query_emb, k)
search_time = (time.time() - start) * 1000
print(f"\nQuery: '{query}'")
print(f"Search time: {search_time:.2f}ms")
for rank, (dist, idx) in enumerate(zip(distances[0], indices[0]), 1):
print(f" {rank}. [{dist:.4f}] {corpus[idx][:80]}")
return [(corpus[i], float(d)) for i, d in zip(indices[0], distances[0])]
# Test
semantic_search("ancient Roman architecture", index_ip, corpus, model)
semantic_search("programming language features", index_ip, corpus, model)
semantic_search("painting and art in Italy", index_ip, corpus, model)
5.2 Persistenza e Caricamento dell'Indice
import faiss
import numpy as np
import json
import os
def build_and_save_index(corpus, model, index_path="faiss_index.bin",
corpus_path="corpus.json"):
"""Costruisce e salva un indice FAISS su disco."""
# Encode
embeddings = model.encode(corpus, convert_to_numpy=True, show_progress_bar=True)
faiss.normalize_L2(embeddings)
dim = embeddings.shape[1]
index = faiss.IndexFlatIP(dim)
index.add(embeddings)
# Salva indice FAISS
faiss.write_index(index, index_path)
# Salva corpus (per recuperare testi)
with open(corpus_path, 'w', encoding='utf-8') as f:
json.dump(corpus, f, ensure_ascii=False, indent=2)
print(f"Index saved: {index.ntotal} vettori -> {index_path}")
return index
def load_index(index_path="faiss_index.bin", corpus_path="corpus.json"):
"""Carica indice FAISS e corpus da disco."""
if not os.path.exists(index_path):
raise FileNotFoundError(f"Index not found: {index_path}")
index = faiss.read_index(index_path)
with open(corpus_path, 'r', encoding='utf-8') as f:
corpus = json.load(f)
print(f"Index loaded: {index.ntotal} vettori")
return index, corpus
# Utilizzo
# Prima volta: costruisce e salva
# index = build_and_save_index(my_corpus, model)
# Riavvii successivi: carica direttamente (molto più veloce)
# index, corpus = load_index()
6. FAQ Matching: Caso d'Uso Completo
Un'applicazione pratica della semantic similarity: matching automatico di domande utente con le FAQ esistenti. Questo pattern e alla base di molti chatbot e sistemi di supporto clienti.
from sentence_transformers import SentenceTransformer, util
import torch
import json
class FAQMatcher:
"""Sistema di FAQ matching semantico con caching e persistenza."""
def __init__(self, model_name='paraphrase-multilingual-MiniLM-L12-v2',
threshold=0.7):
self.model = SentenceTransformer(model_name)
self.threshold = threshold
self.faqs = []
self.faq_embeddings = None
def load_faqs(self, faqs: list):
"""
faqs: lista di dizionari con 'question', 'answer', 'category'
"""
self.faqs = faqs
questions = [faq['question'] for faq in faqs]
print(f"Encoding {len(questions)} FAQ...")
self.faq_embeddings = self.model.encode(
questions,
convert_to_tensor=True,
show_progress_bar=False
)
print("FAQ pronte per la ricerca!")
def match(self, user_query: str, top_k: int = 3) -> list:
"""Trova le FAQ più simili alla domanda utente."""
if self.faq_embeddings is None:
raise ValueError("Carica prima le FAQ con load_faqs()")
query_emb = self.model.encode(user_query, convert_to_tensor=True)
scores = util.cos_sim(query_emb, self.faq_embeddings)[0]
top_k_indices = torch.topk(scores, k=min(top_k, len(self.faqs))).indices
results = []
for idx in top_k_indices:
score = scores[idx].item()
if score >= self.threshold:
results.append({
'question': self.faqs[idx]['question'],
'answer': self.faqs[idx]['answer'],
'category': self.faqs[idx].get('category', 'N/A'),
'score': round(score, 4)
})
return results
def respond(self, user_query: str) -> str:
"""Risposta automatica alla domanda utente."""
matches = self.match(user_query, top_k=1)
if not matches:
return f"Mi dispiace, non ho trovato una risposta per '{user_query}'. Contatta il supporto."
best = matches[0]
return f"[{best['category']}] {best['answer']} (Confidenza: {best['score']:.2f})"
# Esempio di utilizzo
faqs_ecommerce = [
{
"question": "Come posso restituire un prodotto?",
"answer": "Puoi restituire il prodotto entro 30 giorni dall'acquisto contattando il supporto.",
"category": "Resi"
},
{
"question": "Quanto tempo impiega la spedizione?",
"answer": "La consegna standard impiega 3-5 giorni lavorativi, l'express 24 ore.",
"category": "Spedizioni"
},
{
"question": "Come posso pagare?",
"answer": "Accettiamo carte di credito, PayPal, bonifico bancario e contrassegno.",
"category": "Pagamenti"
},
{
"question": "Il prodotto e in garanzia?",
"answer": "Tutti i prodotti hanno 2 anni di garanzia legale del consumatore.",
"category": "Garanzia"
},
{
"question": "Posso tracciare il mio ordine?",
"answer": "Si, riceverai un'email con il numero di tracking dopo la spedizione.",
"category": "Ordini"
},
]
matcher = FAQMatcher()
matcher.load_faqs(faqs_ecommerce)
test_queries = [
"Voglio rimandare indietro la merce",
"Quando arriva il pacco?",
"Accettate il bonifico?",
"Ho bisogno del codice di tracciamento",
"L'articolo si e rotto, cosa faccio?",
]
print("\n=== FAQ Matching ===")
for query in test_queries:
response = matcher.respond(query)
print(f"\nDomanda: {query}")
print(f"Risposta: {response}")
7. Cross-encoder vs Bi-encoder
Esistono due approcci per la semantic similarity che offrono diversi trade-off qualità/velocità. Comprenderli e fondamentale per scegliere l'architettura giusta.
Bi-encoder vs Cross-encoder
| Aspetto | Bi-encoder (SBERT) | Cross-encoder |
|---|---|---|
| Architettura | Due BERT separati, produce embedding | Un BERT che processa la coppia |
| Velocita | Molto veloce (pre-computa embedding) | Lento (processa ogni coppia) |
| Scalabilità | Milioni di documenti | Solo poche centinaia di coppie |
| qualità | Buona (~0.87 Pearson su STS-B) | Eccellente (~0.92 Pearson) |
| Caso d'uso | Retrieval, ricerca semantica | Reranking dei risultati |
| Complessità O(n) | O(1) per query (embedding pre-calcolati) | O(n) per ogni query |
from sentence_transformers import SentenceTransformer, CrossEncoder, util
# Bi-encoder per il retrieval iniziale (veloce)
bi_encoder = SentenceTransformer('all-MiniLM-L6-v2')
# Cross-encoder per il reranking (accurato)
cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
# Pipeline a due stadi (best of both worlds)
def retrieval_and_rerank(query, corpus, corpus_embeddings, top_k=100, final_k=5):
"""
Stage 1: Bi-encoder retrieval (veloce, ritorna top 100)
Stage 2: Cross-encoder reranking (accurato, sui top 100)
"""
# Stage 1: Bi-encoder retrieval
query_emb = bi_encoder.encode(query, convert_to_tensor=True)
hits = util.semantic_search(query_emb, corpus_embeddings, top_k=top_k)[0]
# Stage 2: Cross-encoder reranking
cross_inp = [[query, corpus[hit['corpus_id']]] for hit in hits]
cross_scores = cross_encoder.predict(cross_inp)
# Combina e riordina
for hit, score in zip(hits, cross_scores):
hit['cross_score'] = score
hits = sorted(hits, key=lambda x: x['cross_score'], reverse=True)[:final_k]
print(f"\nQuery: '{query}'")
for rank, hit in enumerate(hits, 1):
bi_score = hit['score']
cross_score = hit['cross_score']
doc = corpus[hit['corpus_id']][:80]
print(f" {rank}. [bi={bi_score:.3f}, cross={cross_score:.3f}] {doc}")
return hits
# Encode corpus una sola volta
corpus_embs = bi_encoder.encode(corpus, convert_to_tensor=True)
retrieval_and_rerank("ancient Roman buildings", corpus, corpus_embs)
8. Evaluazione: STS-B e Metriche
La valutazione corretta di un sistema di semantic similarity richiede dataset benchmark standardizzati. STS-B e il riferimento principale per l'inglese, mentre STS-IT e disponibile per l'italiano.
from sentence_transformers import SentenceTransformer
from sentence_transformers.evaluation import EmbeddingSimilarityEvaluator
from datasets import load_dataset
import numpy as np
from scipy.stats import pearsonr, spearmanr
# Carica STS-B per l'evaluazione
stsb = load_dataset("mteb/stsbenchmark-sts")
val_data = stsb['validation']
sentences1 = val_data['sentence1']
sentences2 = val_data['sentence2']
scores = [s / 5.0 for s in val_data['score']] # normalizza 0-5 a 0-1
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
# Evaluation automatica
evaluator = EmbeddingSimilarityEvaluator(
sentences1=sentences1,
sentences2=sentences2,
scores=scores,
name="sts-val"
)
pearson = model.evaluate(evaluator)
print(f"STS-B validation - Pearson: {pearson:.4f}")
# Valutazione manuale con correlazione di Pearson e Spearman
emb1 = model.encode(sentences1, show_progress_bar=False)
emb2 = model.encode(sentences2, show_progress_bar=False)
from numpy.linalg import norm
cos_sims = [
np.dot(e1, e2) / (norm(e1) * norm(e2))
for e1, e2 in zip(emb1, emb2)
]
pearson_r, _ = pearsonr(cos_sims, scores)
spearman_r, _ = spearmanr(cos_sims, scores)
print(f"Pearson: {pearson_r:.4f}")
print(f"Spearman: {spearman_r:.4f}")
# Analisi degli errori: trova le coppie più sbagliate
errors = [(abs(p - t), s1, s2, p, t)
for p, t, s1, s2 in zip(cos_sims, scores, sentences1, sentences2)]
errors.sort(reverse=True)
print("\n=== Top 3 Errori ===")
for err, s1, s2, pred, true in errors[:3]:
print(f" Errore: {err:.3f} | Pred: {pred:.3f} | True: {true:.3f}")
print(f" '{s1[:60]}'")
print(f" '{s2[:60]}'")
9. Fine-tuning di un Sentence Transformer sul Proprio Dominio
I modelli pre-trainati sono ottimi su testi generici, ma per domini specifici (medico, legale, tecnico) conviene fare fine-tuning con coppie di frasi annotate.
from sentence_transformers import (
SentenceTransformer,
InputExample,
losses,
evaluation
)
from torch.utils.data import DataLoader
# Dati di training: coppie (frase1, frase2, score)
# Score: 0.0 = totalmente diverse, 1.0 = identiche
train_examples = [
InputExample(texts=["Diagnosi di diabete tipo 2", "Paziente con iperglicemia cronica"], label=0.85),
InputExample(texts=["Prescrizione antibiotico", "Terapia con amoxicillina"], label=0.80),
InputExample(texts=["Intervento chirurgico al ginocchio", "Artroscopia del menisco"], label=0.75),
InputExample(texts=["Pressione arteriosa elevata", "Ipertensione arteriosa"], label=0.95),
InputExample(texts=["Dolore toracico", "Bruciore di stomaco"], label=0.30),
InputExample(texts=["Frattura del femore", "Infarto del miocardio"], label=0.05),
]
# Carica modello base
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
# DataLoader
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
# Loss: CosineSimilarityLoss per regressione su score continuo
train_loss = losses.CosineSimilarityLoss(model)
# Valutazione su dati di test
test_examples = [
InputExample(texts=["Cefalea tensiva", "Mal di testa da stress"], label=0.88),
InputExample(texts=["Diabete gestazionale", "Diabete in gravidanza"], label=0.92),
]
evaluator_sentences1 = [e.texts[0] for e in test_examples]
evaluator_sentences2 = [e.texts[1] for e in test_examples]
evaluator_scores = [e.label for e in test_examples]
val_evaluator = evaluation.EmbeddingSimilarityEvaluator(
evaluator_sentences1, evaluator_sentences2, evaluator_scores
)
# Fine-tuning
model.fit(
train_objectives=[(train_dataloader, train_loss)],
evaluator=val_evaluator,
epochs=10,
evaluation_steps=50,
warmup_steps=100,
output_path='./medical-sentence-transformer',
save_best_model=True
)
print("Fine-tuning completato!")
print("Modello salvato in './medical-sentence-transformer'")
10. Pipeline Production-Ready
Un sistema di semantic similarity in produzione deve gestire caching degli embedding, aggiornamento incrementale del corpus, e monitoraggio della qualità.
import faiss
import numpy as np
import json
import hashlib
from sentence_transformers import SentenceTransformer, util
from pathlib import Path
from typing import List, Dict, Optional
class SemanticSearchEngine:
"""
Motore di ricerca semantica production-ready con:
- Caching degli embedding su disco
- Aggiornamento incrementale
- Threshold configurabile
- Logging delle query
"""
def __init__(
self,
model_name: str = 'paraphrase-multilingual-MiniLM-L12-v2',
cache_dir: str = './search_cache',
similarity_threshold: float = 0.5
):
self.model = SentenceTransformer(model_name)
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
self.threshold = similarity_threshold
self.documents: List[Dict] = []
self.embeddings: Optional[np.ndarray] = None
self.index: Optional[faiss.Index] = None
def _doc_hash(self, doc: Dict) -> str:
"""Hash del documento per il caching."""
content = json.dumps(doc, sort_keys=True, ensure_ascii=False)
return hashlib.md5(content.encode()).hexdigest()
def add_documents(self, documents: List[Dict], text_field: str = 'text'):
"""Aggiunge documenti al corpus con caching."""
texts = [doc[text_field] for doc in documents]
new_embeddings = self.model.encode(texts, convert_to_numpy=True, show_progress_bar=True)
if self.embeddings is None:
self.embeddings = new_embeddings
else:
self.embeddings = np.vstack([self.embeddings, new_embeddings])
self.documents.extend(documents)
self._rebuild_index()
print(f"Corpus: {len(self.documents)} documenti")
def _rebuild_index(self):
"""Ricostruisce l'indice FAISS."""
dim = self.embeddings.shape[1]
self.index = faiss.IndexFlatIP(dim)
embs_normalized = self.embeddings.copy()
faiss.normalize_L2(embs_normalized)
self.index.add(embs_normalized)
def search(self, query: str, k: int = 5, text_field: str = 'text') -> List[Dict]:
"""Cerca i documenti più rilevanti per la query."""
if self.index is None or len(self.documents) == 0:
return []
query_emb = self.model.encode([query], convert_to_numpy=True)
faiss.normalize_L2(query_emb)
distances, indices = self.index.search(query_emb, min(k, len(self.documents)))
results = []
for dist, idx in zip(distances[0], indices[0]):
if dist >= self.threshold:
result = dict(self.documents[idx])
result['score'] = float(dist)
results.append(result)
return results
def save(self):
"""Persiste il motore di ricerca su disco."""
faiss.write_index(self.index, str(self.cache_dir / 'index.faiss'))
np.save(str(self.cache_dir / 'embeddings.npy'), self.embeddings)
with open(self.cache_dir / 'documents.json', 'w', encoding='utf-8') as f:
json.dump(self.documents, f, ensure_ascii=False, indent=2)
print(f"Engine saved to {self.cache_dir}")
# Utilizzo
engine = SemanticSearchEngine(similarity_threshold=0.6)
# Aggiungi documenti
docs = [
{"text": "Come configurare un ambiente Python con virtualenv.", "id": "py001", "category": "python"},
{"text": "Installazione e configurazione di Docker su Ubuntu.", "id": "docker001", "category": "devops"},
{"text": "Introduzione alle reti neurali con PyTorch.", "id": "ml001", "category": "ml"},
{"text": "Best practices per la sicurezza delle API REST.", "id": "api001", "category": "security"},
{"text": "Ottimizzazione delle query SQL con indici.", "id": "db001", "category": "database"},
]
engine.add_documents(docs)
# Ricerca
results = engine.search("come creare un ambiente virtuale Python")
for r in results:
print(f"[{r['score']:.3f}] {r['text']}")
11. Sentence Embeddings per l'Italiano
Per l'italiano esistono diverse opzioni, dal modello multilingua al fine-tuning specifico su corpus italiani. Ecco una guida pratica.
from sentence_transformers import SentenceTransformer, util
# Opzione 1: Modello multilingua (più pratico, supporta 50+ lingue)
model_multi = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
# Opzione 2: E5 multilingual (stato dell'arte per retrieval)
model_e5 = SentenceTransformer('intfloat/multilingual-e5-large')
# Frasi italiane di test
frasi_it = [
"Il governo italiano ha approvato la nuova legge sul lavoro.",
"Il parlamento ha votato la riforma del mercato del lavoro.", # simile
"La Serie A e il campionato di calcio italiano.", # diversa
"Juventus e Inter si sfideranno nel prossimo derby.", # correlata (calcio)
"Il PIL italiano e cresciuto del 2% nel 2024.", # diversa
]
# Test con modello multilingua
embeddings = model_multi.encode(frasi_it)
sim_matrix = util.cos_sim(embeddings, embeddings)
print("=== Similarità tra frasi italiane (multilingua mpnet) ===")
for i in range(len(frasi_it)):
for j in range(i+1, len(frasi_it)):
score = sim_matrix[i][j].item()
if score > 0.5:
print(f" {score:.3f} | '{frasi_it[i][:50]}'")
print(f" | '{frasi_it[j][:50]}'")
# Per E5: aggiungere prefisso "query: " o "passage: " per retrieval
query = "query: come va l'economia italiana?"
passages = [f"passage: {f}" for f in frasi_it]
q_emb = model_e5.encode(query)
p_embs = model_e5.encode(passages)
scores = util.cos_sim(q_emb, p_embs)
top3 = scores[0].topk(3)
print("\n=== Top 3 risultati con E5 ===")
for score, idx in zip(top3.values, top3.indices):
print(f" {score:.3f} | {frasi_it[idx]}")
12. Errori Comuni e Anti-Pattern
Anti-Pattern: Usare BERT [CLS] direttamente
Il token [CLS] di BERT non e ottimizzato per la semantic similarity.
Usarlo direttamente (senza fine-tuning su task di similarità) da risultati
molto peggiori di SBERT. Usa sempre un modello sentence-transformers dedicato.
Anti-Pattern: Confrontare embedding di modelli diversi
Gli embedding di all-MiniLM-L6-v2 e di
paraphrase-multilingual-mpnet-base-v2 vivono in spazi vettoriali
completamente diversi. Non puoi confrontare embedding prodotti da modelli differenti.
Usa sempre lo stesso modello per tutte le frasi nel tuo corpus.
Anti-Pattern: Ignorare la normalizzazione
Se usi FAISS con IndexFlatIP per la cosine similarity,
devi normalizzare i vettori a norma unitaria con faiss.normalize_L2()
sia durante l'indicizzazione che durante la ricerca. Dimenticare questo step
produce risultati errati senza errori espliciti.
Best Practices: Checklist
- Usa sentence-transformers invece di BERT raw per la semantic similarity
- Scegli modelli multilingua per contenuti in italiano o cross-linguistici
- Normalizza sempre i vettori prima dell'indicizzazione FAISS con IndexFlatIP
- Persisti gli embedding su disco per evitare re-encoding ad ogni restart
- Pipeline bi-encoder + cross-encoder per retrieval scalabile + alta qualità
- Valuta su STS-B o su un dataset del tuo dominio prima del deploy
- Monitora la distribuzione dei similarity scores in produzione per rilevare drift
- Imposta una soglia di confidenza minima per filtrare match irrilevanti
Conclusioni e Prossimi Passi
La semantic similarity con sentence embeddings e un componente fondamentale di molte applicazioni NLP moderne: ricerca semantica, FAQ matching, deduplicazione, raccomandazione e sistemi RAG. SBERT e i modelli sentence-transformers hanno reso queste capacità accessibili con poche righe di codice, mentre FAISS permette di scalare a milioni di documenti mantenendo latenze nell'ordine dei millisecondi.
Per l'italiano, i modelli multilingua come paraphrase-multilingual-mpnet-base-v2
e intfloat/multilingual-e5-large offrono eccellenti performance
anche in contesti cross-linguistici.
Punti Chiave
- Usa SBERT invece di BERT standard per la semantic similarity (Pearson 0.87 vs 0.54)
- FAISS e essenziale per la ricerca su corpus di grandi dimensioni
- Pipeline bi-encoder + cross-encoder: velocità del retrieval + qualità del reranking
- Modelli multilingua per l'italiano:
paraphrase-multilingual-mpnet-base-v2omultilingual-e5-large - Valuta sempre su STS-B o un dataset del tuo dominio
- Fine-tuning domain-specific con
CosineSimilarityLossper massima qualità
Continua la Serie
- Articolo 10: Monitoring NLP in Produzione — drift detection e retraining automatico
- Articolo 8: LoRA Fine-tuning Locale — adattare LLM al proprio dominio con GPU consumer
- Serie correlata: AI Engineering/RAG — semantic similarity come cuore del dense retrieval
- Serie correlata: Deep Learning Avanzato — triplet loss, metric learning e contrastive learning







