Al cerrar el módulo 3 dejamos a DocuBot en una situación curiosa: es un sistema robusto, con memoria, económicamente vigilado... y ciego. Responde de maravilla cuando le damos la documentación correcta en la mano, pero hasta ahora esa mano ha sido la nuestra: en cada ejemplo hemos pegado nosotros los fragmentos relevantes en el bloque <documentacion>. La pregunta pendiente era: ¿quién encuentra, entre cientos de páginas de la wiki de Nubelia, los dos fragmentos que responden a cada pregunta? Esta lección construye la primera pieza de la respuesta: los embeddings —que en 01-02 presentamos intuitivamente como "coordenadas de significado"— pasan hoy de intuición a código. Aprenderás a obtenerlos vía API, a medir cuánto se parecen dos textos con la similitud coseno, y a montar una búsqueda semántica funcional (aunque de fuerza bruta) sobre la documentación de Nubelia. Y de regalo, resolveremos la limitación que anunciamos en 03-04: el CacheRespuestas exacto que no reconocía preguntas equivalentes formuladas de otra manera.

Contenido

  1. De "coordenadas de significado" a vectores reales
  2. Obtener embeddings vía API
  3. Similitud coseno: medir la cercanía entre significados
  4. Búsqueda semántica de fuerza bruta sobre la doc de Nubelia
  5. Palabras clave vs. significado: por qué gana la búsqueda semántica
  6. Elegir un modelo de embeddings
  7. Aplicación de regalo: el caché semántico de respuestas

De "coordenadas de significado" a vectores reales

En 01-02 vimos la intuición con el ejemplo perro/gato/factura: un embedding coloca cada texto en un mapa donde los significados parecidos quedan cerca. "Perro" y "gato" son vecinos; "factura" vive en otro barrio. Hoy concretamos esa metáfora:

  • Un embedding es una lista de números decimales (un vector) que representa el significado de un texto. Longitudes típicas: entre 256 y 3072 números (sus dimensiones).
  • Lo produce un modelo de embeddings, que es un modelo distinto del LLM que genera texto. No conversa: solo convierte texto en coordenadas.
  • La propiedad clave: dos textos con significado parecido producen vectores cercanos, aunque no compartan ni una sola palabra. "No puedo entrar en mi cuenta" y "restablecer contraseña" quedan cerca en el mapa; "exportar informe" queda lejos de ambos.

Para DocuBot esto lo cambia todo: si convertimos cada fragmento de la documentación de Nubelia en un vector, y también la pregunta del usuario, encontrar la documentación relevante se reduce a un problema geométrico — buscar los vectores más cercanos al de la pregunta.

Obtener embeddings vía API

Igual que en 03-01 con el LLM, usamos una API gestionada. Todos los proveedores de embeddings siguen el mismo patrón: envías una lista de textos, recibes una lista de vectores. Aquí lo mostramos con el SDK de voyageai como ejemplo, pero el código es deliberadamente genérico — cambiar de proveedor es cambiar tres líneas:

# embeddings.py
import os
import voyageai

# La clave de API SIEMPRE por variable de entorno, como en cliente.py (03-01)
vo = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])

# Parametrizado por env var, siguiendo el patrón de DOCUBOT_MODELO (03-01)
MODELO_EMBEDDINGS = os.environ.get("DOCUBOT_MODELO_EMBEDDINGS", "voyage-3.5")

def embed(textos: list[str], tipo: str = "document") -> list[list[float]]:
    """Convierte una lista de textos en una lista de vectores.

    tipo: "document" para fragmentos de documentación,
          "query" para preguntas de usuario.
    Algunos modelos optimizan el embedding según este uso.
    """
    respuesta = vo.embed(textos, model=MODELO_EMBEDDINGS, input_type=tipo)
    return respuesta.embeddings

Desglose para quien lo ve por primera vez:

  • embed() recibe una lista, no un texto suelto: las APIs de embeddings están pensadas para trabajar por lotes, y embeber 50 textos en una llamada es mucho más barato en latencia que 50 llamadas de un texto. Lo explotaremos en la ingesta (04-03).
  • input_type: varios modelos distinguen entre embeber un documento (que será buscado) y una consulta (que busca). Si tu proveedor no lo soporta, ignora el parámetro; si lo soporta, úsalo — mejora la recuperación.
  • El resultado es una lista de vectores; cada vector es una list[float] de tantas dimensiones como tenga el modelo (p. ej. 1024).
vectores = embed(["restablecer contraseña", "no puedo entrar en mi cuenta", "exportar informe a CSV"])
print(len(vectores))       # 3 textos -> 3 vectores
print(len(vectores[0]))    # p. ej. 1024 dimensiones
print(vectores[0][:5])     # [0.0132, -0.0871, 0.0443, ...] — números sin significado individual

Un detalle importante: ningún número del vector significa nada por sí solo. El significado está en la posición global del vector respecto a los demás. Por eso la única operación útil es compararlos.

Similitud coseno: medir la cercanía entre significados

Necesitamos un número que diga "cuánto se parecen" dos vectores. El estándar es la similitud coseno, y la intuición geométrica es simple: imagina cada vector como una flecha que sale del origen del mapa de significados. La similitud coseno mide el ángulo entre las dos flechas:

  • Flechas que apuntan en la misma dirección (ángulo 0°) → similitud 1.0: significados casi idénticos.
  • Flechas perpendiculares (90°) → similitud 0.0: significados sin relación.
  • Flechas opuestas → similitud -1.0 (raro en la práctica con textos reales).

Lo interesante es que mide dirección, no longitud: un párrafo largo y una frase corta sobre el mismo tema apuntan hacia el mismo sitio. La implementación cabe en diez líneas y no necesita más matemática que multiplicar y sumar:

# embeddings.py (continuación)
import math

def similitud_coseno(a: list[float], b: list[float]) -> float:
    """Similitud entre dos vectores: 1.0 = misma dirección, 0.0 = sin relación."""
    producto = sum(x * y for x, y in zip(a, b))          # producto escalar
    norma_a = math.sqrt(sum(x * x for x in a))           # longitud de la flecha a
    norma_b = math.sqrt(sum(x * x for x in b))           # longitud de la flecha b
    return producto / (norma_a * norma_b)
  • El producto escalar (sum(x*y)) crece cuando los dos vectores tienen valores grandes en las mismas posiciones — es decir, cuando "apuntan hacia el mismo lado".
  • Dividir por las dos normas (longitudes) elimina el efecto del tamaño: solo queda el ángulo.
  • En producción usarías numpy por velocidad, pero esta versión pura deja claro que no hay magia.
v = embed(["no puedo entrar en mi cuenta",
           "cómo restablecer la contraseña de acceso",
           "límites de tareas por proyecto en el plan Pro"])

print(similitud_coseno(v[0], v[1]))  # p. ej. 0.83 — muy relacionados
print(similitud_coseno(v[0], v[2]))  # p. ej. 0.24 — poco relacionados

Los valores exactos dependen del modelo (cada modelo tiene su "escala" típica), pero el orden relativo es fiable: lo relacionado puntúa más alto que lo no relacionado. Retén esta idea — reaparecerá cuando calibremos umbrales.

Búsqueda semántica de fuerza bruta sobre la doc de Nubelia

Ya tenemos las dos piezas. La búsqueda semántica más simple posible — fuerza bruta — consiste en: embeber todos los fragmentos una vez, embeber la pregunta, calcular la similitud contra todos los fragmentos y quedarse con los k mejores (top-k):

# busqueda.py — búsqueda semántica de fuerza bruta
from embeddings import embed, similitud_coseno

# Mini-corpus de la documentación de Nubelia (en 04-03 vendrá de la wiki real)
FRAGMENTOS = [
    "Para restablecer tu contraseña, ve a Ajustes > Seguridad y pulsa 'Restablecer'. Recibirás un correo con un enlace válido durante 24 horas.",
    "Los informes de proyecto pueden exportarse a CSV y Excel desde el menú Informes > Exportar. La exportación a PDF no está soportada.",
    "El plan Pro permite hasta 500 tareas por proyecto y 25 miembros por espacio de trabajo.",
    "Para invitar a un miembro, el administrador debe ir a Espacio > Miembros > Invitar e introducir su correo corporativo.",
    "La API de Nubelia usa autenticación por token Bearer. Genera tu token en Ajustes > Desarrolladores.",
    "Si tu cuenta queda bloqueada tras varios intentos fallidos, espera 15 minutos o contacta con el administrador del espacio.",
]

# Paso 1 (una sola vez): embeber todo el corpus
VECTORES = embed(FRAGMENTOS, tipo="document")

def buscar(pregunta: str, k: int = 3) -> list[tuple[float, str]]:
    """Devuelve los k fragmentos más cercanos en significado a la pregunta."""
    v_pregunta = embed([pregunta], tipo="query")[0]          # Paso 2: embeber la pregunta
    puntuados = [
        (similitud_coseno(v_pregunta, v_frag), frag)         # Paso 3: comparar con TODOS
        for v_frag, frag in zip(VECTORES, FRAGMENTOS)
    ]
    puntuados.sort(reverse=True)                             # Paso 4: ordenar de mayor a menor
    return puntuados[:k]                                     # Paso 5: quedarse con los k mejores

for sim, frag in buscar("no puedo entrar en mi cuenta", k=2):
    print(f"{sim:.3f}  {frag[:60]}...")

Salida típica:

0.781  Para restablecer tu contraseña, ve a Ajustes > Seguridad...
0.702  Si tu cuenta queda bloqueada tras varios intentos fallidos...

Fíjate en lo que acaba de pasar: la pregunta "no puedo entrar en mi cuenta" no comparte ninguna palabra clave con "restablecer contraseña" ni con "cuenta bloqueada tras intentos fallidos", y aun así la búsqueda ha encontrado exactamente los dos fragmentos que un humano de soporte habría elegido. Esto es lo que ninguna búsqueda por palabras hace bien.

Dos observaciones antes de seguir:

  • El paso 1 (embeber el corpus) se hace una vez y se reutiliza; solo la pregunta se embebe en cada búsqueda. Aun así, mantener los vectores en una lista de Python tiene problemas serios de escala y persistencia — ese es exactamente el tema de la próxima lección (04-02).
  • Aquí los "fragmentos" nos los hemos dado hechos. Decidir cómo trocear cientos de páginas reales en fragmentos buenos es un arte con nombre propio, chunking, y tiene su propia lección (04-03).

Palabras clave vs. significado: por qué gana la búsqueda semántica

La alternativa clásica es la búsqueda por palabras clave (la de grep, la de un buscador de wiki básico): encontrar documentos que contengan las mismas palabras que la consulta. Comparemos:

Aspecto Búsqueda por palabras clave Búsqueda semántica
Qué compara Palabras literales (o sus raíces) Significado (vectores)
"no puedo entrar" → doc de "restablecer contraseña" ❌ No la encuentra (cero palabras en común) ✅ La encuentra (significados vecinos)
Sinónimos ("factura" vs "recibo") ❌ Fallan salvo diccionarios manuales ✅ Resueltos de serie
Erratas y variaciones ("contrasena") ❌ Frágil ✅ Bastante robusta
Términos exactos raros ("error NB-4012", nombres de endpoint) ✅ Excelente ⚠️ Puede diluirlos entre lo "parecido"
Coste computacional Muy bajo Requiere modelo de embeddings
Explicabilidad ("¿por qué salió esto?") Alta (la palabra está o no está) Baja (cercanía en un espacio abstracto)

La tabla revela algo importante: la semántica no es estrictamente superior en todo. Para códigos de error exactos o nombres de funciones de la API de Nubelia, la búsqueda literal es difícil de batir. Por eso muchos sistemas maduros usan búsqueda híbrida (combinar ambas) — nos basta con saber que existe; para DocuBot, la semántica pura cubre la gran mayoría de preguntas de soporte, que llegan escritas con las palabras del usuario, no con las de la documentación.

Elegir un modelo de embeddings

Un error frecuente es asumir que el modelo de embeddings "viene con" el LLM. Son decisiones independientes: DocuBot puede generar respuestas con el modelo medio elegido en 01-03 y embeber con un modelo de otro proveedor. Los criterios de elección:

Criterio Qué mirar Relevancia para DocuBot
Dimensiones 256–3072 típicas. Más dimensiones ≈ más matiz, pero más almacenamiento y cómputo por comparación Con cientos de páginas, 512–1024 es un punto dulce
Multilingüe ¿Rinde igual en español que en inglés? Crítico: la doc de Nubelia está en español, pero hay páginas técnicas en inglés y usuarios que preguntan mezclando ambos
Coste Se factura por tokens embebidos; órdenes de magnitud más barato que la generación La ingesta inicial es el gasto grande; embeber cada pregunta es casi despreciable frente al coste de la respuesta del LLM (03-04)
Ventana de entrada Cuánto texto acepta por fragmento Marcará el tamaño máximo de chunk en 04-03
Calidad de recuperación Benchmarks públicos (p. ej. MTEB) como orientación, y sobre todo tu evaluación La mediremos con nuestro propio conjunto en 04-05

Y una regla de oro que conviene tatuar: los vectores de modelos distintos no son comparables entre sí. Si mañana cambias de modelo de embeddings, todos los vectores del corpus quedan obsoletos y hay que re-embeber todo. Por eso MODELO_EMBEDDINGS es una constante con env var, y por eso la ingesta de 04-03 será re-ejecutable.

Aplicación de regalo: el caché semántico de respuestas

En 03-04 construimos CacheRespuestas: un caché exacto por hash SHA-256 de la pregunta normalizada, con TTL. Y anunciamos su limitación: "¿cómo restablezco mi contraseña?" y "olvidé mi password, ¿qué hago?" generan hashes distintos → dos llamadas al LLM para la misma respuesta. Con lo aprendido hoy, la solución es directa: cachear también el vector de cada pregunta y, antes de llamar al LLM, comprobar si alguna pregunta cacheada es suficientemente parecida:

# cache_semantico.py — evolución del CacheRespuestas de 03-04
import time
from embeddings import embed, similitud_coseno

class CacheSemantico:
    """Caché de respuestas que reconoce preguntas equivalentes por significado."""

    def __init__(self, umbral: float = 0.92, ttl_segundos: int = 3600):
        self.umbral = umbral
        self.ttl = ttl_segundos
        self.entradas = []  # lista de (vector, respuesta, timestamp)

    def obtener(self, pregunta: str) -> dict | None:
        v = embed([pregunta], tipo="query")[0]
        ahora = time.time()
        mejor_sim, mejor_respuesta = 0.0, None
        for vector, respuesta, ts in self.entradas:
            if ahora - ts > self.ttl:          # entrada caducada: la ignoramos
                continue
            sim = similitud_coseno(v, vector)
            if sim > mejor_sim:
                mejor_sim, mejor_respuesta = sim, respuesta
        if mejor_sim >= self.umbral:            # solo si supera el umbral
            return mejor_respuesta
        return None                             # cache miss -> habrá que llamar al LLM

    def guardar(self, pregunta: str, respuesta: dict):
        v = embed([pregunta], tipo="query")[0]
        self.entradas.append((v, respuesta, time.time()))

    def invalidar(self):
        """Al republicar documentación, igual que en 03-04: se vacía todo."""
        self.entradas = []

