De Knowledge Graph als aanbevelingsmotor : hoe ik de chronologische gerelateerde artikelen heb doodgemaakt
Gepubliceerd op 12 May 2026
- De scène : dinsdag 12 mei, 17:30
- Diagnose: drie soorten relaties die geen sjabloon ziet
- De Oplossing: Een Gradle-Pipeline, Geen Live LLM-Aanroep
- De opkomende ontologie : wanneer artikelen zich verzamelen zonder elkaar te kennen
- Het DAG-contract : Wie telt voor wie?
- Wat We Winnen(En Wat We Niet Winnen)
- Perspectieven: De blog als publiek Knowledge Graph
- Conclusie: Het bestandssysteem weet wat de template negeert
- Referenties
Je leest een artikel over de integratie van pgvector met LangChain4j. Onder aan de pagina, JBake u suggereert « Gerelateerde artikelen ». U klikt. Dit is een artikel over… de configuratie de Kitty Terminal. Het enige gemeenschappelijke punt tussen de twee? Ze zijn gepubliceerd binnen vijftien dagen verschil. Dit is geen aanbeveling. Het is een kalender vermomd als redactioneel
De scène : dinsdag 12 mei, 17:30
Ik lees een van mijn artikelen opnieuw — dat over de Knowledge Graph als hulpmiddel voor het begrijpen van de codebase De inhoud is dicht: knooppunten, randen, gemeenschappen, PlantUML, onboarding. Een artikel 15-minuten leestechniek die mobiliseert`graphify-gradle`, plantuml-gradle, en de concepten van grafentopologie.
Onder aan de pagina stellen de « Verwante artikelen » mij voor:
-
Een artikel over Firebase Contact Form (0113)
-
Een artikel over het Eager/Lazy-mechanisme (0108)
-
Een artikel over de migratie van Gradle script → plugin (0102)
-
Een artikel over de stapeling van abonnementen Ollama Pro (0120)
Drie van deze vier artikelen hebben geen relatie met de Knowledge Graph. Ze zijn daar omdat ze de laatst gepubliceerde zijn — tijdelijke nabijheid, niet semantische omgeving
Ik bekijk de sjabloon`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 een chronologische lijst.`postStat.index lt 4`neemt de vier eerste die niet de huidige post zijn. Dat is alles. Geen logica van similariteit. Geen notie van inhoud. Gewoon een lus`for`verkleed als aanbeveling.
|
Dit is geen bug van JBake. Het is het standaardgedrag van alles. statische sitegenerator: de lijst met berichten is plat en gesorteerd op datum Het sjabloon doet wat het kan met wat het heeft. |
Maar dat is geen reden om het te accepteren.
Diagnose: drie soorten relaties die geen sjabloon ziet
Mijn corpus van artikelen — 23 posts in 4 maanden — heeft een rijke structuur die het chronologische sjabloon overschrijft volledig :
-
Expliciete referenties: ik gebruik`xref:`massief in mijn artikelen.
Artikel 0122 verwijst naar 0106 (knowledge graph) en 0116 (compartimentering epistemisch). De links zijn hard linking redactioneel — ik heb opzettelijk besloten om deze concepten te verbinden
-
Tags gedeeld : elk artikel heeft`:jbake-tags:`. Het artikel over
Graphify + PlantUML (0105) heeft`gradle, graphify, plantuml, knowledge-graph`. Het artikel over de Knowledge Graph (0106) heeft knowledge-graph, graphify, plantuml. Drie tags gemeenschappelijk van de zeven — 43% overlap
-
Co‑occurrende genoemde entiteiten : « pgvector », « RAG », « embedding
verschijnen samen in vier verschillende artikelen. « LangChain4j », Ollama », « Gradle-plugin » in zes andere. Dit zijn geen tags verklaard — dit zijn opkomende patronen van het corpus dat alleen een NLP kan detecteren.
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
title Drie lagen relatie tussen artikelen
package "Laag 1 — Explizite verwijzingen" #CCFFCC {
[Article A] as A1
[Article B] as B1
A1 -down-> B1 : xref:
note right of A1
harde koppelingen
determinist
al in graph.json
end note
}
package "Laag 2 — Gedeelde tags" #FFFFCC {
[Article A] as A2
[Article C] as C2
A2 ..> C2 : tag_cooccurrence
note right of A2
Gewicht = gemeenschappelijke tags / totale tags
Deterministisch
Te extraheren via graphify
end note
}
package "Laag 3 — Opkomende ontologie" #FFCCCC {
[Corpus A] as A3
[Corpus B] as B3
A3 ..> B3 : entity_overlap
note right of A3
Clusters NLP
Co‑occurrences van genoemde entiteiten
TF-IDF / cosinussimilariteit
end note
}
@enduml
Vandaag gebruikt mijn site geen van deze drie lagen. Het gebruikt laag nul: de invoegvolgorde in een Java-lijst
De Oplossing: Een Gradle-Pipeline, Geen Live LLM-Aanroep
De eerste verleiding zou zijn om een LLM aan te roepen tijdens de bake: « Voor dit artikel, vindt de drie meest vergelijkbare artikelen in de corpus Doe dat niet.
|
Een LLM-oproep bij elke`./gradlew bake`kost tijd, geld, en brengt niet-determinisme in jouw build. Het antwoord van de LLM kan Wisselen tussen twee builds zonder dat de inhoud is veranderd. Uw CI wordt niet reproduceerbaar, uw tests worden flaky. |
De oplossing is deterministisch: een Gradle-pipeline die de grafiek vooraf berekent. van gelijkheid en hij bewaart het in`graph.json`. De sjabloon leest het resultaat — Nooit activeert hij een berekening.
De Architectuur: Bakery importeert Graphify, Geen Engine
Dit is het belangrijkste architectonische punt. De verleiding zou zijn om de samenwerking bekabelen in`engine/build.gradle.kts`: toegepaste engine graphify voor de scan, bakery voor de bake, en engine maakt de brug tussen beiden
Dit is een anti-patroon. Engine is een verbruikersterminal — het past toe Het implementeert geen zakelijke logica voor plugins. De regel is: De bewijslast ligt bij het proprietair plugin.
@startuml
skinparam backgroundColor #FEFEFE
skinparam packageBackgroundColor #FFF3CD
title Bakery importeert Graphify — Engine weet niets
package "N3 — ENGINE" #FFCCCC {
[engine/build.gradle.kts] as ENG
note right of ENG
plugins { bakkerij } Geen kennis van graphify 4 taken, ~150 regels
end note
}
package "N2 — BAKERY" #CCFFCC {
[bakery-plugin] as BAK
note right of BAK
plugins { graphify } ← interne import
Leest graph.json
Lost gerelateerde artikelen op
Geeft het model weer aan JBake
end note
}
package "N0 — GRAPHIFY" #CCE5FF {
[graphify-plugin] as GRF
note right of GRF
Scannen werkruimte → graph.json
Verrijkt met:
- xref: randen
- tag‑co‑occurrences
- benoemde entiteiten (NLP)
end note
}
ENG -down-> BAK : "plugins { bakery }"
BAK -down-> GRF : "plugins { graphify } ← N2→N0 OK"
@enduml
Het DAG-contract wordt nageleefd: bakery (N2) importeert graphify (N0), N2 > N0, geen inbreuk. Engine (N3) importeert bakery (N2), N3 > N2, OK.
Engine heeft geen enkele regel code die verwijst`graph.json`, relatedPosts, of enige andere notie van gelijkenis. Hij past bakery toe, punt. De samenwerking is intern bij bakery.
De pijplijn: Scan → Graphe → Template
Hier is de volledige pijplijn in drie stappen:
-
Scan (grafiek) :`graphify-plugin`scant de werkruimte. In zijn/haar
huidige vorm, detecteert het al de`xref:`tussen`.adoc`en de stelt voor in`graph.json`als edges van type`reference`Please provide the text you would like me to translate.
Deze wordt uitgebreid voor de redactionele inhoud: Parse de JBake-metagegevens (:jbake-tags:, :jbake-description:) van elke`.adoc`van de blog * Bereken de co-occurrenties van tags → edges`tag_cooccurrence`met gewicht * Extraheert genoemde entiteiten uit de beschrijvingen via TF-IDF → edges`entity_overlap` * Injecteer een sectie`blog_articles`in het`graph.json`bestaand
// 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 (bakkerij) : op het moment van`./gradlew bake`, `BakeryPlugin`bed
`graph.json`en lost de gerelateerde artikelen op voor elke 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) : het JBake-model ontvangt nu een map
gestructureerd in plaats van een platte chronologische lijst.
<!-- 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>
De sjabloon is hetzelfde — hij weet niet waar de gegevens vandaan komen. Slechts het contract tussen bakery en JBake is gewijzigd :`published_posts`(lijst chronologisch) wordt`relatedPosts`(gewogen kaart door de graaf).
|
Het badge « reden » (bijv:`xref 0122`, |
Fallback chronologisch
Si `graph.json`is afwezig (build local zonder voorafgaande scan, eerste implementatie, CI die nog niet heeft geïntegreerd de scan Graphify), het sjabloon moet gratieus degraderen:
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)
}
Het standaardgedrag is identiek aan het huidige gedrag. De aanbevelingsmotor is een progressieve verbetering — geen brekende wijziging.
De opkomende ontologie : wanneer artikelen zich verzamelen zonder elkaar te kennen
De meest interessante laag is de derde: de emergente ontologie. Artikelen die niet geciteerd worden, die niet dezelfde tags hebben, maar die praten over hetzelfde zonder het te weten.
Laten we een concreet voorbeeld nemen. Drie artikelen uit mijn corpus :
-
0119 — Benchmark DGX Spark vs Cloud-abonnement LLM
-
0121 — Gradle-plugin voor het besturen van twee Ollama Pro-instanties
-
0122 — Verhouding Efficiëntie 27x 45x Vloot AI-experts
Deze drie artikelen hebben geen xref onderling. Hun tags ne se overlappen slechts 20 % (« ollama », « llm » gemeenschappelijk). Maar een lichtgewicht NLP Op de beschrijvingen onthult een duidelijk cluster: 'kosten', 'abonnement', cloud », « GPU », « API-sleutel », « Ollama Pro », « efficiëntie », « verhouding
Ze vormen een ontologische cluster: de economie van de LLM self-hosted vs cloud. Deze cluster wordt nergens gedeclareerd. Hij ontstaat uit het corpus.
@startuml
skinparam backgroundColor #FEFEFE
skinparam packageBackgroundColor #E8F8F5
title Cluster Ontologique Émergent — "Economie LLM zelf-gehost"
package "Cluster gedetecteerd : economie-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 "kosten", "GPU",
"abonnement", "Ollama Pro".
end note
@enduml
Deze ontologische cluster wordt een samengestelde rand 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"
]
}
}
Het NLP is bewust licht — TF-IDF + cosine similarity op de beschrijvingen. Geen behoefte aan BERT, geen behoefte aan een taalmodel. Het corpus bestaat uit 23 artikelen, de similariteitsmatrix past in een JSON-bestand van 50 KB.
|
De kracht van NLP komt niet voort uit de verfijning van het algoritme, maar van de grootte van het corpus en van de kwaliteit van de beschrijvingen. Een `:jbake-description:`goed geschreven van 150 tekens bevat meer van signaal voor TF-IDF dat een volledig artikel van 3000 woorden. |
Het DAG-contract : Wie telt voor wie?
Dit is het punt waarop de architectuurdiscipline zich betaalt. De DAG N0→N3 gedefinieerd in`engine/build.gradle.kts`geeft een eenvoudige regel : Geen project importeert een project van een hoger niveau.
| Plugin consument | Geïmporteerde plug-in | Consumentenniveau | Geïmporteerd niveau | Geldig ? |
|---|---|---|---|---|
bakery-gradle |
graphify-gradle |
N2 |
N0 |
✅ N2 > N0 — OK |
motor |
bakkerij-gradle |
N3 |
N2 |
✅ N3 > N2 — OK |
motor |
graphify-gradle |
N3 |
N0 |
✅ Techniek OK, maar conceptueel fout — de samenwerking is intern bij bakery |
Engine past bakery toe. Bakery past graphify toe. Dat is alles. Als er ooit een dag Ik wil de vector store codebase (N1) toevoegen voor onderzoek semantiek cross-corpus, bakery importeert ook. Engine verandert niet.
|
Het patroon is: plugin N2 is de hub van zijn eigen afhankelijkheden Engine N3 is geen hub — het is een terminal die hubs toepast. |
Wat We Winnen(En Wat We Niet Winnen)
Het belangrijkste voordeel is redactioneel, niet technisch:
| Voor | Na |
|---|---|
Gerelateerde artikelen = 4 recente berichten |
Gerelateerde artikelen = top 4 op gewicht in de Knowledge Graph |
Lezer leest RAG → aanbeveling Kitty Terminal |
Lezer leest RAG → aanbeveling pgvector, chunking, vector store |
Geen transparantie over het waarom |
badge`xref 0122`, |
JBake standaard, geen inspanning, geen waarde |
deterministische, reproduceerbare, testbare Gradle-pipeline |
Geen leercurve voor de lezer |
De lezer ontdekt de topologie van mijn inhoud |
Wat men niet verdient:
-
Dit is geen « echte » aanbevelingsmotor (geen samenwerking
filtering, geen A/B-test, geen feedbacklus)
-
De kwaliteit hangt af van de rijkdom van de JBake-metagegevens — als uw
beschrijvingen zijn leeg, TF-IDF ziet niets
-
De NLP is offline — nieuwe artikelen worden alleen geclusterd op de
volgende scan (dat is goed: de build blijft deterministisch)
Perspectieven: De blog als publiek Knowledge Graph
Deze functionaliteit opent een breder perspectief : wat als`cheroliv.com` werd hijzelf een knowledge graph navigable ?
Artikels zijn knooppunten. Tags zijn gemeenschappen. xref zijn de ribben. De lezer leest niet meer een geïsoleerd artikel — hij navigeert in een kennisgraf waarvan het huidige artikel het instappunt is.
Stel je een homepage voor die niet de chronologische lijst weergeeft van de laatste berichten, maar een kaart van het corpus : ontologische clusters, pivoterende artikelen (die de meeste randen hebben), leespaden aanbevolen (« Als je het artikel over Graphify leuk vond, lees dan verder dat over het Knowledge Graph als hulpmiddel voor begrip )
Het is geen blog meer. Het is een semantische atlas.
En dit is geen sciencefiction. De`graph.json`bestaat al. Hij mist alleen de navigatie-interface.
Conclusie: Het bestandssysteem weet wat de template negeert
Wat ik op dinsdagavond heb ontworpen, is de vervanging van een heuristiek. luie (de chronologische volgorde) door een getrouwe weergave van de structuur van mijn corpus (het Knowledge Graph).
De sjabloon`post.thyme`verandert niet. Wat verandert, is wat we hij geeft hem te eten. Eerder: een Java-lijst gesorteerd op`date`. na : een gewogen graaf afkomstig uit drie analyselagen — xrefs, co-occurrences tags, en opkomende ontologie via NLP
De cirkel is rond. graphify scant de workspace. Bakery leest het grafiek en voedt de sjabloon. De lezer ziet aanbevelingen relevant. En engine — de dirigent — weet zelfs niet dat Alles bestaat.
Dat is het, de goede architectuur. Elk plugin doet één ding. En de consumententerminal hoeft niet te weten hoe.
Referenties
-
Artikel 0105 — Integreren Graphify in een Gradle-workflow
-
Article 0106 — De Knowledge Graph als hulpmiddel voor begrip
-
Article 0121 — Plugin Gradle Besturen twee exemplaren Ollama Pro
-
Artikel 0122 — 27× tot 45× effectiever : vloot van AI-experts