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:

EscenarioEjemploPor qué BM25
Búsqueda exactaIDs, códigos, SKUsExactitud crítica
Keywords técnicas"HTTP 404", "NullPointerException"Términos específicos
Corpus homogéneoTodos docs legales con mismo vocabularioPoca variabilidad
Budget limitadoStartup sin recursosGratis (Elasticsearch)
Latencia crítica<10ms requeridoBM25 es más rápido

USA Embeddings cuando:

EscenarioEjemploPor qué Embeddings
Sinónimos importantes"auto" = "coche" = "carro"BM25 no captura
Queries variadasUsuario usa vocabulario diferenteCaptura paráfrasis
MultiidiomaDocs en inglés + españolModelo multiidioma
Búsqueda conceptual"artículos sobre felicidad" (no literal)Captura concepto
Corpus heterogéneoDiferentes estilos de escrituraNormaliza 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:

  1. Budget limitado + corpus homogéneo → BM25
  2. Variabilidad lingüística + budget flexible → Embeddings
  3. Producción + mejor resultado → Hybrid search

Recursos adicionales

  1. BM25 Explained - Elasticsearch
  2. Hybrid Search Guide - Pinecone
  3. Elasticsearch Vector Search - Docs oficiales
  4. Semantic vs Keyword - SBERT
  5. 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