Embeddings: el significado como vectores
En resumen: Un embedding es un vector de números para una palabra o un texto, de modo que los textos de significado cercano tienen vectores cercanos, y la cercanía se mide con el coseno del ángulo.
Problema
Búsqueda por palabras
El bot de soporte tiene una base de respuestas ya hechas: un texto para la entrega, otro para la devolución y otro para el pago. El cliente escribe una pregunta, y el bot tiene que encontrar el texto que la responde.
# la base de respuestas del soporteDOCS = [ ("entrega", "la entrega tarda hasta dos días"), ("devolver", "devuelve el producto en 14 días y recibe el dinero"), ("pago", "paga con tarjeta o contra reembolso"),]
# las preguntas de los clientesQUESTIONS = [ "cuándo recibo mi paquete", "por qué rechazaron mi pago",]Un compañero busca por palabras en común: cuantas más palabras de la pregunta estén en el texto, mejor.
# cuántas palabras de la pregunta están también en el textodef common(question, text): ws = text.split(" ") return sum(1 for word in question.split(" ") if word in ws)for q in QUESTIONS: print(q) best = None top = 0 for topic, text in DOCS: n = common(q, text) print(f" {topic:<10}{n}") if n > top: top = n best = topic print(" →", best or "ninguno")cuándo recibo mi paquete entrega 0 devolver 0 pago 0 → ningunopor qué rechazaron mi pago entrega 0 devolver 0 pago 0 → ningunoNinguna de las dos preguntas encuentra nada. «paquete» no está en ningún texto, y para el programa «pago» y «paga» son palabras distintas. Los clientes preguntan con sus propias palabras, y los textos de la base están escritos con otras.
Hace falta comparar el significado, no las letras.
La idea
El significado como dirección
A una palabra se la conoce por la compañía que tiene. «paquete» y «pedido» aparecen junto a las mismas palabras («mensajero», «días», «llegará»), así que significan algo cercano, aunque sus letras sean distintas.
Esto se escribe con números. Cada palabra se convierte en un vector: una lista de números, uno por cada eje. Las palabras de significado cercano reciben vectores cercanos, es decir, flechas que apuntan casi en la misma dirección. Un vector así, que representa significado, se llama embedding.
En la lección los ejes son las ocho palabras más frecuentes del texto de la tienda, y el número de cada eje es en cuántas frases aparece la palabra junto a la palabra del eje. Los modelos reales aprenden los vectores con una red neuronal y tienen cientos o miles de ejes, pero la idea es la misma: significado cercano, dirección cercana.
Míralo
Palabras como vectores
Las palabras cortas como «el», «la» y «de» están en todas partes y no dicen nada del tema. No son ejes y no entran en los vectores:
# las palabras cortas que están en todas partes y no dicen nada del temaSTOP = { "el", "la", "los", "las", "un", "una", "de", "del", "a", "al", "en", "con", "por", "para", "y", "o", "pero", "es", "se", "te", "tu", "me", "mi", "muy", "hasta", "antes", "después", "está", "estoy", "fue", "que", "lo", "dos", "puedes", "cuándo", "quiero",}El espacio: los ejes y el vector de una palabra. El vector de un texto es la suma de los vectores de sus palabras.
# las palabras como vectores: un número por cada ejeclass Space: # los ejes: las palabras más frecuentes, sin las cortas def __init__(self, text, stop, n): self.text = text self.stop = stop freq = {} for line in text: for w in line.split(" "): if w not in stop: freq[w] = freq.get(w, 0) + 1 self.vocab = list(freq) self.axes = sorted(freq, key=freq.get, reverse=True)[:n]
# en cuántas frases la palabra aparece junto a la palabra de cada eje def of(self, word): v = [0] * len(self.axes) for line in self.text: ws = line.split(" ") if word not in ws: continue for w in ws: if w in self.axes and w != word: v[self.axes.index(w)] += 1 return v
# el vector de un texto: la suma de los vectores de sus palabras def of_text(self, s): v = [0] * len(self.axes) for w in s.split(" "): if w not in self.stop: v = [x + y for x, y in zip(v, self.of(w))] return vspace = Space(TEXT, STOP, 8)print("ejes:", " ".join(space.axes))for word in ["paquete", "pedido", "pago"]: print(f"{word:<12}{space.of(word)}")ejes: producto pedido mensajero días entrega tarjeta llegará paquetepaquete [0, 0, 1, 1, 0, 0, 1, 0]pedido [0, 0, 1, 1, 0, 0, 2, 0]pago [0, 0, 0, 0, 0, 2, 0, 0]«paquete» está en una frase con «mensajero», una con «días» y una con «llegará». «pedido» está una, una y dos veces: los números son distintos, pero la dirección es casi la misma. «pago» está solo con «tarjeta»: una dirección completamente distinta.
Míralo
Similitud: el coseno del ángulo
Lo cerca que están dos vectores lo dice el ángulo entre ellos. El coseno del ángulo es 1 cuando apuntan en la misma dirección, y 0 cuando no tienen ningún eje en común. Dividir por las longitudes hace que solo cuente la dirección.
# coseno del ángulo: 1 misma dirección, 0 nada en comúndef cosine(a, b): dot = sum(x * y for x, y in zip(a, b)) na = sum(x * x for x in a) nb = sum(y * y for y in b) if na == 0 or nb == 0: return 0.0 return dot / (math.sqrt(na) * math.sqrt(nb))space = Space(TEXT, STOP, 8)v = space.of("paquete")print(f"paquete y pedido: {cosine(v, space.of('pedido')):.3f}")print(f"paquete y pago: {cosine(v, space.of('pago')):.3f}")others = [o for o in space.vocab if o != "paquete"]others.sort(key=lambda o: cosine(v, space.of(o)), reverse=True)print("más cerca de paquete:")for o in others[:5]: print(f" {o:<12}{cosine(v, space.of(o)):.3f}")paquete y pedido: 0.943paquete y pago: 0.000más cerca de paquete: pedido 0.943 entrega 0.816 producto 0.816 tres 0.730 dirección 0.577«paquete» y «pedido» tienen 0,943: casi la misma dirección. Con «pago» son 0,000. Las palabras más cercanas a «paquete» son «pedido» y «entrega». También aparecen «producto» y «tres»: en un texto pequeño también tiran las vecinas por casualidad.
Pruébalo
Predice la salida
¿Cuál es el coseno de [1, 1, 0] y [1, 0, 0]?
print(f"{cosine([1, 1, 0], [1, 0, 0]):.2f}")El producto escalar es 1. Las longitudes son √2 y 1, y 1 / √2 = 0,7071, redondeado 0,71. El ángulo entre los dos vectores es 45°: comparten un eje, y el primero tiene uno más.
Míralo
Búsqueda por significado
Ahora la pregunta y cada texto de la base se convierten en vectores, y la respuesta es el texto con el coseno más grande:
space = Space(TEXT, STOP, 8)for q in QUESTIONS: v = space.of_text(q) print(q) best = DOCS[0] top = -1.0 for topic, text in DOCS: c = cosine(v, space.of_text(text)) print(f" {topic:<10}{c:.3f}") if c > top: top = c best = (topic, text) print(f" → {best[0]}: {best[1]}")cuándo recibo mi paquete entrega 0.722 devolver 0.562 pago 0.000 → entrega: la entrega tarda hasta dos díaspor qué rechazaron mi pago entrega 0.000 devolver 0.162 pago 1.000 → pago: paga con tarjeta o contra reembolsoLa primera pregunta encuentra «entrega» con 0,722, mientras que «devolver» tiene 0,562. La segunda encuentra «pago» con 1,000: «pago» está en frases con «tarjeta», igual que «contra» y «reembolso» del texto de pago. Ninguna de las dos preguntas tiene una palabra en común con su respuesta.
Aquí está la primera pregunta como direcciones. Los vectores tienen 8 dimensiones y el dibujo es plano, por eso conserva solo el ángulo de cada texto con la pregunta:
# coseno del ángulo: 1 misma dirección, 0 nada en comúndef cosine(a, b): dot = sum(x * y for x, y in zip(a, b)) na = sum(x * x for x in a) nb = sum(y * y for y in b) if na == 0 or nb == 0: return 0.0 return dot / (math.sqrt(na) * math.sqrt(nb))
space = Space(TEXT, STOP, 8)for q in QUESTIONS: v = space.of_text(q) print(q) best = DOCS[0] top = -1.0 for topic, text in DOCS: c = cosine(v, space.of_text(text)) print(f" {topic:<10}{c:.3f}") if c > top: top = c best = (topic, text) print(f" → {best[0]}: {best[1]}")Paso a paso, en palabras
- La pregunta «cuándo recibo mi paquete» se convierte en un vector: [0, 0, 1, 1, 0, 0, 1, 0]. El dibujo conserva solo el ángulo hasta él.
- «pago»: coseno 0,000, ángulo 90°. Ningún eje en común: las flechas forman un ángulo recto.
- «devolver»: coseno 0,562, ángulo 56°. Tienen algunos ejes en común, pero está lejos.
- «entrega»: coseno 0,722, ángulo 44°. El ángulo más pequeño.
- Lo más cercano es «entrega»: «la entrega tarda hasta dos días». Ni una sola palabra en común con la pregunta.
Pruébalo
Completa
El coseno divide el producto escalar por las dos longitudes. ¿Qué va en lugar de ___?
return dot / (math.sqrt(na) * math.sqrt())nb, la suma de los cuadrados del segundo vector. Su raíz es la longitud del segundo vector, igual que la raíz de na es la longitud del primero.
Pruébalo
Encuentra el error
Un compañero calcula la similitud más rápido, pero obtiene 5 para la primera pregunta y el texto de la entrega, y la similitud debe estar entre 0 y 1. ¿En qué línea está el error?
Elige la línea con el error.
Devuelve el producto escalar sin dividir por las longitudes. Entonces un texto largo con muchas palabras tiene un vector grande y reúne números grandes, aunque apunte a otro lado. Debe ser dot / (√na · √nb): así solo queda la dirección.
La idea
La atención como idea
En la lección «tarjeta» tiene un solo vector, esté donde esté. Pero en «paga con tarjeta» y «una tarjeta de cumpleaños» la palabra significa cosas distintas: una tarjeta bancaria y una tarjeta de felicitación.
Los modelos reales resuelven esto con la atención (attention). Cada token mira a los demás tokens del texto y mezcla en su propio vector un poco de los de ellos, más de los que le importan. «tarjeta» junto a «paga» toma de «paga», junto a «cumpleaños» de «cumpleaños», y los dos vectores se vuelven distintos. Esto se repite a lo largo de muchas capas, y así el vector de cada token lleva su significado en justo esta frase. El vector del texto entero se calcula a partir de ellos.
Aquí la atención es solo una idea: cómo se calcula exactamente no forma parte del curso.
La idea
Cuándo duele
-
El modelo pequeño conoce palabras, no significado. Un cliente no quiere el producto, y la búsqueda lo manda a la entrega:
space = Space(TEXT, STOP, 8)for q in ["no quiero el producto"]:v = space.of_text(q)print(q)best = DOCS[0]top = -1.0for topic, text in DOCS:c = cosine(v, space.of_text(text))print(f" {topic:<10}{c:.3f}")if c > top:top = cbest = (topic, text)print(f" → {best[0]}: {best[1]}")Salida no quiero el productoentrega 0.707devolver 0.688pago 0.000→ entrega: la entrega tarda hasta dos días«no» no está en el texto y no le dice nada al modelo: no entiende la negación. Queda «producto», y sus vecinas en el texto son «días» y «llegará». Además la palabra no cuenta su propio eje, y justo «producto» es el eje más fuerte del texto de devolución. El vector de la pregunta sale
[0, 0, 0, 1, 0, 0, 1, 0]y apunta a la entrega (0,707), no a la devolución (0,688), que es la respuesta correcta. Con otro texto, la misma pregunta iría a otro sitio: los fallos dependen del texto.Un modelo de embeddings real está entrenado con una cantidad enorme de texto. Sabe que «no quiero el producto» pide una devolución, y ve la pregunta entera, no solo las palabras sueltas. Aun así puede equivocarse, pero mucho menos. Cuántas veces encuentra el texto correcto se mide: en el módulo de búsqueda por significado es recall@k, la parte de las preguntas cuyo texto correcto está entre los primeros k encontrados.
-
Cerca no significa igual. Las palabras de significado contrario suelen estar en las mismas frases:
space = Space(TEXT, STOP, 8)for x, y in [["gratis", "cuesta"], ["rápido", "tarde"]]:print(f"{x} y {y}: {cosine(space.of(x), space.of(y)):.3f}")print("color:", space.of("color"))Salida gratis y cuesta: 0.707rápido y tarde: 0.707color: [0, 0, 0, 0, 0, 0, 0, 0]«gratis» y «cuesta» tienen 0,707, porque las dos están junto a «entrega». Y «rápido» y «tarde» también tienen 0,707, porque las dos están junto a «producto». Los modelos reales lo hacen mejor, pero «puedo» y «no puedo» siguen saliendo cerca.
-
Una palabra desconocida es cero. «color» no está en el texto y su vector son solo ceros. Una pregunta solo con palabras desconocidas tiene 0,000 con todo, pero la búsqueda igual devuelve algún texto. Por eso hace falta un umbral: mira el ejercicio.
-
Los vectores salen del texto. Un texto pequeño da vecinas por casualidad («tres» junto a «paquete»). Modelos distintos dan vectores distintos que no se pueden comparar entre sí: la base y la pregunta deben usar el mismo modelo.
-
Muchos textos, muchos vectores. Con miles de documentos los vectores se guardan en una base de datos que encuentra rápido los más cercanos: bases de datos vectoriales. La búsqueda por significado sobre los documentos de la tienda es más adelante todo un módulo del curso. Cómo ejecutar un modelo de embeddings en un servidor y cómo mantener el índice al día es trabajo del curso ZeroToOps.
Más precisamente, para curiosos
Embeddings reales. Los modelos de embeddings reciben texto y devuelven cientos o miles de números. Se llaman por una API como los demás modelos, o se ejecutan en local, casi siempre desde Python. Los vectores suelen tener longitud 1, y entonces el coseno es simplemente el producto escalar.
Los ceros en los ejes. En la lección una palabra no cuenta su propio eje. Por eso la palabra de un eje que aparece solo con palabras fuera de los ejes es solo ceros: «tarjeta» es un eje, pero su vector es [0, 0, 0, 0, 0, 0, 0, 0], y la propia palabra «tarjeta» no ayuda a la búsqueda. El texto de pago se encuentra gracias a «contra» y «reembolso», que están en frases con «tarjeta». Si una palabra contara también su eje, ese sería el número más grande de su vector y tiraría de todo hacia sí: «paquete» y «pedido» bajarían de 0,943 a 0,165. Las dos elecciones son un compromiso del modelo pequeño; los modelos reales no tienen ejes que sean palabras.
Por qué coseno y no distancia. La distancia entre las puntas depende también de la longitud, y la longitud aquí es cuántas palabras tiene el texto. El coseno mira solo la dirección. Con vectores de longitud 1 los dos dan el mismo orden.
Los k más cercanos. La búsqueda de la lección es k-NN de la lección sobre los vecinos más cercanos con k = 1: solo cambian los vectores y la medida de cercanía.
En Python. math.sqrt es la raíz cuadrada, como Math.sqrt en Java y JavaScript. sum en Python suma con más exactitud que un bucle simple, pero aquí los números son enteros y el resultado es el mismo en los cuatro lenguajes.
Ejercicio
No estar seguro también es una respuesta
Resuélvelo en tu editor.
El bot no debe responder al azar. Escribe una función que devuelva el texto más cercano solo si el coseno es al menos 0,5, y si no, «no estoy seguro, se lo paso a un compañero». Pruébala con las dos preguntas de la lección y con «quiero otro color».
cuándo recibo mi paquete → la entrega tarda hasta dos díaspor qué rechazaron mi pago → paga con tarjeta o contra reembolsoquiero otro color → no estoy seguro, se lo paso a un compañeroLa tercera pregunta tiene 0,000 con todo, y en lugar de una respuesta equivocada el bot la pasa a una persona. El umbral es un compromiso: un umbral alto pasa preguntas cuya respuesta el bot conoce, y uno bajo deja pasar respuestas equivocadas.
Más tareas
- Más ejes. Construye el espacio con 16 ejes. ¿Cómo cambian las similitudes de la lección?
- Los dos primeros. Imprime los dos textos más cercanos para cada pregunta y a qué distancia está el segundo del primero.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: dataclass, match y patrones · Qué es el aprendizaje automático · El texto en trozos: tokens · Predecir el siguiente token
- Continúa en: Búsqueda semántica: los k más cercanos · Dividir documentos en fragmentos (chunking) · RAG: respuestas a partir de los documentos · Evaluar RAG: ¿encuentra el fragmento y la respuesta se apoya en él?
- Ejercicios sobre el tema: Cómo funciona un modelo de lenguaje