Puntos de diseño que merecen atención:

  • El umbral es la decisión crítica. Con 0.92 exigimos preguntas casi equivalentes. Si lo bajas a 0.80, ahorras más llamadas pero arriesgas servir la respuesta de "¿cómo invito a un miembro?" a quien preguntó "¿cómo elimino a un miembro?" — preguntas temáticamente cercanas pero con respuestas opuestas. Un falso positivo en un caché es peor que cien misses: el usuario recibe una respuesta incorrecta con total confianza. Calíbralo con el conjunto de evaluación de 02-04, empezando conservador.
  • Sigue habiendo coste: cada consulta al caché embebe la pregunta (una llamada barata a la API de embeddings). El ahorro neto viene de evitar la llamada cara al LLM. Con los precios relativos de precios.py (03-04), la cuenta sale muy a favor.
  • La búsqueda dentro del caché es fuerza bruta — recorre todas las entradas. Para un caché de cientos de preguntas es perfectamente válido; es el mismo patrón de buscar() de esta lección. (Y sí: si el caché creciera a millones de entradas, necesitaría lo mismo que el corpus — una base vectorial. Todo conecta.)
  • La invalidación se mantiene: cuando Nubelia publica documentación nueva, el caché se vacía, exactamente como decidimos en 03-04.

Errores Comunes y Consejos

  • Comparar vectores de modelos distintos. Da números sin sentido, sin error visible. Fija el modelo con MODELO_EMBEDDINGS y trátalo como parte del esquema de tus datos.
  • Interpretar la similitud como probabilidad universal. Un 0.75 no significa "75% relevante": la escala depende del modelo. Compara siempre dentro del mismo modelo, y calibra umbrales empíricamente con tu conjunto de evaluación.
  • Olvidar input_type (en modelos que lo soportan): embeber preguntas como si fueran documentos degrada la recuperación de forma silenciosa.
  • Embeber texto a texto en bucle. Las APIs aceptan lotes; embeber uno a uno multiplica la latencia y a veces el coste. Reserva el bucle para la pregunta del usuario (que llega de una en una, inevitablemente).
  • Umbral de caché semántico demasiado generoso. Empieza en 0.90+, mide falsos positivos con casos del tipo "preguntas parecidas con respuesta distinta" (los adversariales suaves de 02-04 son buenos candidatos) y baja solo con evidencia.
  • Consejo: guarda junto a cada vector el texto que lo originó. Un vector sin su texto es irrecuperable — no hay operación "des-embeber".

Ejercicios

  1. Top-k con umbral mínimo. Modifica buscar() para que acepte un parámetro umbral_minimo y descarte los fragmentos cuya similitud quede por debajo, aunque eso deje menos de k resultados. Pruébala con la pregunta "¿puedo pagar con criptomonedas?" (que no tiene respuesta en el mini-corpus) y observa qué devuelve.
  2. Keyword vs. semántica. Implementa una función buscar_keywords(pregunta, k) que puntúe cada fragmento por número de palabras en común con la pregunta (ignorando mayúsculas y palabras de menos de 4 letras). Compárala con buscar() para las preguntas "no puedo entrar en mi cuenta" y "token de la API": ¿cuál gana en cada caso y por qué?
  3. Calibrar el caché semántico. Con CacheSemantico, guarda la respuesta a "¿cómo restablezco mi contraseña?" y prueba a recuperarla con: (a) "olvidé mi password, ¿qué hago?", (b) "¿cómo cambio mi contraseña?", (c) "¿cómo invito a un usuario?". Prueba umbrales 0.80, 0.90 y 0.95 y anota en una tabla qué combinaciones aciertan y cuáles producen falsos positivos.

Soluciones

  1. Basta con filtrar antes de cortar:
def buscar(pregunta: str, k: int = 3, umbral_minimo: float = 0.0):
    v_pregunta = embed([pregunta], tipo="query")[0]
    puntuados = [
        (similitud_coseno(v_pregunta, v), f)
        for v, f in zip(VECTORES, FRAGMENTOS)
    ]
    puntuados.sort(reverse=True)
    filtrados = [(s, f) for s, f in puntuados if s >= umbral_minimo]
    return filtrados[:k]

Con "¿puedo pagar con criptomonedas?" y un umbral razonable (p. ej. 0.5), la lista queda vacía o casi: no hay documentación relevante, y devolver una lista vacía es más honesto que devolver ruido. Guarda esta idea — en 04-04 será la pieza que dispare la frase anti-alucinación de SYSTEM_DOCUBOT ("No encuentro esa información en la documentación disponible.") cuando la recuperación no encuentre nada digno.

  1. Una versión mínima:
def buscar_keywords(pregunta: str, k: int = 3):
    palabras = {p for p in pregunta.lower().split() if len(p) >= 4}
    puntuados = []
    for frag in FRAGMENTOS:
        frag_palabras = set(frag.lower().split())
        puntuados.append((len(palabras & frag_palabras), frag))
    puntuados.sort(reverse=True)
    return puntuados[:k]

Resultado esperado: para "no puedo entrar en mi cuenta", buscar_keywords empata casi todo a 0-1 coincidencias (solo "cuenta" ayuda, y aparece en varios fragmentos) mientras la semántica acierta de pleno. Para "token de la API", la keyword gana o empata: "token" y "API" son términos exactos que aparecen literalmente en el fragmento correcto. Conclusión: la semántica domina en lenguaje natural de usuario; la literal sigue siendo fuerte en terminología exacta.

  1. Resultados típicos (los tuyos variarán según el modelo):
Pregunta de prueba Sim. típica 0.80 0.90 0.95
(a) "olvidé mi password, ¿qué hago?" ~0.88 ✅ hit ❌ miss ❌ miss
(b) "¿cómo cambio mi contraseña?" ~0.93 ✅ hit ✅ hit ❌ miss
(c) "¿cómo invito a un usuario?" ~0.55 ✅ correcto (miss) ✅ correcto (miss) ✅ correcto (miss)

Con 0.95 el caché apenas ahorra; con 0.80 acierta más pero el margen de seguridad frente a pares "parecidos con respuesta distinta" se estrecha. 0.90 suele ser el compromiso inicial razonable — y la cifra definitiva debe salir de medir sobre el conjunto de evaluación, no de este ejercicio.

Conclusión

Hoy los embeddings han dejado de ser una metáfora de 01-02 para convertirse en tres funciones que caben en una pantalla: embed() convierte texto en coordenadas de significado, similitud_coseno() mide el ángulo entre dos de esas coordenadas, y buscar() encuentra los top-k fragmentos de la documentación de Nubelia más cercanos a cualquier pregunta — incluida "no puedo entrar", que no comparte ni una palabra con la respuesta que necesita. Hemos visto por qué esto supera a la búsqueda por palabras clave (y cuándo no), qué criterios pesan al elegir modelo de embeddings (independiente del LLM, con sus vectores incompatibles entre modelos), y hemos cobrado el regalo prometido en 03-04: CacheSemantico, que reconoce preguntas equivalentes por significado con un umbral calibrado con cuidado. Pero nuestra búsqueda tiene dos vergüenzas que hemos barrido bajo la alfombra: compara la pregunta contra todos los vectores en cada consulta, y esos vectores viven en una lista de Python que desaparece al reiniciar el proceso — con cientos de páginas de documentación real, ninguna de las dos cosas es aceptable. Resolver ambas es el trabajo de las bases de datos vectoriales, y es exactamente lo que construimos en la siguiente lección (04-02).

© Copyright 2026. Todos los derechos reservados