leestijd: 14 minutes

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:

  1. Een artikel over Firebase Contact Form (0113)

  2. Een artikel over het Eager/Lazy-mechanisme (0108)

  3. Een artikel over de migratie van Gradle script → plugin (0102)

  4. 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 :

  1. 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

  1. 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

  1. 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.

Correcte architectuur — Bakery importe Graphify
@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:

  1. 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)
    )
}
  1. 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 }
        }
}
  1. 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`, tag:gradle, cluster:pgvector-rag) verklaar aan de lezer waarom dit artikel gerelateerd is. Dit is niet alleen transparantie — het is pedagogie over de topologie van uw eigen inhoud. </think>

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 :

  1. 0119 — Benchmark DGX Spark vs Cloud-abonnement LLM

  2. 0121 — Gradle-plugin voor het besturen van twee Ollama Pro-instanties

  3. 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`, tag:gradle, `cluster:rag`zichtbaar

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.

Gerelateerde artikelen