Post
Related Posts, From a Line of Template to Semantic Search
A "related articles" block is the cheapest way to keep a reader on your site: they finished one thing, here are five more they'll probably like. The trick is that "probably like" has three very different price tags, and most sites overpay or underdeliver because they pick the wrong one. So here's the ladder — start at the bottom and climb only when the matches aren't good enough.
Level 1: keyword overlap — one line, no network
If your posts already carry tags and keywords, you're done:
<aside class="related">
{{ range related . 5 }}<a href="{{ .Link }}">{{ .Title }}</a>{{ end }}
</aside>
related . 5 ranks the posts SSG already loaded by how many tags and keywords
they share with this one — most overlap first, then most recent, then by slug so
the order is stable. No configuration, no network, byte-for-byte reproducible.
It's literal — it matches words, not meaning — but for most blogs that's exactly right: your tags already encode "these are the same topic," and a deterministic build is worth a lot. This is the default you should reach for first.
Level 2: the whole database — when the site is a slice
Sometimes the related post you want to link isn't built into this site. One mddb database backing several sites; a 40,000-article archive where you only publish a curated subset. Keyword overlap can only rank what it loaded, so it can't see the rest.
relatedFromMddb asks the database directly:
{{ range relatedFromMddb . 5 }}<a href="{{ .Link }}">{{ .Title }}</a>{{ end }}
It runs a live Search against your mddb server, filtered by this page's
tags/keywords, and can surface articles the current build never touched. The
trade-off is honest: it's a network call at build time, and the ranking is whatever
the server does. Use it when reach matters more than reproducibility.
Level 3: semantic — when words aren't enough
Keyword overlap misses the two posts that are obviously about the same thing but share no vocabulary — "cutting your cloud bill" and "why we left Kubernetes" never match on tags, but a reader who liked one wants the other. That's what embeddings are for: turn each post into a vector that captures meaning, then rank by how close two vectors point.
graph LR
A[each post's text] --> B[embeddings API]
B --> C[vector, content-hash cached]
E[this post's vector] --> D[cosine top-N]
C --> D
D --> F[related list]
The pipeline is small, and it borrows the two tricks the rest of SSG already uses:
- Embed once, cache by content hash — the same content-addressed cache idea as
the
[ai …]shortcode, so a post is only re-embedded when it changes and the build stays reproducible. Commit the vector store and CI needs no key. - Rank by cosine similarity — for a few thousand posts a linear scan at build
time is nothing; past that, reach for a vector index (your mddb server's vector
search, or a prebuilt index loaded via
data_dir).
There's a worked sketch of this in the repository's related-posts example. It
costs one embedding call per changed post — once, thanks to the cache — and buys
you the matches keyword overlap can't find.
How to choose
| You want… | Use | Cost |
|---|---|---|
| Related among your own posts, reproducible | related |
free, offline |
| Related from a bigger corpus | relatedFromMddb |
a live query |
| "About the same thing," different words | embeddings + cosine | one embed per changed post |
Don't start at the top. Ship related . 5 today — it's a line of template and it's
already better than most sites' "related" blocks. Climb the ladder only when you can
point at the specific matches the cheaper level is missing.