De 67% a 80%: construir una búsqueda semántica sobre NudaUI y medirla en serio
Cómo construí un RAG sobre los 1022 componentes de NudaUI, por qué medí antes de tocar nada, y qué pasó cuando enriquecí los embeddings con el CSS. Con los núme
Santiago Gómez

Hace tiempo que quería dejar de integrar LLMs por encima y construir un sistema de retrieval de verdad, uno que pudiera medir y defender. La diferencia entre "conecté una API y responde con mis documentos" y "monté un RAG, saqué un número base, lo mejoré y demostré que subió" es enorme, y es justo la que separa a quien dice que sabe de RAG de quien lo sabe. Así que agarré un corpus que ya era mío y me puse.
El resultado está vivo en nudaui.dev. Es una búsqueda semántica sobre los 1022 componentes de NudaUI, mi librería de animaciones CSS. Escribes "un loader con puntos" y te devuelve los componentes que encajan, aunque no uses ninguna de las palabras exactas del catálogo. Este post es cómo lo construí, y sobre todo cómo lo medí, porque esa parte es la que casi nadie cuenta.

El problema
NudaUI tiene 1022 componentes repartidos en 81 categorías. Encontrar "ese loader de puntitos que vi el otro día" navegando categoría por categoría es un dolor. La búsqueda por palabra clave tampoco sirve de mucho: si el componente se llama "Pulse Dots" y tú buscas "cargando con círculos", un Ctrl+F no te lo encuentra jamás. Ese es exactamente el hueco que llena la búsqueda semántica. En vez de comparar texto con texto, compara significado con significado.
La mecánica de fondo es simple una vez que la ves. Cada componente se convierte en un vector, una lista de números que representa su significado. Tu consulta también se convierte en un vector. Y buscas cuáles componentes tienen el vector más parecido al de tu frase. "Parecido" aquí se mide con similitud coseno, que no es más que qué tan alineados apuntan dos vectores. Nada de esto entrena ni aprende nada, un malentendido que yo mismo tenía al principio. No hay un modelo que mejore con el uso. Hay un modelo de embeddings que ya existe (usé Voyage), y lo único que construyes es la tubería que le pasa la información correcta.
Dos fases, y por qué las separo
El sistema tiene dos momentos que conviene no mezclar.
La indexación pasa una sola vez. Leo el catálogo, convierto cada componente en un vector y lo guardo. Si mañana agrego 50 componentes, genero solo esos 50 y los sumo. No se reconstruye nada más.
La búsqueda pasa en cada consulta. Llega la frase del usuario, la convierto en vector con el mismo modelo, y la comparo contra los 1022 guardados. Devuelvo los más cercanos.

Escribí la similitud coseno a mano, sin LangChain ni LlamaIndex. Para un corpus de este tamaño, recorrer los 1022 vectores en cada búsqueda es más que rápido, y hacerlo a mano significa que entiendo cada paso en vez de tenerlo escondido detrás de una abstracción. Cuando en una entrevista me pregunten qué hace el framework por dentro, voy a poder responder, porque lo escribí yo. Saber cuándo no necesitas la herramienta pesada es tan valioso como saber usarla.
El baseline, y por qué era mediocre a propósito
La primera versión embebía cada componente usando su nombre más la etiqueta y la descripción de su categoría. Funcionaba, pero tenía un problema estructural que descubrí en cuanto empecé a probar.
Busqué "loader con puntos" y el sistema me devolvió cinco loaders, bien, pero puso "Rotating Squares" (que son cuadrados) por encima de "Pulse Dots" (que son justo lo que pedí). ¿Por qué? Porque los 23 loaders comparten la misma descripción de categoría. Para el buscador, "Pulse Dots" y "Rotating Squares" son casi idénticos: comparten todo el texto de la categoría Loaders y solo difieren en dos palabras del nombre. El sistema acertaba la categoría pero fallaba el ranking fino dentro de ella. Le faltaba información propia de cada componente.
Aquí viene la parte importante, y es la que me costó resistir: no toqué nada para arreglarlo. Todavía no.
Medir antes de tocar
El error más común en esto es meter mano por corazonada. Ves que "Rotating Squares" gana mal y tu instinto de ingeniero quiere arreglarlo ya. Pero si ajustas antes de medir, ¿contra qué comparas para saber si mejoraste? A ojo siempre parece que mejora, porque arreglas la búsqueda que estás mirando y no ves las diez que rompiste sin querer. Eso es programar con vibes. La disciplina es al revés: primero congelas un número base, con todos sus fallos, y ese número feo es sagrado.
Para sacarlo armé un golden dataset, que es solo una lista de consultas de prueba con sus respuestas correctas marcadas a mano. 45 consultas repartidas en 15 categorías, mezclando las literales ("barra de progreso con rayas") con las conceptuales ("algo para mostrar que está cargando sin decir el porcentaje"). Muchas las escribí en español aunque el catálogo está en inglés, para que el número también reflejara que el sistema cruza idiomas.
Etiquetar esto tiene sus trampas y aprendí un par. La primera: las respuestas correctas las decides pensando como usuario, nunca copiando lo que el sistema ya devuelve. Si marcas como correcto lo que el buscador te dio, te estás evaluando contra ti mismo y el número no significa nada. La segunda: el catálogo tiene componentes duplicados o casi idénticos, así que la respuesta correcta de una consulta no es un componente, es un conjunto de componentes aceptables. Y la tercera, que me ahorró un susto: antes de medir, un script valida que cada id que marqué exista de verdad en el catálogo. Un id mal escrito es imposible de acertar y te hundiría el número por la razón equivocada.
El baseline quedó así:
| Métrica | Baseline (nombre + categoría) |
|---|---|
| hit@1 | 66.7% |
| hit@3 | 86.7% |
| hit@5 | 93.3% |
| recall@5 | 54.9% |
hit@k es en qué fracción de consultas aparece al menos una respuesta correcta entre los primeros k resultados. La escalera lo dice todo: el sistema casi siempre metía una buena respuesta en el top 5 (93%), pero solo dos de cada tres veces la ponía primera (67%). Ese hueco de 26 puntos entre hit@1 y hit@5 era exactamente el "acierta la categoría, falla el ranking fino" que había visto a ojo, ahora con cifra.
La mejora: el CSS como señal, sin IA
Si el problema era que a los componentes les faltaba información propia, la pregunta era de dónde sacarla sin inventar. La respuesta estaba escrita en el propio código. El CSS de Pulse Dots dice border-radius: 50% (son círculos) y tiene keyframes de pulso. El de Rotating Squares no. Esa diferencia es justo lo que el buscador necesitaba para distinguirlos.
NudaUI expone un endpoint con el código completo de cada componente (lo construí en su día pensando en agentes, resulta que el agente terminé siendo yo). Así que enriquecí cada embedding sumándole el cssInline del componente, esa versión compacta del CSS que ya tenía a mano. Nada de LLM en este paso, lo cual importa: el enriquecido corre una sola vez, es reproducible por cualquiera que clone el repo sin pedirme una API key de Anthropic, y no me cuesta un centavo de inferencia. Es la idea de los contextual embeddings de Anthropic en su forma más barata: darle a cada documento el contexto que le falta antes de indexarlo.
Volví a correr exactamente la misma evaluación, con el mismo golden dataset. Este es el antes y después:

