The Knowledge Graph as a recommendation engine: how I killed the 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 solution: a Gradle pipeline, not a hot LLM call
- Emergent Ontology: When Articles Cluster Without Knowing Each Other
- The DAG Contract: Who Matters Whom
- What We Gain (And What We Don’t Gain)
- Perspectives: The Blog as a Public Knowledge Graph
- Conclusion : The file system knows what the template ignores
- References
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:
-
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 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:
-
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
-
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.
-
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.
@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:
-
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)
)
}
-
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 }
}
}
-
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`, |
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:
-
0119 — Benchmark DGX Spark vs Cloud Subscription LLM
-
0121 — Gradle Plugin to Pilot Two Ollama Instances Pro
-
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`, |
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.
References
-
Article 0106 — The Knowledge Graph as a tool for comprehension
-
Article 0121 — Gradle Plugin Control Two Ollama Pro Instances
-
Article 0122 — 27× to 45× more effective : fleet of AI experts