время чтения: 14 minutes

Вы читаете статью об интеграции pgvector с LangChain4j. Внизу страницы, JBake предлагает « Связанные статьи ». Вы нажимаете. Это статья о…​ конфигурации. из Kitty Terminal. Единственное общее между ними? Они были опубликованы менее чем пятнадцать дней разницы. Это не рекомендация. Это календарь, замаскированный под редакционный.

Сцена: вторник, 12 мая, 17:30

Я перечитываю одну из своих статей — этот о Knowledge Graph как инструменте для понимания кодовой базы Содержание плотное: узлы, рёбра, сообщества, PlantUML, онбординг. Статья техника чтения продолжительностью 15 минут, которая мобилизует`graphify-gradle`, plantuml-gradle, и концепции топологии графа.

Внизу страницы, «Связанные статьи» предлагают мне:

  1. Статья о Firebase Contact Form (0113)

  2. Статья о механизме Eager/Lazy (0108)

  3. Статья о миграции скрипта Gradle → плагин (0102)

  4. Статья о совмещении подписок Ollama Pro (0120)

Три из этих четырех статей не имеют никакого отношения к Knowledge Graph. Они здесь, потому что они последние опубликованные — временная окрестность, не семантическое соседство

Я смотрю на шаблон`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`это хронологический список.`postStat.index lt 4`берёт их Первые четыре, которые не являются текущей публикацией. Всё. Никакой логики. по схожести. Никакого понятия о содержании. Просто цикл`for`переодетá в рекомендация.

Это не ошибка JBake. Это поведение по умолчанию всего. генератор статического сайта: список постов плоский и упорядочен по дате. Шаблон делает то, что может, с тем, что имеет.

Но это не причина принимать его.

Диагностика: Три типа отношений, которые не видит ни один шаблон

Мой корпус статей — 23 поста за 4 месяца — имеет богатую структуру что хронологический шаблон полностью перезаписывает:

  1. Явные ссылки : я использую`xref:`массивно в моих статьях.

Статья 0122 ссылается на 0106 (knowledge graph) и 0116 (компартментализация эпистемного). Эти ссылки являются редакционным hard linking — у меня есть сознательно решил соединить эти концепции.

  1. Общие теги : у каждой статьи есть`:jbake-tags:`. Статья о

Graphify + PlantUML (0105) имеет`gradle, graphify, plantuml, knowledge-graph`. Статья о Knowledge Graph (0106) имеет knowledge-graph, graphify, plantuml. Три общих тега из семи — 43% совпадения.

  1. Совместно встречающиеся именованные сущности : « pgvector », « RAG », « embedding

появляются вместе в четырёх разных статьях. « LangChain4j », Ollama », « plugin Gradle » в шести других. Это не теги объявленные — это patterns émergents корпуса, которые может обнаружить только NLP может обнаружить.

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center

title Trois couches de relation entre articles

package "Couche 1 — Références explicites" #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 "Couche 2 — Tags partagés" #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 "Couche 3 — Ontologie émergente" #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

Сегодня мой сайт не использует ни одного из этих трёх слоёв. Он использует нулевой слой: порядок вставки в список Java

Решение: Gradle-пайплайн, не горячий вызов LLM

Первое соблазнение было бы вызвать LLM в момент bake: « Для эта статья, найди три наиболее похожих статьи в корпусе. Не делайте этого.

Один вызов LLM к каждому`./gradlew bake`стоит времени, денег, и вводит недетерминированность в вашу сборку. Ответ LLM может переключаться между двумя сборками без изменения содержимого. Ваша CI становится не воспроизводимые, ваши тесты становятся нестабильными.

Решение детерминировано: Gradle‑pipeline, который предварительно вычисляет граф о сходстве и сохраняет его в`graph.json`. Шаблон читает результат — Никогда он не запускает расчёт.

Архитектура: Bakery импортирует Graphify, не Engine

Это самый важный архитектурный пункт. Соблазн был бы подключать сотрудничество в`engine/build.gradle.kts`двигатель бра graphify для scan, bakery dla bake, и engine делает мост между оба.

Это антипаттерн. Engine является терминальным потребителем — он применяет о плагинах он не реализует бизнес-логику. Правило: Бремя доказывания лежит на проприетарном плагине.

Правильная архитектура — Bakery импортирует Graphify
@startuml
skinparam backgroundColor #FEFEFE
skinparam packageBackgroundColor #FFF3CD

title Bakery importe Graphify — Engine ne sait rien

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

Контракт DAG соблюдён: bakery (N2) импортирует graphify (N0), N2 > N0, никаких нарушений. Двигатель (N3) импортирует пекарню (N2), N3 > N2, OK.

У Engine нет ни одной строки кода, которая ссылается`graph.json`, relatedPosts, или любое другое понятие схожести. Он применяет bakery, точка. La Сотрудничество является внутренним для bakery.

Конвейер: Сканирование → Граф → Шаблон

Вот полный pipeline из трех этапов :

  1. Scan (graphify) :`graphify-plugin`сканирует рабочее пространство. В его

В текущей форме, он уже обнаруживает их`xref:`между`.adoc`и показывает в`graph.json`как ребра типа`reference`.
Его обогащают для редакционного контента: * Анализировать метаданные JBake (:jbake-tags:, :jbake-description:) каждого`.adoc`блога * Вычисляет совпадения тегов → рёбра`tag_cooccurrence`с весом * Извлекает именованные сущности из описаний с помощью TF-IDF → edges`entity_overlap` Внедрить раздел`blog_articles`в`graph.json`существующий

// 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) : в момент`./gradlew bake`, `BakeryPlugin`кровать

`graph.json`и разрешает связанные статьи для каждого поста.

// 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. Шаблон (post.thyme) : модель JBake теперь получает карту

структурированной вместо плоского хронологического списка

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

Шаблон такой же — он не знает, откуда приходят данные. Только контракт между bakery и JBake изменился:`published_posts`(список хронологически) становится`relatedPosts`(карта, взвешенная графом)

Бейдж « причина » (пример:`xref 0122`, tag:gradle, cluster:pgvector-rag) объясни читателю почему эта статья связана. Это не просто прозрачность — это педагогика о топологии ваш собственный контент. </think>

хронологический fallback

Si `graph.json`отсутствует (build local без предварительного сканирования, первый) развертывание, CI которая ещё не интегрировала скан graphify), шаблон должен плавно деградировать :

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

Поведение по умолчанию совпадает с текущим поведением. Le движок рекомендаций является постепенным улучшением — не разрушительное изменение.

Эмерджентная онтология: когда статьи объединяются, не зная друг друга

Самый интересный слой — третий: возникающая онтология. Статьи, которые не цитируются, у которых нет одинаковых тегов, но которые говорят об одном и том же не знают об этом.

Возьмём конкретный пример. Три статьи из моего корпуса :

  1. 0119 — Benchmark DGX Spark vs Cloud подписка LLM

  2. 0121 — Плагин Gradle управляет двумя экземплярами Ollama Pro

  3. 0122 — Коэффициент эффективности 27x 45x Флот экспертов ИИ

Эти три статьи не имеют никаких перекрёстных ссылок между ними. Их теги не Соответствуют только 20% (« ollama », « llm » общие). Но лёгкий NLP в описаниях выявляет очевидный кластер : « стоимость », « подписка», облако », « GPU », « ключ API », « Ollama Pro », « эффективность », « отношение

Они образуют онтологический кластер: экономика самодостаточного LLM против cloud Этот кластер нигде не объявлен. Он возникает из корпуса.

@startuml
skinparam backgroundColor #FEFEFE
skinparam packageBackgroundColor #E8F8F5

title Cluster Ontologique Émergent — "Économie LLM Self-Hosted"

package "Cluster détecté : économie-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 "coût", "GPU",
    "abonnement", "Ollama Pro".
end note
@enduml

