Last updated on

Building a Knowledge Graph for My Blog

How I built an interactive knowledge graph linking blog posts by semantic similarity and shared tags, using embeddings, NER and D3 force-directed layouts.



I wanted to go beyond simple “related posts” recommendations. In my previous post on semantic blog clustering, I built a system that groups posts by topic similarity. But what if readers could explore my blog like a map of connected ideas? A knowledge graph enables precisely this: a navigable network where every post, tag, and concept is a node, and relationships are edges you can traverse.

Why a Knowledge Graph?

Traditional blog navigation is linear: browse by date, search by keyword, or follow tag links. But ideas don’t work that way. A post about Python might connect to one about embeddings through shared techniques, even if they have different tags. A knowledge graph captures these semantic connections.

My goals were:

  • Discover hidden connections: Find posts that are conceptually similar even without shared tags
  • Visualize topic clusters: See how my writing groups into themes
  • Enable exploration: Let readers navigate by curiosity rather than categories

The Graph Structure

The knowledge graph has three types of nodes and five types of edges. The counts below are approximate, as of the time of writing:

Nodes:

  • Posts (about 40): Individual blog articles, colored by topic cluster
  • Tags (about 95): Explicit labels like “python”, “mathematics”, “react”
  • Concepts (one at the time of writing): Automatically extracted entities from post content

Edges:

  • Similar: Posts connected by semantic similarity (cosine similarity ≥ 0.3)
  • Tagged: Posts linked to their tags
  • Mentions: Posts that reference extracted concepts
  • Co-occurs: Concepts that frequently appear together
  • Shared tags: Posts linked directly when they share at least two tags

Interactive Visualization

Below is the live knowledge graph. Posts are colored by their k-means cluster assignment. You can see how embeddings group related content. Click any post to read it, or toggle “Tags” to see how topics connect through shared labels.

Understanding the colors: Posts are colored by k-means cluster assignment. The algorithm groups posts with similar embeddings into 10 clusters, each with a distinct color: blue, red, green, amber, violet, pink, cyan, orange, lime, and indigo. Posts about related topics cluster together automatically and share the same color. Tags appear as gray rounded rectangles to avoid competing visually with post nodes. Concepts (one currently: “data”) appear as purple diamonds. The colors reveal semantic groupings discovered automatically from content, not manual categories.

Technical Implementation

Embeddings with Sentence Transformers

I use @xenova/transformers with the Xenova/all-MiniLM-L6-v2 model to generate 384-dimensional embeddings for each post. The embedding captures semantic meaning by:

  1. Weighting the title 3x (titles are information-dense)
  2. Including all tags, prefixed with “Tags:”
  3. Adding the description
  4. Sampling the first 500 characters of content, with markdown stripped
function preparePostText(post: BlogPost): string {
  const titleWeight = [post.title, post.title, post.title].join(' ');
  const tagsText = post.tags.length > 0 ? `Tags: ${post.tags.join(', ')}` : '';

  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');
}

Pairwise Similarity Computation

With embeddings in hand, I compute cosine similarity between all post pairs. Posts with similarity ≥ 0.3 receive a “similar” edge. This threshold balances density and quality, ensuring enough connections to navigate while maintaining meaningful relationships.

export function computePairwiseSimilarity(
  ids: string[],
  embeddings: number[][],
  threshold: number = 0.3
): SimilarityEdge[] {
  const edges: SimilarityEdge[] = [];

  for (let i = 0; i < ids.length; i++) {
    for (let j = i + 1; j < ids.length; j++) {
      const similarity = cosineSimilarity(embeddings[i], embeddings[j]);

      if (similarity >= threshold) {
        edges.push({
          source: ids[i] < ids[j] ? ids[i] : ids[j],
          target: ids[i] < ids[j] ? ids[j] : ids[i],
          weight: similarity,
        });
      }
    }
  }

  return edges;
}

Deduplication ensures each pair has one undirected edge, not two directed ones. The logic checks that source < target lexicographically.

