Saltar al contenido

devlog

Decisiones, y lo que salió mal

Los mensajes de commit tienen el detalle técnico. Esto es el porqué: qué probé, qué descarté y con qué números lo decidí. Lo más reciente arriba.

Está en primera persona porque este sitio es mío, pero no lo he escrito todo yo sola: cómo trabajo, incluida la parte que hago apoyándome en IA, está en about.

Guardo las preguntas que no sé responder

Toda la conclusión de este proyecto es que ampliar el corpus rinde más que cambiar el algoritmo — seis veces más, medido. Pero hasta ahora decidía qué ampliar a ojo, porque cuando alguien preguntaba algo que no sé, esa pregunta desaparecía al cerrar la pestaña.

Ahora se guarda. Y nada más:

{ "q": "…", "score": 0.09, "lang": "es", "ts": "…" }

Ni IP, ni user-agent, ni cookie, ni sesión, ni huella. No se puede saber quién preguntó qué, ni juntar dos preguntas de la misma persona. Las que sí contesto no se guardan: sólo me interesan los huecos.

Una función serverless en /api, no en src/pages/api, para no meterle un adaptador a Astro: las doce páginas siguen siendo estáticas en el CDN y sólo esto es servidor. Si faltan las credenciales devuelve 204 sin guardar nada, así que el sitio funciona igual con el registro apagado.

Para leerlo, python model/huecos.py: agrupa las repetidas —esas son los documentos que faltan— y separa las que se quedaron rozando el umbral, que suelen ser preguntas que el corpus ya responde con otras palabras y se arreglan con variantes en vez de documentos nuevos.

Lo que dejó de ser cierto

El pie de la web decía «sin backend · sin analytics». Guardar preguntas es analítica, así que ese texto pasaba a ser mentira. Ahora dice «sin cookies · sin seguimiento», que sigue siendo verdad y además dice más. Y /about explica exactamente qué se guarda y por qué.

Al cambiarlo salió otro fallo: ese texto estaba escrito a pelo en vez de usar la clave traducida, así que la web inglesa llevaba tiempo enseñando «sin backend · sin analytics» en español. Las auditorías anteriores no lo cazaron porque «backend» y «analytics» parecen palabras inglesas.

El bug que no habría dado ningún error

El código buscaba las credenciales como UPSTASH_REDIS_REST_*. Vercel, al crear la base de datos desde su panel, las llama KV_REST_API_*.

Y el endpoint interpreta «sin credenciales» como «registro apagado». Habría tenido la base de datos creada, conectada y vacía, tirando cada pregunta al suelo sin un solo aviso — y lo habría descubierto dentro de un mes al abrir la lista y verla en blanco. Ahora acepta las dos formas.

Proyectos, stack, y la web se parece más a lo que documenta

Faltaba lo más básico: no había dónde ver mi código. El modelo sabía hablar de tokpipe y de redmoon, pero quien quisiera el repo no lo encontraba.

/proyectos, dividido en dos grupos porque son dos oficios distintos: data (cinco pipelines y cuatro análisis, todos públicos) y dev (los tres intentos de mover teclado y portapapeles entre macOS y Windows, safeshell, y zero-waterfaks). Los tres del KVM van en una sola ficha a propósito: por separado parecen tres proyectos abandonados, juntos son una iteración documentada.

Los labs del bootcamp se quedan fuera. Son ejercicios, y meterlos taparía lo que sí es un proyecto.

stack en /about, agrupado por uso real: a diario, en proyectos, y sintaxis prestada. Sin barras de porcentaje — un «Python 90%» no lo verifica nadie, y en una web que publica su matriz de confusión quedaría fuera de sitio. Cada fila lleva su criterio a la vista, porque «nivel intermedio» no significa nada y «lo he usado de principio a fin» sí se puede comprobar.

Esa tercera fila la escribí primero como me apoyo en IA y luego como no lo escribo solo, y las dos estaban mal por el mismo motivo: se leen como «esto no sé hacerlo», y eso es falso. Entiendo el problema y sé cómo se resuelve; lo que no tengo es la sintaxis de un lenguaje que no toco a diario. La diferencia no es de matiz — es la que hay entre delegar el pensamiento y delegar la mecanografía, y sólo una de las dos merece que la escondas.

Cambiarlo obligó a tocar cuatro superficies: la ficha, un comentario del CSS, esta entrada y el documento del corpus, que es el que de verdad contesta cuando alguien lo pregunta en el chat. Y el inglés no se toca a mano: sale de traducir_corpus.py, así que editar corpus.en.jsonl directamente habría durado hasta la siguiente regeneración.

Composición. Las cuatro páginas de documentación pasan a tener índice fijo a la izquierda con la sección activa marcada, cabecera centrada y columna de lectura estrecha. La referencia son los artículos de Anthropic, pero solo la arquitectura de la información: nada de serif ni de fondo claro. Un serif aquí sería copiar la firma de Claude, y esta web defiende justo lo contrario — que el modelo es mío.

