El coseno que un topógrafo ya sabe (y por qué mueve un buscador de significado)
Esta es la primera lección de un proyecto real: reescribir en Haskell un buscador semántico —=brain-search=, que encuentra notas "por significado" en vez de por palabras exactas—. Pascual, el alumno, es topógrafo. Y en la primera clase pasó algo bonito: descubrió que el corazón matemático de un buscador de inteligencia artificial es una fórmula que él ya usaba en el campo, midiendo terreno, sin saber que se llamaba así.
Este post es ese razonamiento entero. Las fórmulas, el porqué, y el salto final que lo convierte en magia: las muchas dimensiones.
El problema: buscar por significado, no por palabras
Un buscador normal (Ctrl-F) busca la palabra exacta. Si escribes "coche" no encuentra la nota que habla de "automóvil". Un buscador semántico sí: entiende que las dos hablan de lo mismo.
¿Cómo? Convierte cada texto en un vector —una lista de números— llamado embedding. Y aquí está la idea clave: dos textos que significan lo mismo producen vectores que apuntan hacia el mismo lado. Textos sin relación, vectores que apuntan a lados distintos.
Así que el problema "¿estos dos textos hablan de lo mismo?" se convierte en "¿estas dos flechas apuntan en la misma dirección?". Y eso, resulta, es pura topografía.
El coseno que un topógrafo ya conoce
En topografía trabajas con rumbos (o azimuts): la dirección hacia la que apunta una alineación, medida en grados. Dos direcciones pueden:
- apuntar al mismo sitio → 0° de diferencia
- ser perpendiculares → 90°
- apuntar al revés → 180°
El coseno es simplemente el número que resume "cuánto coinciden dos direcciones", entre -1 y 1:
| Ángulo entre las dos direcciones | Coseno | En topógrafo |
|---|---|---|
| 0° (mismo rumbo) | 1 | apuntan al mismo sitio |
| 90° (perpendicular) | 0 | no tienen nada que ver |
| 180° (opuesto) | -1 | apuntan justo al revés |
cos(0°)=1, cos(90°)=0,
cos(180°)=-1. El coseno es un medidor de "cuánto se parecen
dos rumbos", comprimido a un solo número.
Dibujado, con dos flechas saliendo del mismo punto:
mismo rumbo (0°) perpendicular (90°) opuesto (180°)
coseno = 1 coseno = 0 coseno = -1
b b
↗ ↑
↗ │
a,b → └────→ a ←──── b a ────→
(van juntas) (nada que ver) (justo al revés)
Distancia vs coseno: dos preguntas distintas
Aquí Pascual hizo la pregunta correcta: /"si tengo dos coordenadas, lo que me interesa es la distancia entre ellas, no el ángulo. ¿Qué sentido tiene el coseno?"/.
Y tiene toda la razón —para replantear terreno—. Pero son dos preguntas diferentes:
| Medida | Qué responde | En topografía |
|---|---|---|
| Distancia | ¿Cuán LEJOS está un punto de otro? | la reducida entre estacas |
| Coseno | ¿Apuntan en la MISMA dirección? | ¿mismo rumbo dos alineaciones? |
Para buscar por significado queremos el coseno, no la distancia. Y hay una razón muy concreta: la longitud del vector de un texto depende de cosas que no nos importan —sobre todo de cuánto texto hay—.
Imagina dos notas:
- A: "el gato duerme"
- B: "gato gato gato duerme duerme duerme" (mismo tema, más largo)
Hablan de lo mismo. Pero la flecha de B es mucho más larga. Si midieras por distancia, dirías "están lejos, son distintas" —error—. Si mides por coseno, dices "mismo rumbo, mismo significado" —correcto—. El coseno ignora el tamaño y se queda solo con la dirección. Justo lo que un topógrafo hace cuando comprueba si dos alineaciones son paralelas sin importarle cuál es más larga.
¿Y por qué no usar la tecla `cos` de la calculadora?
Otra pregunta afilada: "al final estamos calculando un coseno… ¿eso no es con la función cos de siempre?".
Ojo al matiz, porque es la clave de todo:
- La
cosde la calculadora toma un ángulo y te da un número.cos(30°) = 0.866. Necesita el ángulo de entrada. - Lo que construimos toma dos vectores y te da el coseno del ángulo entre ellos sin calcular nunca el ángulo.
Nos saltamos el ángulo por completo. Metemos dos flechas, sale el coseno directamente. ¿Cómo es posible? Por un teorema del producto escalar que un topógrafo reconoce:
a · b = |a| · |b| · cos(θ)
El producto escalar de dos vectores es igual a sus longitudes multiplicadas por el coseno del ángulo entre ellos. Despejamos el coseno:
cos(θ) = (a · b) / (|a| · |b|)
Eso es todo. Esa fórmula te da cos(θ)
directamente de las coordenadas, sin que averigües
cuánto vale θ. a·b es el producto escalar;
|a|·|b| son las longitudes. No hace falta
transportador.
El salto: no dos dimensiones, MUCHAS
Y aquí está la parte que se le escapaba a Pascual, y que lo cambia todo.
En 2D podrías, si quisieras, medir el ángulo con un transportador y
usar la cos normal. Pero los embeddings de verdad
no viven en 2 dimensiones. Viven en cientos. El modelo
que usamos (bge-m3) produce vectores de 1024 números
cada uno. Otros modelos, 384, 768, 1536.
En un espacio de 1024 dimensiones no existe el transportador. No puedes "medir los grados" entre dos flechas de 1024 componentes —no hay forma física de dibujar ese ángulo, ni instrumento que lo mida—.
Pero la fórmula (a·b) / (|a|·|b|) funciona en
cualquier número de dimensiones. Da exactamente igual que sean
2, 3, 1024 o un millón. El producto escalar sigue siendo "multiplica
componente a componente y suma", y la norma sigue siendo "raíz de la
suma de cuadrados". La receta no cambia; solo hay más números en la
lista.
Por eso construimos el coseno a mano con producto
escalar y norma, en vez de usar la tecla cos: porque la
cos necesita un ángulo que en 1024 dimensiones no podemos
ni medir, y nuestra fórmula lo esquiva elegantemente. La misma idea de
rumbos del campo, estirada a un espacio que no podemos ver pero sí
calcular.
El vector es siempre "una lista de números". Lo que cambia es cuántos:
en el campo (2D): un rumbo = [ Este, Norte ] → [3.0, 4.0]
2 números
un texto (embedding): significado = [ n1, n2, n3, ... n1024 ] → [0.02, -0.7, ...]
1024 números
dot = multiplica componente a componente y suma ── funciona con 2 o con 1024
norm = raíz de la suma de cuadrados ── funciona con 2 o con 1024
cosine = dot / (norm·norm) ── funciona con 2 o con 1024
No hay transportador para 1024 grados de libertad. Pero sí hay
sum, zipWith (*) y sqrt. La
flecha no la puedes dibujar; el coseno sí lo puedes calcular.
Esa es la gracia. Un topógrafo mide direcciones en un plano. Un buscador semántico mide "direcciones de significado" en un espacio de mil dimensiones. La matemática es idéntica. Solo cambia cuántos números tiene cada flecha.
El código: tres piezas de LEGO
En Haskell, todo esto son tres funciones minúsculas que encajan. Nada de fórmulas asustadoras: cada pieza hace una cosa.
import Data.List (foldl')
-- producto escalar: empareja, multiplica pareja a pareja, suma
dot :: [Double] -> [Double] -> Double
dot a b = sum (zipWith (*) a b)
-- norma (longitud): raíz de la suma de cuadrados. Un vector consigo
-- mismo en dot ya da la suma de cuadrados; bonito reuso.
norm :: [Double] -> Double
norm v = sqrt (dot v v)
-- el coseno: junta las dos piezas. Es la fórmula despejada de arriba.
cosine :: [Double] -> [Double] -> Double
cosine a b = dot a b / (norm a * norm b)Léelo despacio:
zipWith (*) a bempareja las dos listas y multiplica cada pareja.zipWith (*) [3,4] [4,3]da[12,12].sumsuma esa lista.dot [3,4] [4,3]da24.norm v = sqrt (dot v v)reusadot: un vector por sí mismo da la suma de cuadrados, y la raíz de eso es la longitud.cosinees la fórmula del teorema, tal cual.
Y lo importante: estas funciones son puras. Mismos vectores de entrada, mismo número de salida, siempre. Sin efectos, sin mundo exterior. El sitio perfecto para empezar a aprender Haskell.
Verificación: mismo número, dos caminos
Para probar que nuestro cosine y la cos de
siempre son lo mismo, comparamos (cuando SÍ conocemos el ángulo, en
2D):
| Ángulo | cos (necesita el ángulo) |
nuestro cosine (solo vectores) |
|---|---|---|
| 0° | cos 0 = 1.0 |
cosine [1,0] [1,0] = 1.0 |
| 60° | cos (pi/3) = 0.5 |
cosine [1,0] [0.5,0.866] = 0.5 |
| 90° | cos (pi/2) = 0.0 |
cosine [1,0] [0,1] = 0.0 |
El mismo número por dos rutas: una parte del ángulo, la otra de las coordenadas. En 2D coinciden y se puede comprobar. En 1024 dimensiones solo existe la segunda ruta —y sigue funcionando—.
Cómo se "depura" código puro (sin debugger)
Un apunte de método, porque sorprende a quien viene de lenguajes imperativos. Para código puro no hacen falta breakpoints. Se depura descomponiendo y evaluando piezas en el REPL:
cosine [3,4] [4,3] → 0.96 ¿de dónde sale?
zipWith (*) [3,4] [4,3] → [12,12] (empareja y multiplica)
dot [3,4] [4,3] → 24.0 (suma)
norm [3,4] → 5.0 (longitud)
norm [3,4] * norm [4,3] → 25.0 (denominador)
24 / 25 → 0.96 (el resultado)
Como cada función siempre da lo mismo, no hay estado escondido que te engañe. Miras la pieza que sospechas y ya. En Haskell puro, descomponer es depurar.
Lo que queda
Esto es solo la lección 1: el corazón matemático, una función pura. El proyecto sigue —tokenizar textos, hablar con el modelo de embeddings por HTTP (el salto a los efectos, a IO), cachear vectores, montar el buscador entero— y cada pieza es una lección de Haskell con una recompensa real: una herramienta que se va a usar de verdad.
Pero la lección de hoy es la que más me gusta, porque no es sobre Haskell: es sobre reconocer que una idea que ya tenías —medir si dos direcciones coinciden— es exactamente la que mueve la inteligencia artificial que busca por significado. Solo que ella lo hace en mil dimensiones en vez de en un plano. La matemática, la misma.
Las funciones puras no tienen prisa. Y el mejor concepto nuevo es el que descubres que ya sabías.
— Haskell-Sensei, para Pascual, verano de 2026.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario