reading time : 14 minutes

You are reading an article about the integration of 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 point they have in common? They were published less than fifteen days apart. This is not a recommendation. It is 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 comprehension. The content is dense: nodes, edges, communities, PlantUML, onboarding. An article 15-minute reading technique that mobilizes`graphify-gradle`, plantuml-gradle, and the concepts of graph topology.

At the bottom of the page, the "Related articles" suggest:

  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 the cumulative Ollama Pro subscriptions (0120)

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

I am looking 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 The first four that are not the current post. That’s it. No logic of similarity. No notion of content. Just a loop`for`disguised as recommendation.

It’s not a JBake bug. It’s the default behavior of everything. static site generator: the list of posts is flat and ordered by date. The template does what it can with what it has.

But that’s not a 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 overwhelms:

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

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

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

Graphify + PlantUML (0105) has`gradle, graphify, plantuml, knowledge-graph`. The article about the Knowledge Graph (0106) has knowledge-graph, graphify, plantuml. Three tags in common 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 tags declared — these are patterns émergents of the corpus that only an NLP can detect.

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center

title Three layers of relation between articles
package "Layer 1 — Explicit references" #CCFFCC {
    [Article A] as A1
    [Article B] as B1
    A1 -down-> B1 : xref:
    note right of A1
        Liens durs
        Déterministes
        Déjà dans graph.json
    end note
}

package "Layer 2 — Shared Tags" #FFFFCC {
    [Article A] as A2
    [Article C] as C2
    A2 ..> C2 : tag_cooccurrence
    note right of A2
        Poids = tags communs / tags totaux
        Déterministes
        À extraire via graphify
    end note
}

package "Layer 3 — Emergent Ontology" #FFCCCC {
    [Corpus A] as A3
    [Corpus B] as B3
    A3 ..> B3 : entity_overlap
    note right of A3
        Clusters NLP
        Co-occurrences d'entités nommées
        TF-IDF / cosine similarity
    end note
}

@enduml

Today, my site does not use any of these three layers. It uses 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 at bake time: « For this article, finds the three most similar articles in the corpus. Don’t do that.

An LLM call to each`./gradlew bake`costs time, money, and introduces non-determinism into your build. The LLM’s response can to switch 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 graph of similarity and stores it in`graph.json`. The template reads the result — he never triggers a calculation.

Architecture : Bakery imports Graphify, not engine

This is the most important architectural point. The temptation would be to wire the collaboration in`engine/build.gradle.kts`engine applies graphify for the scan, bakery for the bake, and engine makes the bridge between the two.

It’s an anti-pattern. Engine is a consumer terminal — it applies plugins, it does not implement business logic. The rule is: The burden of proof lies with the proprietary plugin.

Architecture correct — Bakery imports Graphify
@startuml
skinparam backgroundColor #FEFEFE
skinparam packageBackgroundColor #FFF3CD

title Bakery imports Graphify — Engine knows nothing
package "N3 — ENGINE" #FFCCCC {
    [engine/build.gradle.kts] as ENG
    note right of ENG
        plugins { bakery }
        Zéro connaissance de graphify
        4 tâches, ~150 lignes
    end note
}

package "N2 — BAKERY" #CCFFCC {
    [bakery-plugin] as BAK
    note right of BAK
        plugins { graphify } ← import interne
        Lit graph.json
        Résout articles connexes
        Expose le modèle à JBake
    end note
}

package "N0 — GRAPHIFY" #CCE5FF {
    [graphify-plugin] as GRF
    note right of GRF
        Scan workspace → graph.json
        Enrichi avec :
        - xref: edges
        - co-occurrences de tags
        - entités nommées (NLP)
    end note
}

ENG -down-> BAK : "plugins { bakery }"
BAK -down-> GRF : "plugins { graphify } ← N2→N0 OK"
@enduml

The DAG contract is respected: bakery (N2) imports graphify (N0), N2 > N0, no violation. Engine (N3) imports bakery (N2), N3 > N2, OK.

Engine has no line of code that references`graph.json`, relatedPosts, or any other notion of similarity. He applies bakery, period. The collaboration is internal to bakery.

The Pipeline: Scan → Graph → Template

Here is the complete three-step pipeline:

  1. Scan (graphify):`graphify-plugin`scans the workspace. In its

current form, it already detects the`xref:`between`.adoc`and the presnted in`graph.json`like edges of type`reference`.
We enrich it for editorial content: * Parse JBake metadata (:jbake-tags:, :jbake-description:) of each`.adoc`of the blog * Calculate tag co-occurrences → edges`tag_cooccurrence`with weight * Extract named entities from descriptions via TF-IDF → edges`entity_overlap` * Injects a section`blog_articles`in the`graph.json`existing