| Métrica | Baseline | Enriquecido (+ CSS) |
|---|---|---|
| hit@1 | 66.7% | 80.0% |
| hit@3 | 86.7% | 91.1% |
| hit@5 | 93.3% | 95.6% |
| recall@5 | 54.9% | 59.4% |
hit@1 subió 13 puntos. Esa era la métrica que sufría, la de "acerté a la primera", y el CSS la arregló. Todas subieron a la vez, que es la señal de una mejora real y no de mover un número a costa de otro. Y en el desglose por categoría estaba la confirmación de que mi hipótesis era correcta: loaders pasó de 67% a 100%, skeletons de 67% a 100%. Las dos categorías que fallaban por confundir componentes dentro del mismo grupo fueron justo las que el CSS curó.
Lo que salió mal, y por qué lo cuento
Aquí es donde la mayoría de los posts terminan con el número bonito. El mío no, porque la evaluación cazó algo que no quiero esconder: text-effects empeoró, de 100% a 67%.

¿Qué pasó? Muchos efectos de texto comparten propiedades de CSS parecidas (filter, opacity, transiciones) mientras hacen cosas visualmente distintas. Meter el CSS acercó componentes que se escriben parecido pero significan cosas diferentes. La lectura honesta no es "el enriquecido es bueno", es "el enriquecido sube la media y arregla las categorías donde la distinción es de estilo, pero mete ruido donde la distinción es semántica". Ese matiz, con el "pero" incluido, vale más en una entrevista que el titular limpio, porque demuestra que no me creo mi propio número a ciegas.
Hubo otro aprendizaje escondido. Una consulta ("algo cargando sin porcentaje") salía como fallo, pero al mirar los resultados eran loaders indeterminados que cualquier usuario aceptaría. El fallo no era del sistema, era de mi ground truth, que había sido demasiado estrecho al etiquetar. Los golden datasets también tienen bugs. Un fallo de evaluación a veces te dice que tu etiqueta estaba mal, no que el modelo lo esté.
Desplegarlo casi gratis
Envolví el retriever en una API con FastAPI y lo desplegué en Vercel como contenedor. Vercel apaga el servicio tras unos minutos de inactividad, y para un proyecto de portafolio ese "escalar a cero" no es un defecto, es lo que lo mantiene gratis. La primera búsqueda tras un rato tarda un par de segundos en despertar, y a partir de ahí va rápido. Como el archivo de embeddings es estático, se hornea dentro de la imagen y no necesito almacenamiento persistente. La key de Voyage vive como variable de entorno, nunca en el código.
Lo que me llevo
RAG es fácil de montar para una demo y difícil de hacer bueno. La demo la tiene cualquiera en una tarde. Lo que cuesta, y lo que de verdad importa, es la parte que no se ve: el golden dataset, la disciplina de congelar el baseline, medir cada cambio contra el mismo set, y tener el estómago de reportar la categoría que empeoró. Los evals no son un extra que le pones al final a un RAG. Son la diferencia entre creer que funciona y saber que funciona.
Y una lección más pequeña pero que me gustó: la mejora más grande no vino de un modelo más caro ni de un framework de moda. Vino de mirar los datos que ya tenía, entender por qué el baseline fallaba, y darle al sistema la señal que le faltaba. Gratis, reproducible, medido.
Puedes probar la búsqueda tú mismo en nudaui.dev y el código completo está en GitHub. Pruébala con la frase más rara que se te ocurra, la API está viva en nudaui.dev.
