The Knowledge Graph as a Recommendation Engine: How I Killed Chronological "Related Articles"
Published on 12 May 2026
- The Scene: Tuesday, May 12, 5:30 PM
- The Diagnosis: Three Types of Relations That No Template Sees
- The first temptation would be to call an LLM during the bake: "For
- Emerging Ontology: When Articles Group Together Without Knowing Each Other
- The DAG Contract: Who Imports Whom
- What We Gain (And What We Don’t)
- Perspectives: The Blog as a Public Knowledge Graph
- Conclusion: The File System Knows What the Template Ignores
- Article 0105 — Integrating Graphify into a Gradle workflow
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:
-
An article on Firebase Contact Form (0113)
-
An article on the Eager/Lazy mechanism (0108)
-
An article on Gradle script → plugin migration (0102)
-
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:
-
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.
-
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.
-
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
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,
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):
-
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::jbake-tags:`,
* Parse JBake metadata ( ):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)
)
}
-
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 }
}
}
-
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., |
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:
-
0119 — DGX Spark Benchmark vs LLM Subscription Cloud
-
0121 — Gradle Plugin Controlling Two Ollama Pro Instances
-
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.
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 |
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