Cómo salen los «Quizá te interese» de este blog
ContenidoContents
Al final de casi todos los posts de este blog hay tres tarjetas bajo el título Quizá te interese. No las elijo a mano. Tampoco se las pido a un modelo de texto. Y no hay Algolia, ni un plugin de «related posts» que mire si dos artículos comparten el tag ia.
Las calcula una IA que corre en el mismo Mac mini que sirve esta página. Lo hace de madrugada. Cuando tú abres el post, Hugo solo lee un JSON. En la petición no corre nada de IA.
Este post explica el montaje, con el código que uso de verdad. Y avisa de antemano: hasta mañana por la mañana, este artículo no tendrá relacionados. El recálculo es a las 05:00. Es una decisión, no un despiste.
El problema
Quería que un post sobre Cloudflare Tunnel llevara a otros de infraestructura, y que uno del podcast llevara a otros del podcast. Tres caminos típicos, tres motivos para no cogerlos:
- Tags. En este blog casi todo acaba con
self-hostingoia. Dos artículos que comparten tag no tienen por qué ir juntos. En One More Thing, el otro sitio, es todavía peor:apple,iphoneyespanaestán en medio archivo. - Un buscador de pagos. Algolia y compañía resuelven esto en diez minutos. También meten otra cuenta, otra factura y otro JavaScript de terceros. Para tres enlaces al pie, no.
- Preguntarle a un chat. «Dame tres posts relacionados con este». Un modelo de texto puede inventar una URL. Un embedding no: es un vector. Compara lo que hay. No escribe.
Me quedé con el tercero, pero al revés: la IA no sugiere títulos. Mide parecido.
Qué es un embedding (lo justo)
Un embedding convierte un texto en una lista de números. Dos textos que hablan de lo mismo quedan cerca; dos que no, lejos. La distancia se mide con el coseno de esos vectores: 1 es idéntico, 0 es «nada que ver».
El modelo es nomic-embed-text, vía Ollama, en local. No genera frases. No tiene ventana de chat. Come un trozo de post y devuelve un vector.
Ollama, en esta máquina, no es un servicio permanente. Ya lo conté al probar modelos locales para preguntas de Moodle: el Mac mini ya sostiene la web, el aula y el resto. El script de relacionados arranca Ollama si no está, vectoriza, y lo apaga si lo encendió él. Así no se queda un modelo ocupando RAM a las cuatro de la tarde porque corregí una errata.
El script
Vive en blog-relacionados.py, al lado del blog. Recorre los .es.md y los .en.md, se salta los draft: true, y de cada post se queda con título, descripción y cuerpo (sin bloques de código: mandan las frases, no los kubectl).
Eso se recorta a unos 3.500 caracteres. nomic-embed-text tiene 2.048 tokens de contexto; por encima, Ollama responde HTTP 500. El título y la descripción van al principio a propósito: si hay que recortar, no se caen.
Con los vectores en la mano, para cada post se calculan los cosenos contra todos los demás y se guardan como mucho tres, y solo si pasan un umbral:
1CUANTOS = 3
2UMBRAL = 0.75
El resultado no se escribe en el front matter de los Markdown. Va a blog/data/related.json, que Hugo lee como .Site.Data.related. Un ejemplo real, el del post de self-hosting:
1"self-hosting-cloudflare-mac-mini": [
2 {
3 "slug": "cloudflare-tunnel",
4 "title": "Cloudflare Tunnel: expón tu servidor en casa sin abrir puertos",
5 "url": "/blog/posts/cloudflare-tunnel/",
6 "score": 0.865
7 },
8 {
9 "slug": "hoy-laliga-ha-apagado-mi-aula",
10 "title": "Hoy LaLiga ha apagado mi aula",
11 "url": "/blog/posts/hoy-laliga-ha-apagado-mi-aula/",
12 "score": 0.815
13 },
14 {
15 "slug": "pagina-mantenimiento-servidor-apagado",
16 "title": "Cómo muestro «en mantenimiento» cuando mi servidor está apagado",
17 "url": "/blog/posts/pagina-mantenimiento-servidor-apagado/",
18 "score": 0.808
19 }
20]
Túnel, apagón del aula, página de mantenimiento. Infraestructura. No «otro post que también tiene el tag self-hosting».
Hoy el blog tiene 61 artículos en español. 53 salen con relacionados (38 con tres, 4 con dos, 11 con uno). Los 8 que no llegan al 0,75 no pintan la sección. El de la analítica sin cookies es uno de ellos: mejor un pie limpio que tres tarjetas flojas.
El umbral no se inventa
La primera vez puse 0,70 y el grafo se encadenaba: un post tocaba a otro, ese a un tercero, y acababas con un blob de infra mezclado con Moodle. Medí sobre el corpus real:
- 0,75 para las tarjetas. Casi todos los núcleos (Cloudflare, podcast, Claude) quedan juntos. Desaparecen las parejas del 0,70–0,74, que eran coincidencias.
- 0,78 para el mapa. Con 0,75 el mapa era una mancha. Con 0,78 salen grupos de 11-7-4 y unos cuantos puntos sueltos, que es lo que se puede leer.
Eso no es un hiperparámetro de paper. Es «abrí el JSON, miré las parejas raras y subí el corte».
Lo que ve el visitante
Un partial de Hugo. Si el slug no está en el JSON, no se renderiza nada:
1{{ $rel := index (index .Site.Data.related .Site.Language.Lang) .File.TranslationBaseName }}
2{{ with $rel }}
3<aside class="related">
4 <h2 class="related-title">Quizá te interese</h2>
5 …
6{{ end }}
Título y foto salen del propio post (GetPage), no del JSON, por si cambié el título después del último recálculo. El JSON solo dice quién va al lado de quién.
En la petición: HTML estático. Ni fetch, ni API, ni Ollama. Encaja con la CSP de este sitio y con que Cloudflare pueda cachear la página entera.
Por qué de noche y no en cada commit
Arrancar Ollama en esta máquina son 15–30 segundos. Vectorizar el blog entero, medio minuto. Si eso corriera en el hook de cada commit, corregir un typo costaría lo mismo que publicar un post.
Un LaunchAgent (com.sergiocomeron.blog-relacionados) dispara el script todas las noches a las 05:00. Si el JSON no ha cambiado, no reconstruye. Si ha cambiado, llama a build-blog.sh y Apache sirve la versión nueva. El JSON queda como modificación pendiente en git y entra en el siguiente commit normal. El script no hace commit solo.
Consecuencia práctica: un post publicado a las 18:00 aparece en los relacionados a la mañana siguiente. Puedo vivir con eso. Lo que no quiero es pagarle 20 segundos de Ollama a cada errata.
El mapa es el mismo cálculo
Los vectores que sirven para las tarjetas se proyectan a 2D (PCA, sin t-SNE ni dependencias raras: son sesenta puntos, no un millón) y se dibujan en /blog/mapa/. Los grupos no los nombro yo: son las componentes conexas del grafo de similitud ≥ 0,78, etiquetadas con el tag más frecuente del grupo.
Los datos se embeben en el HTML al compilar. El canvas es JavaScript vanilla. Cero fetch. La frase del mapa que apuntaba a «la IA local de esta web» y caía en el post de clonar la voz… era un apaño. Este post es el que debería haber estado ahí.
La segunda web, el mismo truco
Cuando monté los relacionados en onemore.sergiocomeron.com, copié la idea y no los tags. Allí hay ~90 posts de Apple. Con iphone en casi todos, un cruce por etiquetas mezcla un rumor del 18 Pro con una serie de Apple TV.
Mismo modelo, umbral 0,80 (el corpus es más homogéneo: si dejas 0,75, todo parece primo de todo). El how-to de Traducción en vivo cae junto a otros de iOS 26; un post de Ternus, junto a la carta de Cook. El job de las 05:00 ahora recalcula las dos webs.
Qué hay que tener para copiarlo
No es un producto. Es un script de 200 líneas y un partial. Hace falta:
- Un sitio estático (Hugo, en mi caso) que sepa leer un JSON de
data/. - Ollama con
nomic-embed-text(ollama pull nomic-embed-text). - Un cron o LaunchAgent que no se ejecute en cada guardado.
- Un umbral medido sobre tus posts, no copiado del mío. Empieza en 0,75, imprime las parejas con
--dryy sube hasta que dejen de salir rarezas.
Lo que no hace falta: una API de embeddings de pago, un vector store, ni un modelo que «razone» qué te puede gustar. Para tres enlaces al pie, un coseno basta.
Lo que esto no es
No es un recomendador personal. No sabe si tú leíste el de Tunnel ayer. No hay usuario, no hay historial, no hay A/B. Es parecido entre textos, calculado una vez al día, igual para todo el mundo.
Tampoco es magia. Si escribo dos posts flojos sobre temas distintos con la misma muletilla, el vector los acerca. El umbral recorta lo peor; no sustituye escribir títulos y descripciones que distingan.
Y no sustituye la navegación de siempre: categorías, tags y el anterior/siguiente cronológico siguen ahí. Las tarjetas son el atajo que yo quería al acabar un artículo.
En resumen
- Un embedding local por post, no un chat.
- Un JSON que Hugo lee al construir.
- Tres sugerencias, o ninguna si no llegan al 0,75.
- Recálculo de madrugada, no en cada commit.
- El mapa sale de los mismos vectores.
- En la visita, cero IA.
Si al pie de este post, cuando lo leas dentro de un día, ves tres tarjetas que pegan, el montaje hizo su trabajo. Si no ves ninguna, también: es que todavía no eran las cinco de la mañana, o es que este texto no se parece lo bastante a ningún otro. Las dos cosas me parecen bien.