tiempo de lectura : 14 minutes

Está leyendo un artículo sobre la integración de pgvector con LangChain4j. Al final de la página, JBake le sugiere « Artículos relacionados ». Usted hace clic. Este es un artículo sobre…​ la configuración de Kitty Terminal. ¿Cuál es el único punto en común entre los dos? Han sido publicados hace menos de quince días de diferencia. No es una recomendación. Es un calendario disfrazado de editorial.

La Escena : Martes 12 de mayo, 17:30

Estoy releyendo uno de mis artículos — ese sobre el Knowledge Graph como herramienta de comprensión de la base de código El contenido es denso : nodos, aristas, comunidades, PlantUML, incorporación. Un artículo técnica de 15 minutos de lectura que moviliza`graphify-gradle`, plantuml-gradle, y los conceptos de topología de grafos.

Al pie de página, los « Artículos relacionados » me sugieren:

  1. Un artículo sobre Firebase Contact Form (0113)

  2. Un artículo sobre el mecanismo Eager/Lazy (0108)

  3. Un artículo sobre la migración Gradle script → plugin (0102)

  4. Un artículo sobre la acumulación de suscripciones Ollama Pro (0120)

Tres de estos cuatro artículos no tienen ninguna relación con el Knowledge Graph. Están allí porque son los últimos publicados — vecindad temporal, no vecindario semántico.

Estoy mirando el 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`es una lista cronológica.`postStat.index lt 4`toma los los cuatro primeros que no son el post actual. Eso es todo. Ninguna lógica de similitud. Ninguna noción de contenido. Sólo un bucle`for`disfrazada de recomendación

No es un error de JBake. Es el comportamiento predeterminado de todo generador de sitio estático: la lista de posts es plana y ordenada por fecha. La plantilla hace lo que puede con lo que tiene.

Pero eso no es una razón para aceptarlo.

El diagnóstico: Tres tipos de relaciones que ninguna plantilla ve

Mi corpus de artículos — 23 publicaciones en 4 meses — tiene una estructura rica que la plantilla cronológica sobrescribe completamente :

  1. Referencias explícitas : utilizo`xref:`masivamente en mis artículos.

El artículo 0122 referencia 0106 (knowledge graph) y 0116 (compartimentado epistemológico). Estos enlaces son de hard linking editorial — tengo ha decidido deliberadamente conectar estos conceptos.

  1. Etiquetas compartidas : cada artículo tiene algunos`:jbake-tags:`. El artículo sobre

Graphify + PlantUML (0105) a`gradle, graphify, plantuml, knowledge-graph`. El artículo sobre el Knowledge Graph (0106) tiene knowledge-graph, graphify, plantuml. Tres etiquetas en común de siete — 43% de superposición.

  1. Entidades nombradas co-ocurrentes : « pgvector », « RAG », « embedding

aparecen juntos en cuatro artículos diferentes. « LangChain4j », Ollama », « plugin Gradle » en seis otros. No son etiquetas declarados — son patrones emergentes del corpus que solo un NLP puede detectar.

trois couches relation articles

Hoy, mi sitio no utiliza ninguna de estas tres capas. Utiliza la capa cero: el orden de inserción en una lista Java.

La Solución: Un Pipeline Gradle, No una Llamada LLM en Caliente

La primera tentación sería llamar a un LLM en el momento del bake : « Para este artículo, encuentre los tres artículos más similares en el corpus. No lo hace.

Una llamada LLM a cada`./gradlew bake`cuesta tiempo, dinero y Introduce no determinismo en tu build. La respuesta del LLM puede cambiar entre dos builds sin que el contenido haya cambiado. Tu CI se vuelve No reproducible, sus pruebas se vuelven inestables.

La solución es determinista: un pipeline Gradle que precalcula el grafo de similitud y lo almacena en`graph.json`. La plantilla lee el resultado — nunca desencadena un cálculo.

La Arquitectura: Bakery importa Graphify, No Engine

Es el punto de arquitectura más importante. La tentación sería de cablear la colaboración en`engine/build.gradle.kts`: motor aplica graphify para el scan, bakery para el bake, y engine hace el puente entre los dos

Es un anti-patrón. Engine es un terminal consumidor — lo aplica de los plugins, no implementa lógica de negocio. La regla es : la carga de la prueba está sobre el plugin propietario.

bakery import graphify
Figure 1. Arquitectura correcta — Bakery importa Graphify

El contrato DAG se cumple: bakery (N2) importa graphify (N0), N2 > N0, ninguna violación. Engine (N3) importa bakery (N2), N3 > N2, OK.

Engine no tiene ninguna línea de código que referencia`graph.json`, relatedPosts, o cualquier otra noción de similitud. Aplica bakery, punto. La colaboración es interna a bakery

El pipeline: Escaneo → Grafo → Plantilla

Aquí está el pipeline completo en tres pasos:

  1. Scan (graphify):`graphify-plugin`escanea el workspace. En su

forma actual, ya detecta los`xref:`entre`.adoc`y los mostrado en`graph.json`como edges de tipo`reference`.
Lo enriquecemos para el contenido editorial: * Analiza los metadatos JBake`:jbake-tags:`, :jbake-description:) de cada`.adoc`del blog * Calcula las co-ocurrencias de tags → edges`tag_cooccurrence`con peso * Extrae las entidades nombradas de las descripciones mediante TF-IDF → edges`entity_overlap` * Injecta una sección`blog_articles`en el`graph.json`existente

// 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 (panadería) : en el momento de`./gradlew bake`, `BakeryPlugin`cama

`graph.json`y resuelve los artículos relacionados para cada publicación.

// 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. (Note: The assistant must output nothing as there is no French text to translate.)post.thyme) : el modelo JBake recibe un mapa

estructurada en lugar de una lista cronológica plana

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

La plantilla es la misma — no sabe de dónde vienen los datos. Sólo el contrato entre bakery y JBake ha cambiado :`published_posts`(lista cronológico) se vuelve`relatedPosts`(mapa ponderado por el grafo).

El distintivo « razón » (ej:`xref 0122`, tag:gradle, cluster:pgvector-rag) explique al lector por qué este artículo está relacionado. No es solo de transparencia — es pedagogía sobre la topología de su propio contenido.

Respaldo cronológico

Si `graph.json`está ausente(compilación local sin escaneo previo, primero) despliegue, CI que aún no ha integrado el escaneo graphify), la plantilla debe degradarse con elegancia:

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

El comportamiento predeterminado es idéntico al comportamiento actual. El motor de recomendación es una mejora progresiva — no un cambio disruptivo

La ontología emergente: Cuando los artículos se reagrupan sin conocerse

La capa más interesante es la tercera: la ontología emergente. Artículos que no se citan, que no tienen los mismos tags, pero que hablan de lo mismo sin saberlo.

Tomemos un ejemplo concreto. Tres artículos de mi corpus :

  1. 0119 — Benchmark DGX Spark vs Cloud Suscripción LLM

  2. 0121 — Plugin Gradle Gestionar Dos Instancias Ollama Pro

  3. 0122 — Ratio de Eficiencia 27x 45x Flota Expertos IA

Estos tres artículos no tienen ninguno xref entre ellos. Sus etiquetas no se coinciden únicamente en un 20% (« ollama », « llm » comunes). Pero un NLP ligero en las descripciones revela un clúster obvio: « costo », « suscripción », nube », « GPU », « clave API », « Ollama Pro », « eficiencia », « ratio

Forman un cluster ontológico : la economía del LLM self-hosted vs cloud. Este clúster no está declarado en ninguna parte. Surge del corpus.

cluster ontologique exemple

Este clúster ontológico se convierte en una arista compuesta en`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"
    ]
  }
}

El NLP es voluntariamente ligero — TF-IDF + cosine similarity sobre los descripciones. No se necesita BERT, no se necesita un modelo de lenguaje. El corpus tiene 23 artículos, la matriz de similitud cabe en un archivo JSON de 50 KB.

La potencia del NLP no viene de la sofisticación del algoritmo pero del tamaño del corpus y de la calidad de las descripciones. `:jbake-description:`bien escrita de 150 caracteres contiene más de señal para TF-IDF que un artículo completo de 3000 palabras

El Contrato DAG: Quién Importa Quién

Es el punto donde la disciplina arquitectónica paga. El DAG N0→N3 definido en`engine/build.gradle.kts`da una regla simple : ningún proyecto importa un proyecto de nivel superior

Plugin consumidor Plugin importado Nivel consumidor Nivel importado ¿Válido?

bakery-gradle

graphify-gradle

N2

N0

✅ N2 > N0 — OK

motor

bakery-gradle

N3

N2

✅ N3 > N2 — OK

motor

graphify-gradle

N3

N0

✅ Técnica OK, conceptualmente falso — la colaboración es interna de bakery

Engine aplica panadería. Panadería aplica graphify. Eso es todo. Si algún día Quiero agregar el codebase del vector store (N1) para investigación semántica cross-corpus, la panadería también lo importa. el motor no cambia.

El patrón es: el plugin N2 es el hub de sus propias dependencias Engine N3 no es un hub — es un terminal que aplica hubs.

Lo que se gana (y lo que no se gana)

El beneficio principal es editorial, no técnica:

Antes Después

Artículos relacionados = 4 últimos posts

Artículos relacionados = top 4 por peso en el Knowledge Graph

Lector lee RAG → recomendación Kitty Terminal

Lector lee del RAG → recomendación pgvector, chunking, almacenén de vectores

Cero transparencia sobre el por qué

Insignia`xref 0122`, tag:gradle, `cluster:rag`visible

JBake estándar, cero esfuerzo, cero valor

Pipeline Gradle determinista, reproducible, testable

Ninguna curva de aprendizaje para el lector

El lector descubre la topologie de mi contenido

Lo que no se gana :

  • No es un motor de recomendación « verdadero » (no colaborativo

filtrado, sin pruebas A/B, sin bucle de retroalimentación)

  • La calidad depende de la riqueza de los metadatos de JBake — si sus

las descripciones están vacías, TF-IDF no ve nada

  • El NLP está offline — los nuevos artículos no se agrupan qu’au

Próximo escaneo (está bien: el build sigue siendo determinista)

Perspectivas : El Blog Como Knowledge Graph público

Esta funcionalidad abre una perspectiva más amplia: y si`cheroliv.com` ¿se convertía él mismo en un knowledge graph navigable ?

Los artículos son nodos. Los tags son comunidades. Los xref son Algunas aristas. El lector ya no lee un artículo aislado — navega dentro de una grafo de conocimiento cuyo artículo actual es el punto de entrada.

Imagina una página de inicio que no muestre la lista cronológica de los últimos posts, pero una mapa del corpus: clústeres ontológicos, artículos pivote (aquellos con más aristas), rutas de lectura recomendados (« Si te ha gustado el artículo sobre Graphify, lee a continuación el que sobre el Knowledge Graph como herramienta de comprensión )

No es un blog. Es un atlas semántico.

Y esto no es ciencia ficción. El`graph.json`ya existe. Solo le falta la interfaz de navegación.

Conclusion : El Sistema de Archivos Sabe Lo Que la Plantilla Ignora

Lo que diseñé esa noche de martes es el reemplazo de una heurística perezosa (el orden cronológico) por una representación fiel de la estructura de mi corpus (el Knowledge Graph).

La plantilla`post.thyme`no cambia. Lo que cambia, es lo que le da de comer. Antes: una lista Java ordenada por`date`. Después : un grafo ponderado derivado de tres capas de análisis — xrefs, co‑ocurrencias de etiquetas, y ontología emergente mediante NLP.

El bucle está cerrado. graphify escanea el espacio de trabajo. Bakery lee el grafico y alimenta la plantilla. El lector ve recomendaciones pertinentes. Y engine — el director de orquesta — ni siquiera sabe que Todo esto existe.

Así es, la buena arquitectura. Cada plugin hace una cosa. Y el terminal del consumidor no necesita saber cómo.

Articles connexes