Módulo 3: Integration Testing & Estrategias No-Determinísticas
4. Semantic Similarity Assertions
Descripción
Cuando el output del LLM varía en redacción pero no en significado, la igualdad de strings falla aunque el output sea correcto. Semantic similarity compara el significado usando embeddings: dos textos son "equivalentes" si su similitud coseno supera un umbral calibrado. Esta cápsula implementa assertions semánticas robustas, cómo calibrar los thresholds, qué modelo de embeddings usar (local vs API), y cuándo la similitud semántica es la herramienta correcta — y cuándo no lo es.
El problema que resuelve
# El LLM puede producir cualquiera de estas respuestas para el mismo input.
# Todas son semánticamente equivalentes y correctas:
respuesta_1 = "La capital de Francia es París."
respuesta_2 = "París es la capital de Francia."
respuesta_3 = "En Francia, la ciudad capital es París."
respuesta_4 = "París funciona como capital del país francés."
# Assertions que FALLAN aunque el output sea correcto:
assert result == respuesta_1 # ❌ String exact match
assert result.startswith("La capital") # ❌ Prefix match
assert "La capital de Francia" in result # ❌ Substring
# Assertion que PASA para todos los casos correctos:
assert_semantically_similar(result, "Francia tiene a París como capital", threshold=0.85)
# ✅ Compara significado, no palabras exactas
Cómo funcionan los embeddings
Un embedding es una representación vectorial del significado de un texto. Textos con significados similares tienen vectores cercanos en el espacio de embeddings.
# Visualización simplificada:
# "París es capital de Francia" → [0.2, -0.8, 0.5, ...] (1536 dimensiones)
# "Francia tiene capital en París" → [0.21, -0.79, 0.48, ...] (muy similar)
# "La pizza es italiana" → [0.9, 0.1, -0.3, ...] (muy diferente)
# Cosine similarity mide el ángulo entre vectores:
# similarity = dot(a, b) / (|a| × |b|)
# 1.0 = idénticos, 0.0 = ortogonales, -1.0 = opuestos
# En práctica:
# 0.95+: casi idénticos (mismas palabras, mínima variación)
# 0.85-0.95: mismo significado, diferente redacción
# 0.70-0.85: mismo tema, perspectiva diferente
# <0.70: diferentes contenidos o perspectivas
Implementación con OpenAI Embeddings API
# tests/semantic.py
import numpy as np
from typing import Optional
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calcula la similitud coseno entre dos vectores."""
a_arr = np.array(a, dtype=float)
b_arr = np.array(b, dtype=float)
norm_a = np.linalg.norm(a_arr)
norm_b = np.linalg.norm(b_arr)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(np.dot(a_arr, b_arr) / (norm_a * norm_b))
def get_embedding_openai(text: str, client) -> list[float]:
"""
Obtiene embedding usando OpenAI text-embedding-3-small.
Costo: ~$0.00002 por 1K tokens (muy barato)
Dimensiones: 1536
"""
response = client.embeddings.create(
model="text-embedding-3-small",
input=text.strip()
)
return response.data[0].embedding
def semantic_similarity_openai(
text_a: str,
text_b: str,
client
) -> float:
"""
Calcula la similitud semántica entre dos textos usando OpenAI embeddings.
Returns:
float entre 0.0 y 1.0
"""
emb_a = get_embedding_openai(text_a, client)
emb_b = get_embedding_openai(text_b, client)
return cosine_similarity(emb_a, emb_b)
def assert_semantically_similar(
actual: str,
expected: str,
threshold: float = 0.85,
client = None,
message: str = None
) -> float:
"""
Assertion semántica: falla si actual y expected no son suficientemente similares.
Args:
actual: El output real del LLM
expected: El significado esperado (no tiene que ser texto exacto)
threshold: Similitud mínima aceptable (0-1)
client: Cliente OpenAI (si None, usa sentence-transformers local)
message: Mensaje de error personalizado
Returns:
La similitud calculada (útil para debugging)
Raises:
AssertionError: Si similarity < threshold
"""
if client is not None:
similarity = semantic_similarity_openai(actual, expected, client)
else:
similarity = semantic_similarity_local(actual, expected)
error_msg = message or (
f"Similitud semántica {similarity:.3f} < umbral {threshold}\n"
f" Actual: '{actual[:100]}...'\n"
f" Expected: '{expected[:100]}...'"
)
assert similarity >= threshold, error_msg
return similarity
Implementación con sentence-transformers (local, sin costo)
Para cuando no quieres hacer llamadas a la API de embeddings:
# pip install sentence-transformers
from functools import lru_cache
from typing import Callable
@lru_cache(maxsize=1)
def get_local_model():
"""Carga el modelo una vez y lo cachea."""
from sentence_transformers import SentenceTransformer
# Modelos recomendados para semantic similarity:
# - all-MiniLM-L6-v2: pequeño, rápido, bueno para inglés
# - paraphrase-multilingual-MiniLM-L12-v2: multilingüe (español incluido)
return SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
def get_embedding_local(text: str) -> list[float]:
"""Obtiene embedding local con sentence-transformers."""
model = get_local_model()
embedding = model.encode(text, normalize_embeddings=True)
return embedding.tolist()
def semantic_similarity_local(text_a: str, text_b: str) -> float:
"""Similitud semántica local — sin API calls, sin costo."""
emb_a = get_embedding_local(text_a)
emb_b = get_embedding_local(text_b)
return cosine_similarity(emb_a, emb_b)
Caché de embeddings: ahorrar tiempo y dinero
Calcular embeddings dos veces para el mismo texto es innecesario:
# tests/semantic.py
_embedding_cache: dict[str, list[float]] = {}
def get_embedding_cached(
text: str,
client = None,
provider: str = "auto"
) -> list[float]:
"""
Obtiene embedding con caché en memoria.
El caché persiste durante la sesión de tests (no entre sesiones).
Para la mayoría de tests, el texto de "expected" se repite — el caché evita
llamadas duplicadas a la API.
"""
cache_key = f"{provider}:{text}"
if cache_key not in _embedding_cache:
if provider == "local" or (provider == "auto" and client is None):
_embedding_cache[cache_key] = get_embedding_local(text)
else:
_embedding_cache[cache_key] = get_embedding_openai(text, client)
return _embedding_cache[cache_key]
def assert_semantically_similar_cached(
actual: str,
expected: str,
threshold: float = 0.85,
client = None
) -> float:
"""Versión con caché — recomendada para suites de tests."""
emb_actual = get_embedding_cached(actual, client)
emb_expected = get_embedding_cached(expected, client)
similarity = cosine_similarity(emb_actual, emb_expected)
assert similarity >= threshold, (
f"Similitud {similarity:.3f} < {threshold}\n"
f" Actual: '{actual[:80]}'\n"
f" Expected: '{expected[:80]}'"
)
return similarity
Calibrar el threshold: la parte más importante
El threshold no es un número mágico — necesitas calibrarlo para tu caso:
Proceso de calibración
# tests/calibrate_threshold.py
# Ejecutar una vez para calibrar — no es parte del test suite regular
def calibrate_threshold():
"""
Calibra el threshold de similitud semántica para tu dominio.
Cómo usar:
1. Collect 10-20 pares de textos (5 equivalentes, 5 distintos)
2. Calcula la similitud para cada par
3. El threshold debe estar entre el mínimo de equivalentes y el máximo de distintos
"""
# Pares EQUIVALENTES (deben pasar el test)
equivalent_pairs = [
(
"Python es un lenguaje de programación de alto nivel",
"Python es un lenguaje interpretado y de alto nivel"
),
(
"El sentimiento del texto es positivo",
"El texto expresa una emoción positiva"
),
(
"La respuesta es un JSON con los campos summary y confidence",
"El output JSON contiene summary y confidence"
),
]
# Pares DISTINTOS (deben fallar el test)
different_pairs = [
(
"El texto es positivo y alegre",
"El texto es negativo y triste"
),
(
"El resumen habla de Python",
"El resumen habla de JavaScript"
),
]
print("=== PARES EQUIVALENTES ===")
equivalent_scores = []
for a, b in equivalent_pairs:
sim = semantic_similarity_local(a, b)
equivalent_scores.append(sim)
print(f" {sim:.3f}: '{a[:50]}' vs '{b[:50]}'")
print("\n=== PARES DISTINTOS ===")
different_scores = []
for a, b in different_pairs:
sim = semantic_similarity_local(a, b)
different_scores.append(sim)
print(f" {sim:.3f}: '{a[:50]}' vs '{b[:50]}'")
min_equivalent = min(equivalent_scores)
max_different = max(different_scores)
print(f"\n=== RECOMENDACIÓN ===")
print(f" Similitud mínima de equivalentes: {min_equivalent:.3f}")
print(f" Similitud máxima de distintos: {max_different:.3f}")
recommended = (min_equivalent + max_different) / 2
print(f" Threshold recomendado: {recommended:.3f}")
return recommended
if __name__ == "__main__":
calibrate_threshold()
Thresholds por caso de uso
| Tipo de assertion | Threshold recomendado | Razón |
|---|---|---|
| Mismo significado, redacción diferente | 0.82-0.88 | Suficiente para parafraseos |
| Mismo tema, diferentes palabras | 0.70-0.80 | Más flexible |
| Respuesta factual exacta | 0.90-0.95 | La respuesta no debe cambiar mucho |
| Tono o emoción general | 0.75-0.85 | El tono puede expresarse de muchas formas |
| Para tu dominio específico | Calibrar | Siempre calibrar con tus datos |
Uso en tests de integración
# tests/integration/test_semantic.py
import pytest
import os
from tests.semantic import assert_semantically_similar_cached
@pytest.mark.integration
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="Requiere API key para test semántico"
)
class TestSentimentSemanticQuality:
"""Tests de calidad semántica del analizador de sentimiento."""
def test_positive_text_recognized_as_positive(self, integration_client, e2e_budget):
"""El análisis de un texto positivo produce una explicación coherente."""
text = "Me encanta este producto, es absolutamente increíble y vale cada peso."
result = analyze_sentiment(text, client=integration_client)
e2e_budget.add_cost("gpt-4o-mini", 100, 50)
# Assertion estructural (siempre)
assert result["sentiment"] == "positivo"
# Assertion semántica: la explicación debe reflejar positividad
assert_semantically_similar_cached(
actual=result["explanation"],
expected="El texto usa lenguaje positivo y expresiones de aprobación",
threshold=0.70, # Flexible — la explicación puede variar mucho
client=None # Usar embeddings locales para ahorrar
)
def test_summary_captures_main_topic(self, integration_client):
"""El resumen captura el tema principal del texto original."""
original = "Python fue creado por Guido van Rossum y lanzado en 1991. Es un lenguaje de alto nivel."
result = summarize(original, client=integration_client)
# La similitud del resumen con el original debe ser alta
sim = assert_semantically_similar_cached(
actual=result["summary"],
expected=original,
threshold=0.75 # El resumen no es igual al original, pero debe ser similar
)
# También verificar que el resumen es más corto
assert len(result["summary"]) < len(original)
def test_different_sentiments_are_dissimilar(self, integration_client):
"""Los outputs para textos con sentimientos opuestos deben ser disimilares."""
positive = "Increíble experiencia, me encantó todo."
negative = "Terrible experiencia, horrible en todo aspecto."
result_positive = analyze_sentiment(positive, client=integration_client)
result_negative = analyze_sentiment(negative, client=integration_client)
# Las explicaciones de sentimientos opuestos deben ser diferentes
from tests.semantic import semantic_similarity_local
sim = semantic_similarity_local(
result_positive["explanation"],
result_negative["explanation"]
)
assert sim < 0.60, (
f"Las explicaciones de sentimientos opuestos son demasiado similares: {sim:.3f}\n"
f"Positivo: {result_positive['explanation']}\n"
f"Negativo: {result_negative['explanation']}"
)
Cuándo NO usar semantic similarity
La similitud semántica no es siempre la herramienta correcta:
# ❌ Caso 1: Para validar datos exactos (números, fechas, nombres)
result = extract_data("La reunión es el 15 de enero de 2025 a las 3pm")
# Mal:
assert_semantically_similar(result["date"], "enero 2025", threshold=0.8)
# Bien (exacto):
assert result["date"] == "2025-01-15"
assert result["time"] == "15:00"
# ❌ Caso 2: Para verificar que NO se mencionó algo
result = analyze_sensitivity("Texto que no debe revelar información privada")
# Mal: semantic similarity no puede verificar ausencia
# Bien:
assert result["pii_detected"] is False
assert "nombre" not in result["output"].lower()
# ❌ Caso 3: Para verificar listas ordenadas específicas
result = rank_items(items)
# Mal: el ranking puede variar semánticamente
# Bien:
assert result["top_item"] == expected_top
assert result["items"][0]["score"] >= result["items"][1]["score"] # Orden correcto
Combinación: semántica + propiedades
La mejor práctica es combinar assertions semánticas con assertions de propiedades:
def assert_quality_summary(result: dict, original: str, client=None):
"""
Assertion completa de calidad para un resumen:
combina propiedades + similitud semántica.
"""
# Propiedades (determinísticas, siempre correr)
assert isinstance(result["summary"], str)
assert len(result["summary"]) > 0
assert len(result["summary"]) < len(original) # Más corto que el original
assert 0 <= result["confidence"] <= 1
# Relevancia (semántica — solo si el LLM real fue usado)
if client is not None:
assert_semantically_similar_cached(
actual=result["summary"],
expected=original,
threshold=0.70, # El resumen debe capturar el tema general
client=client,
message="El resumen no parece relacionado con el texto original"
)
Ejercicios
Ejercicio 1: Implementar con sentence-transformers
Implementa assert_semantically_similar_local que use sentence-transformers sin API calls. Pruébala con 3 pares de textos: dos equivalentes y uno diferente.
Ver solución
from sentence_transformers import SentenceTransformer
import numpy as np
_model = None
def get_model():
global _model
if _model is None:
_model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
return _model
def assert_semantically_similar_local(actual, expected, threshold=0.8):
model = get_model()
embeddings = model.encode([actual, expected], normalize_embeddings=True)
similarity = float(np.dot(embeddings[0], embeddings[1]))
assert similarity >= threshold, f"Similitud {similarity:.3f} < {threshold}"
return similarity
# Pruebas:
# ✅ Equivalentes:
sim1 = assert_semantically_similar_local(
"Python es un lenguaje interpretado",
"Python es un lenguaje de alto nivel interpretado"
) # ~0.90
# ✅ Equivalentes:
sim2 = assert_semantically_similar_local(
"El texto es positivo",
"El texto expresa sentimiento positivo"
) # ~0.88
# ❌ Distintos (debe fallar):
try:
assert_semantically_similar_local(
"El texto es positivo",
"El texto es negativo",
threshold=0.8
)
except AssertionError as e:
print(f"Correctamente falló: {e}") # ~0.55
Ejercicio 2: Calibrar el threshold
Para los siguientes pares, calcula la similitud manualmente (usando la función implementada) y determina el threshold apropiado:
- "Sentimiento positivo y alegre" vs "El texto expresa alegría y positividad"
- "El resultado contiene el resumen" vs "El output tiene el campo summary"
- "El texto es positivo" vs "El texto es negativo"
Ver guía
# Calculando con sentence-transformers:
pairs = [
("Sentimiento positivo y alegre", "El texto expresa alegría y positividad"),
("El resultado contiene el resumen", "El output tiene el campo summary"),
("El texto es positivo", "El texto es negativo"),
]
for a, b in pairs:
sim = semantic_similarity_local(a, b)
print(f"{sim:.3f}: '{a[:40]}' vs '{b[:40]}'")
# Resultado aproximado:
# 0.89: Equivalente — usar threshold 0.82
# 0.81: Técnicamente equivalente — usar threshold 0.75
# 0.52: Diferentes — threshold de 0.7 los separa bien
# Threshold recomendado para estos casos: 0.75-0.80
Ejercicio 3: Assertion semántica para resumen
Escribe un test de integración que use semantic similarity para verificar que el resumen de un texto sobre Python menciona el lenguaje Python como tema principal:
Ver solución
@pytest.mark.integration
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="Requiere API key")
def test_summary_about_python():
"""El resumen de texto sobre Python debe ser semánticamente sobre Python."""
text = """Python es un lenguaje de programación de alto nivel, interpretado y de propósito general.
Fue creado por Guido van Rossum y lanzado en 1991. Es conocido por su sintaxis clara y legible,
lo que lo hace popular para principiantes y expertos por igual."""
result = summarize(text)
# Semantic assertion: el resumen debe ser sobre Python
assert_semantically_similar_cached(
actual=result["summary"],
expected="Python es un lenguaje de programación popular creado por Guido van Rossum",
threshold=0.75
)
# También verificar que "Python" o "lenguaje" está en el resumen (backup assertion)
assert "python" in result["summary"].lower() or "lenguaje" in result["summary"].lower()
Ejercicio 4: Cuándo no usar semantic
Para cada caso, decide si usar semantic similarity o una assertion más específica:
- Verificar que la respuesta menciona la fecha "15 de enero"
- Verificar que el tono de la respuesta es "profesional y formal"
- Verificar que el output no contiene "error" ni "excepción"
- Verificar que el resumen es coherente con el texto original
Ver guía
- No semantic →
assert "15 de enero" in resultoassert result["date"] == "2025-01-15"— exactitud requerida - Semantic →
assert_semantically_similar(result, "tono formal y profesional", threshold=0.70)— tono es semántico - No semantic →
assert "error" not in result.lower()— verificación de ausencia, no similitud - Semantic →
assert_semantically_similar(result["summary"], original_text, threshold=0.70)— relevancia semántica
Ejercicio 5: Debugging de assertion fallida
La siguiente assertion falla con similarity=0.62 cuando esperabas 0.85+. ¿Cómo debuggeas?
assert_semantically_similar(
actual="El output del modelo es un JSON con campos sentiment y score",
expected="El resultado tiene la estructura esperada de sentimiento",
threshold=0.85
)
Ver guía
Paso 1: Analizar el fallo La similitud de 0.62 indica que los textos son del mismo dominio pero diferentes en especificidad. "JSON con campos X e Y" es muy específico; "estructura esperada de sentimiento" es vago.
Posibles causas:
- El texto "expected" es muy genérico — embeddings de textos vagos vs específicos tienen menor similitud
- El modelo de embeddings no captura bien la relación entre "JSON" y "estructura esperada"
Soluciones:
-
Ajustar el expected para ser más específico:
expected = "El output es JSON con campos sentiment y score como se esperaba" # Más cercano al actual → similitud más alta -
Bajar el threshold:
threshold = 0.70 # Más apropiado para afirmaciones de "contiene X" -
Usar assertion diferente:
# En vez de semántica, usar propiedades más directas: assert "sentiment" in result and "score" in result -
Calibrar primero con tus pares específicos antes de elegir threshold.
Resumen
- Semantic similarity compara significado, no palabras exactas — ideal para outputs que varían en redacción
- Cosine similarity sobre embeddings:
similarity = dot(a,b) / (|a| × |b|) - Dos opciones: OpenAI API (mejor calidad, tiene costo) o sentence-transformers local (sin costo, bueno para multilingüe)
- Calibrar el threshold con pares de textos de tu dominio — no usar valores "mágicos"
- Caché de embeddings para evitar llamadas duplicadas
- Combinar con propiedades: semantic similarity + assertions de estructura = cobertura completa
- Cuándo NO usar: datos exactos (fechas, IDs), verificar ausencia, listas ordenadas
Recursos adicionales
- OpenAI Embeddings Guide — API oficial y modelos disponibles
- sentence-transformers Documentation — Embeddings locales, multilingüe
- Cosine Similarity — Wikipedia — Matemática del concepto
- Sentence Transformers Pretrained Models — Qué modelo elegir
- MTEB Benchmark — Comparación de modelos de embeddings
- Evaluation Frameworks Guide — Para evaluar calidad más allá de similarity
- numpy — Linear Algebra — Para calcular normas y productos punto