Named Entity Recognition

I attempted to extract concepts using Xenova/bert-base-NER, a BERT-based model that identifies organizations, technologies, and entities. The problem: my content spans mathematics, React, operational research, and military AI.

The NER model surfaces plenty of one-off entities (PostgreSQL, Python, Monte Carlo), but almost none of them appear in more than one post. My threshold requires a concept to span at least two posts to become a node — as the blog has grown, just one concept (“data”) has cleared that bar.

Lesson learned: NER works best with homogeneous content. A focused blog about PostgreSQL would surface dozens of shared concepts. For diverse content like mine, tags remain the better explicit signal.

Pre-Computed Generation

The graph is not generated by the build. A git pre-commit hook runs pnpm generate:all (clusters, then graph) whenever a commit touches a blog post, and stages the regenerated JSON. I can also run it by hand:

pnpm generate:graph    # Regenerate the graph only
pnpm generate:all      # Regenerate related posts and the graph
pnpm dev:graph         # Regenerate both and start dev server

This approach offers:

  • Zero runtime overhead: The graph is a static JSON file
  • Cacheable: The embedding model (~90MB at full precision) downloads once
  • Reviewable: Changes to knowledge-graph.json are visible in PRs

The first run takes roughly 90 seconds (downloading models), while subsequent runs take 35 seconds. This overhead is acceptable for a step that only runs when posts change.

Visualization with D3

The interactive visualization uses D3’s force-directed layout. Each node repels others (charge force) while edges pull connected nodes together (link force). The simulation settles into an equilibrium where clusters emerge naturally.

Key implementation details:

  1. Collision detection: Posts receive a larger radius (20px) than tags (15px), preventing overlap
  2. Edge strength: Similarity edges use their weight as strength, with higher similarity pulling nodes closer
  3. Dynamic layers: Toggling tags adds or removes nodes and re-heats the simulation with smooth transitions
  4. Cluster coloring: Ten distinct colors map to the k-means cluster assignments from the generation step

The component renders client-side only because D3 requires DOM access.

Lessons Learned

1. Tags Beat NER for Diverse Content

I initially hoped NER would surface implicit connections: concepts I wrote about without explicitly tagging. In practice, almost none of the extracted concepts appeared in more than one post. Explicit tags remain more useful.

For a focused blog (all React tutorials), NER works better. For eclectic writing, it produces noise.

2. Embeddings Capture Semantic Similarity Well

Despite the NER limitation, embeddings effectively capture semantic similarity. My post adapting a naval combat model to Warhammer 40k connects to the Terni Lapilli game investigation and the ship deceleration calculation, and all three share a cluster. The linear algebra crib sheet, matrix methods review and numerical linear algebra posts form a tight group. The PostgreSQL posts are a useful counterexample: they are spread across several clusters, because embeddings group them by what they do with the database (writing tests, upserting DataFrames, choosing JSONB) rather than by the database itself. The 0.3 threshold strikes a good balance.

3. Pre-Computed Generation Is The Right Choice

Generating the graph ahead of time means:

  • Readers get instant interactivity (no loading spinner)
  • I can review changes to the graph structure in version control
  • No server costs for embedding computation

The tradeoff is a slower commit when posts change, but 35 seconds is acceptable for a step I run once per blog post.

4. Force Layouts Require Careful Tuning

D3’s force simulation is powerful but unpredictable. Small parameter tweaks can drastically change the layout. I spent several hours tuning charge strength, link distance, and collision radius to achieve readable results.

For production use, consider pre-computing node positions (for example, using a graph layout algorithm like Fruchterman-Reingold) and animating transitions.

What’s Next?

The knowledge graph unlocks several potential features:

  • Shortest path: “How is this post connected to that post?”
  • Subgraph filtering: Show only posts within 2 edges of the current one
  • Temporal view: Animate how the graph evolved over time as I published posts
  • Concept evolution: If I write more focused series, concepts might actually appear and show how ideas develop

For now, it remains a tool for exploration.


Code & Resources: