
slater v0.24.4
Графовая БД с низким потреблением памяти, поддержкой Bolt+TLS, шифрованием в состоянии покоя и векторами, предназначенная для сценариев использования локальных реплик графов.
Slater
Текущая версия: v0.24.4 — все релизы.
В двух словах: Slater обслуживает графы, которые не помещаются в память — сотни миллионов узлов и миллиарды рёбер в пределах низких сотен МБ ОЗУ — по стандартному Bolt, так что любой neo4j-драйвер просто работает, рядом с графом находится дисковый нативный векторный поиск, и он принимает живые, долговечные записи, не отказываясь от этого. Резидентная память определяется выбранным вами бюджетом кэша, а не размером графа.
Быстрые ссылки
Зачем существует Slater
Графовая база данных хранит данные как вещи (узлы) и связи между ними (рёбра), при этом связи являются полноправными элементами. Это то, что нужно, когда ваши вопросы касаются связей, а не строк — «кто находится в пределах трёх переходов от этого счёта?», «какова полная цепочка зависимостей за этой сборкой?», «какие счета используют одно устройство, один адрес и одну карту?» — запросы, которые превращаются в болото рекурсивных JOIN в SQL, но естественным образом решаются в графе.
Самая распространённая жалоба на графовые базы данных — это то, что они не масштабируются за пределы того, что можно удержать в ОЗУ. Многие из них (например, neo4j, Memgraph, FalkorDB и др.) держат весь граф в памяти: граф на 40 ГБ требует 40 ГБ памяти — на каждый экземпляр. Хотите по реплике на регион, на тенанта или на под? Умножайте счёт. А после определённого размера они просто не загрузятся: например, граф Wikidata с 90 миллионами узлов и 1,5 млрд рёбер требует ~64–128 ГиБ резидентной памяти, так что движки, работающие в памяти, вообще не могут его открыть.
Slater — это ответ. Вместо загрузки графа в память он компилирует его один раз, офлайн: slater-build превращает ваши данные в контентно-адресуемый, неизменяемый образ на диске, а любое количество серверов Slater затем обслуживает этот образ по Bolt (так что ваши существующие neo4j-драйверы просто работают), подгружая блоки по мере необходимости и удерживая в памяти лишь фиксированный бюджет кэша. Именно так тот же граф на 90M узлов обслуживается из нескольких сотен МБ ОЗУ — размер графа и счёт за память развязаны. Граф на 4 ГБ и граф на 400 ГБ стоят одинаково с точки зрения ОЗУ для обслуживания, поэтому вы можете разворачивать дешёвые, не имеющие состояния реплики для чтения и позволить хранилищу, а не куче, удерживать граф.
Это делает его естественным выбором для графов знаний за RAG, рекомендательных и идентификационных графов, графов зависимостей — всего большого и связанного, что вы хотите запрашивать дёшево и часто. Дисковый нативный векторный поиск находится прямо рядом с графом, так что тот же движок служит и уровнем извлечения для эмбеддингов.
Скомпилирован один раз — не значит заморожен. Этот образ является базой, а не конечным состоянием: поверх него находится опциональный слой записи, так что живой граф можно исправлять и расширять без пересборки чего-либо.
Чтение и запись
Ядро неизменяемо; граф — нет. Включите слой записи (delta.enabled) — и вы пишете через Bolt: исправьте одно свойство, добавьте узел, отзовите ребро — и изменение сохраняется надёжно, без пересборки образа. Чтение остаётся дешёвым благодаря тому, где живут записи.
Записи накапливаются в журнально-структурированном слое слияния (LSM) поверх неизменяемого ядра: журнал упреждающей записи и таблица в памяти, сбрасываемая в неизменяемые дельта-сегменты, которые при периодической консолидации сворачиваются обратно в свежее ядро. Что это даёт:
- Чтение неперезаписанного графа стоит ровно столько же, сколько и раньше. Пустая дельта — это одна предсказуемая ветвь, а не слияние — путь чтения побайтно идентичен, включён ли слой записи или нет.
- Стоимость чтения записи масштабируется с размером дельты, а не с размером графа. Ответы по всему графу —
count(*), маргиналы по меткам и типам связей — остаются чтением метаданных, даже когда есть незакрытые записи: дельта ведёт собственные счётчики, поэтомуcount(*)по ядру на 91,6M узлов с полумиллионом ожидающих записей по-прежнему отвечает за десятки миллисекунд, не задевая ни одного блока. - Подтверждено — значит долговечно. Единственный писатель опустошает очередь и возвращает
SUCCESSтолько послеfsync, покрывающего запись. Группируйте записи — и они дёшевы: write-UNWINDфиксирует одинfsyncна пакет, а не на строку. - Записи по бизнес-ключам, в обоих диалектах.
MERGE/MATCH … SET/DELETE(а такжеCREATE/REMOVE, detach delete, записи связей), адресованные по свойству идентичности узла — или эквивалентные операторы модификации данных ISO GQL (INSERT/SET/REMOVE/DELETE), которые опускаются на тот же путь. Исправление, вставка, апсерт и отзыв по узлам и рёбрам, адресованные так, как уже устроены ваши данные.
Со слоем, выключенным по умолчанию, Slater обслуживает чистое неизменяемое ядро и отказывает в записях. Полную модель см. в Слой записи.
О названии. Slater назван в честь агента ЦРУ из Archer (отличный сериал), который настаивает на том, чтобы его называли только по фамилии — «Просто… Слейтер» — и один из моих любимых персонажей в нём. См. страницу вики о персонаже.
Что вы получаете
- ОЗУ определяется вашим бюджетом кэша, а не размером графа — разворачивайте столько реплик чтения, сколько хотите; граф никогда не должен помещаться в память.
- Готовая замена графа — говорит на Bolt, поэтому любой стандартный neo4j-драйвер (JS, Python, Go…) работает без изменений. Это Cypher (плюс часть ISO GQL, чтение и запись); ничему новому учиться не нужно.
- Живые, долговечные записи — опциональный LSM-слой поверх неизменяемого ядра: бизнес-ключевые
MERGE/SET/DELETEпо узлам и рёбрам, с групповой фиксацией иfsync-долговечностью, сворачиваемые в свежее ядро консолидацией. Чтение за это не платит. - Развёртывание заменой файла — соберите новое поколение с хэшем содержимого офлайн, атомарно переключите указатель
current, и серверы подхватят его. Каждый блок проверяется контрольной суммой, поэтому наполовину скопированный образ отклоняется, а не обслуживается. - Встроенный векторный поиск — дисковый нативный поиск приблизительных ближайших соседей (косинус, L2 или точечный KNN) находится прямо рядом с вашим графом, для случаев, когда это уровень извлечения за RAG-конвейером, а эмбеддинги можно перезаписывать на месте — без офлайн-пересборки для добавления или изменения вектора.
- Защищён по построению — права на чтение и запись независимы, плюс опциональное шифрование на диске, Bolt через TLS, ACL с хэшированием argon2id и корневая файловая система контейнера только для чтения в репликах чтения. Настройте мастер-ключ — и образ на диске становится аутентифицированным, а не только зашифрованным: его манифест несёт ключевой MAC, так что злоумышленник с доступом на запись в каталог данных, но без ключа, не сможет подделать манифест, который сервер примет. Без ключа вы всё равно получаете хэш содержимого, который ловит наполовину скопированный или повреждённый образ — но не намеренно модифицированный. Что покупает какая конфигурация.
Возможности
| Возможность | Что это значит для вас |
|---|---|
| Ограниченная, предсказуемая память | Резидентная память отслеживает три бюджета кэша, которые задаёте вы, с ограниченными накладными расходами на запись и аллокатор — она не растёт с размером графа; вы настраиваете компромисс между производительностью и ОЗУ, а не резервируете ресурсы под весь граф. Аллокатор jemalloc с фоновой очисткой возвращает освобождённую память ОС после тяжёлых всплесков запросов, так что резидентный размер возвращается к своему нижнему порогу простоя, а не остаётся зафиксированным на послемпиковом максимуме. |
| Мультитенантность из коробки | Один сервер размещает много графов с попользовательскими правами на чтение — мультибазовая изоляция, которую большинство графовых БД приберегает для платного/корпоративного уровня. |
| Шифрование на диске и в пути | Поблочное запечатывание XChaCha20-Poly1305 (ключ никогда не записывается на диск) плюс опциональный TLS (bolt+s://). По построению соответствует GDPR. Шифрование также обеспечивает аутентифицированную целостность: сборщик запечатывает манифест ключевым MAC, а сервер, владеющий ключом, проверяет его и отказывается обслуживать поколение, чей манифест подделан, изменён или лишён MAC. Образ без ключа (открытым текстом) защищён только неключевым хэшем содержимого — это полнота и защита от повреждений, но не от подделки. См. Что означает целостность в каждой конфигурации. |
| Крошечная установка | Небольшой урезанный бинарник на дистрольной базе glibc (без shell/apt) — мультиархитектурный (amd64/arm64) образ весит ~22 МБ, или ~12 МБ для серверного тега slater:latest-lite; чистый Rust TLS, без OpenSSL. Скачал и запустил. |
| Создан для периодической публикации | Соберите граф офлайн, обслуживайте его неизменяемым, затем атомарно подмените новую версию без простоев — идеально для рабочих нагрузок хранилища данных / планового обновления. |
| Устойчив под нагрузкой | Сервер и офлайн-сборщик компилируются с #![forbid(unsafe_code)] — единственный unsafe в движке живёт в проверенном крейте аллокатора jemalloc. Ядро неизменяемо, поэтому чтение не берёт блокировок и никогда не ждёт писателя; единственный писатель сериализует мутации за одним лишь путём записи. Никаких пауз GC, никаких гонок данных. Один плохой запрос не может уронить сервер. |
| Работает с вашими neo4j-инструментами | Говорит на Bolt 5.4 / 4.4 / 4.1 — используйте стандартные neo4j-драйверы (JS, Python, Go, Java…), cypher-shell или графовые браузеры без изменений. |
| Богатая поверхность Cypher-запросов | Широкий набор для чтения: MATCH/WHERE/WITH/UNION, подзапросы CALL {…}, 70+ функций и агрегаций, временные и геопространственные значения, регулярные выражения. |
| Живые, долговечные записи | Опциональный одноместный LSM-слой над неизменяемым ядром (delta.enabled): бизнес-ключевые MERGE / SET / DELETE / CREATE / REMOVE по узлам и связям, пакетный write-UNWIND (один fsync на пакет) и CALL slater.consolidate() — групповая фиксация, fsync-долговечность и сворачивание в свежее ядро консолидацией. Путь чтения побайтно идентичен, когда дельта пуста. |
| ISO GQL, чтение и запись | Говорит на подмножестве ISO GQL (ISO/IEC 39075) по тому же Bolt-соединению — квантифицированные пути, ограничители путей, селекторы кратчайшего пути, булевы выражения по меткам/типам, FOR, CAST, опциональный префикс диалекта GQL/CYPHER — и, при включённом слое записи, операторы модификации данных GQL (INSERT / SET / REMOVE / [DETACH] DELETE) опускаются на тот же долговечный путь записи. Cypher и GQL, чтение и запись, в одном движке. |
| Векторы + граф в одном движке | Дисковый нативный ANN-поиск векторов (Vamana + PQ; косинус / L2 / точечный) для эмбеддингов/RAG, плюс графовые алгоритмы (PageRank, BFS, посредничество, WCC…) — ограниченная память даже с миллионами векторов. Эмбеддинги перезаписываемы (лестница записи в стиле FreshDiskANN): вставка / обновление / удаление вектора, сразу видимого для KNN, сворачивается в базу без пересборки. |
| Безопасен в сетевых хранилищах | Каждый файл хэшируется BLAKE3 по содержимому и проверяется при открытии; рваные или наполовину скопированные образы отклоняются, а не обслуживаются. Спроектирован для NFS/удалённых томов (без сюрпризов mmap). |
| Сменные бэкенды хранилища | Обслуживайте один и тот же формат поколения из локальной файловой системы, S3-совместимого бакета или бакета Google Cloud Storage — публикуйте один раз, разворачивайте реплики без состояния — с опциональным локальным SSD-кэшем перед объектным хранилищем. См. Бэкенды хранилища. |
Рабочее пространство состоит из двух бинарников:
| Бинарник | Роль |
|---|---|
slater | Онлайн-сервер Bolt (ENTRYPOINT контейнера): обслуживает чтение и, при delta.enabled, путь долговечной записи с одним писателем. |
slater-build | Офлайн-компилятор: превращает дамп на примитивном Cypher в неизменяемый каталог поколения с хэшем содержимого. |
Slater разделяет пакетное построение и обслуживание: slater-build выполняет
тяжёлую работу офлайн — принимает ваши данные и компилирует их в неизменяемое
поколение, — поэтому холодный граф никогда не собирается на горячем пути
обслуживания. Внутри сервера поверхность чтения отвечает на широкий срез Cypher —
сопоставление с образцом, подзапросы WITH/UNION/CALL {…}, 70+ скалярных
и агрегатных функций, временные и геопространственные значения, графовые
алгоритмы (algo.*) и дисковый векторный KNN (db.idx.vector.queryNodes) —
а дельта-оверлей слоя записи находится ниже этой поверхности и ничего не стоит,
когда пуст, так что чтение никогда не несёт на себе механику среды записи.
Обновить граф можно двумя способами: писать в него вживую через Bolt (см.
Слой записи), или построить новое поколение офлайн и
атомарно переключить указатель current, который работающий сервер подхватывает
через свой сторож поколений (см. Сторож поколений).
Документация
Полное руководство пользователя находится в docs/manual/ —
это пошаговое руководство по функциям, объясняющее для каждой возможности, что это,
зачем она нужна и как её использовать, с рабочими примерами, которые можно запустить
на прилагаемом образцовом графе. Начните с него для всего, что выходит за рамки этого обзора.
- Новичок? Быстрый старт собирает и обслуживает граф за пять шагов.
- Пишете запросы? Запросы, Функции и выражения, Процедуры и алгоритмы, Векторный поиск, Запись данных.
- Собираете графы? Построение графов и Справочник CLI сборки.
- Эксплуатируете Slater? Развёртывание, Хранилище, Справочник по конфигурации, Безопасность, Настройка производительности.
Запуск с Docker
Slater спроектирован для запуска как Docker-развёртывание — это ожидаемый
способ его использования. Готовые мультиархитектурные образы (linux/amd64 + linux/arm64)
публикуются на Docker Hub по адресу
hikarisystems/slater,
с тегами :latest и :vX.Y.Z при каждом релизе:```sh
docker pull hikarisystems/slater:latest
Руководство по использованию, конфигурации и эксплуатации, использующее только команды Docker, находится в
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (и продублировано на странице обзора Docker Hub) —
**начните с него, если вы развёртываете.** Короче:```sh
# Build a graph generation with the offline writer:
docker run --rm -v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
--entrypoint /app/slater-build hikarisystems/slater:latest \
--input /dumps/people.cypher --graph people --data-dir /data
# Serve it over Bolt on 7687 (read-only unless `delta.enabled`):
docker run -d --name slater -p 7687:7687 \
-v slater-data:/data:ro -v "$PWD/acl.json:/config/acl.json:ro" \
hikarisystems/slater:latest
Чтобы вместо этого собрать образ локально (например, для разработки):```sh
Build the image (both binaries).
docker compose build
Serve (expects generations under the slater-data volume / your /data mount).
docker compose up slater
Build a generation with the offline writer (profile build):
docker compose run --rm builder
--input /dumps/people.cypher --graph people --data-dir /data
Стадия сборки устанавливает `cmake`, `clang` и `libclang-dev` для rustls
бэкенда `aws-lc-rs`; `git` (уже есть в базовом образе) требуется для
зависимости `hs-utils` git+tag, которую `.cargo/config.toml` получает через git CLI.
Следующие разделы описывают формат на диске, конфигурацию, ACL и
локальный (не-Docker) пример работы.
## Как это работает```
slater-build slater (Bolt server)
dump.cypher ──────────▶ /data/<graph>/<uuid>/ ──────────▶ neo4j driver
(offline, atomic) MANIFEST.json, *.blk, (bolt / bolt+s)
range/*.isam, vector/*.{vamana,pq},
current → <uuid>
- Поколение — это одна неизменяемая директория:
MANIFEST.json(таблицы символов, дескрипторы индексов, необязательный заголовок шифрования), файлы колоночных блоков (node_props.blk,node_labels.blk,edge_props.blk,topology.csr.blk,vectors.f32.blk), диапазонные индексы (range/<name>.isam), ANN-индексы выше порога (vector/<label>.<prop>.{vamana,pq}) и текстовый указательcurrent. - Каждый блок сжимается zstd и снабжается контрольной суммой BLAKE3; при использовании
--encryptкаждый блок дополнительно запечатывается шифром XChaCha20-Poly1305 (AEAD для данных в покое). - Сервер открывает поколение, повторно хешируя каждый файл и сверяя его с манифестом, поэтому частично скопированный / обрезанный образ — оборванная копия в каталоге данных, который может быть удалённым/сетевым хранилищем, — отклоняется, а не обслуживается.
- Чтения проходят через три ограниченных пула кэша — LRU для распакованных блоков, пул векторных индексов (резидентные PQ-коды + LRU для блоков Vamana) и LRU результатов — каждый со своим бюджетом в байтах. Каждый пул взвешивает то, что содержит, и вытесняет записи, чтобы оставаться в рамках своего бюджета, поэтому RSS отслеживает бюджеты, отклоняясь не более чем на ограниченные накладные расходы на запись и аллокатор, а не растёт вместе с графом.
Слой записи
При delta.enabled неизменяемое поколение становится полностью уплотнённым нижним
уровнем («ядром») небольшого LSM-дерева (log-structured merge-tree), а активные записи размещаются
поверх него:```
write (Bolt) read (Bolt)
│ │
▼ ▼
┌──────────────┐ flush ┌──────────────┐ ┌──────────────────────┐
│ WAL + active │ ───────▶ │ L0 delta │ │ a query pins one │
│ memtable │ │ segments │ │ (core, delta) view │
└──────────────┘ └──────┬───────┘ │ and reads the merge │
(fsync = ack) │ └──────────────────────┘
consolidation │ (folds core + delta → fresh core)
▼
┌─────────────┐
│ new core │ (atomic current swap)
└─────────────┘
* **Гарантия долговечности — WAL.** Каждая мутация сериализуется за одним
писателем на граф, дописывается в журнал упреждающей записи (write-ahead log)
для каждого графа и синхронизируется через `fsync` перед тем, как возвращается
`SUCCESS` Bolt — поэтому *подтверждено ⇒ долговечно*, а оборванный хвост
отбрасывается при воспроизведении. Пакетная запись через `UNWIND` добавляет свои строки и фиксирует **один**
`fsync` для всего пакета. WAL существует **только на локальном диске** (он не маршрутизируется
через бэкенд хранилища), что делает *пишущий* узел сохраняющим состояние: ему нужен
долговечный локальный том в `delta.walDir`. Читающие реплики остаются без состояния.
* **Memtable → L0 → консолидация.** Записи накапливаются в memtable в оперативной памяти
(ограниченном `delta.memtableBytes`); когда он заполняется, он сбрасывается в неизменяемый
сегмент дельты L0. **Консолидация** сворачивает `{core + delta}` в новый core,
сериализуя объединённое представление обратно через `slater-build` и атомарно подменяя
`current` — та же защита по content-hash, что и для любой опубликованной генерации. Запустите её
вручную с помощью `CALL slater.consolidate()`, автоматически при `delta.deltaCorePercent`
от размера core (при необходимости ограничив её внепиковым окном `delta.consolidateWindow`), или
позвольте предохранителю `delta.deltaHardBytes` остановить неконтролируемый рост.
* **Оверлей находится под поверхностью чтения.** Исполнитель читает через
`ReadView`, которое представляет собой либо голый core (дельта всегда пуста), либо объединённое
представление `(core, delta)`; движок мономорфизирован по нему, поэтому пустая дельта
компилируется в одну предсказуемую ветвь, а путь только для чтения байт-в-байт идентичен.
Общеграфовые счётчики (`count(*)`, маргиналы меток/типов отношений) обслуживаются из
собственных живых счётчиков дельты, поэтому они остаются чтениями метаданных даже при ожидающих записях.
* **Запрос видит стабильный снимок.** Он закрепляет один кортеж `(core, delta)` на всё
время своей жизни. Здесь нет многооператорных транзакций и отката — запись — это
долговечная правка, адресуемая бизнес-ключом, а не OLTP-транзакция.
Точная грамматика записи и параметры приведены в таблице [Конфигурация](#environment--configuration)
(`delta.*`) и в [рабочем примере](#worked-example) ниже.
### Диапазонные индексы (ISAM)
Диапазонный индекс (`range/<name>.isam`, по одному на каждую пару `(label, property)`) позволяет
`MATCH (n:Label {prop: v})` или `WHERE n.prop <op> v` разрешаться в соответствующие идентификаторы
узлов **без сканирования метки**. Это
**[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method)
структура — классический *статический, отсортированный, блочно-структурированный* индекс, который идеально
подходит для неизменяемой генерации: здесь нет вставок, требующих перебалансировки, поэтому
простота ISAM даёт ровно то, что механизм мутаций B-дерева только усложнил бы.
* Записи `(value, entity_id)` отсортированы по значению и упакованы в те же
zstd-сжатые блоки по 256 КиБ, что и всё остальное.
* Небольшой **резидентный верхний уровень** хранит первый ключ каждого блока (разреженный
индекс). Поиск бинарным поиском проходит по этому верхнему уровню в памяти, чтобы найти *тот единственный* блок,
в котором может находиться ключ, читает + распаковывает этот блок и сканирует его — поэтому
поиск по равенству — это **чтение одного блока**, а диапазонное сканирование проходит по непрерывной
последовательности блоков, которые оно охватывает. (Именно поэтому поиск по индексированному `meshUi` занимает единицы миллисекунд,
тогда как такой же `MATCH` по неиндексированному свойству сканирует всю метку.)
* Планировщик выбирает его через `NodeScan::RangeEq` / `RangeRange`; неиндексированный
предикат откатывается к заметанию метки или полному сканированию, при этом исполнитель
в любом случае перепроверяет каждый предикат.
### Векторный поиск (Vamana + PQ) — косинус, L2 и скалярное произведение, чтение *и* запись
Векторный KNN (`db.idx.vector.queryNodes`) работает поверх индексов **косинусного, L2 или скалярного произведения (MIPS)**.
Базовый индекс строится офлайн двумя путями исполнения, выбираемыми для каждого индекса параметром
`--ann-threshold` (по умолчанию 50 000 векторов):
* **Ниже порога — полный перебор.** Полные векторы `f32` хранятся в
`vectors.f32.blk`; запрос сканирует группу индекса и вычисляет точное расстояние в
метрике индекса. Просто и точно; хорошо, когда набор векторов невелик.
* **На пороге или выше — Vamana + PQ** — дискретный путь ANN, который удерживает
резидентную память ограниченной независимо от количества векторов:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** — это графовый индекс из
линии работ DiskANN: одиночный граф близости, рёбра которого обрезаются (исходящая
степень `--vamana-r` и фактор длинных рёбер `--vamana-alpha`), так что *жадный
лучевой поиск* — старт в медоиде, повторные переходы к запросу, удержание списка
кандидатов шириной `vectorQuery.beamWidth` — достигает истинных соседей узла
за несколько переходов, т.е. **за несколько случайных чтений блоков на запрос**. Блоки
графа (`vector/<label>.<prop>.vamana`) подкачиваются через векторный кэш,
а не удерживаются целиком.
* **[Продуктовая квантизация (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)**
сжимает каждый вектор в короткий код (`--pq-subspaces` × `--pq-bits`):
размерности разбиваются на подпространства, каждое независимо кластеризуется k-means, и
вектор хранится как кортеж идентификаторов ближайших центроидов. Эти коды
(`vector/<label>.<prop>.pq`) достаточно малы, чтобы держать их **резидентными**, поэтому
лучевой поиск оценивает кандидатов из оперативной памяти, и лишь немногие выбранные полные векторы
читаются с диска. Именно этот резидентный набор PQ закрепляет пул
`cache.vectorCacheBytes`.
**Записываемые эмбеддинги — лестница записи векторов (в стиле [FreshDiskANN](https://arxiv.org/abs/2105.09613)).**
Индексируемый эмбеддинг — полноценное записываемое значение. `SET n.embedding = vecf32([…])` (и `REMOVE`) попадает в
дельту записи и **немедленно видим для KNN с точным рангом**, а затем переживает сброс сегмента,
слияние и консолидацию. Запрос объединяет до трёх уровней — запечатанный базовый
индекс, запечатанный посегментный индекс и **RW-индекс** в памяти (живой изменяемый Vamana
поверх дельты записи) — поэтому задержка остаётся плоской по мере накопления записей, а не растёт
вместе с количеством ожидающих записей. Удаление оставляет *дыру*: узел перестаёт возвращаться,
но остаётся навигационной точкой, пока фоновая **delete-consolidation** не вырежет его
из графа, поэтому удаления перестают стоить IO при запросах. А поскольку граф на диске
адресует своих соседей по позиции в раскладке, а не по идентификатору узла, `CALL slater.consolidate()`
переносит Vamana **по ссылке** — жёстко слинкованным, байт-в-байт идентичным — и переписывает лишь
небольшую колонку идентификаторов, сворачивая векторные записи в базу **без** перестройки графа
O(N·R·L). Измеренные цифры, с оговорками, приведены в [отчёте о производительности](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).
## Бэкенды хранилища (файловая система / S3 / GCS)
Каждый файл поколения открывается через абстракцию **`ObjectStore`**, а не
напрямую через `std::fs`, поэтому *тот же* дисковый байтовый формат — блоки, индексы,
манифест, указатель `current` — обслуживается без изменений из любого бэкенда; отличается только *откуда
берутся байты*, но никогда — читатели, движок запросов или проверки
целостности. Горячий путь — позиционные чтения (`read_exact_at`), которые отображаются
на `pread` для локального файла и на HTTP-запрос диапазона байтов для объектного хранилища
— Slater никогда не использует mmap, поэтому явная модель чтения с ограниченным размером идентична
везде.
**Три полноценных бэкенда**, выбираемых параметром `dataBackend.kind`. Файловая система —
простой вариант по умолчанию; **Amazon S3 и Google Cloud Storage — равноправные, полностью
поддерживаемые объектные бэкенды** — публикуемый образ включает оба скомпилированными,
поэтому каждый из них настраивается только через конфигурацию, и однажды собранная генерация может обслуживаться
из любого из них (даже мигрировав `fs` → S3 → GCS) без пересборки.
| `dataBackend.kind` | Позиционное чтение | Целостность при открытии | Учётные данные |
| --- | --- | --- | --- |
| `fs` *(по умолчанию)* | `pread` | полный повторный хеш BLAKE3 каждого файла | — |
| `s3` | HTTP `Range` GET | серверный **SHA-256** через `HEAD` (→ повторный хеш тела BLAKE3 при отсутствии) | ключи конфигурации, цепочка AWS или роль IAM |
| `gcs` | HTTP-чтение диапазона | серверный **CRC32C** через `get_object` (→ повторный хеш тела BLAKE3 при отсутствии) | ADC / Workload Identity или JSON сервисного аккаунта |
Оба объектных хранилища проверяют целостность по **контрольной сумме, которую хранилище уже
вычисляет и хранит**, получаемой как метаданные объекта: `slater-build` отправляет
контрольную сумму при загрузке (хранилище проверяет по ней байты и сохраняет её), а
сервер считывает её при открытии и сравнивает с манифестом — один запрос
метаданных на файл, без загрузки тела. Это проверка на уровне содержимого и идентична по духу
в S3 (SHA-256) и GCS (CRC32C). Когда объект **не** имеет сохранённой сервером
контрольной суммы (скопирован внеполосно или загружен с иной контрольной суммой по умолчанию), сервер
**повторно хеширует тело объекта против BLAKE3 из манифеста**, а не доверяет его
длине в байтах — запрошенная проверка целостности никогда молча не понижается до сравнения
размеров. Генерации, опубликованные Slater, всегда несут контрольную сумму, поэтому остаются
на дешёвом пути метаданных.
То, что проверяет эта колонка на каждом бэкенде, — это соответствие файлов **манифесту**.
Можно ли доверять самому манифесту — отдельный вопрос, и отвечает на него именно
мастер-ключ: при настроенном ключе манифест несёт ключевой MAC,
который сервер проверяет, прежде чем доверять любому полю (включая эти хеши), поэтому
манифест, переписанный для описания подменённых файлов, отклоняется; без ключа сравнение полностью неключевое, и
тот, кто может писать в каталог данных, может переписать вместе и файл, и манифест.
См.
[Что означает целостность в каждой конфигурации](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration).
Саму проверку можно отключить параметром `dataBackend.verifyIntegrity: false`, обменяв
её на более быстрое открытие.
### Файловая система (`fs`)
Вариант по умолчанию, корнем которого является `dataBackend.fs.dir`. Правильный выбор для большинства
развёртываний: поколение на локальном SSD (или на смонтированном NFS/EBS), обслуживаемое только для чтения.
Целостность — это полный повторный хеш BLAKE3 каждого файла при открытии.
### Amazon S3 (`s3`)
Сегмент S3 или совместимый с S3 (AWS, MinIO, localstack). Учётные данные берутся **в первую очередь**
из конфигурации (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, плюс
`awsSessionToken` для временных учётных данных STS) и при пустых значениях переходят к стандартной
цепочке AWS (переменные окружения `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, общий профиль или
роль instance/IRSA).```sh
# serve from S3 (env-var form; see the config table for every key)
dataBackend__kind=s3
dataBackend__s3__bucket=slater
dataBackend__s3__region=eu-west-2
dataBackend__s3__awsAccessKey=… # omit to use the AWS chain / instance role
dataBackend__s3__awsSecretKey=…
# S3-compatible (e.g. MinIO): also set
dataBackend__s3__endpoint=http://minio:9000
dataBackend__s3__pathStyle=true # required by most S3-compatible servers
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
--publish-s3-bucket slater --publish-s3-region eu-west-2 --publish-s3-prefix prod
# MinIO: add --publish-s3-endpoint http://localhost:9000 --publish-s3-path-style
Google Cloud Storage (gcs)
Сегмент GCS, доступ к которому осуществляется через JSON API. Авторизация является нативной для GCP: по умолчанию используются Application Default Credentials — Workload Identity в GKE, сервер метаданных GCE или ключ gcloud / GOOGLE_APPLICATION_CREDENTIALS. Задайте dataBackend.gcs.credentialsPath (JSON-файл ключа сервисного аккаунта) или встроенный credentialsJson, чтобы указать явный ключ. dataBackend.gcs.endpoint указывает на эмулятор fake-gcs-server, а dataBackend.gcs.anonymous=true включает неаутентифицированный доступ только для этого эмулятора — никогда для реального GCS.```sh
serve from GCS (env-var form; see the config table for every key)
dataBackend__kind=gcs dataBackend__gcs__bucket=slater dataBackend__gcs__prefix=prod dataBackend__gcs__credentialsPath=/secrets/sa.json # omit for ADC / Workload Identity
```sh
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
--publish-gcs-bucket slater --publish-gcs-prefix prod
# explicit key: add --publish-gcs-credentials /secrets/sa.json
Во всех случаях slater-build сначала записывает готовое поколение в --data-dir (свою локальную промежуточную область) и дополнительно загружает его в бакет; удалённый указатель current записывается последним, поэтому обслуживающий узел никогда не видит наполовину опубликованное поколение.
Когда использовать объектное хранилище (S3 или GCS)
Обращайтесь к s3 или gcs, когда вам нужны поколения в долговечном центральном объектном хранилище, а не на диске узла — например: опубликовать один раз и раздать множеству не имеющих состояния и диска реплик сервера, которые читают один и тот же бакет; развязать сборочный хост и обслуживающие хосты; или положиться на долговечность/версионирование/жизненный цикл хранилища вместо управления томами. Плата за это — задержка: холодный блок — это сетевой round-trip (~10–50 мс), а не локальное чтение (~0,1 мс). Slater скрывает большую часть этого с помощью кэша блоков в памяти, упреждающего чтения и опционального дискового кэша ниже. Если ваши поколения уже лежат на быстром локальном хранилище и вам не нужна модель центрального бакета, fs проще и быстрее.
Локальный дисковый кэш блоков (второй уровень для объектного хранилища)
Внутренний BlockCache намеренно мал (ограниченный RSS — главная гарантия), поэтому на рабочем наборе, превышающем RAM, одни и те же блоки при каждом вытеснении заново запрашивались бы из объектного хранилища. Опциональный второй уровень кэша на локальном SSD исправляет это: блок, вытесненный из RAM, обслуживается с локального диска (~0,1 мс) вместо нового GET к объектному хранилищу, переживая вытеснение из памяти и сокращая количество/стоимость запросов к объектному хранилищу — что приближает узел на объектном хранилище к производительности локальной файловой системы после прогрева. Он включается явно для обоих s3 и gcs установкой dataBackend.<s3|gcs>.diskCacheBytes > 0 и доступного для записи diskCacheDir.
- Он кэширует запечатанные байты ровно в том виде, в каком они были получены — уже сжатые и (для поколений с
--encrypt) всё ещё запечатанные AEAD — ниже уровня расшифровки/распаковки. Слой кэша никогда не хранит ключ шифрования и не перешифровывает, поэтому статус at-rest сохраняется без дополнительных усилий: зашифрованное поколение попадает на диск всё ещё запечатанным. - Записи выполняются с отложенной записью (write-behind): при промахе полученные байты сразу возвращаются запросу, а затем фоновый поток выполняет запись на диск и обрезку LRU, поэтому путь запроса никогда не блокируется на дисковом I/O. Вытеснение удерживает кэш в рамках его байтового бюджета; контрольная сумма для каждого файла, проверяемая при каждом чтении, самовосстанавливает повреждённый файл кэша до состояния промаха (→ повторное получение из объектного хранилища).
diskCacheDirобязан указывать на настоящий доступный для записи том — никогда наtmpfs(tmpfs — это RAM и нарушил бы гарантию ограниченного RSS). Индекс в памяти, отслеживающий его, стоит немного RAM (~десятки байтов на кэшированный блок), что учитывается в вашем потолке RSS — выделите под каталог объём ≫ кэша блоков в памяти.- Другая стоимость уровня в RAM — очередь отложенной записи, которая подготавливает блоки на пути к диску. Её размер ограничен
blockCacheBytes / 8(с нижней границейdiskCacheBytes) — 8 МиБ по умолчанию — и она сбрасывает нагрузку, а не растёт, так что холодное сканирование не может её раздуть; сброшенный блок просто повторно запрашивается при следующем промахе. Она не требует настройки: масштабируется вместе сblockCacheBytes, поэтому дисковый уровень не добавляет новых чисел в бюджет RSS, кроме своего индекса.
Точки монтирования
Реплика для чтения работает с корневой файловой системой только для чтения и непривилегированным пользователем (appuser:1000) — всё, что ей нужно, смонтировано только для чтения. Пишущий узел (delta.enabled) дополнительно требует один долговечный том с возможностью записи для своего WAL.
| Path | Purpose | Notes |
|---|---|---|
/data | Поколения графа (<graph>/<uuid>/… + current). | Только для чтения для реплик; создаётся slater-build. Может находиться на удалённом/сетевом хранилище (например, NFS), поэтому чтения не считаются быстрыми, как на локальном SSD. |
/sandbox | Наложение конфигурации для окружения + секреты. | /sandbox/config.json глубоко объединяется поверх встроенного config.json; также содержит acl.json, TLS PEM-материалы, файл ключа at-rest. |
/tmp, /run | Временные данные (tmpfs). | Реплика для чтения по умолчанию никогда не пишет на диск. |
(writer) delta.walDir | Журнал упреждающей записи (WAL) + L0-сегменты дельт, когда delta.enabled. | Доступен для записи и представляет собой долговечный настоящий том — никогда tmpfs (это нижняя граница долговечности). Относительный путь разрешается внутри каталога данных; укажите здесь собственный постоянный том для пишущего узла. |
| (optional) disk cache | Локальный дисковый кэш блоков, когда dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Доступен для записи, настоящий том — не tmpfs. Используется бэкендами s3 и gcs; см. Хранилища. |
Окружение / конфигурация
Конфигурация загружается стандартным для проекта многослойным загрузчиком: встроенный config.json, затем глубоко объединённый поверх него /sandbox/config.json, затем переопределения окружения KEY__sub (двойное подчёркивание для вложенности; ключи соответствуют camelCase-конфигурации).
Каждый параметр конфигурации — его camelCase-ключ, переопределение окружения KEY__sub, значение по умолчанию и назначение — сведён в таблицу в Справочнике по конфигурации. Наиболее часто настраиваемые параметры — бюджеты кэша (cache.*), защитные ограничения запросов (query.*), лимиты соединений (server.*), бэкенд хранилища (dataBackend.*) и слой записи (delta.*).
Резидентная память отслеживает blockCacheBytes + vectorCacheBytes + resultCacheBytes с точностью до ограниченных накладных расходов на запись и аллокатор — каждый пул взвешивает собственное содержимое (строки и контейнеры по выделенной ёмкости) и вытесняет элементы, чтобы остаться в рамках бюджета, но учёт на запись и округление классов размеров аллокатора ложатся поверх установленного числа — плюс небольшие фиксированные накладные расходы (и до degreeColumnBytes для lazy-колонки степеней, после задействования быстрого пути суммарной степени count(endpoint)). Она не зависит от размера графа — это главная гарантия, проверяемая интеграционным тестом rss_stays_bounded_under_sustained_knn_load, который удерживает рост RSS от тёплого состояния к пику с большим запасом внутри суммарных бюджетов. Буферы на соединение находятся вне бюджетов кэша, поэтому гарантия сохраняется при враждебной нагрузке только потому, что server.maxConnections ограничивает, сколько соединений может существовать одновременно.
Сетевая позиция
Slater — это реплика для чтения; основной контроль безопасности соединений — сеть, а не бинарник. Привяжите его к приватному интерфейсу, ограничьте исходные диапазоны на сетевом уровне (security groups / NetworkPolicy) и — если он обращён к чему-то кроме доверенных клиентов — поставьте перед ним L4-прокси с ограничением соединений (HAProxy maxconn + stick-table для каждого источника или nftables connlimit + hashlimit). Это находится до того, как файловый дескриптор вообще передаётся процессу, поэтому это самый надёжный лимит.
Встроенные в бинарник лимиты выше (maxConnections, maxPreAuthConnections, maxConnectionsPerIp, дифференциальные байтовые лимиты и loginTimeoutMs) — это защита в глубину: они включены по умолчанию и достаточно щедры, чтобы быть невидимыми для легитимной клиентской базы, но они обеспечивают выполнение гарантии ограниченного RSS даже когда прокси забыли. См. docs/HARDENING.md для полной оборонительной позиции и THREAT_MODEL.md / SECURITY_WORKLIST.md для канонических деталей.
Контроль поколений
Slater опрашивает указатель current каждого графа каждые generationPollMs (poll, а не inotify — каталог данных может находиться на удалённом/сетевом хранилище вроде NFS, где события изменений файловой системы ненадёжны). Когда он меняется:
reloadStrategy=exit(по умолчанию): сервер пишет в журнал фатальную ошибку и завершает работу с ненулевым кодом, чтобы оркестратор перезапустил его начисто на новом поколении.reloadStrategy=swap: сервер открывает и проверяет новое поколение (та же защита по хэшу содержимого, что и при загрузке), атомарно заменяет его и даёт выполняющимся запросам завершиться на старом. Повреждённый/неполный новый образ отклоняется, и старое поколение продолжает обслуживать.
ACL
acl.json сопоставляет пользователей с хэшами паролей argon2id и правами read / write для каждого графа. Создайте хэш (никогда не храните открытый текст) с помощью:```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
В корне репозитория поставляется стартовый `acl.json`; его структура выглядит так:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
-
users— по одной записи на каждый логин, ключ — имя пользователя. -
passwordArgon2id— строка$argon2id$…изslater hash-password(никогда не в открытом виде; сам файл — это обычный JSON и находится в общем хранилище). -
grants— списки прав для каждого графа. Значимы два разрешения:read— читать граф. Граф, отсутствующий в правах пользователя, для него невидим.write— изменять граф через слой записи (delta.enabled): операторыMERGE/SET/DELETEиCALL slater.consolidate().
Они независимы: право
readне даёт доступа на запись. Поэтому включение слоя записи не превратит существующих читателей в писателей. Для записи нужны оба права —["read", "write"]— потому что разрешение бизнес-ключа для его записи — это чтение. Нераспознанные строки разрешений игнорируются (они не дают никаких прав).
Монтируйте его только для чтения по пути, заданному aclPath (по умолчанию /config/acl.json).
Сервер перезагружает его при каждой «горячей» смене поколения, а хранящийся в файле штамп ACL
повторно проверяется при каждой перезагрузке (см. requireAclStamp).
Проверка работоспособности
Бинарник slater также выступает в роли собственного зонда живости: slater healthcheck [host] [port] выполняет Bolt-рукопожатие (не HTTP-запрос) с сервером и
завершается с кодом 0, если согласована версия протокола, и 1 в противном случае — по умолчанию
используется localhost и настроенный Bolt-порт. Именно это выполняет контейнерный
HEALTHCHECK, поэтому оркестраторы видят по-настоящему готовый к Bolt сервер, а не просто
открытый сокет:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## Разовый запрос
Для скриптов, CI-проверок и быстрых поисков `slater query` монтирует текущее
поколение графа, выполняет один Cypher-запрос только для чтения в процессе, печатает
результат в виде JSON-объекта и завершается — без сервера и без Bolt-подключения.
Он использует ту же конфигурацию, что и сервер (бэкенд хранилища, ключ шифрования, бюджеты запросов):```sh
# GRAPH defaults to `defaultGraph`. Without -q, normal datestamped logging
# (config, "opened generation", …) is written to stdout alongside the result.
slater query mygraph 'MATCH (n) RETURN count(n) AS c'
# -q/--quiet ⇒ logging suppressed, so stdout is *only* the compact result JSON
slater query mygraph -q 'MATCH (c:Company) RETURN c.ticker AS t LIMIT 3' | jq
# {"columns":["t"],"rows":[["AUPH"],["KYMR"],["MREO"]]}
Nodes and relationships expand to their labels/type and properties. Use -q
when you want machine-parseable output (the result JSON is the only thing on
stdout); omit it for an operator-facing run with logs. Without -q a
metrics-only summary is logged after each run — e.g.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
несущий запрос `cost` (запрошенные элементы), `resultCount`, `execMs` и
`limitRowCount` (только если запрос указывает `LIMIT`) — но никогда текст запроса
или какие-либо значения результатов. Код возврата `0` при успехе, `1` при ошибке
разбора/открытия/выполнения (сообщение в stderr).
## Экспорт графа (`slater dump`)
`slater dump` экспортирует граф с **работающего** сервера в виде Cypher-кода
`MERGE` на основе бизнес-ключей — того же диалекта, который принимает на вход
`slater-build`, — так что граф проходит полный цикл
(dump → `slater-build` → новая генерация) для миграции или текстового резервного
копирования. В отличие от `slater query`, он подключается по **Bolt**,
выполняет аутентификацию и учитывает ACL для каждого графа, поэтому ему не нужен
дисковый доступ к серверу. Пароль считывается из `SLATER_DUMP_PASSWORD` или
stdin (никогда не из флага, что не позволяет ему попасть в `ps`/историю).```sh
# List the graphs the authenticated user may read.
SLATER_DUMP_PASSWORD=pw slater dump --list -u reporting
# Dump a graph to a file (identity keys inferred from range indexes).
SLATER_DUMP_PASSWORD=pw slater dump people -u reporting -o people.cypher
# Rebuild it into a fresh generation.
slater-build --input people.cypher --graph people --data-dir ./data
Ключом идентичности каждой метки является свойство из её индекса диапазона; переопределите его с помощью --key Label=prop (повторяемый флаг) или глобального --pk <field>. DDL-оператор CREATE INDEX выдаётся первым, чтобы пересборка воссоздала индексы. Узел с несколькими метками сохраняет все метки — он выводится как MERGE (n:Ident:Other {key: v}), где идентифицирующая метка (та, что содержит бизнес-ключ) идёт первой, а остальные отсортированы; при этом ключом слияния служит только идентифицирующая метка, поэтому последующие метки добавляются к узлу без создания нового. Метки, типы отношений и ключи свойств, содержащие специальные символы, при выводе заключаются в обратные кавычки, поэтому необычные имена без потерь проходят цикл экспорта/импорта и не могут внедрить Cypher в пересборку. Векторы (и другие значения, не имеющие литеральной записи в Cypher) не могут быть включены в дамп MERGE и отбрасываются с предупреждением в stderr. Код возврата: 0 при успехе, 1 при ошибке.
Рабочий пример
Полное и готовое к запуску пошаговое руководство — построить граф, запустить его, подключиться через драйверы neo4j для JavaScript и Python и записать в него — находится на страницах Quickstart и Writing data руководства; в нём используется встроенный пример графа из docs/manual/examples/.
Разработка```sh
export PATH="$HOME/.cargo/bin:$PATH" cargo build cargo test # unit + the bounded-RSS headline integration test cargo clippy --all-targets -- -D warnings cargo fmt --all -- --check
### Бэкенды объектного хранилища — опциональные функции cargo
Обычный `cargo build` создаёт **только файловый** бинарник — бэкенды `s3` и `gcs`
включаются через функции cargo, поэтому сборка по умолчанию остаётся небольшой (без AWS
или Google SDK, без асинхронного рантайма). Включите нужный вам бэкенд в **обоих** `slater`
(serve) и `slater-build` (publish):```sh
# S3 only / GCS only / both
cargo build -p slater -p slater-build --features s3
cargo build -p slater -p slater-build --features gcs
cargo build -p slater -p slater-build --features s3,gcs
Каждый крейт предоставляет соответствующие возможности s3 / gcs, которые пробрасываются на graph-format/{s3,gcs}. Запрос бэкенда во время выполнения (dataBackend.kind=s3|gcs или slater-build --publish-{s3,gcs}-*) без включённой при компиляции соответствующей возможности приводит к быстрому отказу с понятной ошибкой «собран без возможности …». В опубликованном Docker-образе включены обе (CARGO_FEATURES в Dockerfile), поэтому готовым образам не нужны дополнительные флаги — это важно только при сборке из исходников. Интеграционные тесты точно так же ограничены: --features s3 --test s3_minio, --features gcs --test gcs_emulator (это fake-gcs-server) и --features gcs --test gcs_real (реальный GCS через ADC); каждый из них пропускается, если не заданы его переменные окружения SLATER_*.
Описание архитектуры, реестр этапов и журнал решений см. в docs/PLAN.md, docs/PROGRESS.md и docs/DECISIONS.md.
Производительность
До шести движков, один набор тестов с одним клиентом, графы от игрушечного на 62 тыс. узлов до Wikidata: 91,6 млн узлов / 1,5 млрд рёбер. Каждый движок измеряется изолированно (все остальные контейнеры остановлены — RSS и задержки относятся только к нему). Таблицы задержек ниже были переизмерены на Slater 0.21.0 (сборка с поддержкой записи): малые/средние графы (MeSH, EU-AI-Act) — заново, а граф на 91,6 млн — как новый прогон slater против Neo4j на одной машине с общими якорями (см. эту таблицу). Значения резидентной памяти перенесены из более раннего прогона (измерены через cgroup контейнера; путь чтения побайтово идентичен, а слой записи простаивает). Показатели остальных движков — из устоявшегося кросс-движкового прогона (их версии и производительность не менялись). Все значения — медианы (мс) или пиковая резидентная память (МиБ). Ниже — лучше во всех случаях; полужирный = лучший в строке. slater запускался на своём бэкенде локальной файловой системы (fs); бэкенды S3 и GCS обменивают задержку локального чтения на сетевые обращения к объектному хранилищу (это смягчается кэшами в памяти и опциональным уровнем локального дискового кэша), поэтому эти цифры характеризуют сам движок, а не развёртывание на сетевом хранилище.
| движок | класс | ограничение памяти |
|---|---|---|
| slater | на диске, со страничной организацией | query.maxIntermediate автоматически ограничивает рабочее множество |
| Neo4j 5 | на диске, JVM | ~2 ГиБ кучи + вне кучи, выделяется независимо от запроса |
| Memgraph · FalkorDB | в памяти | весь граф в ОЗУ |
| ArcadeDB | в памяти, JVM | весь граф в памяти; самый тяжёлый |
| LadybugDB | встраиваемый, колоночный | ручной буферный пул, который должен превышать объём запроса |
Три движка, которые подкачивают страницы с диска — slater, Neo4j 5 и LadybugDB, — загружают все пять графов. Трио в памяти (Memgraph · FalkorDB · ArcadeDB) вообще не может удержать граф на 1,5 млрд рёбер (для него нужно ~64–128 ГиБ резидентной памяти), а импортёр ArcadeDB не может даже завершить его импорт.
Резидентная память (МиБ) — остаётся ограниченной при росте графа ~1 500×
Каждое значение — это закреплённая рабочая память — то, что ОС не может вернуть себе. Каждый движок, кроме slater, хранит свой граф в закреплённой анонимной памяти (собственная куча, внекучевой страничный кэш Neo4j или буферный пул), поэтому его пиковый RSS и есть его закреплённый след. Только slater обслуживает запросы из возвращаемого ОС страничного кэша своего дискового хранилища, поэтому его цифра — это анонимное рабочее множество; страничный кэш хранилища (вытесняемый под давлением — slater продолжает обслуживать) исключён и показан как итог в скобках для графа на 91,6 млн. Полужирный = минимум.
| граф (узлы / рёбра) | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| pole — 62k / 106k | 11 | 746 | 114 | 140 | 1556 | 198 |
| MeSH — 341k / 469k | 63 | 1083 | 358 | 455 | 1631 | 121 |
| EU-AI-Act — 21k / 45k (+55 MiB vec) | 99 | 729 | 229 | 312 | 1948 | 286 |
| Wikidata — 91.6M / 1.5B | 584 (4 595 всего) | ~2 900 | не загружается | не загружается | не загружается | ~652 † |
slater — самый низкий на всех масштабах — и растёт примерно в 50 раз при росте графа в ~1 500 раз: его след отслеживает рабочее множество запроса, а не граф (в простое ~16–71 МиБ на всём протяжении). Трио в памяти растёт почти линейно и не может загрузить граф на 1,5 млрд рёбер; Neo4j выделяет кучу ~2 ГиБ независимо от запроса. († LadybugDB — только на ограниченных формах запросов: её обходы hub / переменной длины / кратчайшего пути на 1,5 млрд рёбер требуют поднятия пула чтения до ≥2 ГиБ, тогда как slater автоматически ограничивает через maxIntermediate.) Построенные во время сборки гистограммы «значение→количество» добавляют пренебрежимо мало резидентной памяти — несколько КБ для индексируемой колонки с низкой кардинальностью и ноль для графов с уникальными ключами, таких как Wikidata (wikidata_id превышает порог кардинальности гистограммы, поэтому ничего не сохраняется), — так что эта возможность не меняет приведённые цифры.
Задержка (медиана, мс) — граф помещается в ОЗУ (MeSH, 341k / 469k)
| операция | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| count(*) всех узлов | 0,41 | 15,0 | 23,8 | 16,4 | 82,0 | 2,2 |
| count по метке | 0,42 | 4,2 | 20,7 | 1,1 | 4,4 | 4,3 |
| индексированный точечный поиск | 0,43 | 3,9 | 0,48 | 0,48 | 0,65 | 8,8 |
| count через idx-eq | 0,42 | 4,9 | 5,0 | 2,0 | 381 | 2,5 |
| 1 переход (индексированный якорь) | 1,28 | 5,8 | 1,21 | 4,1 | 390 | 4,9 |
| 2 перехода (без якоря) | 1,40 | 5,6 | 8,5 | 16,7 | 444 | 6,4 |
| group-by / count(DISTINCT) | 0,45 | 47–51 | 63–64 | 31–39 | 411 | 5,3 |
полное сканирование CONTAINS | 0,43 | 5,4 | 24,1 | 1,7 | 16,3 | 4,1 |
slater доминирует в операциях метаданные / индекс / сканирование (count, label, idx-eq, scan — ~0,4 мс, в 10–200 раз быстрее сервисных движков), в индексированном точечном поиске (0,43 мс, теперь едва опережая пару в памяти с её 0,48 мс), в многопереходных запросах без якоря (2 перехода за 1,40 мс через сканирование по типу отношения — быстрее всех), а также — благодаря построенной во время сборки гистограмме «значение→количество» по индексируемому ключу группировки — в group-by / count(DISTINCT) по всей метке (0,45 мс, впереди колоночных 5,3 мс у LadybugDB). Серверы в памяти сохраняют первенство только в простом 1 переходе (Memgraph 1,21 мс против 1,28 мс у slater). (pole 62k/106k выглядит так же: slater — единственный самый быстрый на count/scan — ~0,4 мс, на переходах — ~1,3–2,6 мс.)
Задержка (медиана, мс) — векторы (kNN на EU-AI-Act, 15k × 1024-мерн.)
| операция | slater | Neo4j 5 | Memgraph | FalkorDB | LadybugDB |
|---|---|---|---|---|---|
| kNN top-10 Concept | 2,9 | 8,6 | 1,9 | 1,2 | 2,8 |
| kNN top-10 Chunk | 2,4 | 5,7 | 1,9 | 1,5 | 3,2 |
slater отвечает на kNN точным полным перебором (эти наборы ниже его порога ANN в 50 тыс. векторов), тогда как остальные используют приближённый резидентный HNSW, — поэтому результаты slater точны (полнота 1,0). SIMD-ядро вычисления расстояний + резидентная предварительно нормализованная матрица векторов сократили Concept с ~23 до ~2,9 мс, а Chunk — с ~10 до ~2,4 мс, так что теперь slater обходит Neo4j и LadybugDB и находится в пределах ~1,4× от Memgraph, уступая только FalkorDB, — и при этом точен.
Лестница записи векторов — вставка / обновление / удаление без перестроения
Таблицы выше — это кросс-движковые сравнения чтения. Путь записи векторов (лестница записи в стиле FreshDiskANN поверх статической базы Vamana) не имеет аналогов среди других движков — ни один другой движок здесь не выполняет дисковый, изменяемый ANN, — поэтому цифры ниже — это однодвижковые компонентные бенчмарки на синтетическом наборе, похожем на эмбеддинги (многообразие малого ранга, размерность 768, неравные нормы), зафиксированные в crates/slater/benches/ и полностью описанные — с методологией и всеми оговорками — в docs/PERF-REPORT.md. Полнота всегда измеряется относительно точного полного перебора по живому множеству, а не сравнением одного индекса с другим. Масштаб здесь репрезентативен и экстраполируется только там, где метрика линейна по размеру.
| свойство | измерение | почему это важно |
|---|---|---|
| Задержка kNN при накоплении ожидающих записей | RW-индекс ~1,5–2 мс, стабильно до 50 тыс. ожидающих записей; наложение полного перебора до включения в индекс 1,9 → 115 мс (линейно от дельты) — 61× при 50 тыс. | задержка запроса не деградирует по мере накопления записей между консолидациями |
| Вставка эмбеддинга | ~1,5–2 мс на вектор в живой индекс | запись сразу видна для kNN; бюджет перестроения дельты ≈ 2 мс × предельный размер дельты |
| IO удаления при той же полноте | в 2,9 раза меньше выборок узлов на запрос при 67 % удалённых, в 5,2 раза при 80 % (полнота ≥ 0,90) | консолидированный граф не платит налог на чтение за удалённые векторы |
| Консолидация, чистая перестановка | O(1) — файл .vamana жёстко слинкован и побайтово идентичен, переписывается только колонка id | встраивание векторных записей в базу пропускает перестроение O(N·R·L) |
| Полнота на всех ступенях лестницы | консолидированная ≥ базовая для cosine, L2 и dot | лестница записи сохраняет полноту на каждой ступени |
Единственный показатель, который требует отдельного перф-стенда, — это пропускная способность перезаписи при консолидации по медленному пути: когда консолидация несёт удаления или новые векторы, а не чистую перестановку, это последовательное пережатие, ограниченное однопоточным zstd и локальным диском, поэтому абсолютные МиБ/с зависят от окружения (в отчёте показана форма кривой и объяснён диапазон влияния окружения).
Задержка (медиана, мс) — граф ≫ ОЗУ (Wikidata 91,6 млн / 1,5 млрд)
Движки в памяти (Memgraph / FalkorDB / ArcadeDB) вообще не могут загрузить этот граф (~64–128 ГиБ резидентной памяти). На это способны только slater и Neo4j 5. Это свежий прогон на одной машине в один и тот же день против общего фиксированного набора якорей — каждый запрос попадает в одинаковые узлы на обоих движках, так что сравнение один в один корректно (общий пул wikidata_id из якорей умеренной степени; см. примечание ниже о том, почему это важно). slater показан при обоих значениях fanout (query.maxFanout 1 = пропускная способность по умолчанию, 8 = регулятор задержки, перекрывающий чтения холодных блоков). Полужирный = лучший в строке.
| операция | slater (fan 1) | slater (fan 8) | Neo4j 5 |
|---|---|---|---|
| count(*) всех узлов | 0,41 | 0,41 | 3606 |
| точечный поиск (по индексу) | 0,72 | 0,49 | 6,3 |
| степень (count за 1 переход) | 0,43 | 0,44 | 6,0 |
| соседи за 1 переход | 9,8 | 4,5 | 10,1 |
| 2 перехода | 37 | 23 | 34,5 |
| 3 перехода | 32 | 25 | 74 |
переменной длины *1..2 distinct | 985 | 1056 | 47 |
Честная картина: slater доминирует в операциях над метаданными и индексами — count(*) обслуживается из метаданных (0,41 мс против дискового сканирования Neo4j за 3,6 с, ~8800×), а точечный поиск / степень / 3 перехода выполняются в ~2–10 раз быстрее — наравне с Neo4j на 1–2 переходах (fanout 8 вырывается вперёд на холодных чтениях), но уверенно проигрывает var-length *1..2 distinct (≈1 с против 47 мс у Neo4j): развёртывание distinct переменной длины у slater здесь существенно медленнее — реальная слабость, заслуживающая отдельного исследования. И всё это при нескольких сотнях МБ RSS против закреплённой кучи Neo4j в ~2 ГиБ.
О якорях. Эти показатели обходов сильно зависят от того, с каких узлов вы начинаете, — узел на расстоянии одного перехода от мегахаба Wikidata («human», «country») имеет окружение за 2 перехода, насчитывающее миллионы узлов, поэтому стоимость обходов переменной длины / переходов меняется на порядки в зависимости от выбора якоря. Более ранняя версия этой таблицы брала у каждого движка его собственные «первые N по сканированию», что не является ни стабильным, ни сопоставимым; в этом прогоне для обоих движков зафиксирован единый общий набор якорей с ограниченной степенью. (shortestPath в этом прогоне опущен — между двумя произвольными якорями он зависит от существования пути и имеет слишком высокую дисперсию, чтобы медиана была осмысленной.)
Многопереходный count(*) — память не зависит от размера результата
Многопереходный RETURN count(*) без верхнего предела считает во время развёртывания, а не материализует совпавшие строки. Те же якоря-хабы на графе 91,6 млн, maxIntermediate=20M:
| 3-переходный count(*) @ 91,6 млн | fanout=1 | fanout=8 |
|---|---|---|
| задержка / пиковое рабочее множество | 554 мс / 0,66 ГиБ | 298 мс / 1,9 ГиБ |
Счётчик содержит O(1) строк. Правила учёта не изменились, поэтому count от мегахаба по-прежнему упирается в лимит maxIntermediate на вычислениях (чтение смежности), с ограничением, как и раньше.
Параллелизм в рамках запроса (maxFanout)
Повышение query.maxFanout распараллеливает холодные, ограниченные вводом-выводом чтения блоков запроса по ядрам — это помогает дисковым операциям с большим холодным рабочим множеством и не даёт эффекта на тёплых операциях. На графе 1,5 млрд: shortestPath ≤6 918 → 608 мс (в 1,5 раза; самый крупный поиск 6 269 → 2 350 мс, в 2,7 раза); 3-переходный count 547 → 298 мс. maxFanout=1 — значение по умолчанию (ориентировано на пропускную способность); 8 — регулятор задержки ценой большего объёма временной памяти рабочих потоков.
Где slater выигрывает / проигрывает
| параметр | slater | лучший среди конкурентов | вердикт |
|---|---|---|---|
| резидентная память, любой масштаб | 11–584 МиБ (62k → 91,6 млн) | в памяти 1,5–2,7 ГиБ; не может загрузить 1,5 млрд | slater |
| count / метаданные / сканирование | ~0,4 мс | сервисные движки 5–80 мс | slater (10–200×) |
| индексированный точечный поиск | 0,43 мс (MeSH) | Memgraph · FalkorDB 0,48 мс | slater (едва опережает пару в памяти) |
| многопереходный запрос без якоря (строки) | 1,40 мс (MeSH, 2 перехода) | Neo4j 5,6 мс | slater (сканирование по типу отношения) |
| агрегация (group-by / DISTINCT) | 0,45 мс | LadybugDB 5 мс (колоночный) | slater (гистограмма времени сборки) |
| kNN | 2,4–2,9 мс (точно) | FalkorDB 1,2 мс (HNSW) | обходит Neo4j/Ladybug; ~1,4× от Memgraph; точный |
| 91,6 млн: метаданные / точка / степень / 3 перехода | 0,4–32 мс | Neo4j 6–3600 мс | slater (2–8800×) |
| 91,6 млн: 1–2 перехода | 4,5–23 мс (fan 8) | Neo4j 10–35 мс | ~наравне |
91,6 млн: переменной длины *1..2 distinct | ~1 с | Neo4j 47 мс | Neo4j (реальное слабое место slater) |
многопереходный count(*) в масштабе | 0,3–0,6 ГиБ | движки в памяти материализуют набор строк | slater, с ограничением |
Полные таблицы по каждому движку (pole, MeSH, EU-AI-Act + регулятор blockCacheBytes «ОЗУ↔задержка», Wikidata 1M и 91,6 млн) находятся в perf/cross-engine-hs/README.md; свежий прогон только на slater (оба значения fanout, все наборы данных) — в perf/PERF_CURRENT_STATUS.md.
Конкурентность и деградация (нагрузочное тестирование)
Бенчмарки выше — одноклиентские. Дополнительная ось — поведение при множестве конкурентных клиентов — имеет собственный стенд, perf/loadtest/: драйвер Locust поверх Bolt плюс координатор, который плавно повышает нагрузку, читает CALL slater.diagnostics(), находит точку перегиба пропускной способности и определяет ограничивающий фактор (полный метод — в docs/LOAD-TESTING.md). Ключевые результаты прогона с кэшем 256 МиБ на графе Wikidata-1M (одна машина на 16 ядрах):
| результат | измерение |
|---|---|
| Выдерживает 1000 конкурентных клиентов без единого сбоя | пропускная способность достигает пика ~2,5 тыс. запросов/с; точка перегиба задержки наступает примерно при 750 клиентах (p99 51 → 750 мс) — это ожидание в очередях при конкуренции за ядра, а не жёсткий предел (одиночный прогон, WSL2) |
| Кэш блоков ограничен и эффективен | 100 % попаданий, 0 вытеснений, 50 МБ резидентной памяти для рабочего множества, умещающегося в кэш |
| RSS удерживается под постоянной нагрузкой | аллокатор jemalloc удерживает RSS на уровне ~0,6 ГБ на всём участке роста 100→500 клиентов wiki_cache_churn — ограничено кэшем и стабильно, без настройки MALLOC_* (прежние MALLOC_ARENA_MAX=2 и порог усечения упразднены); его фоновая очистка также возвращает пиковое значение после всплеска, а не оставляет его закреплённым |
| Совокупная память ограничена | общесерверный query.maxIntermediateGlobal + развёртывание с учётом смежности удерживают 2-переходный поток wiki_budget при 1000 клиентах без OOM (RSS ~0,6 ГБ; предохранитель отклоняет ~60 % хаб-запросов, возвращая повторяемые ошибки бюджета) |
Обе проблемы с памятью, которые выявил нагрузочный тест, уже закрыты; всё отслеживается в документации по нагрузочному тестированию.
Лицензия
Лицензия — Apache License, Version 2.0. Полный текст см. в LICENSE, сведения об авторстве — в NOTICE. Если явно не указано иное, любой вклад, намеренно представленный для включения в эту работу, как определено лицензией Apache 2.0, лицензируется указанным выше образом без каких-либо дополнительных условий.
SPDX-License-Identifier: Apache-2.0