// 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): at the moment of the`./gradlew bake`, `BakeryPlugin`bed

`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 map

structured 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`(list chronological) becomes`relatedPosts`(graph-weighted map).

The badge « raison » (e.g:`xref 0122`, tag:gradle, cluster:pgvector-rag) explains to the reader why this article is related. It is not just transparency — it’s pedagogy about the topology of your own content.

Fallback Chronological

Si `graph.json`is absent (local build without prior scan, first deployment, CI that has not yet integrated the graphify scan), 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 improvement — not a breaking change.

Emergent Ontology: When Articles Cluster Without Knowing Each Other

The most interesting layer is the third: the emergent ontology Articles that are not cited, that do not have the same tags, but who are talking about the same thing without realizing it

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

  1. 0119 — Benchmark DGX Spark vs Cloud Subscription LLM

  2. 0121 — Gradle Plugin to Pilot Two Ollama Instances Pro

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

These three articles have no xref between them. Their tags do not They only overlap by 20% ('ollama', 'llm' common). But a lightweight NLP about the descriptions reveals an obvious cluster: “cost”, “subscription”, cloud », « GPU », « API key », « Ollama Pro », « efficiency », « ratio ».

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

@startuml
skinparam backgroundColor #FEFEFE
skinparam packageBackgroundColor #E8F8F5

title Cluster Ontologique Émergent — "Economy LLM Self-Hosted"

package "Detected cluster: economy-llm" #D5F5E3 {
    [0119 — Benchmark DGX Spark\nvs Cloud Abonnement] as A
    [0121 — Plugin Gradle Piloter\nDeux Instances Ollama Pro] as B
    [0122 — Ratio Efficacité\n27x 45x Flotte Experts] as C
}

A .. B : entity_overlap (0.73)
B .. C : entity_overlap (0.81)
A .. C : entity_overlap (0.68)

note bottom of A
    Aucun xref entre ces articles.
    Tags communs : "ollama", "llm".
    Cluster découvert par NLP :
    co-occurrences "cost", "GPU",
    "subscription", "Ollama Pro".
end note
@enduml

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"
    ]
  }
}

NLP is intentionally lightweight — TF-IDF + cosine similarity on the 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 does not come from the sophistication of the algorithm, but of the corpus size and of the quality of the descriptions. One `:jbake-description:`well written of 150 characters contains more of signal for TF-IDF that an entire article of 3000 words

The DAG Contract: Who Matters Whom

It is where architectural discipline pays off. The DAG N0→N3 defined in`engine/build.gradle.kts`gives a simple rule: No project imports a higher‑level project.

Consumer plugin Plugin imported 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

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

Engine applies bakery. Bakery applies graphify. That’s all. If one day I want to add the vector store codebase (N1) for research. cross-corpus semantics, bakery imports it too. Engine does not change.

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

What We Gain (And What We Don’t Gain)

The main benefit is editorial, not technical:

Before After

Related articles = 4 latest posts

Related articles = top 4 by weight in the Knowledge Graph

Reader reads RAG → Kitty Terminal recommendation

Reader reads RAG → recommendation pgvector, chunking, vector store

Zero transparency on the why

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

JBake standard, 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 win:

  • It is not a « true » recommendation engine (no collaborative

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

  • The quality depends on the richness of the JBake metadata — if your

descriptions are empty, TF-IDF sees nothing

  • NLP is offline — new articles are only clustered up to

next scan (it’s good : the build remains deterministic)

Perspectives: The Blog as a Public Knowledge Graph

This feature opens up a broader perspective: what if`cheroliv.com` becoming himself a knowledge graph navigable ?

Articles are nodes. Tags are communities. xref are edges. The reader no longer reads an isolated article — he navigates in a knowledge graph whose current article is the entry point.

Imagine a home page that does not display the chronological list of the latest posts, but a map of the corpus: ontological clusters, pivot articles (those with the most edges), reading paths If you liked the article about Graphify, read next that on the Knowledge Graph as a comprehension tool

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

And it’s not science fiction. The`graph.json`already exists He/She is missing only the navigation interface.

Conclusion : The file system knows what the template ignores

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

The template`post.thyme`doesn’t change. What changes is what we feeds him/her. Before: a Java list ordered by`date`. After: a weighted graph derived from three layers of analysis — xrefs, co-occurrences of tags, and emerging ontology by NLP.

The loop is closed. graphify scans the workspace. Bakery reads the graph and feeds the template. The reader sees recommendations relevant. And engine — the conductor — doesn’t even know that All that exists.

That’s it, good architecture. Each plugin does one thing. And the consumer terminal does not need to know how.

Related articles