Этот онтологический кластер становится составным ребром в`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 является намеренно лёгким — TF-IDF + косинусное сходство по описания. Не нужно BERT, не нужно языковой модели. Корпус содержит 23 статьи, матрица схожести помещается в файл JSON размером 50 КБ.

Сила NLP не исходит из сложности алгоритма, но от размера корпуса и качества описаний. Одна `:jbake-description:`хорошо написанная из 150 символов содержит больше сигнал для TF-IDF, что статья полностью состоит из 3000 слов

Договор DAG: кто кому важен

Это точка, где архитектурная дисциплина окупается. DAG N0→N3 определено в`engine/build.gradle.kts`даёт простое правило: Ни один проект не имеет значения проекту более высокого уровня.

Плагин потребитель Импортированный плагин Уровень потребителя Импортированный уровень Валидно?

bakery-gradle

graphify-gradle

N2

N0

✅ N2 > N0 — OK

двигатель

bakery-gradle

N3

N2

✅ N3 > N2 — OK

двигатель

graphify-gradle

N3

N0

✅ Technique OK, но концептуально неверно — сотрудничество является внутренним для bakery

Двигатель применяет пекарню. Пекарня применяет graphify. Это всё. Если однажды Я хочу добавить vector store codebase (N1) для поиска семантика кросскорпус, bakery также импортирует его. Engine не изменяется.

Шаблон: плагин N2 является центром его собственных зависимостей Engine N3 не является хабом — это терминал, который применяет хабы.

Что мы получаем (и что мы не получаем)

Основное преимущество редакционное, а не техническое :

перед После

Связанные статьи = последние 4 посты

Связанные статьи = топ‑4 по весу в Knowledge Graph

Читатель читает из RAG → рекомендация Kitty Terminal

Читатель читает из RAG → рекомендация pgvector, chunking, векторное хранилище

Нулевая прозрачность относительно причины

бейдж`xref 0122`, tag:gradle, `cluster:rag`visible

JBake стандарт, ноль усилий, ноль ценности

Gradle-пайплайн детерминированный, воспроизводимый, тестируемый

Нет кривой обучения для читателя

Читатель обнаруживает topologie моего контента

Что мы не выигрываем :

  • Это не «настоящий» двигатель рекомендаций (нет совместной

фильтрация, отсутствие A/B тестирования, отсутствие цикла обратной связи)

  • Качество зависит от богатства метаданных JBake — если ваш

описания пусты, TF-IDF ничего не видит

  • NLP отключён — новые статьи кластеризуются только до

следующее сканирование (это хорошо: сборка остаётся детерминированной)

Перспективы: Блог как публичный граф знаний

Эта функция открывает более широкие перспективы: а если`cheroliv.com` Он становился самим knowledge graph navigable ?

Статьи являются узлами. Теги являются сообществами. xref являются Рёбра. Читатель больше не читает изолированную статью — он перемещается в граф знаний где текущая статья является точкой входа.

Представьте домашнюю страницу, которая отображает не хронологический список последних постов, но карта корпуса : онтологические кластеры, ключевые статьи (те, у которых наибольшее количество рёбер), пути чтения рекомендуемые (« Если вам понравилась статья о Graphify, читайте далее тот о Knowledge Graph как инструмент для понимания »)

Это уже не блог. Это семантический атлас.

И это не научная фантастика. Le`graph.json`уже существует. Ему не хватает только интерфейса навигации.

Вывод: Система файлов знает то, что игнорирует шаблон

Что я разработал в этот вторник вечером, это замена эвристики ленивая (хронологический порядок) верным представлением структура моего корпуса (Knowledge Graph)

Шаблон`post.thyme`не меняется. То, что меняется, это что мы Он его кормит. До: список Java, упорядоченный по`date`. После : взвешенный граф, полученный из трёх слоёв анализа — xrefs, co-occurrences тегов и возникающей онтологии с использованием NLP

Петля замкнута. graphify сканирует workspace. Bakery читает граф и заполняет шаблон. Читатель видит рекомендации релевантные. И двигатель — дирижёр — даже не знает что всё это существует.

Это оно, правильная архитектура. Каждый плагин делает одно дело. И потребительский терминал не должен знать как.

Похожие статьи