Podobieństwo semantyczne i osadzanie zdań: porównywanie tekstów
Jak podobne są dwa zdania? Nie w sensie leksykalnym (te same słowa), ale w sens semantyczny (to samo znaczenie). „Pies goni kota” i „Kot przychodzi goniony przez psa” są semantycznie prawie identyczne, ale leksykalnie bardzo różne. Odpowiedź na to pytanie jest wyzwaniem Podobieństwo semantyczne.
Aplikacje są wszędzie: wyszukiwarki semantyczne, systemy rekomendacji, deduplikacja treści, odpowiadanie na pytania, RAG (Retrieval-Augmented Generation), chatbot i dopasowanie FAQ. W tym artykule zbudujemy systemy podobieństwa semantycznego od podstaw: od podobieństwa cosinus do osadzania zdań za pomocą Sentence-BERT, aż do szybkiego wyszukiwania wektorowego za pomocą FAISS.
To dziewiąty artykuł z tej serii Nowoczesne NLP: od BERT do LLM. Ten temat łączy się bezpośrednio z serią Inżynieria AI/RAG gdzie osadzanie semantyczne jest sercem gęstego wyszukiwania.
Czego się nauczysz
- Podobieństwo cosinusowe i iloczyn skalarny: wzory i kiedy je stosować
- Granice BERT dla podobieństwa semantycznego i dlaczego potrzebne jest zdanie-BERT
- Zdanie-BERT (SBERT): Architektura syjamska i trening utraty trójek
- Modele transformatorów zdań na HuggingFace: który wybrać
- Wyszukiwanie semantyczne w dużych korpusach za pomocą FAISS
- Osadzanie zdań w języku włoskim
- Benchmarking: STS-B, SICK i metryki ewaluacyjne
- Koder krzyżowy a koder bi-enkoder: kompromis jakość/szybkość
- Dostosowywanie transformatora zdań w Twojej domenie
- Pełna implementacja systemu dopasowywania często zadawanych pytań
- Gotowy do produkcji potok z buforowaniem i optymalizacją
1. Problem podobieństwa semantycznego
Rozważmy te trzy grupy zdań i ich wyzwania:
Przykłady podobieństwa semantycznego
- Wysokie podobieństwo: „Bank podniósł stopy” / „Instytucja bankowa podwyższa stopy procentowe”
- Niskie podobieństwo: „Bank podniósł stopy procentowe” / „Kot śpi na kanapie”
- Zwodnicze (te same słowa, inne znaczenie): „Szkolna ławka” / „Łaga rybna na targu”
- Międzyjęzykowość: „Pies szybko biegnie” (ta sama semantyka, różne języki)
Tradycyjne mierniki, np Podobieństwo Jaccarda lub BM25 opierają się na nakładaniu się leksykalnym i całkowicie zawodzą w przypadku synonimów i parafraz. Nawet prosty TF-IDF nie oddaje znaczenia. Rozwiązanie leży w osadzania semantyczne: Gęste reprezentacje wektorowe, w których bliskość geometryczny odzwierciedla bliskość semantyczną.
1.1 Podobieństwo cosinusowe: podstawowa metryka
La małe podobieństwo mierzy kąt między dwoma wektorami w przestrzeni osadzania. Zakresy od -1 (przeciwny) do 1 (identyczny), gdzie 0 dla wektorów ortogonalnych. Wzór matematyczny to:
cos(A, B) = (A · B) / (||A|| · ||B||)
Kiedy wektory są znormalizowane do normy jednostkowej, podobieństwo cosinus jest zbieżne z iloczynem skalarnym, który sprawia, że obliczenia są znacznie wydajniejsze na sprzęcie 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 Inne wskaźniki odległości
Porównanie wskaźników podobieństwa/odległości
| Metryczny | Formuła | Zakres | Użyj przypadku |
|---|---|---|---|
| Cosinus podobieństwa | cos(A, B) | [-1, 1] | Standardowe podobieństwo semantyczne |
| Odległość euklidesowa | ||A - B|| | [0, +inf) | Grupowanie, k-NN |
| Produkt kropkowy | A · B | (-inf, +inf) | Przy znormalizowanych wektorach = cosinus |
| Odległość Manhattanu | suma(|A-B|) | [0, +inf) | Odporność na wartości odstające |
| Korelacja Pearsona | cov(A,B)/sigma | [-1, 1] | Ocena w benchmarku STS |
2. Dlaczego standardowy BERT nie sprawdza się w przypadku podobieństwa
Intuicyjnie moglibyśmy użyć BERT do wyodrębnienia osadzonych zdań i porównania ich. Jednak badania Reimersa i Gurevycha (2019) wykazały, że tak podejście E zaskakująco nieskuteczne.
Głównym problemem jest to, że BERT jest wstępnie przeszkolony w zakresie modelowania języka maskowanego (MLM) i
Przewidywanie następnego zdania (NSP). Znaczek [CLS] koduje informacje
przydatne do klasyfikacji par zdań (NSP), ale nie jest zoptymalizowane pod kątem
tworzą osadzania, które odzwierciedlają podobieństwo semantyczne w porównaniu
z niewielkim podobieństwem.
Co więcej, średnie łączenie wszystkich tokenów tworzy przestrzeń do osadzania anizotropowy: kierunki nie są równomiernie rozłożone, a skupiska zdań różniących się semantycznie nakładają się na siebie.
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
Wydajność BERT na STS-B (benchmark)
W zadaniu STS-B (Semantic Textual podobieństwo benchmark), BERT z łączeniem średnich po prostu sięga Pearsona r = 0,54, znacznie poniżej podejść nadzorowany jako SBERT (0,87). Nawet sam token [CLS] osiąga tylko 0,20. Jeśli chodzi o podobieństwo semantyczne, właściwym wyborem jest SBERT.
3. Zdanie-BERT (SBERT): Rozwiązanie
Zdanie-BERT (Reimers i Gurevych, EMNLP 2019) rozwiązuje problem z architekturą syjamski: dwa przypadki wag udziałowych BERT, przetwarzają dwa zdania oddzielnie, a funkcja straty wymusza reprezentacje semantycznie podobne do bycia blisko w przestrzeni wektorowej.
3.1 Architektura syjamska
Kluczową ideą jest to, że obie „sieci” mają dokładnie te same wagi. Nie są to dwa osobne modele, ale ten sam model nazywany dwukrotnie. Strata jest obliczana na parze wyjściowej:
- Cel regresji: MSE między przewidywanym podobieństwem cosinusa a wynikiem ludzkim (dla STS)
- Cel klasyfikacji: Entropia krzyżowa na [u, v, |u-v|] (dla NLI)
- Potrójna strata: strata marży na kotwicy/dodatnim/ujemnym (w przypadku wydobywania parafraz)
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. Modele transformatorów zdaniowych: który wybrać
Główne modele transformatorów zdań (2024-2025)
| Model | Języki | Ciemny. | Prędkość | STS-B Pearson |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | EN | 384 | Bardzo szybko | 0,834 |
| all-mpnet-base-v2 | EN | 768 | Przeciętny | 0,869 |
| parafraza-wielojęzyczna-MiniLM-L12-v2 | Ponad 50 języków | 384 | Szybko | 0,821 |
| parafraza-wielojęzyczna-mpnet-base-v2 | Ponad 50 języków | 768 | Przeciętny | 0,853 |
| intfloat/wielojęzyczny-e5-duży | Ponad 100 języków | 1024 | Powolny | 0,892 |
| osadzanie tekstu-3-małe (OpenAI) | Wielojęzyczny | 1536 | Tylko API | ~0,90 |
4.1 Wybór modelu: Poradnik praktyczny
Wybór zależy od trzech głównych czynników: języka, szybkości i niezbędnej jakości.
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. Wyszukiwanie semantyczne za pomocą FAISS
W przypadku dużych korpusów (miliony dokumentów) wyszukiwanie metodą brute-force (obliczyć podobieństwo ze wszystkimi dokumentami) i zbyt wolno. FAISS (Facebook AI podobieństwo wyszukiwania) umożliwia przybliżone wyszukiwanie najbliższego sąsiada w czasie subliniowym z różnymi typami indeksów.
5.1 Rodzaje indeksu FAISS
Wskaźniki FAISS: kompromis między szybkością i dokładnością
| Indeks | Typ | Użyj przypadku | Przypomnienia (%) | Prędkość |
|---|---|---|---|---|
| IndeksPłaskiL2 | Dokładny | < 100 tys. dokumentów | 100% | Powolny |
| IndeksFlatIP | Dokładnie (drobne rzeczy) | < 100 tys. dokumentów | 100% | Powolny |
| IndeksIVFFpła | Przybliżony | 100 tys. - 10 mln | ~95% | Szybko |
| IndeksHNSW | Przybliżony | 1M+ | ~99% | Bardzo szybko |
| IndeksIVFPQ | Sprężony | 10M+, ograniczona pamięć RAM | ~85% | Bardzo szybko |
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 Trwałość i ładowanie indeksu
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. Często zadawane pytania Dopasowanie: kompletny przypadek użycia
Praktyczne zastosowanie podobieństwa semantycznego: automatyczne dopasowywanie pytań użytkownika z istniejącymi często zadawanymi pytaniami. Ten wzorzec jest podstawą wielu chatbotów i systemów obsługa klienta.
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. Koder krzyżowy a koder podwójny
Istnieją dwa podejścia do podobieństwa semantycznego, które oferują różne kompromisy jakość/szybkość. Zrozumienie ich jest niezbędne do wyboru właściwej architektury.
Bi-enkoder vs cross-enkoder
| Czekam | Bi-enkoder (SBERT) | Koder krzyżowy |
|---|---|---|
| Architektura | Dwa oddzielne BERT-y produkują osady | BERT, który przetwarza parę |
| Prędkość | Bardzo szybkie (osadzanie przed obliczeniami) | Powolny (przetwarzaj każdą parę) |
| Skalowalność | Miliony dokumentów | Tylko kilkaset par |
| jakość | Dobry (~0,87 Pearson na STS-B) | Znakomity (~0,92 Pearsona) |
| Użyj przypadku | Wyszukiwanie, wyszukiwanie semantyczne | Reranking wyników |
| Złożoność O(n) | O(1) dla zapytań (wstępnie obliczone osadzenie) | O(n) dla każdego zapytania |
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. Ocena: STS-B i metryki
Wymagana jest poprawna ocena systemu podobieństwa semantycznego standaryzowane zbiory danych porównawczych. STS-B jest głównym punktem odniesienia dla języka angielskiego, podczas gdy STS-IT jest dostępny dla języka włoskiego.
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. Dostosowywanie transformatora zdań w Twojej domenie
Wstępnie wytrenowane modele świetnie sprawdzają się w tekstach ogólnych, ale w przypadku konkretnych dziedzin (medyczne, prawne, techniczne) lepiej jest dostroić się za pomocą par zdań z adnotacjami.
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. Rurociąg gotowy do produkcji
System podobieństwa semantycznego w środowisku produkcyjnym musi zarządzać osadzaniem buforowania, przyrostowa aktualizacja korpusu i monitorowanie jakości.
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. Osadzanie zdań w języku włoskim
W przypadku języka włoskiego dostępne są różne opcje, od modelu wielojęzycznego po dostrajanie specyficzne dla korpusów włoskich. Oto praktyczny przewodnik.
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. Typowe błędy i anty-wzorce
Anti-Pattern: Użyj bezpośrednio BERT [CLS].
Znaczek [CLS] BERT nie jest zoptymalizowany pod kątem podobieństwa semantycznego.
Użycie go bezpośrednio (bez dostrajania zadań związanych z podobieństwem) daje rezultaty
znacznie gorszy od SBERTA. Zawsze używaj dedykowanego szablonu transformatorów zdań.
Anti-Pattern: Porównaj osadzenie różnych wzorców
Osadzenia all-MiniLM-L6-v2 i z
paraphrase-multilingual-mpnet-base-v2 żyją w przestrzeniach wektorowych
zupełnie inaczej. Nie można porównywać osadzań produkowanych przez różne modele.
Zawsze używaj tego samego wzorca dla wszystkich zdań w swoim korpusie.
Anty-wzorzec: Ignoruj normalizację
Jeśli używasz FAISS z IndexFlatIP dla cosinusa podobieństwa,
musisz znormalizować wektory do normy jednostkowej faiss.normalize_L2()
zarówno podczas indeksowania, jak i wyszukiwania. Zapomnij o tym kroku
daje nieprawidłowe wyniki bez wyraźnych błędów.
Najlepsze praktyki: lista kontrolna
- USA transformatory zdań zamiast surowego BERT dla podobieństwa semantycznego
- Wybierz wielojęzyczne szablony dla treści włoskich lub międzyjęzycznych
- Zawsze normalizuj wektory przed indeksowaniem FAISS za pomocą IndexFlatIP
- Utrzymuj osadzenie na dysku, aby uniknąć ponownego kodowania przy każdym ponownym uruchomieniu
- Potok Bi-encoder + cross-enkoder zapewniający skalowalne i wysokiej jakości pobieranie
- Przed wdrożeniem przeprowadź ocenę STS-B lub zestawu danych ze swojej domeny
- Monitoruj rozkład wyników podobieństwa w produkcji, aby wykryć dryf
- Ustaw minimalny próg ufności, aby odfiltrować nieistotne dopasowania
Wnioski i dalsze kroki
Podobieństwo semantyczne z osadzeniem zdań jest podstawowym elementem wielu nowoczesnych zastosowań NLP: wyszukiwanie semantyczne, dopasowywanie FAQ, deduplikacja, systemy rekomendacji i RAG. Modele SBERT i transformatorów zdań udostępnili te możliwości za pomocą zaledwie kilku linijek kodu, natomiast FAISS umożliwia skalowanie do milionów dokumentów przy zachowaniu opóźnień rzędu milisekund.
W przypadku modeli włoskich, wielojęzycznych, takich jak paraphrase-multilingual-mpnet-base-v2
e intfloat/multilingual-e5-large oferują doskonałą wydajność
even in cross-linguistic contexts.
Kluczowe punkty
- USA SBERTA zamiast standardowego BERT dla podobieństwa semantycznego (Pearson 0,87 vs 0,54)
- FAISS i niezbędne do badań na dużych korpusach
- Bi-enkoder + potok między koderami: szybkość wyszukiwania + jakość ponownego rankingu
- Wielojęzyczne szablony dla języka włoskiego:
paraphrase-multilingual-mpnet-base-v2omultilingual-e5-large - Zawsze oceniaj STS-B lub zbiór danych z Twojej domeny
- Dostrajanie specyficzne dla domeny za pomocą
CosineSimilarityLossdla maksymalnej jakości
Seria trwa
- Artykuł 10: Monitorowanie NLP w produkcji — wykrywanie dryfu i automatyczne przekwalifikowanie
- Artykuł 8: Lokalne dostrajanie LoRA — dostosuj LLM do swojej domeny za pomocą konsumenckich procesorów graficznych
- Powiązane serie: Inżynieria AI/RAG — podobieństwo semantyczne jako serce gęstego wyszukiwania
- Powiązane serie: Zaawansowane głębokie uczenie się — utrata trójek, uczenie się metryczne i uczenie kontrastowe







