El coseno que un topógrafo ya sabe (y por qué mueve un buscador de significado)


24 de julio de 2026

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:

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:

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:

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:

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)
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.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario