Le Knowledge Graph comme moteur de recommandation : comment j'ai tué les « articles connexes » chronologiques
Опубликовано 12 May 2026
- Сцена: вторник, 12 мая, 17:30
- Диагностика: Три типа отношений, которые не видит ни один шаблон
- Решение: Gradle-пайплайн, не горячий вызов LLM
- Эмерджентная онтология: когда статьи объединяются, не зная друг друга
- Договор DAG: кто кому важен
- Что мы получаем (и что мы не получаем)
- Перспективы: Блог как публичный граф знаний
- Вывод: Система файлов знает то, что игнорирует шаблон
- Литература
Вы читаете статью об интеграции pgvector с LangChain4j. Внизу страницы, JBake предлагает « Связанные статьи ». Вы нажимаете. Это статья о… конфигурации. из Kitty Terminal. Единственное общее между ними? Они были опубликованы менее чем пятнадцать дней разницы. Это не рекомендация. Это календарь, замаскированный под редакционный.
Сцена: вторник, 12 мая, 17:30
Я перечитываю одну из своих статей — этот о Knowledge Graph как инструменте для понимания кодовой базы Содержание плотное: узлы, рёбра, сообщества, PlantUML, онбординг. Статья техника чтения продолжительностью 15 минут, которая мобилизует`graphify-gradle`, plantuml-gradle, и концепции топологии графа.
Внизу страницы, «Связанные статьи» предлагают мне:
-
Статья о Firebase Contact Form (0113)
-
Статья о механизме Eager/Lazy (0108)
-
Статья о миграции скрипта Gradle → плагин (0102)
-
Статья о совмещении подписок 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 месяца — имеет богатую структуру что хронологический шаблон полностью перезаписывает:
-
Явные ссылки : я использую`xref:`массивно в моих статьях.
Статья 0122 ссылается на 0106 (knowledge graph) и 0116 (компартментализация эпистемного). Эти ссылки являются редакционным hard linking — у меня есть сознательно решил соединить эти концепции.
-
Общие теги : у каждой статьи есть`:jbake-tags:`. Статья о
Graphify + PlantUML (0105) имеет`gradle, graphify, plantuml, knowledge-graph`. Статья о Knowledge Graph (0106) имеет knowledge-graph, graphify, plantuml. Три общих тега из семи — 43% совпадения.
-
Совместно встречающиеся именованные сущности : « 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 является терминальным потребителем — он применяет о плагинах он не реализует бизнес-логику. Правило: Бремя доказывания лежит на проприетарном плагине.
@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 из трех этапов :
-
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)
)
}
-
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 }
}
}
-
Шаблон (
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`, |
хронологический 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 движок рекомендаций является постепенным улучшением — не разрушительное изменение.
Эмерджентная онтология: когда статьи объединяются, не зная друг друга
Самый интересный слой — третий: возникающая онтология. Статьи, которые не цитируются, у которых нет одинаковых тегов, но которые говорят об одном и том же не знают об этом.
Возьмём конкретный пример. Три статьи из моего корпуса :
-
0119 — Benchmark DGX Spark vs Cloud подписка LLM
-
0121 — Плагин Gradle управляет двумя экземплярами Ollama Pro
-
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`, |
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 читает граф и заполняет шаблон. Читатель видит рекомендации релевантные. И двигатель — дирижёр — даже не знает что всё это существует.
Это оно, правильная архитектура. Каждый плагин делает одно дело. И потребительский терминал не должен знать как.