pontecesures.net es un proyecto personal que mantengo desde hace años: el portal de noticias de Pontecesures, mi pueblo, un municipio gallego de unos 3.000 habitantes. La idea siempre fue sencilla, mantener viva la información de lo que pasa allí. Veintiún años de archivo, de 2005 a 2026, 29.559 entradas.
El problema es que la mayoría están sin categorizar o categorizadas de forma inconsistente. Cuando llevas dos décadas publicando, las taxonomías se degradan solas: acabé con 56 categorías que ya no significaban nada. Un archivo así deja de ser consultable, y entonces buena parte de su valor se pierde. Es el tipo de tarea que da pereza empezar y que llevaba tiempo queriendo resolver.
Este fin de semana me puse. La idea era asignar categorías y etiquetas de forma automatizada, con revisión humana por encima, y devolver el resultado a WordPress. Lo que salió es una prueba de concepto que funciona, y sobre todo una colección de sorpresas que no esperaba.
Conviene decirlo de entrada: esto es escribir sobre un sitio en producción que es mío. Eso explica varias decisiones que de otro modo parecerían paranoia, y también por qué uno de los fallos que cuento más abajo me hizo sudar de verdad.
Me marqué tres restricciones desde el principio:
- Todo en local. Sin servicios en la nube. Un MacBook Pro M2 Max con 32 GB y nada más.
- Sin Python. Manía personal, lo admito.
- Contenido bilingüe. Español y gallego mezclados, a veces en la misma entrada.
Esas tres condiciones marcaron todo lo demás.
La arquitectura
flowchart LR
WP[("WordPress REST API")] -->|ingesta| DB[("SQLite + sqlite-vec")]
DB <--> EMB["bge-m3 · embeddings"]
DB <--> LLM["qwen3-4b · clasificador"]
DB --> UI["UI Astro + React · revisión humana"]
UI -->|sync| WP
subgraph LMS["LM Studio (local)"]
EMB
LLM
endBun + TypeScript, SQLite con la extensión sqlite-vec, LM Studio para los modelos, y Astro con React y Tailwind para la interfaz de revisión.
La pregunta obvia es por qué SQLite y no una base vectorial dedicada. La respuesta es que 29.559 vectores de 1.024 dimensiones ocupan unos 120 MB. Cabe en un fichero. Qdrant o Chroma me obligarían a levantar un proceso servidor aparte para un volumen que SQLite maneja sin despeinarse, y encima perdería la ventaja de tener metadatos y vectores en el mismo SQL. Para este tamaño, montar infraestructura sería trabajar de más.
El corpus, y una sorpresa desagradable
| Métrica | Valor |
|---|---|
| Entradas | 29.559 |
| Rango temporal | 2005-08 → 2026-08 |
| Vectorizadas | 29.543 (las 16 restantes no tienen texto) |
| Longitud mediana | ~381 tokens |
| Percentil 99 | ~1.650 tokens |
| Entradas con corrupción de codificación | 5.172 (17,5%) |
Ese último dato merece mención aparte. Un 17,5% del archivo tiene caracteres perdidos en origen: Éxito aparece como ??xito, PABELLÓN como PABELL??N. Y no es mojibake recuperable —no hay à ni †por ningún lado—: los bytes originales ya no están en lo que sirve la API de WordPress. Se perdieron en alguna migración de hace años.
Lo curioso es que los embeddings lo toleran sorprendentemente bien. El clustering sale coherente igual. Se ve feo en cualquier interfaz, pero no rompió nada.
El idioma dejó de ser un problema
El primer obstáculo real fue el gallego. El modelo de embeddings que tenía instalado, nomic-embed-text, es monolingüe inglés. Inútil aquí.
Elegí bge-m3 (multilingüe, 1.024 dimensiones, 8k tokens de contexto) y me obligué a validarlo antes de construir nada encima:
| Comparación | Similitud coseno |
|---|---|
| Mismo tema, español ↔ gallego | 0,96 |
| Temas distintos | 0,37 |
El margen es enorme. Y lo confirmé con datos reales del archivo: partiendo de una entrada en gallego, “Botellón na estación”, el vecino más próximo fue una noticia en español sobre el mismo suceso, “Personal de ADIF evitó un nuevo botellón en la estación”.
La conclusión es reutilizable más allá de este proyecto: con un embedding multilingüe decente, el idioma deja de ser un problema de clasificación. No hace falta separar corpus por idioma ni traducir nada. Es de las cosas que más me sorprendieron por lo bien que funciona.
El clustering no te da temas, te da lugares
Aquí está el hallazgo que menos esperaba.
Descarté las 56 categorías que ya existían en WordPress —un caos acumulado durante años— y decidí construir la taxonomía desde cero. El plan era hacer un k-means sobre los vectores y dejar que los temas emergieran solos.
No funcionó. Con k=10 y k=14, los clusters mayores fueron estos:
| Cluster | Entradas | Lo que agrupaba |
|---|---|---|
| c6 | 3.878 | Pontecesures |
| c3 | 3.796 | Padrón |
| c1 | 3.723 | Valga |
Son lugares, no temas. El territorio pesa tanto en los embeddings que el algoritmo agrupa por concello antes que por asunto: una noticia deportiva de Valga cae junto a una de fiestas de Valga, porque comparten topónimos, nombres de alcaldes y calles. Solo emergieron limpios Esquelas, Emprego, Deportes y Sucesos, cuyo vocabulario es tan distintivo que vence al del lugar.
Para lo que sí sirvió el clustering fue para descubrir temas que no estaban en mi lista inicial: esquelas (690 entradas), empleo (unas 1.500), el tren de cercanías, la lamprea del Ulla, el Camiño de Santiago, las orquestas de la sala Chanteclair. Cuatro de ellos acabaron siendo categorías que yo no había anticipado.
La taxonomía final son 14 categorías en gallego, deliberadamente pocas y amplias:
Política · Obras · Sucesos · Deportes · Cultura · Festas · Sociedade · Avisos · Novidades · Esquelas · Emprego · Turismo · Medio ambiente · Resumos
Y una decisión que resultó clave: el territorio va en etiquetas, no en categorías. Una noticia de obras en Valga es Obras con etiqueta Valga, no una categoría “Otros concellos”. Así “todo Obras” incluye lo de fuera del municipio y se pueden cruzar tema y lugar libremente.
El híbrido k-NN + LLM
El clasificador LLM tarda unos 2,8 segundos por entrada. Para 29.559 serían unas 23 horas de portátil echando humo.
La alternativa evidente: usar el k-NN sobre los embeddings para las entradas cuyos vecinos ya clasificados coinciden, y reservar el LLM solo para las dudosas. Si tus cinco vecinos más próximos son todos Deportes, probablemente tú también lo seas.
Calibré el k-NN con leave-one-out sobre 305 entradas etiquetadas a mano:
| K | Consenso | Acierto | Cobertura |
|---|---|---|---|
| 5 | 100% | 97,4% | 13% |
| 7 | 100% | 100% | 6% |
| 5 | ≥80% | 87,0% | 30% |
Elegí K=5 con consenso unánime. Con consenso parcial el acierto cae al 87%, demasiado error para aceptarlo sin revisar.
Aquí cometí un error metodológico que conviene evitar: el leave-one-out subestima la cobertura real. Daba un 13%, pero en producción la primera pasada dio 28% y la segunda 51%. El motivo es que el leave-one-out compara cada entrada solo contra las otras de la semilla, mientras que en producción una entrada puede tener por vecino a cualquiera de las ya clasificadas, incluidas las que el propio k-NN acaba de asignar en esa misma pasada. El efecto es acumulativo dentro de una sola ejecución.
El rendimiento por ronda cuenta el resto de la historia:
| Ronda | Semilla | Cobertura | Nuevas |
|---|---|---|---|
| 1 | 628 | 54,9% | 16.680 |
| 2 | 17.195 | 40,3% | 1.613 |
| 3 | 18.846 | 19,8% | 2.121 |
El rendimiento decae entre rondas en lugar de crecer. Lo que queda al final son las entradas genuinamente ambiguas, aquellas cuyos vecinos discrepan entre sí. Visto así, el k-NN hace un trabajo muy concreto: separa lo fácil de lo difícil. Y lo difícil es exactamente el trabajo del LLM.
El ahorro fue real: 21.289 entradas resueltas por k-NN a unos 25 ms cada una. Con LLM habrían sido unas 17 horas.
El experimento que falló, que es la parte más interesante
Un experimento inicial me sugirió algo muy prometedor: alternar bloques de LLM con pasadas de k-NN multiplicaba el rendimiento. 34 entradas clasificadas por el LLM desbloquearon 419 del k-NN, un factor de ×12. La lógica parecía impecable: cada entrada que el LLM resuelve se convierte en vecino disponible para muchas otras.
Implementé un ciclo automático que alternaba ambos. Y no se sostuvo:
| Ejecución | Rendimiento |
|---|---|
| Experimento inicial | ×12 |
| Ciclo, ronda 1 | ×1,9 |
| Ciclo, ronda 2 | ×0,1 |
| Ciclo con selección “densa” | ×0,8 |
| Ciclo con k-NN espaciado | ×0,2 |
| Bloque grande (1.400 entradas) | ×0,07 |
El ×12 fue suerte. Aquellas 34 entradas cayeron por casualidad en zonas densas del corpus.
La medición decisiva llegó al final: 1.400 entradas clasificadas por el LLM de una sola vez desbloquearon 103 del k-NN. Un ×0,07, con cobertura del 1,9%. Con un volumen 40 veces mayor que el experimento inicial, el rendimiento fue 170 veces peor. No hay ambigüedad posible: el k-NN estaba agotado en este corpus.
Probé además una heurística para elegir mejor el bloque, priorizando las entradas pendientes con más vecinos sin clasificar, con la hipótesis de que desbloquearían más. Tampoco funcionó: ×0,8 frente al ×1,0 del azar. El problema no era que faltasen vecinos, sino que discrepan entre sí.
La comparación final, sobre 6.900 entradas pendientes:
| Estrategia | Tiempo |
|---|---|
| Ciclo LLM+k-NN alternado | 5,3 h |
| LLM directo + un k-NN al final | 5,4 h |
Seis minutos de diferencia. Toda la complejidad del ciclo no aportaba absolutamente nada.
La lección me parece que vale para muchos contextos: un experimento con resultado espectacular y muestra pequeña merece replicarse antes de construir sobre él. Y conviene medir el coste total de la alternativa simple antes de ponerse a optimizar. Yo construí el ciclo entero antes de comprobar cuánto tardaba hacerlo a lo bruto.
Dónde falla un LLM pequeño
qwen3-4b clasifica bien por tema, pero tiene dos puntos ciegos que documenté con detalle.
No detecta agregados. Las entradas que agrupan varias noticias distintas (“Noticias de Padrón e Valga – 8 de xuño”) reciben la categoría del primer tema en lugar de reconocerse como recopilación. 0 de 70 detectadas, incluso añadiendo una regla explícita al principio del prompt. El modelo entiende de qué habla un texto, pero no reconoce la forma del texto.
Lo resolví con una regla por patrón: marcas inequívocas como varios “Ler máis” seguidos o un pie de “Noticias recollidas de Google News”. Diez líneas de código aciertan donde el modelo falla el 100% de las veces.
La confianza está comprimida. No baja de 0,85 casi nunca. Pero lo interesante es que el modelo sí discrimina:
| Caso | Confianza |
|---|---|
| Esquela (inequívoco) | 1,00 |
| Resumen con varios temas | 0,85 |
| Texto vacío | se niega a clasificar |
Su “dudo” es 0,85, no 0,5. Añadir anclas explícitas al prompt no cambió la escala. Para poner un umbral operativo hay que ponerlo donde está la señal real, no donde diría la intuición.
El fallo silencioso que sobrevivió a todo el proceso
Este es mi favorito, porque es el tipo de bug que da miedo.
Ya con el 96,8% del archivo clasificado, una entrada concreta seguía sin categoría. Tenía 3.754 caracteres, era inequívocamente política —el portavoz del PP criticando al gobierno local— y el sistema la reportaba como “no se pudo clasificar: normalmente por falta de texto”.
El diagnóstico era falso. Al pedir la respuesta cruda del modelo apareció esto:
{"categorias": ["Política"], "confianza": 0.9, "razon": "O texto trata da
análise crítica do goberno local... a mención de "análise global do goberno"
indican un enquadramento político claro."}
El modelo había acertado. Con confianza 0,9 y un razonamiento impecable. Lo que fallaba era mi parser: el LLM escribe comillas dobles sin escapar dentro del campo de razonamiento, JSON.parse lanza una excepción, y mi código lo trataba como “el modelo no ha sabido”.
Dos cosas hicieron que pasara desapercibido durante todo el proyecto:
- El mensaje de error mentía. Decía “demasiado poco texto” porque esa era la causa habitual de que
classify()devolvieranull. Nadie sospecha de un parser cuando el sistema culpa a los datos. - La tasa de fallo era plausible. Un 1-2% de entradas sin clasificar no levanta sospechas en un corpus con entradas de una sola línea. Y el fallo solo se disparaba cuando el modelo se explayaba lo bastante como para entrecomillar algo, es decir, en las respuestas mejor razonadas.
El impacto medido: en las 30 entradas que quedaban pendientes, el parser antiguo resolvía 6 y el corregido resuelve 20. La tasa de fallo pasó del 80% al 13%. Nunca sabré cuántas de las ~7.000 clasificadas por LLM se perdieron por esto a lo largo del proceso, porque quedaron mezcladas con las genuinamente imposibles.
La lección: un mensaje de error que atribuye la culpa a los datos es el mejor escondite para un fallo del código. Cuando un catch convierte cualquier excepción en un null con una explicación genérica, el sistema deja de distinguir “no se puede” de “no supe leerlo”.
Una columna olvidada que borró categorías en producción
Y este es el que me hizo sudar.
Categorías y etiquetas viven en la misma tabla, taxonomy_terms, distinguidas por una columna kind. Es un diseño normal y compacto. También es una mina: cualquier consulta que olvide filtrar por kind mezcla las dos cosas, y el resultado casi nunca falla de forma ruidosa. Simplemente devuelve de más.
El olvido apareció en siete sitios. Seis eran cosméticos: la portada listaba Valga y Lamprea entre las categorías, los recuentos estaban inflados. No lo detectó ninguna prueba, lo detecté yo mirando la interfaz y pensando «en categorías veo un montón que creo que son etiquetas».
El séptimo escribía en producción. La función que envía categorías a WordPress seleccionaba los términos así:
SELECT c.post_id, group_concat(t.wp_term_id) ids
FROM classifications c
JOIN taxonomy_terms t ON t.id = c.term_id
WHERE c.taxonomy_version_id = ?
AND (c.reviewed_at IS NOT NULL OR c.source = 'llm'
OR (c.source = 'knn' AND c.confidence >= 0.75))
Sin AND t.kind = 'category'. Y ahí se juntaron tres circunstancias:
- Las etiquetas se asignan por regla, con
source='llm'y confianza 1,0: pasaban todos los filtros de calidad, siempre. - Los IDs de etiqueta y de categoría son ambos enteros. Nada en el tipo distingue un
341de un368. - WordPress no protesta. Recibe
categories: [219, 368, 352], descarta en silencio los que no son categorías y, si no queda ninguno válido, vacía el campo.
El resultado: entradas que tenían categoría en WordPress la perdieron. Una noticia titulada «O Día da Bandeira», con su Festas correcta en la base de datos local, quedó en “Sin Categoría” en el sitio público. La petición devolvió 200 OK y el sistema contabilizó “500 actualizadas, 0 fallos”.
Merece la pena detenerse aquí: estaba borrando, en directo y sin enterarme, categorías de un archivo de veintiún años que llevo manteniendo yo. No había entorno de pruebas ni copia de seguridad reciente que valiese, porque el sitio es el sitio.
Alcance: 143 entradas de las 224 escritas antes de detectarlo, un 64%. Las otras 81 tenían al menos una categoría válida entre los IDs enviados y sobrevivieron por casualidad.
Se pudo reparar porque cada escritura queda registrada en wp_writes con el estado anterior:
{"post_id": 921, "payload": "{\"categories\":[219,368,352]}",
"previous": "[20,23,2]", "applied_at": "2026-08-10 14:55:37"}
Ese previous fue un requisito que me puse desde el primer día por tratarse de un sitio en producción. Sin él no habría habido forma de saber siquiera cuáles entradas tocar: nada en WordPress distingue una entrada que nunca tuvo categoría de una a la que se la acabas de borrar. Las 143 se repararon en un minuto.
Lo que lo delató no fue una prueba, sino mirar el resultado. El sistema reportaba éxito. Solo al abrir una entrada concreta en el sitio y comparar con lo que decía la base de datos apareció la discrepancia. La verificación que funcionó fue la más tonta: ir a ver si de verdad está ahí.
Me quedo con tres cosas:
- Un discriminador en una columna es una invitación al olvido. No hay tipo que lo respalde:
SELECT ... FROM taxonomy_termscompila igual de bien con o sin el filtro. Si lo rehiciera, dos vistas SQL (categories,tags) sobre la misma tabla harían imposible el error. - Los datos de máxima confianza son los más peligrosos. Las etiquetas entraron precisamente porque tenían confianza 1,0. Un filtro de calidad no sustituye a un filtro de identidad: comprobaba cómo de fiable era el término, nunca qué clase de término era.
- “0 fallos” mide lo que el código sabe comprobar. El bucle contaba respuestas HTTP correctas, y todas lo eran.
Las etiquetas: cuando una regex gana al modelo
Las categorías responden a “de qué va esto”. Las etiquetas hacen otra cosa: relacionan entradas entre sí. Lugares (Estación, N-550), entidades (ADIF, Nestlé, BNG), temas recurrentes (Lamprea, Entroido).
El criterio que me fijé: una buena etiqueta es la que, al pulsarla, devuelve un conjunto de noticias que tiene sentido leer junto.
Y aquí el clustering sí funcionó. Con k=30 emergieron grupos nítidos —tren de cercanías, lamprea del Ulla, baloncesto, Náutico, Nestlé, paro— y el LLM, al nombrarlos, propuso etiquetas que yo no había anticipado: N-550, Alto de Cordeiro, Valeiros, Mancomunidade Ulla-Umia, Cerámica Celta, Iria Flavia.
Es el reverso exacto de lo que pasó con las categorías. El mismo clustering que allí solo daba lugares, aquí produce justo lo que se busca.
Hubo que filtrar bastante ruido del LLM, eso sí: etiquetas en castellano pese a pedirlas en gallego (Huelga de basura), genéricas que son categorías disfrazadas (Emprego, Accidente), sin valor como etiqueta (Decembro, Fin de semana) y duplicados (Ría de Arousa / Mar de Arousa). También descarté Concello, que aparece en 10.153 entradas —un tercio del archivo—: una etiqueta que marca un tercio de todo no relaciona nada.
Pero la decisión importante fue otra: la asignación es por expresión regular, no por LLM. Son entidades nombradas: o el patrón aparece en el texto o no aparece. Una regla acierta más que el modelo y cuesta tres órdenes de magnitud menos.
| LLM | Regla | |
|---|---|---|
| Tiempo para 29.559 entradas | ~23 h | 8 s |
El resultado: 41 etiquetas, 43.745 asignaciones, 1,5 por entrada de media, 75,2% del archivo etiquetado. Sin ninguna etiqueta dominante, desde Valga (6.563) hasta Alto de Cordeiro (36).
Un ejemplo de lo que consigue:
“No se coloca el nuevo mobiliario urbano en la Estación” →
Estación · Padrón · Catoira · ADIF · Tren de proximidade
La lección aquí es que no todo problema de clasificación necesita un modelo. Para entidades nombradas, una lista de patrones bien elegida gana en precisión, coste y previsibilidad. El LLM sirvió para descubrir qué etiquetas debían existir; asignarlas es trabajo de una expresión regular.
Y visto en frío, esa división del trabajo es lo que mejor resume el proyecto entero. Empecé dando por supuesto que con embeddings y un LLM tendría el problema resuelto de punta a punta, y el archivo me corrigió tres veces:
| Tarea | Lo que suponía | Lo que funcionó |
|---|---|---|
| Definir la taxonomía | Clustering sobre los vectores | Clustering para descubrir temas, decidir a mano |
| Detectar recopilaciones | El LLM lee el texto y lo ve | Regla por patrón (el LLM: 0 de 70) |
| Asignar etiquetas | El LLM etiqueta cada entrada | Expresión regular (23 h → 8 s) |
En los tres casos el error fue el mismo: pedirle al modelo que decidiera en lugar de que propusiera. El clustering es excelente enseñándote qué hay en un corpus que no habías anticipado, y bastante malo diciéndote cómo organizarlo. El LLM entiende de qué habla un texto, pero no reconoce su forma, y para “¿aparece ADIF aquí?” una regla de una línea es más precisa, más barata y —esto pesa más de lo que parece— predecible: puedo leerla y saber exactamente qué va a marcar.
Lo que sí resolvió el modelo de punta a punta fue la clasificación temática de 12.154 entradas, que es un trabajo de juicio genuino y donde nada más habría servido. La cuestión no es si usar IA, sino qué parte del problema es realmente de juicio y cuál es de reconocer un patrón. Confundirlas cuesta tres órdenes de magnitud.
Dónde está ahora
| Métrica | Valor |
|---|---|
| Entradas clasificadas | 28.637 de 29.559 (96,9%) |
| — por k-NN | 16.321 |
| — por LLM | 12.154 |
| — revisadas por humano | 162 |
| Categorías escritas en WordPress | 18.633 |
| Etiquetas escritas en WordPress | 22.241 |
La distribución por categoría quedó así:
| Categoría | Entradas | Categoría | Entradas | |
|---|---|---|---|---|
| Sociedade | 5.491 | Medio ambiente | 1.113 | |
| Política | 4.038 | Esquelas | 775 | |
| Obras | 3.558 | Turismo | 462 | |
| Cultura | 3.172 | Avisos | 151 | |
| Festas | 3.055 | Novidades | 43 | |
| Deportes | 2.663 | Resumos | 24 | |
| Sucesos | 2.339 | |||
| Emprego | 1.981 |
Avisos con 151 entradas sigue siendo anómalo: hay muchas más convocatorias y bandos en el archivo. Es el mismo punto ciego que con Resumos: el modelo prefiere categorías temáticas frente a las que dependen de la forma del texto. Probablemente necesite también una regla por patrón.
Y hay una distinción que me costó ver: clasificado no es lo mismo que publicado. De las 28.637 entradas con categoría, solo 20.553 llegan a WordPress. El resto son asignaciones del k-NN por debajo del umbral de confianza de 0,75, que se quedan fuera a propósito porque ahí se concentran los errores:
| Entrada | Categoría asignada |
|---|---|
| “La gripe A” | Política |
| “TRAMPITAS” | Deportes |
Esa distinción no era visible en la interfaz. Una entrada con categoría del k-NN al 0,68 se veía igual que una revisada a mano, y no había forma de localizar las que no iban a publicarse. Añadí un filtro explícito y un distintivo por fila. Es la misma cifra vista desde dos lados: lo que la base de datos sabe y lo que el sitio muestra no coinciden, y la interfaz debe decir cuál de las dos está enseñando.
Tiempos de referencia
Por si a alguien le sirve para dimensionar algo parecido:
| Operación | Tiempo |
|---|---|
| Ingesta de 29.559 entradas | 169 s |
| Vectorización completa (bge-m3) | 37 min (13,2/s) |
| k-NN sobre 29.000 pendientes | ~3 min (~25 ms/entrada) |
| Clasificación LLM | ~2,8 s/entrada |
| Sincronización con WordPress | ~90 entradas/min |
Los modelos que usé, y por qué
Dos modelos, los dos corriendo en local sobre LM Studio y elegidos con criterios muy distintos.
bge-m3, para los embeddings
BAAI/bge-m3 (text-embedding-bge-m3), 1.024 dimensiones, 8k tokens de contexto.
La razón principal fue el gallego. Mi primera opción era nomic-embed-text, que ya tenía instalado, pero es monolingüe inglés y aquí no servía de nada. bge-m3 está entrenado en más de 100 idiomas y, lo que importa en este caso, coloca el mismo concepto en el mismo punto del espacio vectorial independientemente del idioma. Ese 0,96 de similitud entre español y gallego frente a 0,37 entre temas distintos es lo que permitió tratar todo el archivo como un único corpus.
Los otros dos motivos fueron el contexto y el tamaño. Los 8k tokens cubren el percentil 99 de mis entradas (~1.650 tokens) con muchísimo margen, así que nunca tuve que trocear texto. Y 1.024 dimensiones es un punto razonable: los 29.543 vectores ocupan unos 120 MB, que caben en un fichero SQLite sin necesitar infraestructura aparte.
qwen3-4b, para clasificar
Qwen3-4B (qwen/qwen3-4b-2507).
Aquí el criterio fue distinto: quería el modelo más pequeño que hiciera bien el trabajo. Con 29.559 entradas por procesar, cada décima de segundo por entrada son horas de diferencia. Un modelo de 4B cabe holgadamente en 32 GB de RAM junto con el de embeddings, deja el portátil usable mientras trabaja y clasifica en ~2,8 s por entrada. Uno de 30B habría multiplicado ese tiempo sin una mejora que lo justificase, porque la tarea —leer una noticia local y decir si va en Deportes o en Festas— no es especialmente difícil.
Que sea multilingüe importaba tanto como en los embeddings: tiene que entender textos en gallego y devolver las categorías en gallego.
La versión 2507 es la actualizada de julio de 2025, con mejor seguimiento de instrucciones que la original, que es justo lo que necesita un clasificador al que le pides un JSON con un esquema concreto.
Y acertó: 12.154 entradas clasificadas con buen criterio. Sus dos límites —no detecta agregados y comprime la confianza en 0,85— son de los que se rodean con una regla, no de los que se arreglan con un modelo más grande. De hecho probé a explicarle lo de los agregados en el prompt y siguió fallando las 70 veces, lo que sugiere que es una limitación del tipo de tarea y no de la escala.
Qué no usé
Ninguna API de pago. No por precio —clasificar 29.559 entradas con un modelo comercial pequeño costaría unos pocos euros— sino porque no hacía falta: la tarea la resuelve un modelo local, y así el archivo entero se queda en mi máquina. También descarté afinar un modelo propio; con 305 entradas etiquetadas a mano no hay material suficiente, y el k-NN sobre los embeddings ya cubría ese papel mucho más barato.
Lo que me llevo
Veintiún años de archivo, 29.559 entradas, dos modelos corriendo en local y cero euros de API. Todo en un portátil, en un fin de semana. Esa parte, la de que hoy se pueda hacer esto sin infraestructura ni presupuesto, sigue pareciéndome notable.
Y el resultado es el que quería: veintiún años de noticias de Pontecesures que ahora se pueden recorrer por tema y por lugar. Que alguien pueda pulsar Lamprea y ver lo que se ha publicado sobre la lamprea del Ulla desde 2005 es exactamente para lo que monté el sitio. El archivo estaba ahí, pero un archivo que no se puede recorrer está a medio camino de no existir.
Pero empecé con una suposición que resultó falsa: que embeddings y LLM me resolverían el problema completo. No fue así, y no porque fallaran —el modelo clasificó 12.154 entradas por tema con buen criterio—, sino porque buena parte del trabajo no era de juicio, era de reconocer patrones. Ahí una expresión regular gana: acierta más, cuesta 8 segundos en lugar de 23 horas y puedo leerla y saber qué va a marcar. Los modelos brillaron proponiendo —descubriendo temas que yo no había anticipado, sugiriendo etiquetas como Alto de Cordeiro—; las decisiones las tomé yo o las tomó una regla.
El resto de lecciones tampoco son sobre modelos. El embedding multilingüe hizo que el problema del idioma desapareciera sin esfuerzo. Un experimento con un resultado de ×12 me llevó a construir un ciclo entero que no aportaba ni seis minutos. Un catch demasiado genérico escondió un bug durante todo el proyecto culpando a los datos. Y una columna olvidada en un WHERE borró categorías en un sitio en producción sin que nada devolviera un error.
Los modelos hicieron bien su parte. Lo que se rompió fue todo lo de alrededor, que es donde siempre se rompe.










Comentarios