reading time: 14 minutes

You are reading an article about integrating pgvector with LangChain4j. At the bottom of the page, JBake suggests "Related Articles." You click. It’s an article about…​ the configuration of Kitty Terminal. The only common point between the two? They were published less than fifteen days apart. That’s not a recommendation. It’s a calendar disguised as editorial.

The Scene: Tuesday, May 12, 5:30 PM

I am rereading one of my articles — the one on the Knowledge Graph as a tool for codebase understanding. The content is dense: nodes, edges, communities, PlantUML, onboarding. A technical article with a 15-minute read that leverages`graphify-gradle`, plantuml-gradle, and graph topology concepts.

At the bottom of the page, "Related Articles" propose:

  1. An article on Firebase Contact Form (0113)

  2. An article on the Eager/Lazy mechanism (0108)

  3. An article on Gradle script → plugin migration (0102)

  4. An article on stacking Ollama Pro subscriptions (0120)

Three of these four articles have no connection to the Knowledge Graph. They are there because they are the latest published — temporal proximity, not semantic proximity.

I look at the template`post.thyme`:

<!-- Articles connexes (liens internes SEO) -->
<th:block th:each="post,postStat : ${published_posts}">
    <th:block th:if="${!post.uri.equals(content.uri) and postStat.index lt 4}">
        ...
    </th:block>
</th:block>

`published_posts`is a chronological list.`postStat.index lt 4`takes the first four that are not the current post. That’s it. No similarity logic. No notion of content. Just a`for`loop disguised as a recommendation.

This isn’t a JBake bug. It’s the default behavior of every static site generator: the post list is flat and ordered by date. The template does what it can with what it has.

But that’s no reason to accept it.

The Diagnosis: Three Types of Relations That No Template Sees

My article corpus — 23 posts in 4 months — has a rich structure that the chronological template completely flattens:

  1. Explicit references: I use`xref:`extensively in my articles.

Article 0122 references 0106 (knowledge graph) and 0116 (epistemic compartmentalization). These links are editorial hard linking — I deliberately decided to connect these concepts.

  1. Shared tags: each article has`:jbake-tags:`. The article on

Graphify + PlantUML (0105) has`gradle, graphify, plantuml, knowledge-graph`. The article on the Knowledge Graph (0106) has knowledge-graph, graphify, plantuml. Three common tags out of seven — 43% overlap.

  1. Co-occurring named entities: "pgvector", "RAG", "embedding"

appear together in four different articles. "LangChain4j", "Ollama", "Gradle plugin" in six others. These are not declared tags — they are emerging patterns from the corpus that only an NLP can detect. Today, my site uses none of these three layers. It uses

trois couches relation articles

layer zero: the insertion order in a Java list. The Solution: A Gradle Pipeline, Not a Hot LLM Call

The first temptation would be to call an LLM during the bake: "For

this article, find the three most similar articles in the corpus." Don’t do that. An LLM call for every

costs time, money, and`./gradlew bake`introduces non-determinism into your build. The LLM’s response may change between two builds without the content having changed. Your CI becomes non-reproducible, your tests become flaky. The solution is deterministic: a Gradle pipeline that pre-calculates the similarity graph

and stores it in . The template reads the result`graph.json`— it never triggers a calculation. The Architecture: Bakery Imports Graphify, Not Engine

This is the most important architectural point. The temptation would be to

wire the collaboration in : engine applies`engine/build.gradle.kts`graphify for the scan, bakery for the bake, and engine bridges the two. This is an anti-pattern. Engine is a consumer terminal — it applies

plugins; it doesn’t implement business logic. The rule is: the burden of proof is on the proprietary plugin. .Correct architecture — Bakery imports Graphify

The DAG contract is respected: bakery (N2) imports graphify (N0), N2 > N0,

bakery import graphify

no violation. Engine (N3) imports bakery (N2), N3 > N2, OK. Engine has no line of code that references

or any other notion of similarity. It applies bakery, period. The`graph.json`, relatedPosts, collaboration is internal to bakery. The Pipeline: Scan → Graph → Template

Here is the complete three-step pipeline:

Scan (graphify):

  1. scans the workspace. In its`graphify-plugin`current form, it already detects

between`xref:`and`.adoc`exposes them in as`graph.json`type edges.reference`We enrich it for editorial content:
* Parse JBake metadata ( )
:jbake-tags:`, :jbake-description:`of each blog de chaque `.adoc* Calculate tag co-occurrences → edges with weight`tag_cooccurrence`* Extract named entities from descriptions via TF-IDF → edges`entity_overlap` * Inject a`blog_articles`section into the existing`graph.json` existant

// Extrait de l'enrichissement graphify pour le blog
fun enrichBlogSection(graphJson: File, blogDir: File): GraphJson {
    val articles = blogDir.listFiles { f -> f.extension == "adoc" }
        .map { parseJbakeMetadata(it) }

    val nodes = articles.map { ArticleNode(it.slug, it.title, it.tags) }
    val edges = mutableListOf<GraphEdge>()

    // Couche 1 : xref (déjà fait par scanWorkspace)

    // Couche 2 : co-occurrences de tags
    for (a in articles) {
        for (b in articles) {
            if (a.slug == b.slug) continue
            val common = a.tags.intersect(b.tags)
            if (common.isNotEmpty()) {
                edges.add(GraphEdge(
                    source = a.slug,
                    target = b.slug,
                    type = "tag_cooccurrence",
                    weight = common.size.toDouble() / (a.tags.size + b.tags.size)
                ))
            }
        }
    }

    return graphJson.copy(
        blogArticles = BlogSection(nodes, edges)
    )
}
  1. Bake (bakery): during the`./gradlew bake`, `BakeryPlugin`it reads

`graph.json`and resolves related articles for each post.

// BakeryPlugin — résolution des articles connexes
fun resolveRelatedPosts(
    currentSlug: String,
    graph: GraphJson,
    maxResults: Int = 4
): List<RelatedPost> {
    val edges = graph.blogArticles.edges
        .filter { it.source == currentSlug || it.target == currentSlug }

    return edges
        .sortedByDescending { it.weight }
        .take(maxResults)
        .map { edge ->
            val relatedSlug = if (edge.source == currentSlug) edge.target else edge.source
            graph.blogArticles.nodes.first { it.slug == relatedSlug }
        }
}
  1. Template (post.thyme): the JBake model now receives a

structured map instead of a flat chronological list.

<!-- Articles connexes basés sur le Knowledge Graph -->
<th:block th:if="${relatedPosts != null and !relatedPosts.empty}">
    <section class="mt-5 pt-4 border-top">
        <h2 class="h4 mb-3">Articles connexes</h2>
        <th:block th:each="related : ${relatedPosts}">
            <div class="mb-2">
                <a th:href="${content.rootpath} + ${related.uri}"
                   th:text="${related.title}" class="fw-semibold"></a>
                <br/>
                <small class="text-muted">
                    <th:block th:each="reason,iterStat : ${related.reasons}">
                        <span class="badge bg-light text-dark"
                              th:text="${reason}"></span>
                    </th:block>
                </small>
            </div>
        </th:block>
    </section>
</th:block>

The template is the same — it doesn’t know where the data comes from. Only the contract between bakery and JBake has changed:`published_posts`(chronological list) becomes`relatedPosts`(graph-weighted map).

The "reason" badge (e.g.,xref 0122, tag:gradle, cluster:pgvector-rag) explains to the reader why this article is related. This isn’t just transparency — it’s pedagogy on the topology of your own content.

Chronological Fallback

Si `graph.json`is missing (local build without prior scan, first deployment, CI that hasn’t integrated the graphify scan yet), the template must degrade gracefully:

fun resolveRelatedPosts(currentSlug: String, graph: GraphJson?): List<RelatedPost> {
    if (graph != null && graph.blogArticles != null) {
        return resolveFromGraph(currentSlug, graph)
    }
    // Fallback chronologique — même comportement qu'aujourd'hui
    logger.warn("[bakery] graph.json absent — fallback chronologique")
    return resolveFromChronology(currentSlug)
}

The default behavior is identical to the current behavior. The recommendation engine is a progressive enhancement — not a breaking change.

Emerging Ontology: When Articles Group Together Without Knowing Each Other

The most interesting layer is the third: the emerging ontology. Articles that don’t cite each other, don’t have the same tags, but talk about the same thing without knowing it.

Let’s take a concrete example. Three articles from my corpus:

  1. 0119 — DGX Spark Benchmark vs LLM Subscription Cloud

  2. 0121 — Gradle Plugin Controlling Two Ollama Pro Instances

  3. 0122 — 27x 45x Efficiency Ratio AI Expert Fleet

These three articles have no xrefs between them. Their tags only overlap by 20% ("ollama", "llm" common). But a light NLP on the descriptions reveals an obvious cluster: "cost", "subscription", "cloud", "GPU", "API key", "Ollama Pro", "efficiency", "ratio".

They form an ontological cluster: the economics of self-hosted vs cloud LLM. This cluster is declared nowhere. It emerges from the corpus.

cluster ontologique exemple

This ontological cluster becomes a composite edge in`graph.json`:

{
  "source": "0119-benchmark-dgx-spark",
  "target": "cluster:economie-llm",
  "type": "entity_cluster",
  "weight": 0.73,
  "metadata": {
    "clusterLabel": "Économie LLM Self-Hosted vs Cloud",
    "commonEntities": ["coût", "GPU", "abonnement", "Ollama Pro", "ratio"],
    "articlesInCluster": [
      "0119-benchmark-dgx-spark",
      "0121-ollama-pro-deux-instances",
      "0122-ratio-efficacite-flotte-experts"
    ]
  }
}

The NLP is intentionally light — TF-IDF + cosine similarity on descriptions. No need for BERT, no need for a language model. The corpus has 23 articles; the similarity matrix fits in a 50 KB JSON file.

The power of NLP doesn’t come from the sophistication of the algorithm, but from the corpus size and the quality of the descriptions. A `:jbake-description:`well-written description of 150 characters contains more signal for TF-IDF than a full 3000-word article.

The DAG Contract: Who Imports Whom

This is where architectural discipline pays off. The N0→N3 DAG defined in`engine/build.gradle.kts`provides a simple rule: no project imports a higher-level project.

Consumer Plugin Imported Plugin Consumer Level Imported Level Valid?

bakery-gradle

graphify-gradle

N2

N0

✅ N2 > N0 — OK

engine

bakery-gradle

N3

N2

✅ N3 > N2 — OK

engine

graphify-gradle

N3

N0

✅ Technically OK, but conceptually wrong — the collaboration is internal to bakery

Engine applies bakery. Bakery applies graphify. That’s it. If one day I want to add the codebase vector store (N1) for cross-corpus semantic search, bakery imports it too. Engine doesn’t change.

The pattern is: the N2 plugin is the hub for its own dependencies. Engine N3 is not a hub — it’s a terminal that applies hubs.

What We Gain (And What We Don’t)

The main gain is editorial, not technical:

Before After

Related articles = 4 latest posts

Related articles = top 4 by weight in the Knowledge Graph

Reader reads about RAG → Kitty Terminal recommendation

Reader reads about RAG → pgvector, chunking, vector store recommendation

Zero transparency on why

Badge xref 0122, tag:gradle, `cluster:rag`badge visible

Standard JBake, zero effort, zero value

Deterministic, reproducible, testable Gradle pipeline

No learning curve for the reader

The reader discovers the topology of my content

What we don’t gain:

  • This isn’t a "real" recommendation engine (no collaborative

filtering, no A/B testing, no feedback loop)

  • Quality depends on the richness of JBake metadata — if your

descriptions are empty, TF-IDF sees nothing

  • NLP is offline — new articles are only clustered during the

next scan (which is good: the build remains deterministic)

Perspectives: The Blog as a Public Knowledge Graph

This feature opens a broader perspective: what if`cheroliv.com` became a navigable knowledge graph itself?

Articles are nodes. Tags are communities. Xrefs are edges. The reader no longer reads an isolated article — they navigate a knowledge graph where the current article is the entry point.

Imagine a homepage that displays not a chronological list of recent posts, but a corpus map: ontological clusters, pivot articles (those with the most edges), recommended reading paths ("If you liked the article on Graphify, then read the one on the Knowledge Graph as a tool for understanding").

It’s no longer a blog. It’s a semantic atlas.

And this isn’t science fiction. The`graph.json`already exists. It only lacks the navigation interface.

Conclusion: The File System Knows What the Template Ignores

What I designed this Tuesday evening is the replacement of a lazy heuristic (chronological order) with a faithful representation of the structure of my corpus (the Knowledge Graph).

The template`post.thyme`doesn’t change. What changes is what we feed it. Before: a Java list ordered by`date`. After: a weighted graph resulting from three layers of analysis — xrefs, tag co-occurrences, and emerging ontology via NLP.

The loop is closed. graphify scans the workspace. Bakery reads the graph and feeds the template. The reader sees relevant recommendations. And engine — the conductor — doesn’t even know that any of this exists. That is good architecture. Each plugin does one thing.

And the consumer terminal doesn’t need to know how. References

Related articles