Tipografía. Tenía monoespaciada en todo, así que había dejado de significar nada. Ahora la mono marca dato —scores, ids, métricas, versiones, tablas— y el resto va en sans. Nada visible baja de 0.78rem; lo que había antes hacía que la web pareciera un log en vez de un producto.

Y tres cosas que llevaban roto tiempo sin que nadie las viera:

  • /en/eval servía la evaluación del modelo español. No era un texto sin traducir: enseñaba otras preguntas, otros fallos y otro umbral. El informe inglés se generaba en cada entrenamiento y no lo leía nadie. Ahora /eval da 95% y /en/eval da 100%, que son sus cifras de verdad.
  • El nav se partía en móvil: «model card» saltaba a una segunda línea y el botón de tema se salía de pantalla.
  • El favicon no existía. 404 en todas las páginas desde el primer día.

Los modelos pasan a llamarse por su etapa del pipeline

aroa-1, aroa-2 y aroa-3 no decían nada. Un número no explica qué hace un modelo, y obligaba a leer el párrafo entero del model card para distinguir uno de otro.

Los nombres nuevos son las etapas de un motor de búsqueda:

antesahoraqué es
aroa-1aroa indexTF-IDF. En producción
aroa-2aroa vectorEmbeddings. Descartado
aroa-3aroa rankBM25. Descartado
aroa-1-proaroa inferLLM en el navegador. Opcional
aroa serveAPI. Aún sin construir

No es sólo estética. index es literalmente lo que produce train.py: un índice invertido de término a documentos, con pesos. El nombre deja de prometer un razonamiento que no existe, y puestos en fila los cinco son el pipeline entero de un buscador — que es lo que hay debajo de esta web.

Dos bugs que aparecieron al hacerlo.

El primero, en el historial: había dos entrenamientos distintos etiquetados ambos como 1.0.0, en español y en inglés. Al ampliar el corpus no se subió la versión, así que el historial mostraba dos entradas con el mismo número y métricas diferentes. Corregido a 1.0.0 y 1.1.0.

El segundo, en la comparativa: vector y rank seguían evaluados contra el corpus anterior, mientras que index ya estaba reentrenado sobre el nuevo. La tabla del model card comparaba modelos que no habían visto los mismos datos — que es exactamente el error que esa tabla existe para no cometer. Los dos se han reentrenado sobre el corpus actual (1.0.1) y sus artefactos se renombraron sin tocar las fechas originales del historial, para no falsear cuándo se probó cada cosa.

Las entradas anteriores de este devlog siguen diciendo aroa-1. Se quedan así: era el nombre que tenía entonces.

Datos falsos corregidos, corpus a 63 documentos, y dos algoritmos descartados

Lo más serio del día: había datos falsos publicados sobre mí.

  • El corpus decía “vivo en Barcelona”. Vivo en Valencia.
  • /about afirmaba que escucho “Masego, Tom Misch, Khruangbin, Men I Trust”. No escucho música con letra ni tengo artistas favoritos.

Los dos venían de la misma fuente: textos rescatados de mi sitio anterior a la migración a Astro, que se dieron por buenos sin verificar. Al auditar el resto del grafo de /about contra el código aparecieron dos más: decía que el pixel art aparece en esta web (no hay ni una imagen en el repo) y que hay cosas escondidas en el código (no hay ninguna).

Regla que sale de aquí, escrita ya en el propio código: ninguna afirmación sobre mí entra en el corpus sin que yo la confirme. El corpus ya tenía el mecanismo (revisar: true); lo que faltaba era aplicarlo también al contenido heredado.

Corpus: 50 → 63 documentos. Doce salidos de una encuesta que contesté yo (hardware, software, música, videojuegos, skincare, comida, asignaturas, recursos, cómo graba, haters, edad, idiomas) más color y animal favoritos. Cinco colisiones arregladas, todas del mismo patrón: en preguntas cortas la plantilla pesa más que el sustantivo, así que “cuál es tu película favorita” caía en el documento de comida.

Dos algoritmos probados y descartados con datos delante:

accuracycoberturarechazoútil*
TF-IDF (se queda)96,8%90,5%88%87,6%
BM25 crudo95,2%95,2%80%90,7%
BM25 normalizado95,2%79,4%100%75,6%
embeddings (e5-small)95,2%73,0%84%69,5%

* útil = cobertura x accuracy: de las preguntas legítimas, cuántas acaban con la respuesta correcta.

Y la conclusión que más vale de todo el proyecto: mientras los dos cambios de algoritmo se movían dentro del ruido, el mismo TF-IDF sin tocar una línea pasó de 71% a 88% de útil sólo por ampliar y limpiar el corpus. Trabajar los datos dio seis veces más mejora que cambiar el algoritmo.

