Building a Semantic Blog Post Clustering System
An automated system that finds related blog posts with embeddings and k-means clustering. It runs before the build, so recommendations cost nothing at runtime.
The Problem: Finding Related Content
When I started writing more blog posts, I noticed visitors would read one article and leave, even when I had other relevant content they might find interesting. The obvious solution is a “related posts” section, but implementing one properly is harder than it seems.
The naive approach is to match posts by tags. If two posts share the tag “machine learning,” they’re related, right? Not always. A post about neural network architectures and a post about k-means clustering both involve machine learning, but they’re quite different topics. Meanwhile, a post about dimensionality reduction might be highly relevant to the clustering post, even if they don’t share tags.
What I needed was semantic similarity: a way to understand what posts are actually about, not just what tags they’ve been labeled with.
The Solution: Pre-Computed Clustering
I decided to implement a clustering system that would:
- Run ahead of time - No runtime performance penalty, no API costs
- Use semantic embeddings - Understand post content, not just keywords
- Automatically group similar posts - No manual curation required
- Fall back gracefully - Use tag-based matching for edge cases
The key insight: generate embeddings, cluster posts, and write the results to a static JSON file ahead of the build. The build simply bundles that file with the site—no runtime overhead.
Here’s how the entire process works:
Step 1: Text Embeddings
The first challenge was converting blog posts into something mathematical that we can compare. This is where embeddings come in.
An embedding is a dense vector representation of text. You can think of it as converting words and sentences into coordinates in a high-dimensional space. Similar concepts cluster together, making similarity measurable by distance.
I used the all-MiniLM-L6-v2 model from the Transformers library, which generates 384-dimensional vectors. At 90MB, it’s lightweight and reliably captures semantic meaning with minimal latency.
import { pipeline } from '@xenova/transformers';
const extractor = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',
{ quantized: false }
);
Preparing the Text
Before generating embeddings, I weighted text components by signal strength: title (strongest indicator of topic), description, tags, and content preview.
function preparePostText(post: BlogPost): string {
// Title appears 3 times for extra weight
const titleWeight = [post.title, post.title, post.title].join(' ');
// Tags formatted as text
const tagsText = post.tags.length > 0
? `Tags: ${post.tags.join(', ')}`
: '';
// First 500 characters of content (stripped of markdown)
const contentPreview = post.content
.replace(/^---[\s\S]*?---/, '') // Remove frontmatter
.replace(/import\s+.*?from\s+['"].*?['"];?/g, '') // Remove imports
.replace(/^#+\s+/gm, '') // Remove headers
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Links to text
.replace(/[*_`]/g, '') // Remove formatting
.trim()
.slice(0, 500);
return [titleWeight, tagsText, post.description, contentPreview]
.filter(Boolean)
.join('\n\n');
}
I determined the 3x title weight empirically through testing. Insufficient weighting caused posts about different topics to cluster based on shared technologies; excessive weighting matched only exact title similarities.
Generating Embeddings
With the text prepared, I could generate embeddings for all posts:
const texts = posts.map(preparePostText);
const embeddings = await Promise.all(
texts.map(async (text) => {
const result = await extractor(text, {
pooling: 'mean', // Average token embeddings
normalize: true, // Normalize to unit length
});
return Array.from(result.data);
})
);
The normalize: true parameter is important because it ensures all vectors have length 1, which means we can use cosine similarity by simply taking the dot product.
Step 2: K-Means Clustering
Now I had 30+ blog posts represented as 384-dimensional vectors. The next step was grouping similar posts together using k-means clustering.
K-means is an algorithm that partitions data into k clusters by:
- Randomly initializing k cluster centers
- Assigning each point to its nearest center
- Updating centers to the mean of assigned points
- Repeating steps 2-3 until convergence
The challenge is choosing k. Too few clusters and unrelated posts get lumped together. Too many and you split related posts apart.
Determining Optimal K with Silhouette Analysis
I used silhouette analysis to find the optimal number of clusters. The silhouette score measures how well each point fits in its assigned cluster by comparing how tightly a point fits its cluster versus how far it is from neighboring clusters:
Where:
a(i) = average distance to other points in the same clusterb(i) = average distance to points in the nearest other cluster
A score near +1 means the point is well-matched to its cluster. Near 0 means it’s on the boundary. Near -1 means it’s probably in the wrong cluster.
function calculateSilhouetteScore(
embeddings: number[][],
clusters: number[]
): number {
let totalScore = 0;
for (let i = 0; i < embeddings.length; i++) {
const clusterI = clusters[i];
// a(i): average distance to points in same cluster
const sameCluster = embeddings
.filter((_, idx) => clusters[idx] === clusterI && idx !== i);
const a = sameCluster.reduce((sum, emb) => {
return sum + (1 - cosineSimilarity(embeddings[i], emb));
}, 0) / sameCluster.length;
// b(i): min average distance to other clusters
const otherClusters = Array.from(new Set(clusters))
.filter(c => c !== clusterI);
const b = Math.min(...otherClusters.map(cluster => {
const clusterPoints = embeddings
.filter((_, idx) => clusters[idx] === cluster);
return clusterPoints.reduce((sum, emb) => {
return sum + (1 - cosineSimilarity(embeddings[i], emb));
}, 0) / clusterPoints.length;
}));
// Calculate silhouette score for this point
totalScore += (b - a) / Math.max(a, b);
}
return totalScore / embeddings.length;
}
I test k values from 3 to 10 and pick the one with the highest silhouette score:
function determineOptimalK(embeddings: number[][]): number {
let bestK = 3;
let bestScore = -1;
for (let k = 3; k <= 10; k++) {
const result = kmeans(embeddings, k, {
initialization: 'kmeans++',
seed: 42 // Reproducible results
});
const score = calculateSilhouetteScore(embeddings, result.clusters);
if (score > bestScore) {
bestScore = score;
bestK = k;
}
}
return bestK;
}
For my blog (about 40 published posts at the time of writing), this currently selects k=9, creating clusters like:
- Data science / learning to code
- Linear algebra / numerical methods
- Sailing / navigation
- Naval architecture / games and simulation
- AI agents / developer tooling
Step 3: Finding Related Posts
My first version only recommended posts from the same k-means cluster. In practice the cluster boundary was too blunt: a post near the edge of a small cluster could miss a close neighbour sitting just across the line. So the recommendations now come from a straightforward nearest-neighbour search across every post, and the clusters are kept for grouping posts by topic:
- Calculate cosine similarity between the current post and every other post
- Sort by similarity
- Return the top 4 most similar posts
for (let i = 0; i < posts.length; i++) {
const currentCluster = clusters[i];
const clusterSize = clusters.filter((c) => c === currentCluster).length;
// Compare against every other post, keeping the original index
// so each post is paired with its own embedding
const related = posts
.map((post, idx) => ({ post, idx }))
.filter(({ idx }) => idx !== i)
.map(({ post, idx }) => ({
post,
similarity: cosineSimilarity(embeddings[i], embeddings[idx]),
}))
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 4);
}
Cosine Similarity
Cosine similarity measures the angle between two vectors, ranging from -1 (opposite) to 1 (identical):
Since we normalised our embeddings, the magnitudes are both 1 and this reduces to the dot product. The shared helper still divides by the magnitudes, so it stays correct for vectors that aren’t normalised:
function cosineSimilarity(a: number[], b: number[]): number {
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
return dotProduct / (magA * magB);
}
On my blog, scores above 0.5 indicate closely related posts, and scores below 0.3 suggest loose connections.
Step 4: Fallback Strategies
Nearest-neighbour search always returns something, but for a genuinely isolated post the “nearest” posts may not be related at all. If even the best match scores below 0.2, I fall back to tag-based matching:
const MIN_CONFIDENT_SIMILARITY = 0.2;
const hasConfidentMatch =
related.length > 0 && related[0].similarity >= MIN_CONFIDENT_SIMILARITY;
if (!hasConfidentMatch) {
// Fall back to tag-based matching
const tagBased = findTagBasedRelated(currentPost, posts, 4);
output[currentPost.id] = {
relatedPosts: tagBased,
clusterSize: 0,
fallbackToRecent: true
};
}
An earlier version fell back whenever the cluster was small or the top match scored below 0.5, which surfaced unrelated old posts under perfectly good articles. With the lower bar, the fallback is reserved for the degenerate case; in the current data no post needs it.
Tag-based matching scores overlap with the Jaccard index (shared tags divided by all distinct tags across the two posts), so posts with long tag lists don’t win by sheer volume:
function findTagBasedRelated(
currentPost: BlogPost,
allPosts: BlogPost[],
maxResults: number = 4
): RelatedPost[] {
const currentTags = new Set(currentPost.tags);
const scored = allPosts
.filter((p) => p.id !== currentPost.id)
.map((post) => {
const postTags = new Set(post.tags);
const intersection = [...currentTags].filter((t) => postTags.has(t)).length;
const union = new Set([...currentTags, ...postTags]).size;
return { post, similarity: union > 0 ? intersection / union : 0 };
})
.filter(({ similarity }) => similarity > 0)
.sort((a, b) => b.similarity - a.similarity);
return scored.slice(0, maxResults).map(({ post, similarity }) => ({
id: post.id,
title: post.title,
description: post.description,
pubDate: post.pubDate,
similarity,
}));
}
If a post has no tags, it falls back to showing recent posts sorted by publication date.
Integration with the Build Process
The clustering process is not part of the build itself. It runs as a standalone script, alongside the knowledge graph generator:
{
"scripts": {
"generate:clusters": "tsx scripts/generate-clusters.ts",
"generate:graph": "tsx scripts/generate-knowledge-graph.ts",
"generate:all": "pnpm generate:clusters && pnpm generate:graph"
}
}
A git pre-commit hook runs pnpm generate:all whenever a commit touches src/content/blog/, and stages the regenerated JSON. Commits that don’t change posts skip it, and I can run pnpm generate:all by hand at any time.
It generates src/data/related-posts.json with this structure:
{
"post-slug": {
"relatedPosts": [
{
"id": "related-post-slug",
"title": "Related Post Title",
"description": "Post description",
"pubDate": "2025-11-19T00:00:00.000Z",
"similarity": 0.53
}
],
"clusterSize": 4,
"fallbackToRecent": false
},
"_metadata": {
"generatedAt": "2026-08-22T12:11:26.318Z",
"totalPosts": 42,
"clusterCount": 9,
"model": "Xenova/all-MiniLM-L6-v2"
}
}
This file is committed to git, making it easy to review changes in pull requests. You can see exactly how clustering results changed when you add a new post.
Consuming the Data
In the blog post layout, I load the related posts:
import { getRelatedPosts } from '@/lib/related-posts';
const relatedPostsData = getRelatedPosts(postId);
The getRelatedPosts utility provides type-safe access to the generated data:
export function getRelatedPosts(postId: string): ClusterOutput | null {
const data = relatedPostsData[postId];
return data || null;
}
Performance
The clustering process is surprisingly fast:
- First run: ~30-40 seconds (downloads the full-precision embedding model, ~90MB)
- Subsequent runs: ~5-10 seconds (uses the cached model from
node_modules/@xenova/transformers/.cache) - Build impact: None (the script runs at commit time, not during
pnpm build) - Runtime impact: Zero (everything is pre-computed)
The embedding model is cached between runs, so regenerating after each new post is quick. CI never downloads it: it builds from the committed JSON.
Results and Observations
-
Semantic clustering captures conceptual similarity. Posts about backgammon probability (using combinatorics) grouped naturally with game theory posts (using minimax and strategic analysis), despite different technical approaches. The embeddings identified the underlying conceptual connection.
-
Title weight matters. Without the 3x title weighting, posts would cluster by tools mentioned rather than topics discussed.
-
Small clusters indicate niche topics. In the original cluster-gated version, posts that fell back to tag-based matching were usually about unique topics I’d only written about once or twice.
-
Similarity scores are meaningful. This post and its follow-up on the knowledge graph score 0.74, the highest pair on the blog. Two Solent and Bermuda sailing posts score 0.59, while this post and a loosely related piece on PostgreSQL upserts score 0.23. Scores above 0.5 indicate posts covering similar ground.
-
The system improves with more content. As I write more posts, clusters become more coherent and recommendations improve.
Alternatives I Considered
Before settling on this approach, I explored several alternatives:
1. Tag-Based Matching Only
Pros: Simple, no dependencies, fast Cons: Too coarse-grained, misses semantic relationships
2. TF-IDF + Cosine Similarity
Pros: No ML model needed, interpretable Cons: Misses semantic similarity (synonyms, related concepts)
3. OpenAI Embeddings API
Pros: State-of-the-art quality Cons: API costs, requires internet at build time, slower
4. Runtime Computation
Pros: Always up-to-date Cons: Slow page loads, server costs, complexity
The pre-computed approach with local embeddings hit the sweet spot: good enough quality, zero runtime cost, and fast enough to run on every commit that adds a post.
Future Improvements
There are several ways I could enhance this system:
- Hierarchical clustering - Create topic hierarchies for better navigation
- Time decay - Weight recent posts higher in recommendations
- Click tracking - Use actual user behavior to refine clusters
- Multi-modal embeddings - Include images and code snippets
- A/B testing - Compare cluster-based vs. tag-based recommendations
For now, the system works well enough to ship and validate in production.
Conclusion
Building a semantic blog post clustering system taught me that sophisticated ML features don’t always require complex infrastructure. By running the clustering ahead of the build and committing the results, I got:
- Zero runtime performance penalty
- No API costs or external dependencies
- Semantic understanding of post content
- Automatic recommendations for 40+ posts
- Complete transparency (results are in git)
The entire system is ~400 lines of TypeScript, runs in seconds, and dramatically improved the discoverability of related content on my blog.
For content sites seeking related-post recommendations, this approach works well: low upfront complexity, zero runtime cost, and reliable results.
The full implementation is available in the blog’s repository in scripts/generate-clusters.ts. The clustering runs automatically from a pre-commit hook whenever posts change (or on demand with pnpm generate:all) and generates src/data/related-posts.json.