tempo di lettura : 14 minutes

Stai leggendo un articolo sull’integrazione di pgvector con LangChain4j. In fondo alla pagina, JBake ti suggerisce « Articoli correlati ». Clicchi. È un articolo su…​ la configurazione di Kitty Terminal. L’unico punto in comune tra i due? Sono stati pubblicati da meno di quindici giorni di distanza. Non è una raccomandazione. È un calendario travestito da editoriale.

La scena : Martedì 12 maggio, 17:30

Sto rileggendo uno dei miei articoli — quello sul Knowledge Graph come strumento di comprensione della codebase Il contenuto è denso : nodi, archi, comunità, PlantUML, onboarding. Un articolo tecnica di 15 minuti di lettura che mobilizza`graphify-gradle`, plantuml-gradle, e i concetti di topologia di grafo.

Alla fine della pagina, gli « Articoli correlati » mi propongono :

  1. Un articolo su Firebase Contact Form (0113)

  2. Un articolo sul meccanismo Eager/Lazy (0108)

  3. Un articolo sulla migrazione Gradle script → plugin (0102)

  4. Un articolo sul cumulo di abbonamenti Ollama Pro (0120)

Tre di questi quattro articoli non hanno alcun rapporto con il Knowledge Graph. Sono lì perché sono gli ultimi pubblicati — vicinanza temporale, non vicinato semantico

Sto guardando il 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`è una lista cronologica.`postStat.index lt 4`prende le i primi quattro che non sono il post corrente. È tutto. Nessuna logica di similarità. Nessuna nozione di contenuto. Solo un ciclo`for`travestita da raccomandazione.

Non è un bug di JBake. È il comportamento predefinito di tutto generatore di sito statico : l’elenco dei post è piatto e ordinato per data. Il modello fa quello che può con quello che ha.

Ma non è una ragione per accettarlo.

La Diagnosi: Tre Tipi di Relazioni che Nessun Template Vede

Il mio corpus di articoli — 23 post in 4 mesi — ha una struttura ricca che il template cronologico sovrascrive completamente :

  1. Riferimenti espliciti : utilizzo`xref:`massivamente nei miei articoli.

L’articolo 0122 fa riferimento al 0106 (knowledge graph) e al 0116 (compartimentaggio) epistemico). Questi link sono del hard linking editoriale — ho ha deliberatamente deciso di collegare questi concetti.

  1. Tag condivisi : ogni articolo ha`:jbake-tags:`. L’articolo su

Graphify + PlantUML (0105) ha`gradle, graphify, plantuml, knowledge-graph`. L’articolo sul Knowledge Graph (0106) ha knowledge-graph, graphify, plantuml. Tre tag in comune su sette — 43% di sovrapposizione.

  1. Entità nominate co-occorrenti : « pgvector », « RAG », « embedding

appaiono insieme in quattro articoli diversi. « LangChain4j », </think> appaiono insieme in quattro articoli diversi. « LangChain4j », </think> appaiono insieme in quattro articoli diversi. « LangChain4j », Ollama », « plugin Gradle » in sei altri. Questi non sono tag dichiarati — sono dei patterns emergenti del corpus che solo un NLP può rilevare.

trois couches relation articles

Oggi, il mio sito non utilizza nessuna di questi tre livelli. Utilizza lo strato zero : l’ordine di inserimento in una lista Java

La Soluzione : Un pipeline Gradle, non una chiamata LLM a caldo

La prima tentazione sarebbe quella di chiamare un LLM al momento del bake : « Per questo articolo, trova i tre articoli più simili nel corpus. Non lo fate.

Una chiamata LLM a ciascuno`./gradlew bake`costa tempo, denaro e introduce non determinismo nel tuo build. La risposta del LLM può cambiare tra due build senza che il contenuto sia cambiato. Il tuo CI diventa non riproducibile, i vostri test diventano flaky.

La soluzione è deterministica: un pipeline Gradle che precalcola il grafo di similarità e lo memorizza in`graph.json`. Il template legge il risultato — mai avvia un calcolo.

L’architettura : Bakery Importa Graphify, Non Engine

È il punto di architettura più importante. La tentazione sarebbe di cablare la collaborazione in`engine/build.gradle.kts` : engine applique graphify per lo scan, bakery per il bake, e engine fa da ponte tra i due.

È un antipattern. Engine è un terminal consumatore — aplica dei plug-in, non implementa la logica di business. La regola è : L’onere della prova è sul plugin proprietario.

bakery import graphify
Figure 1. Architettura corretta — Bakery importa Graphify

Il contratto DAG è rispettato : bakery (N2) importa graphify (N0), N2 > N0, Nessuna violazione. Il motore (N3) importa la panetteria (N2), N3 > N2, OK.

Motore non ha alcuna linea di codice che fa riferimento`graph.json`, relatedPosts, o qualsiasi altra nozione di similarità. Applica bakery, punto. La La collaborazione è interna a bakery.

Il Pipeline : Scan → Grafo → Modello

Ecco il pipeline completo in tre fasi

  1. Scansione (graficare) :`graphify-plugin`scansiona il workspace. Nel suo

forma attuale, già rileva i`xref:`tra`.adoc`e i esponi in`graph.json`come degli edge di tipo`reference`.
Lo arricchiamo per il contenuto editoriale: * Analizza i metadati JBake (:jbake-tags:, :jbake-description:) di ogni`.adoc`del blog * Calcola le co-occorrenze dei tag → edges`tag_cooccurrence`con peso * Estrai le entità nominate dalle descrizioni tramite TF-IDF → archi`entity_overlap` * Inietti una sezione`blog_articles`nel`graph.json`esistente (No output)

// 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) : al momento del`./gradlew bake`, `BakeryPlugin`letto

`graph.json`e risolve gli articoli correlati per ogni post. (No output)

// 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. (No output)post.thyme) : il modello JBake riceve ora una mappa

strutturata invece di una lista cronologica piatta

<!-- 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>

Il template è lo stesso — non sa da dove vengono i dati. Solo il contratto tra bakery e JBake è cambiato :`published_posts`(lista cronologico) diventa`relatedPosts`(mappa ponderata dal grafo).

Il badge « ragione » (es:`xref 0122`, tag:gradle, cluster:pgvector-rag) spiega al lettore perché questo articolo è correlato. Non è solo della trasparenza — è pedagogia sulla topologia di il tuo contenuto.

Fallback cronologico

Si `graph.json`est assente (build locale senza scan preliminare, primo) distribuzione, CI che non ha ancora integrato lo scan graphify), il template deve degradarsi elegantemente:

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)
}

Il comportamento predefinito è identico al comportamento corrente. Il motore di raccomandazione è un miglioramento progressivo — non un modifica di rottura.

L’ontologia emergente: Quando gli articoli si raggruppano senza conoscersi

Lo strato più interessante è il terzo: l’ontologia emergente. Articoli che non vengono citati, che non hanno gli stessi tag, ma chi parlano della stessa cosa senza rendersene conto.

Prendiamo un esempio concreto. Tre articoli del mio corpus :

  1. 0119 — Benchmark DGX Spark vs Abbonamento Cloud LLM

  2. 0121 — Plugin Gradle Pilotare due istanze Ollama Pro

  3. 0122 — Rapporto di efficienza 27x 45x Flotta di esperti IA

Questi tre articoli non hanno nessuno xref tra di loro. I loro tag non si si limitano al 20% (« ollama », « llm » comuni). Ma un NLP leggero sulle descrizioni rivela un cluster evidente: « costo », « abbonamento », cloud », « GPU », « API key », « Ollama Pro », « efficacia », « ratio

Formano un cluster ontologico: l’economia del LLM self-hosted vs cloud. Questo cluster non è dichiarato da nessuna parte. Esso emerge dal corpus.

cluster ontologique exemple

Questo cluster ontologico diventa un edge composite 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"
    ]
  }
}

Il NLP è volontariamente leggero — TF-IDF + cosine similarity sui descrizioni. Non c’è bisogno di BERT, non c’è bisogno di un modello linguistico Il corpus è composto da 23 articoli, la matrice di similarità sta in un file JSON di 50 KB.

La potenza del NLP non deriva dalla sofisticazione dell’algoritmo, ma del dimensione del corpus e del qualità delle descrizioni. Una `:jbake-description:`ben scritta di 150 caratteri contiene più di segnale per TF-IDF che un articolo intero di 3000 parole.

Il Contratto DAG: Chi Importa Chi

È il punto in cui la disciplina architettonica paga. Il DAG N0→N3 definito in`engine/build.gradle.kts`dà una regola semplice: nessun progetto importa un progetto di livello superiore.

Plugin consumatore Plugin importato Livello consumatore Livello importato Valido?

bakery-gradle

graphify-gradle

N2

N0

✅ N2 > N0 — OK

engine

bakery-gradle

N3

N2

✅ N3 > N2 — OK

engine

graphify-gradle

N3

N0

✅ Tecnica OK, ma concettualmente falsa — la collaborazione è interna alla bakery

Il motore applica la panetteria. La panetteria applica graphify. È tutto. Se un giorno voglio aggiungere il vector store codebase (N1) per la ricerca semantica cross-corpus, bakery lo importa anche. Il motore non cambia.

Il pattern è : il plugin N2 è l’hub delle sue dipendenze. Engine N3 non è un hub — è un terminale che applica gli hub.

Cosa si guadagna (e cosa non si guadagna)

Il guadagno principale è editoriale, non tecnico

Prima Dopo

Articoli correlati = 4 ultimi post

Articoli correlati = top 4 per peso nel Knowledge Graph

Lettore legge il RAG → raccomandazione Kitty Terminal

Il lettore legge RAG → raccomandazione pgvector, chunking, vector store

Zero trasparenza sul perché

Distintivo`xref 0122`, tag:gradle, `cluster:rag`visibile

JBake standard, zero sforzo, zero valore

Pipeline Gradle deterministica, riproducibile, testabile

Nessuna curva di apprendimento per il lettore

Il lettore scopre la topologia del mio contenuto

Ciò che non si guadagna:

  • Non è un « vero » motore di raccomandazione (nessun collaborativo

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

  • La qualità dipende dalla ricchezza dei metadati JBake — se il tuo

le descrizioni sono vuote, TF-IDF non vede nulla

  • Il NLP è offline — i nuovi articoli sono clusterizzati solo al

prossimo scan (è buono : il build rimane deterministico)

Prospettive : Il Blog Come Knowledge Graph Pubblico

Questa funzionalità apre una prospettiva più ampia: e se`cheroliv.com` diventava lui stesso un knowledge graph navigabile ?

Gli articoli sono nodi. I tag sono comunità. I xref sono degli spigoli. Il lettore non legge più un articolo isolato — si muove in un grafo della conoscenza di cui l’articolo corrente è il punto di ingresso.

Immaginate una home page che non mostra l’elenco cronologico degli ultimi post, ma una mappa del corpus : cluster ontologici, articoli pivot (quelli con il maggior numero di archi), percorsi di lettura consigliati « Se ti è piaciuto l’articolo su Graphify, leggi poi quello sul Knowledge Graph come strumento di comprensione )

Non è più un blog. È un atlas semantico.

E non è fantascienza. Il`graph.json`esiste già. Gli manca solo l’interfaccia di navigazione.

Conclusione : Il sistema di file sa ciò che il modello ignora

Ciò che ho progettato martedì sera, è la sostituzione di una euristica pigra (l’ordine cronologico) mediante una rappresentazione fedele della struttura del mio corpus (il Knowledge Graph)

Il template`post.thyme`Non cambia. Ciò che cambia, è ciò che si gli dà da mangiare. Prima : una lista Java ordinata per`date`. Dopo : un grafo pesato derivato da tre livelli di analisi — xrefs, co-occorrenze di tags, e ontologia emergente per NLP.

Il ciclo è chiuso. graphify scansiona il workspace. Bakery legge il grafico e nutre il modello. Il lettore vede delle raccomandazioni pertinenti. E engine — il maestro d’orchestra — non sa nemmeno che Tutto questo esiste.

Questa è la buona architettura. Ogni plugin fa una cosa. E il terminale del consumatore non ha bisogno di sapere come.

Articoli correlati