El modo generativo (aroa-1-pro) estaba roto, y no era el prompt. Medido en el navegador: Llama-3.2-1B se negaba a contestar “hola”, contradecía el documento que se le pasaba (“No soy Aroa Xinping, soy un modelo de lenguaje”) y mezclaba documentos en masculino. Cambiado a Qwen2.5-3B, que responde bien — pero cuesta 2 GB de descarga y más de dos minutos la primera respuesta. Queda como demostración técnica, no como buena experiencia de uso; está dicho así en el código.

Tres ramas explorando cómo dar el salto a generativo

main se queda en TF-IDF puro + Llama local opcional (aroa-1-pro) — cero coste, cero backend. Para explorar “que suene más a mí” o “que razone de verdad” sin comprometer eso, se abrieron tres ramas en paralelo, cada una probando un camino distinto:

RamaQué pruebaCosteEstado
feat/finetune-voiceAfinar un modelo con LoRA sobre la voz real€0 (Colab gratis)Mergeado. Pendiente: ejecutar el notebook. Ojo — el fine-tuning ajusta el tono, no la velocidad, así que no resuelve la latencia medida el 16
feat/api-generativoRAG de verdad con Claude Haiku detrás de un backend (Vercel Functions)~€2-4/mes estimadoEndpoint probado hasta la llamada real a la API. Sin clave real, sin enganchar a la UI
aroagpt-nano (repo aparte, no en aroa-ai)Entender cómo funciona un transformer por dentro, construido a mano€0Notebook completo. Es un ejercicio educativo — no me representa, y eso lo digo explícitamente en el propio notebook

Por qué tres ramas y no una decisión ya tomada: cada camino tiene un trade-off distinto (coste, complejidad, cuánto se parece de verdad a cómo hablo yo) y la forma correcta de decidir es probar cada uno con evidencia, en vez de elegir a priori. Ver el README de cada rama para el detalle técnico.

Embeddings: probado y descartado, con los números delante

Se probó sustituir TF-IDF por multilingual-e5-small (embeddings de frase) para el retrieval, esperando mejor comprensión de paráfrasis.

v1 TF-IDF (se queda)v2 embeddings
accuracy@197.7%95.5%
cobertura72.7%75.0%
rechazo fuera de dominio96.7%70%

Los embeddings sí entienden mejor la paráfrasis, pero son mucho peores diciendo “no lo sé” — por anisotropía (el coseno entre frases de un transformer se comprime en una banda estrecha, ~0.78-0.92, acierte o no). Ni el umbral absoluto ni el margen top1/top2 separaban las distribuciones.

Decisión: TF-IDF se queda en producción. El experimento no se descartó en silencio — está publicado en /eval con la comparación completa y en public/model/aroa-2.json.

Construcción inicial: aroa-1 y la web como su interfaz

  • El modelo: TF-IDF (palabras 1-2 gramas + n-gramas de carácter 3-5) + similitud del coseno, entrenado con scikit-learn sobre un corpus propio de 50 documentos. Retrieval, no generativo — nunca dice algo que no esté ya escrito en su corpus. Sin backend: el artefacto es un JSON de ~415 KB, la inferencia corre entera en el navegador.
  • La web: consola de chat oscura con acento rosa pastel, comandos slash (/ayuda, /corpus, /doc, /eval…), modo explicación (ver los candidatos y qué términos decidieron la respuesta), input por voz (Web Speech API), y un selector de variantes (aroa-1-flash / aroa-1-pro con Llama 3.2 local vía WebGPU).
  • /eval: matriz de confusión, histograma de scores, todos los fallos publicados — nada escondido.
  • /model-card: la documentación técnica del modelo.
  • /about: no el modelo — la persona, con el mismo formato de documentación (rescatado del about.html del sitio anterior a la migración a Astro: el timeline git log --graph y el grafo interactivo de intereses).
  • Corpus, dos rondas de arreglos reales: “hola” ya estaba en el corpus pero pesaba tan poco como palabra suelta que no llegaba al umbral — reforzado. Luego, más serio: el set de evaluación fuera de dominio tenía solo 8 preguntas de trivia (capitales, recetas…) y nunca se probó contra chit-chat real — “¿cómo estás?” llegó a devolver la arquitectura del modelo con la misma confianza que un acierto. Con 22 preguntas de small talk añadidas a eval.jsonl, el umbral se recalibró: rechazo fuera de dominio 93%→97%, a costa de cobertura 91%→73%. Preferir un “no lo sé” de más a una respuesta inventada, medido, no solo dicho.
  • Migración a repo limpio: el repo original (aroaxinping.com) tenía en su historial (no en HEAD) documentos de operativa de contenido — tarifas, lista de marcas. Se purgó el historial de verdad con git-filter-repo (verificado contra un clon fresco del propio GitHub) y se migró todo a este repo, aroa-ai, con historial nuevo.