Módulo 1: ¿Qué son Embeddings?
Embeddings vs Keyword Search
Descripción de la cápsula
No todos los problemas de búsqueda requieren embeddings—a veces keyword search (BM25, Elasticsearch) es más apropiado, más rápido y más barato.
En esta cápsula aprenderás la diferencia fundamental entre embeddings (semantic) y keyword search (lexical), cuándo usar cada uno, las ventajas y desventajas de ambos approaches, y cómo combinarlos en hybrid search para obtener lo mejor de ambos mundos.
También verás código práctico comparando ambos métodos lado a lado y aprenderás a tomar decisiones informadas de arquitectura.
Keyword Search: BM25 y TF-IDF
Qué es keyword search:
Búsqueda basada en coincidencia de palabras exactas (o stemmed) entre query y documento.
Algoritmos principales:
- TF-IDF (Term Frequency - Inverse Document Frequency): Clásico, simple
- BM25 (Best Match 25): Mejora de TF-IDF, estándar en Elasticsearch
Cómo funciona BM25:
1. Tokenización:
Query: "python tutorial" → ["python", "tutorial"]
Doc 1: "Python tutorial for beginners" → ["python", "tutorial", "for", "beginners"]
2. Matching:
¿Cuántas palabras de query están en doc?
Doc 1: 2/2 palabras (100% match) → Score alto
3. Scoring (simplificado):
score(doc, query) = Σ IDF(term) × TF(term, doc)
IDF = log(N / df(term)) # Términos raros → mayor peso
TF = freq(term, doc) # Términos frecuentes en doc → mayor peso
Características:
- ✅ Rápido (índice invertido)
- ✅ Exacto (coincidencia literal)
- ❌ No entiende sinónimos
- ❌ No entiende contexto
Ejemplo con Elasticsearch (conceptual):
from elasticsearch import Elasticsearch
es = Elasticsearch()
# Indexar documentos
documents = [
{"id": 1, "text": "Python tutorial for beginners"},
{"id": 2, "text": "JavaScript guide for developers"},
{"id": 3, "text": "Learn Python programming"}
]
for doc in documents:
es.index(index="docs", id=doc["id"], document=doc)
# Búsqueda con BM25 (default en Elasticsearch)
query = "python tutorial"
results = es.search(index="docs", query={
"match": {
"text": query
}
})
# Resultados ordenados por score BM25
for hit in results["hits"]["hits"]:
print(f"Doc {hit['_id']}: {hit['_source']['text']} (score: {hit['_score']})")
Output esperado:
Doc 1: Python tutorial for beginners (score: 2.45) ← Ambas palabras
Doc 3: Learn Python programming (score: 1.12) ← Solo "Python"
Doc 2: JavaScript guide for developers (score: 0) ← Ninguna palabra
Semantic Search: Embeddings
Qué es semantic search:
Búsqueda basada en significado semántico mediante vectores densos.
Cómo funciona (ya cubierto en cápsulas previas):
1. Embedding:
Query: "python tutorial" → [0.023, -0.145, 0.892, ..., 0.567] (1536D)
Doc 1: "Python guide" → [0.025, -0.143, 0.895, ..., 0.570] (1536D)
2. Similaridad:
cosine_similarity(query_emb, doc1_emb) → 0.95 (muy similar)
3. Ranking:
Ordenar docs por similaridad descendente
Características:
- ✅ Entiende sinónimos ("tutorial" ≈ "guide")
- ✅ Entiende paráfrasis
- ❌ Más lento (cálculo vectorial)
- ❌ Más costoso (API calls o GPU)
Comparación directa: Mismo query, ambos métodos
Setup: 5 documentos
documents = [
{"id": 1, "text": "Python tutorial for beginners"},
{"id": 2, "text": "Learn Python programming from scratch"},
{"id": 3, "text": "JavaScript guide for developers"},
{"id": 4, "text": "How to start coding in Python"},
{"id": 5, "text": "Java programming basics"}
]
query = "python tutorial"
Método 1: BM25 (keyword)
# Simulación simple de BM25 (sin Elasticsearch)
def simple_bm25(query, doc):
"""
Simulación simplificada de BM25
Cuenta coincidencias de palabras (stemmed)
"""
query_words = set(query.lower().split())
doc_words = set(doc.lower().split())
# Coincidencias
matches = query_words.intersection(doc_words)
# Score = # de coincidencias (simplificado)
return len(matches)
# Aplicar a todos los docs
bm25_results = []
for doc in documents:
score = simple_bm25(query, doc["text"])
bm25_results.append((doc["id"], doc["text"], score))
# Ordenar por score
bm25_results.sort(key=lambda x: x[2], reverse=True)
print("Resultados BM25:")
for doc_id, text, score in bm25_results:
print(f" Doc {doc_id}: {text} (score: {score})")
Output:
Resultados BM25:
Doc 1: Python tutorial for beginners (score: 2) ← "python" + "tutorial"
Doc 2: Learn Python programming from scratch (score: 1) ← Solo "python"
Doc 4: How to start coding in Python (score: 1) ← Solo "python"
Doc 3: JavaScript guide for developers (score: 0) ← Ninguna
Doc 5: Java programming basics (score: 0) ← Ninguna
Observa: Doc 2 y Doc 4 tienen MISMO score, pero Doc 2 es más relevante (contenido sobre aprender Python).
Método 2: Embeddings (semantic)
from openai import OpenAI
import numpy as np
import os
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def get_embedding(text):
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return np.array(response.data[0].embedding)
def cosine_similarity(vec_a, vec_b):
return np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b))
# Embed query
query_embedding = get_embedding(query)
# Embed documentos y calcular similaridades
semantic_results = []
for doc in documents:
doc_embedding = get_embedding(doc["text"])
similarity = cosine_similarity(query_embedding, doc_embedding)
semantic_results.append((doc["id"], doc["text"], similarity))
# Ordenar por similaridad
semantic_results.sort(key=lambda x: x[2], reverse=True)
print("\nResultados Semantic (Embeddings):")
for doc_id, text, sim in semantic_results:
print(f" Doc {doc_id}: {text} (score: {sim:.4f})")
Output esperado:
Resultados Semantic (Embeddings):
Doc 1: Python tutorial for beginners (score: 0.92) ← Exacto
Doc 2: Learn Python programming from scratch (score: 0.88) ← Semántico
Doc 4: How to start coding in Python (score: 0.82) ← Semántico
Doc 5: Java programming basics (score: 0.72) ← Relacionado (lenguaje)
Doc 3: JavaScript guide for developers (score: 0.68) ← Algo relacionado
Observa:
- Doc 2 y Doc 4 tienen scores diferentes (embeddings capturan matiz)
- Doc 5 (Java) aparece con score > 0 (relacionado aunque no Python)
Ventajas y desventajas
BM25 / Keyword Search
Ventajas:
✅ 1. Búsqueda exacta perfecta
Query: "ERROR-404-USER-123"
BM25: Encuentra exactamente "ERROR-404-USER-123" ✅
Embeddings: Puede confundir con "ERROR-404-USER-124" ⚠️
✅ 2. Rápido (milisegundos)
# Elasticsearch con índice invertido:
# 10 millones de docs → ~5-10ms por query
✅ 3. Gratis (open-source)
# Elasticsearch, Apache Solr: Gratis
# Embeddings: $0.00002/1K tokens (OpenAI)
✅ 4. Interpretable
# Puedes ver QUÉ palabras matchearon:
Query: "python tutorial"
Doc: "Python tutorial for beginners"
Match: ["python", "tutorial"] ← Claro
Desventajas:
❌ 1. No entiende sinónimos
Query: "tutorial"
Doc 1: "Tutorial de Python" ✅ Match
Doc 2: "Guía de Python" ❌ No match (aunque "guía" = "tutorial")
❌ 2. No entiende paráfrasis
Query: "cómo resetear mi laptop"
Doc 1: "resetear laptop" ✅ Match
Doc 2: "reiniciar computadora" ❌ No match (aunque significa lo mismo)
❌ 3. Sensible al vocabulario
# Usuario usa términos diferentes al documento:
Query: "auto"
Doc: "coche" ❌ No match (misma cosa, palabra diferente)
Embeddings / Semantic Search
Ventajas:
✅ 1. Entiende sinónimos
Query: "tutorial"
Doc: "Guía de Python" ✅ Encuentra (similarity ~0.85)
✅ 2. Entiende paráfrasis
Query: "cómo resetear mi laptop"
Doc: "reiniciar computadora" ✅ Encuentra (similarity ~0.88)
✅ 3. Multiidioma (con modelo apropiado)
Query: "python tutorial" (inglés)
Doc: "tutorial de Python" (español) ✅ Encuentra (similarity ~0.90)
✅ 4. Captura contexto
Query: "banco"
Doc 1: "banco del río" → Embedding A
Doc 2: "banco para sacar dinero" → Embedding B
# Embeddings A y B son diferentes (contexto diferente)
Desventajas:
❌ 1. Puede sobre-generalizar
Query: "Python 3.9"
BM25: Encuentra exactamente "Python 3.9" ✅
Embeddings: Puede retornar "Python 3.10" (similar pero no exacto) ⚠️
❌ 2. Más lento
# Embedding generation:
# 1 query → 1 API call (~100-300ms)
# Similarity calculation:
# 1 millón de docs → 1 millón de cálculos de cosine similarity
# Sin índice: ~10-30 segundos 😱
# Con vector DB (HNSW): ~50-200ms ✅
❌ 3. Más costoso
# OpenAI embeddings: $0.00002/1K tokens
# 1 millón de docs × 500 tokens promedio × $0.00002 = $10 USD
# BM25 / Elasticsearch: $0 (open-source)
❌ 4. Menos interpretable
# No puedes ver POR QUÉ similarity = 0.85
# Son 1536 dimensiones (caja negra)
Casos de uso: Cuándo usar qué
USA BM25 cuando:
| Escenario | Ejemplo | Por qué BM25 |
|---|---|---|
| Búsqueda exacta | IDs, códigos, SKUs | Exactitud crítica |
| Keywords técnicas | "HTTP 404", "NullPointerException" | Términos específicos |
| Corpus homogéneo | Todos docs legales con mismo vocabulario | Poca variabilidad |
| Budget limitado | Startup sin recursos | Gratis (Elasticsearch) |
| Latencia crítica | <10ms requerido | BM25 es más rápido |
USA Embeddings cuando:
| Escenario | Ejemplo | Por qué Embeddings |
|---|---|---|
| Sinónimos importantes | "auto" = "coche" = "carro" | BM25 no captura |
| Queries variadas | Usuario usa vocabulario diferente | Captura paráfrasis |
| Multiidioma | Docs en inglés + español | Modelo multiidioma |
| Búsqueda conceptual | "artículos sobre felicidad" (no literal) | Captura concepto |
| Corpus heterogéneo | Diferentes estilos de escritura | Normaliza semántica |
Hybrid Search: Lo mejor de ambos mundos
Concepto:
Combinar BM25 (keyword) + Embeddings (semantic) en un solo score.
Fórmula típica:
hybrid_score = α × bm25_score + (1 - α) × semantic_score
donde α ∈ [0, 1] (típicamente α = 0.5)
Implementación conceptual:
def hybrid_search(query, documents, alpha=0.5):
"""
Hybrid search: BM25 + Embeddings
Args:
query: Query del usuario
documents: Lista de documentos
alpha: Peso de BM25 (1-alpha = peso de embeddings)
Returns:
Documentos ordenados por hybrid score
"""
# Paso 1: BM25 scores (normalizar a [0, 1])
bm25_scores = {}
for doc in documents:
score = simple_bm25(query, doc["text"])
bm25_scores[doc["id"]] = score
# Normalizar BM25 scores
max_bm25 = max(bm25_scores.values()) if bm25_scores else 1
bm25_normalized = {
doc_id: score / max_bm25
for doc_id, score in bm25_scores.items()
}
# Paso 2: Semantic scores (cosine similarity ya en [0, 1])
query_embedding = get_embedding(query)
semantic_scores = {}
for doc in documents:
doc_embedding = get_embedding(doc["text"])
sim = cosine_similarity(query_embedding, doc_embedding)
# Convertir de [-1, 1] a [0, 1]
sim_normalized = (sim + 1) / 2
semantic_scores[doc["id"]] = sim_normalized
# Paso 3: Combinar scores
hybrid_scores = []
for doc in documents:
doc_id = doc["id"]
bm25_score = bm25_normalized.get(doc_id, 0)
semantic_score = semantic_scores.get(doc_id, 0)
# Hybrid score
hybrid_score = alpha * bm25_score + (1 - alpha) * semantic_score
hybrid_scores.append({
"id": doc_id,
"text": doc["text"],
"hybrid_score": hybrid_score,
"bm25_score": bm25_score,
"semantic_score": semantic_score
})
# Ordenar por hybrid score
hybrid_scores.sort(key=lambda x: x["hybrid_score"], reverse=True)
return hybrid_scores
# Ejemplo
query = "python tutorial"
results = hybrid_search(query, documents, alpha=0.5)
print("Hybrid Search Results (α=0.5):")
for r in results:
print(f"Doc {r['id']}: {r['text']}")
print(f" BM25: {r['bm25_score']:.2f}, Semantic: {r['semantic_score']:.2f}, Hybrid: {r['hybrid_score']:.2f}\n")
Output esperado:
Hybrid Search Results (α=0.5):
Doc 1: Python tutorial for beginners
BM25: 1.00, Semantic: 0.96, Hybrid: 0.98
Doc 2: Learn Python programming from scratch
BM25: 0.50, Semantic: 0.94, Hybrid: 0.72
Doc 4: How to start coding in Python
BM25: 0.50, Semantic: 0.91, Hybrid: 0.71
Doc 5: Java programming basics
BM25: 0.00, Semantic: 0.86, Hybrid: 0.43
Doc 3: JavaScript guide for developers
BM25: 0.00, Semantic: 0.84, Hybrid: 0.42
Ajustando α (trade-off BM25 vs Semantic):
# α = 0.0 → 100% Semantic (ignora keywords)
# α = 0.5 → 50/50 balance
# α = 1.0 → 100% BM25 (ignora semantic)
# Ejemplo: Búsqueda de código (keywords importantes)
α = 0.7 # 70% BM25, 30% Semantic
# Ejemplo: Búsqueda conceptual (significado importante)
α = 0.3 # 30% BM25, 70% Semantic
Ejemplo real: Elasticsearch + Vector Search
Elasticsearch 8.0+ incluye soporte nativo para embeddings:
from elasticsearch import Elasticsearch
es = Elasticsearch()
# Indexar con embeddings
doc = {
"text": "Python tutorial for beginners",
"embedding": get_embedding("Python tutorial for beginners") # [1536 dims]
}
es.index(index="hybrid-docs", document=doc)
# Hybrid query (BM25 + KNN)
query = "python tutorial"
query_embedding = get_embedding(query)
response = es.search(index="hybrid-docs", query={
"bool": {
"should": [
# BM25 (keyword)
{
"match": {
"text": {
"query": query,
"boost": 0.5 # α = 0.5
}
}
},
# KNN (semantic)
{
"knn": {
"field": "embedding",
"query_vector": query_embedding,
"k": 10,
"num_candidates": 100,
"boost": 0.5 # 1-α = 0.5
}
}
]
}
})
# Resultados combinan ambos scores
for hit in response["hits"]["hits"]:
print(f"{hit['_source']['text']} (score: {hit['_score']})")
Ejercicios
Ejercicio 1: Implementar BM25 simple
Implementa un BM25 simplificado que cuente coincidencias:
def simple_bm25(query, doc):
# Implementa coincidencia de palabras
pass
query = "python tutorial"
doc = "Python tutorial for beginners"
# Debería retornar 2 (ambas palabras matchean)
Ver solución
def simple_bm25(query, doc):
"""
BM25 simplificado: cuenta coincidencias de palabras
"""
# Convertir a minúsculas y separar palabras
query_words = set(query.lower().split())
doc_words = set(doc.lower().split())
# Contar coincidencias
matches = query_words.intersection(doc_words)
return len(matches)
query = "python tutorial"
doc = "Python tutorial for beginners"
score = simple_bm25(query, doc)
print(f"BM25 score: {score}") # 2
Explicación:
- Query: {"python", "tutorial"}
- Doc: {"python", "tutorial", "for", "beginners"}
- Intersección: {"python", "tutorial"} → 2 coincidencias
Ejercicio 2: Comparar BM25 vs Semantic
Compara ambos métodos para este query:
query = "auto rojo"
documents = [
"Vendo coche rojo",
"Auto deportivo color rojo",
"Carro usado rojo"
]
# Implementa búsqueda con ambos métodos
# ¿Cuál encuentra los 3 documentos?
Ver solución
# BM25
print("BM25 Results:")
bm25_results = []
for doc in documents:
score = simple_bm25(query, doc)
bm25_results.append((doc, score))
print(f" '{doc}' → score: {score}")
# Semantic
print("\nSemantic Results:")
query_emb = get_embedding(query)
semantic_results = []
for doc in documents:
doc_emb = get_embedding(doc)
sim = cosine_similarity(query_emb, doc_emb)
semantic_results.append((doc, sim))
print(f" '{doc}' → score: {sim:.4f}")
Output esperado:
BM25 Results:
'Vendo coche rojo' → score: 1 (solo "rojo")
'Auto deportivo color rojo' → score: 2 ("auto", "rojo")
'Carro usado rojo' → score: 1 (solo "rojo")
Semantic Results:
'Vendo coche rojo' → score: 0.92 ("coche" ≈ "auto")
'Auto deportivo color rojo' → score: 0.95 (exacto + contexto)
'Carro usado rojo' → score: 0.90 ("carro" ≈ "auto")
Conclusión: Semantic encuentra todos con scores altos (captura sinónimos). BM25 puntúa diferente aunque todos son relevantes.
Ejercicio 3: Hybrid search
Implementa hybrid search con α=0.6:
query = "error de conexión"
documents = [
"Error al conectar a base de datos",
"Problema de conexión de red",
"Fallo en la conexión"
]
# Implementa hybrid search con α=0.6 (60% BM25, 40% Semantic)
Ver solución
def hybrid_search(query, documents, alpha=0.6):
# BM25 scores
bm25_scores = []
for doc in documents:
score = simple_bm25(query, doc)
bm25_scores.append(score)
# Normalizar BM25
max_bm25 = max(bm25_scores) if max(bm25_scores) > 0 else 1
bm25_normalized = [score / max_bm25 for score in bm25_scores]
# Semantic scores
query_emb = get_embedding(query)
semantic_scores = []
for doc in documents:
doc_emb = get_embedding(doc)
sim = cosine_similarity(query_emb, doc_emb)
# Normalizar de [-1, 1] a [0, 1]
sim_normalized = (sim + 1) / 2
semantic_scores.append(sim_normalized)
# Hybrid scores
hybrid_results = []
for i, doc in enumerate(documents):
hybrid_score = alpha * bm25_normalized[i] + (1 - alpha) * semantic_scores[i]
hybrid_results.append({
"doc": doc,
"bm25": bm25_normalized[i],
"semantic": semantic_scores[i],
"hybrid": hybrid_score
})
# Ordenar por hybrid score
hybrid_results.sort(key=lambda x: x["hybrid"], reverse=True)
return hybrid_results
query = "error de conexión"
results = hybrid_search(query, documents, alpha=0.6)
print("Hybrid Results (α=0.6):")
for r in results:
print(f" '{r['doc']}'")
print(f" BM25: {r['bm25']:.2f}, Semantic: {r['semantic']:.2f}, Hybrid: {r['hybrid']:.2f}\n")
Output esperado:
Hybrid Results (α=0.6):
'Error al conectar a base de datos'
BM25: 0.50, Semantic: 0.94, Hybrid: 0.68
'Problema de conexión de red'
BM25: 1.00, Semantic: 0.96, Hybrid: 0.98
'Fallo en la conexión'
BM25: 0.50, Semantic: 0.92, Hybrid: 0.67
Observación: Doc 2 gana porque tiene mejor balance (keyword "conexión" + alto semantic similarity).
Troubleshooting común
Problema 1: BM25 no encuentra sinónimos
Query: "auto"
Doc: "coche rojo"
BM25 score: 0 # ❌ No match
Solución: Usa hybrid search o solo semantic.
Problema 2: Embeddings sobre-generaliza
Query: "Python 3.9"
Semantic retorna: "Python 3.10" (similarity 0.95)
# Pero usuario quería ESPECÍFICAMENTE 3.9
Solución: Usa hybrid search con α alto (e.g., 0.7) para dar más peso a keywords.
Problema 3: Hybrid scores dominados por un método
# BM25 scores: 0.1, 0.2, 0.3
# Semantic scores: 0.85, 0.90, 0.95
# Hybrid (α=0.5): Semantic domina
Solución: Ajusta α o normaliza mejor ambos scores a misma escala.
Resumen
Qué aprendiste:
- ✅ BM25: Keyword search, rápido, exacto, no captura sinónimos
- ✅ Embeddings: Semantic search, sinónimos, más lento, más costoso
- ✅ Hybrid: Combina ambos con α (trade-off)
- ✅ Cuándo usar qué: IDs/códigos → BM25, sinónimos/paráfrasis → Embeddings
- ✅ Best practice: Hybrid search (α=0.5 como baseline)
Decisiones de arquitectura:
- Budget limitado + corpus homogéneo → BM25
- Variabilidad lingüística + budget flexible → Embeddings
- Producción + mejor resultado → Hybrid search
Recursos adicionales
- BM25 Explained - Elasticsearch
- Hybrid Search Guide - Pinecone
- Elasticsearch Vector Search - Docs oficiales
- Semantic vs Keyword - SBERT
- Reciprocal Rank Fusion - Alternativa a weighted hybrid
En la siguiente cápsula
Cápsula 07: Arquitectura Overview
Aprenderás:
- Transformers high-level (encoder-only)
- Tokenización con tiktoken
- Self-attention (conceptual)
- Pooling strategies (mean, CLS)
- Cómo se genera un embedding end-to-end
De decisiones de búsqueda a arquitectura técnica.
Módulo 1 - Embeddings Deep Dive Guide Eligiendo la herramienta correcta para cada problema