How the “You might also like” cards on this blog get made
ContenidoContents
At the bottom of almost every post on this blog there are three cards under You might also like. I don’t pick them by hand. I don’t ask a text model for them either. There is no Algolia, and no “related posts” plugin that checks whether two articles share the tag ai.
They are computed by an AI that runs on the same Mac mini that serves this page. It does it in the small hours. When you open the post, Hugo only reads a JSON file. Nothing from an AI runs on the request.
This post is the setup, with the code I actually use. And a heads-up: until tomorrow morning, this article will have no related cards. The job runs at 05:00. That’s a decision, not a slip.
The problem
I wanted a post about Cloudflare Tunnel to lead to other infrastructure posts, and a podcast post to lead to other podcast posts. Three usual paths, three reasons not to take them:
- Tags. Almost everything here ends up tagged
self-hostingorai. Two articles sharing a tag don’t have to belong together. On One More Thing, the other site, it’s worse:apple,iphoneandespanaare in half the archive. - A paid search box. Algolia and the rest solve this in ten minutes. They also add another account, another bill and another third-party script. For three links at the foot, no.
- Ask a chatbot. “Give me three posts related to this one.” A text model can invent a URL. An embedding can’t: it’s a vector. It compares what exists. It doesn’t write.
I kept the third idea, backwards: the AI doesn’t suggest titles. It measures likeness.
What an embedding is (just enough)
An embedding turns a text into a list of numbers. Two texts about the same thing sit close; two that aren’t, sit far. Distance is the cosine of those vectors: 1 is identical, 0 is “nothing in common”.
The model is nomic-embed-text, via Ollama, locally. It doesn’t generate sentences. It has no chat window. It eats a slice of a post and returns a vector.
On this machine Ollama is not a permanent service. I already wrote that when trying local models for Moodle exam questions: the Mac mini already holds the site, the classroom and the rest. The related-posts script starts Ollama if it isn’t up, vectorises, and stops it if it was the one that started it. That way a model isn’t sitting on RAM at four in the afternoon because I fixed a typo.
The script
It lives in blog-relacionados.py, next to the blog. It walks the .es.md and .en.md files, skips draft: true, and from each post keeps title, description and body (no code fences: the sentences count, not the kubectl).
That gets trimmed to about 3,500 characters. nomic-embed-text has a 2,048-token context; above that, Ollama returns HTTP 500. Title and description go first on purpose: if it has to cut, they don’t fall off.
With the vectors in hand, for each post it scores cosines against all the others and keeps at most three, and only if they clear a threshold:
1CUANTOS = 3
2UMBRAL = 0.75
The result is not written into the Markdown front matter. It goes to blog/data/related.json, which Hugo reads as .Site.Data.related. A real example, from the self-hosting post:
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]
Tunnel, the classroom blackout, the maintenance page. Infrastructure. Not “another post that also has the self-hosting tag”.
Today the blog has 61 articles in Spanish. 53 get related cards (38 with three, 4 with two, 11 with one). The 8 that don’t reach 0.75 don’t render the section. The analytics without cookies post is one of them: a clean footer beats three weak cards.
The threshold is not made up
The first time I set 0.70 and the graph chained: one post touched another, that one a third, and you ended up with a blob of infra mixed with Moodle. I measured on the real corpus:
- 0.75 for the cards. The cores (Cloudflare, podcast, Claude) stay together. The 0.70–0.74 pairs, which were coincidences, go away.
- 0.78 for the map. At 0.75 the map was a stain. At 0.78 you get clusters of 11-7-4 and a few lone dots, which you can actually read.
That is not a paper hyperparameter. It’s “I opened the JSON, looked at the weird pairs and raised the cut”.
What the visitor sees
A Hugo partial. If the slug isn’t in the JSON, nothing is rendered:
1{{ $rel := index (index .Site.Data.related .Site.Language.Lang) .File.TranslationBaseName }}
2{{ with $rel }}
3<aside class="related">
4 <h2 class="related-title">You might also like</h2>
5 …
6{{ end }}
Title and photo come from the post itself (GetPage), not from the JSON, in case I changed the title after the last run. The JSON only says who sits next to whom.
On the request: static HTML. No fetch, no API, no Ollama. It fits this site’s CSP and Cloudflare being able to cache the whole page.
Why at night and not on every commit
Starting Ollama on this machine takes 15–30 seconds. Vectorising the whole blog, half a minute. If that ran on every commit hook, fixing a typo would cost the same as shipping a post.
A LaunchAgent (com.sergiocomeron.blog-relacionados) fires the script every night at 05:00. If the JSON hasn’t changed, it doesn’t rebuild. If it has, it calls build-blog.sh and Apache serves the new version. The JSON sits as a pending git change and goes into the next normal commit. The script does not commit on its own.
Practical result: a post published at 18:00 shows up in related cards the next morning. I can live with that. What I don’t want is to pay 20 seconds of Ollama for every typo.
The map is the same calculation
The vectors that feed the cards are projected to 2D (PCA, no t-SNE, no odd dependencies: sixty points, not a million) and drawn at /blog/mapa/. I don’t name the groups: they are the connected components of the similarity graph ≥ 0.78, labelled with the group’s most frequent tag.
The data is embedded in the HTML at build time. The canvas is vanilla JavaScript. Zero fetch. The line on the map that pointed to “the local AI on this site” and landed on the voice-cloning post… was a workaround. This is the post that should have been there.
The second site, the same trick
When I wired related posts on onemore.sergiocomeron.com, I copied the idea, not the tags. There are ~90 Apple posts. With iphone on almost all of them, a tag overlap mixes an 18 Pro rumour with an Apple TV series.
Same model, threshold 0.80 (the corpus is more uniform: leave it at 0.75 and everything looks related). The Live Translation how-to lands next to other iOS 26 pieces; a Ternus post, next to Cook’s letter. The 05:00 job now rebuilds both sites.
What you need to copy it
It isn’t a product. It’s a 200-line script and a partial. You need:
- A static site (Hugo, in my case) that can read a JSON file from
data/. - Ollama with
nomic-embed-text(ollama pull nomic-embed-text). - A cron or LaunchAgent that does not run on every save.
- A threshold measured on your posts, not copied from mine. Start at 0.75, print the pairs with
--dryand raise it until the odd ones disappear.
What you don’t need: a paid embeddings API, a vector store, or a model that “reasons” about what you might like. For three links at the foot, a cosine is enough.
What this is not
It is not a personal recommender. It doesn’t know if you read the Tunnel post yesterday. No user, no history, no A/B. It is likeness between texts, computed once a day, the same for everyone.
It isn’t magic either. If I write two thin posts on different topics with the same tic, the vector pulls them together. The threshold cuts the worst of it; it doesn’t replace titles and descriptions that actually distinguish.
And it doesn’t replace the usual navigation: categories, tags and chronological prev/next are still there. The cards are the shortcut I wanted at the end of an article.
In short
- A local embedding per post, not a chat.
- A JSON file Hugo reads at build time.
- Three suggestions, or none if they don’t reach 0.75.
- Recalc before dawn, not on every commit.
- The map comes from the same vectors.
- On the visit, zero AI.
If at the foot of this post, when you read it a day from now, you see three cards that fit, the setup did its job. If you see none, that works too: it wasn’t 5 a.m. yet, or this text doesn’t look enough like any other. I’m fine with both.