Building a Semantic Blog Post Clustering System

I implemented an automated system to find related blog posts using embeddings and k-means clustering. The system runs at build time, using semantic similarity to group posts by topic and generate intelligent recommendations without any runtime overhead.



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: Build-Time Clustering

I decided to implement a clustering system that would:

  1. Run at build time - No runtime performance penalty, no API costs
  2. Use semantic embeddings - Understand post content, not just keywords
  3. Automatically group similar posts - No manual curation required
  4. 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 during the build process. Everything gets bundled with the site—no runtime overhead.

Here’s how the entire process works:

Loading diagram…

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:

  1. Randomly initializing k cluster centers
  2. Assigning each point to its nearest center
  3. Updating centers to the mean of assigned points
  4. 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:

  • = average distance to other points in the same cluster
  • = 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 31-post blog, this typically selects k=7, creating clusters like:

  • Machine learning / data science
  • Sailing / navigation
  • Backgammon / game theory
  • Startups / product development
  • Programming tools / infrastructure

Once posts are clustered, finding related posts is straightforward:

  1. Look at posts in the same cluster
  2. Calculate cosine similarity to the current post
  3. Return the top 4 most similar posts
for (let i = 0; i < posts.length; i++) {
  const currentPost = posts[i];
  const currentCluster = clusters[i];

  // Find posts in same cluster
  const clusterPosts = posts
    .filter((_, idx) => clusters[idx] === currentCluster && idx !== i);

  // Calculate similarities
  const similarities = clusterPosts.map((post, idx) => ({
    post,
    similarity: cosineSimilarity(
      embeddings[i],
      embeddings[idx]
    )
  }));

  // Sort by similarity and take top 4
  const related = similarities
    .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 normalized our embeddings, this simplifies to just the dot product:

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.7 indicate closely related posts, and scores below 0.5 suggest loose connections.

Step 4: Fallback Strategies

Clustering works well for most posts, but edge cases need handling. K-means can produce clusters with only 2-3 posts. For these, I fall back to tag-based matching:

const meetsMinimumSize = clusterPosts.length >= 2;
const meetsMinimumSimilarity = topRelated[0]?.similarity > 0.5;

if (!meetsMinimumSize || !meetsMinimumSimilarity) {
  // Fall back to tag-based matching
  const tagBased = findTagBasedRelated(currentPost, posts, 4);

  output[currentPost.id] = {
    relatedPosts: tagBased,
    clusterSize: 0,
    fallbackToRecent: true
  };
}

Tag-based matching calculates overlap and prioritizes posts with the most shared tags:

function findTagBasedRelated(
  currentPost: BlogPost,
  allPosts: BlogPost[],
  maxResults: number = 4
): RelatedPost[] {
  const scored = allPosts
    .filter(p => p.id !== currentPost.id)
    .map(post => ({
      post,
      score: post.tags.filter(tag => currentPost.tags.includes(tag)).length
    }))
    .filter(({ score }) => score > 0)
    .sort((a, b) => b.score - a.score);

  return scored.slice(0, maxResults).map(({ post }) => ({
    id: post.id,
    title: post.title,
    description: post.description,
    pubDate: post.pubDate,
    similarity: 0
  }));
}

If a post has no tags, it falls back to showing recent posts sorted by publication date.

Integration with the Build Process

The entire clustering process runs as a prebuild script:

{
  "scripts": {
    "prebuild": "pnpm exec tsx scripts/generate-clusters.ts",
    "build": "astro check && astro build"
  }
}

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.87
      }
    ],
    "clusterSize": 6,
    "fallbackToRecent": false
  },
  "_metadata": {
    "generatedAt": "2025-11-30T12:00:00.000Z",
    "totalPosts": 31,
    "clusterCount": 7,
    "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 embedding model, ~90MB)
  • Subsequent runs: ~5-10 seconds (uses cached model from node_modules/.cache)
  • Build impact: Adds ~8-10 seconds to total build time
  • Runtime impact: Zero (everything is pre-computed)

The embedding model is cached between runs, so local development is fast. On CI/CD, the model download happens once and is cached for subsequent builds.

Results and Observations

  1. 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.

  2. Title weight matters. Without the 3x title weighting, posts would cluster by tools mentioned rather than topics discussed.

  3. Small clusters indicate niche topics. Posts that fell back to tag-based matching were usually about unique topics I’d only written about once or twice.

  4. Similarity scores are meaningful. Machine learning clustering posts scored 0.82 similarity with dimensionality reduction posts (closely related), while web development posts mentioning ML scored 0.61 (loosely related). Scores above 0.8 indicate posts covering similar ground.

  5. 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 build-time approach with local embeddings hit the sweet spot: good enough quality, zero runtime cost, and fast enough to run on every build.

Future Improvements

There are several ways I could enhance this system:

  1. Hierarchical clustering - Create topic hierarchies for better navigation
  2. Time decay - Weight recent posts higher in recommendations
  3. Click tracking - Use actual user behavior to refine clusters
  4. Multi-modal embeddings - Include images and code snippets
  5. 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 at build time and committing the results, I got:

  • Zero runtime performance penalty
  • No API costs or external dependencies
  • Semantic understanding of post content
  • Automatic recommendations for 30+ posts
  • Complete transparency (results are in git)

The entire system is ~385 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 on every build and generates src/data/related-posts.json.