3 апреля 2026 года Андрей Карпати описал реально работающую систему, где он складывает необработанные исследовательские материалы в папку, показывает их LLM, которая с нуля создает и поддерживает всю взаимосвязанную вики-систему. ИИ пишет статьи, создает обратные ссылки между связанными идеями, классифицирует концепции и постоянно обновляет всю систему по мере поступления новых материалов. Вот промпт, который всё это делает LLM Wiki gist

Его новый рабочий процесс превращает необработанные исследовательские материалы в самоподдерживающуюся вики, базу знаний без RAG, только файлы Markdown и LLM, которая выполняет функции библиотекаря. Вместо того чтобы просматривать необработанные документы по каждому запросу, как в RAG, здесь LLM считывает исходный материал один раз и компилирует его в структурированную, организованную вики-систему. Его исследовательская вики-страница по одной теме разрослась примерно до 100 статей и 400 000 слов.

Мы взяли эту идею и инфраструктурно доработали:

Аспект

Karpathy LLM Wiki

Наша реализация

Хранение

Markdown-файлы в папке

AlloyDB + pgvector (SQL)

Связи

[[wikilinks]] в тексте

Типизированные рёбра графа (11 типов)

Поиск

Нет (или grep)

Векторный + графовый гибрид

Обновление

LLM переписывает markdown

SQL-функции + LLM-классификация из БД

Доступ

Локальная папка

HTTP-сервер с авторизацией

Мультипользовательность

Нет

Роли admin и reader

Протокол

MCP (StreamableHTTP + SSE)

Классификация связей

LLM при записи

ai.generate() прямо в SQL функции

Ключевое отличие: у Карпати wiki — это заметки Obsidian, которые LLM читает и пишет. У нас wiki — это база данных с графом, к которой несколько агентов обращаются через стандартизированный протокол. Карпати решал задачу «один человек + один LLM ведут заметки». Мы решаем задачу «несколько AI-агентов + человек совместно работают с растущей базой знаний». Разные масштабы — разная инфраструктура.

С чего всё начиналось

Домашняя wiki уже давно была назрела, так как проект оказался непрост. Смысл проекта - добавить блоки Titans в стандартную Gemma 3 и посмотреть что из этого выйдет. Технология Titans описана здесь

Конечно, сначала мы реализовали подход Karpaty, добавив векторный поиск по эмбеддингам.

Потом мы добавили граф знаний с типизированными рёбрами. Выбор технологии построения графа потребовал размышлений и занял некоторое время: Как выбирали технологию построения графа Но с ИИ-кодером реализация всегда быстрее, чем выбор технологий. Раз-раз, реализовали и увидели неплохой рост recall:

Метрика

До (вектор)

После (вектор + граф)

Avg recall

46.7%

68.3%

Enrichment

92 концепции найдены графом

… И серьезный прирост на абстрактных запросах:

Запрос

Было

Стало

«Что общего у MesaNet и Titans»

0%

67%

«Альтернативы Softmax attention»

0%

67%

«Ассоциативное сканирование»

50%

100%

«Дистилляция в TTT»

67%

100%

Граф в общем не нужен на точных запросах. Но там, где человек спрашивает «как связано X и Y», граф находит путь: Линейные RNN → Ассоциативное сканирование → NLM → M3 Optimizer — три прыжка по концепциям, которые векторный поиск не найдёт. Граф – это очень круто.

И всё было хорошо, пока wiki была личным инструментом. Сервер, который это всё обслуживал, был однопользовательским. stdio-транспорт — процесс запускается, обслуживает одного клиента, умирает. Это агент OpenClaw написал себе такую локальную базу знаний, и у него можно теперь спрашивать в телеграме. Точнее у нее, ее зовут Мнемозина, и она богиня знаний. Но. Хоть и прикольно писать код в телеграме, но иногда хочется вернуться в VS Code, да и проект, ради которого всё вот это, лежит на локальной машине. А значит нужен mcp-server. Stdio-транспорт MCP подразумевает, что сервер — это дочерний процесс клиента. Клиент подключился → сервер родился → клиент отключился → сервер умер. Для CLI-утилиты это нормально, но для сервера знаний, к которому стучатся 3-4 агента одновременно — не работает. Кроме того, чтобы подключить удалённого агента, приходилось пробрасывать порт через SSH-туннель со всеми паролями и явками, или запускать отдельный SSE-прокси. Кстати, запустили такой прокси, нормально отдает наружу. Но без авторизации. Все инструменты — и читающие, и пишущие — доступны любому, кто подключился. Агент-читатель случайно дёрнет graph_classify_edge — и перепишет типы рёбер.

Что изменилось в v3

StreamableHTTP + SSE в одном процессе

Мы перешли на двухтранспортную архитектуру в рамках одного Express-приложения:

POST/GET/DELETE /mcp        → StreamableHTTP (новый протокол MCP 2025-11-25)
GET /mcp/sse + POST /mcp/messages → SSE (legacy, протокол 2024-11-05)

StreamableHTTP — это свежий стандарт MCP transport. Один endpoint /mcp, на нем три HTTP-метода. Клиент может открыть долговременное соединение, сервер держит сессию в памяти через InMemoryEventStore. Старые клиенты, которые умеют только SSE, подключаются через /mcp/sse — и это тоже работает.

Зачем оставлять два транспорта? Не все MCP-клиенты уже перешли на StreamableHTTP. SSE-legacy гарантирует, что ничего не сломается. Зачем это в нашем камерном проекте? А чтобы было.

Ролевая модель: admin и reader

Два уровня доступа:

Роль

Инструменты

Что может

admin

Все read + graph_upsert_node, graph_classify_edge, graph_dedup_edges

Чтение + мутации графа

reader

wiki_search, wiki_read, wiki_graph, wiki_tags, wiki_backlinks, wiki_graph_*

Только чтение

Роль определяется при подключении — через Basic Auth. Admin знает пароль админа, reader знает пароль читателя. Инструменты, недоступные роли, просто не регистрируются в MCP-сервере этой сессии. Ограничение здесь на уровне регистрации инструментов, а не runtime-проверки. MCP-клиент получает список доступных инструментов при initialize. Если инструмента нет в списке — он просто не появится в UI, не попадёт в tool_choice модели, не будет случайно вызван.

Кстати интересно получилось: мы дали другому агенту OpenClaw админский доступ, и он сказал, что инструменты изменения wiki он видит, но использовать не имеет права, потому что он простой кодер. То есть, дословно:

Сейчас у меня в подключены 15 инструментов через reader. Если я не прокинул write-инструменты — скорее всего это политика tools.profile: "coding", которая фильтрует мутационные MCP-инструменты.

Разделение по ролям решается на сервере так:

function buildServer(role) {
  const server = new McpServer({ name: 'titans-wiki', version: '3.0.0' });
  registerReadTools(server);
  if (role === 'admin') registerAdminTools(server);
  return server;
}

При новом подключении — создаётся инстанс McpServer с нужным набором инструментов, подключается к выбранному транспорту. Всё.

Архитектура v3

                    ┌─────────────────────┐
                    │   Express + CORS    │
                    │   Port 8000         │
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
                    │  Auth Middleware    │
                    │  Basic / Bearer     │
                    └──────────┬──────────┘
                               │
               ┌───────────────┼───────────────┐
               │                               │
    ┌──────────▼──────────┐         ┌──────────▼──────────┐
    │  StreamableHTTP     │         │  SSE (legacy)       │
    │  /mcp               │         │  /mcp/sse           │
    │  POST/GET/DELETE    │         │  + /mcp/messages    │
    └──────────┬──────────┘         └──────────┬──────────┘
               │                               │
               └───────────────┬───────────────┘
                               │
                    ┌──────────▼──────────┐
                    │  buildServer(role)  │
                    │  ┌─ registerRead    │
                    │  └─ registerAdmin   │  (только admin)
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
                    │  AlloyDB + pgvector │
                    │  graph_nodes (72)   │
                    │  graph_edges (215)  │
                    │  wiki_pages         │
                    └─────────────────────┘

LLM из SQL: ai.generate() в AlloyDB

Один из самых изящных трюков — классификация рёбер прямо из базы данных:

CREATE OR REPLACE FUNCTION graph_classify_edge(
  _source_label TEXT,
  _target_label TEXT,
  _context TEXT DEFAULT NULL
) ...
  _result := ai.generate(_prompt);
...

AlloyDB Omni с версии 1.5.2 поддерживает google_ml_integration — можно вызвать Gemini прямо из plpgsql. Один пакетный прогон — и 205 нетипизированных рёбер получили конкретные типы (depends_on, develops, based_on). Стоимость: $0.01 на весь прогон.

Трудности, с которыми столкнулись

  1. Направленность рёбер. MesaNet → Conjugate Gradient Solver — это depends_on? uses? used_in? Пришлось разделить uses (A использует B) и used_in (B применяется в A), плюс depends_on (A не может без B). Двукратный прогон классификации.

  2. Дубли рёбер. После LLM-классификации одна пара страниц имела два ребра: depends_on и mentions. Функция graph_dedup_edges() оставляет ребро с максимальным весом.

  3. authored_by наоборот. Gemini иногда ставила person → paper вместо paper → person. Правило: authored_by только для конкретных работ, не для обзорных тем.

  4. Версия AlloyDB. Для ai.generate() нужен google_ml_integration ≥ 1.5.2. У нас была 1.4.3. Обновление контейнера с 16.8.0 до 16.11.0 с перепривязкой volume — аккуратная операция с данными на борту.

Многопользовательский сценарий: как это работает сейчас

У нас два OpenClaw-агента:

  • Мнемозина (основной) — подключается с ролью admin, может всё: искать, читать, обновлять граф, классифицировать рёбра.

  • Полифем (второй агент) — подключается с ролью reader, только читает. graph_upsert_node и graph_classify_edge у него просто не появляются в списке инструментов.

  • gemini-cli на локальной машине – reader

  • Cline в VS-Code – reader

Набор инструментов v3

Read-инструменты (доступны всем):

Инструмент

Назначение

wiki_read

Чтение содержимого страницы

wiki_graph

Связи-[[wikilinks]] из страницы

wiki_tags

Страницы по тегу / все теги

wiki_backlinks

Обратные ссылки на страницу

wiki_graph_neighbors

BFS-обход от ноды

wiki_graph_path

Кратчайший путь между концептами

wiki_graph_context

Гибридный retrieval (вектор + граф)

wiki_graph_edges

Все рёбра определённого типа

wiki_graph_stats

Статистика графа + сироты

wiki_graph_contradictions

Поиск противоречий

Admin-инструменты (только admin):

Инструмент

Назначение

graph_upsert_node

Создать/обновить ноду

graph_classify_edge

LLM-классификация типа ребра

graph_dedup_edges

Удаление дублей, селф-лупов, разворот directed-ошибок

Чему мы научились

  1. Граф + вектор > вектор. На абстрактных запросах гибридный retrieval выигрывает у чистого векторного поиска. На точных — не проигрывает. Нет причин не использовать граф, если данные позволяют.

  2. Типы рёбер — это не украшение, а архитектура. 11 типов с весами (depends_on = 0.95, mentions = 0.3) позволяют ранжировать hop-результаты осмысленно, а не просто «найдено на расстоянии 2».

  3. Авторизация на уровне регистрации инструментов надёжнее runtime-проверок. Если инструмент не зарегистрирован — клиент его не видит. Модель его не вызывает. Пользователь его не ждёт.

  4. LLM из SQL — killer feature для энтерпрайз-графов. Нет нужды в промежуточном слое: вызов Gemini прямо из plpgsql через ai.generate() классифицирует рёбра. Кстати, там можно и эмбеддинги вычислять для строки, чтобы на SQL передавать текст параметром, а не вектор, но иногда этот вектор нужно повторно использовать на клиенте

  5. Два транспорта — таков миграционный путь. StreamableHTTP — будущее MCP. SSE — настоящее. Поддержка обоих в одном процессе означает, что клиенты переходят на новый протокол в своём темпе.

Что еще накрутили:

  • Графовый reasoning: не просто находить путь, но и объяснять его. «MesaNet связан с Titans через 3 хопа, потому что оба используют Surrogate Memory, которая основана на Fast Weight Programmers».

  • Авто-обнаружение противоречий: при добавлении нового источника проверять, не противоречит ли он существующим утверждениям.

Что еще интересно попробовать:

  • Версионирование рёбер: отслеживать, когда связь была создана и почему — для растущей wiki это становится критичным.

  • WebSocket-транспорт: для real-time уведомлений об изменениях графа.


Стек: Node.js, Express, AlloyDB Omni, pgvector, MCP SDK, Vertex AI (text-embedding-004), Gemini 2.5 Flash Lite (edge classification)

P.S. Кому интересно почитать про Titans, Miras, MesaNet и прочие технологии Test-Time Training (TTT), напишите, я дам подключение к этому MCP, он не опубликован на публичных хабах. А всякий научпоп – в канале @veriga_pro_AI

Комментарии (6)


  1. flowing_abyss
    05.06.2026 16:57

    Спасибо за статью, очень интересный подход. Хотелось бы чуть лучше понять механику wiki_graph_context.

    Правильно ли я понимаю, что сначала находятся seed-страницы через vector search, а потом к ним добавляются соседние страницы из графа? Если да, то интересно, как вы дедуплицируете seed/hop-страницы, как выбираете глубину обхода, откуда берутся веса для разных типов рёбер, и что именно отдаёте LLM – полный текст страниц или только релевантные фрагменты?

    И ещё вопрос. Планируете ли вы где-нибудь выложить код (например, на GitHub)?


    1. veriga Автор
      05.06.2026 16:57

      Да, именно так — двухэтапный retrieval. Вот механика:

      Seed: Запрос - эмбеддинг - cosine similarity находит top-K (стоит хардкодом 5) страниц из graph_nodes. Это seed-ноды.

      Hop : Каждая seed-нода раскрывается через graph_edges — прямые соседи (hop1) и соседи соседей (hop2). Глубина по умолчанию 1.

      Дедупликация: SELECT DISTINCT ON (wp.path) — если одна и та же страница пришла и как seed, и как hop, остаётся seed (приоритет по relevance: seed > hop1 > hop2).

      Веса: заранее заданная таблица внутри SQL-функции:

      depends_on = 0.95, develops = 0.9, part_of = 0.85,
      based_on = 0.8, alternative_to = 0.75, contradicts = 0.7,
      authored_by = 0.5, tagged = 0.4, mentions = 0.3
      

      Rank: rank_score = similarity × edge_weight / (depth + 1). У seed-нод rank = чистый cosine (edge_weight=1, depth=0). У hop-нод — discount по типу ребра и глубине.

      Контент для LLM: страницы, обрезанные по relevance:

      Общий лимит max_chars = 8000 символов на весь результат.

      seed: left(content, max_chars / top_k)
      hop1: 500 символов
      hop2: 200 символов

      Если нужен код, скажите ваш гитхаб аккаунт, я вас добавлю


      1. flowing_abyss
        05.06.2026 16:57

        Содержательно. Это именно то, что я хотел узнать. Спасибо за ответ.
        Мой GitHub.


        1. veriga Автор
          05.06.2026 16:57

          пригласил


  1. veriga Автор
    05.06.2026 16:57

    Да, именно так — двухэтапный retrieval. Вот механика:

    Seed: Запрос - эмбеддинг - cosine similarity находит top-K (стоит хардкодом 5) страниц из graph_nodes. Это seed-ноды.

    Hop : Каждая seed-нода раскрывается через graph_edges — прямые соседи (hop1) и соседи соседей (hop2). Глубина по умолчанию 1.

    Дедупликация: SELECT DISTINCT ON (wp.path) — если одна и та же страница пришла и как seed, и как hop, остаётся seed (приоритет по relevance: seed > hop1 > hop2).

    Веса: заранее заданная таблица внутри SQL-функции:

    depends_on = 0.95, develops = 0.9, part_of = 0.85,
    based_on = 0.8, alternative_to = 0.75, contradicts = 0.7,
    authored_by = 0.5, tagged = 0.4, mentions = 0.3
    

    Rank: rank_score = similarity × edge_weight / (depth + 1). У seed-нод rank = чистый cosine (edge_weight=1, depth=0). У hop-нод — discount по типу ребра и глубине.

    Контент для LLM: страницы, обрезанные по relevance:

    Общий лимит max_chars = 8000 символов на весь результат.

    seed: left(content, max_chars / top_k)
    hop1: 500 символов
    hop2: 200 символов

    Если нужен код, скажите ваш гитхаб аккаунт, я вас добавлю



  1. alterbred
    05.06.2026 16:57

    Сначала показалось, что выдумывал сам что-то похожее...
    но у меня всё без Вики и нейросетей...

    Полной реализации нет, только визуализация вывода
    https://www.walks.ru/wm_dr/
    (лучше смотреть на большом экране)