Retour aux mises à jour
New releaseAug 5, 2026

slater v0.24.4

Graphdb à faible empreinte mémoire avec prise en charge de Bolt+tls, chiffrement au repos et vecteurs conçus pour les cas d’utilisation de graphes en réplica local.

Partager

Slater

CI Release

Version actuelle : v0.24.4toutes les versions.

En une phrase : Slater sert des graphes qui ne tiennent pas en mémoire — des centaines de millions de nœuds et des milliards d'arêtes dans quelques centaines de Mo de RAM — via Bolt standard, donc n'importe quel driver neo4j fonctionne tel quel, avec une recherche vectorielle native sur disque à côté du graphe, et il accepte des écritures live et durables sans rien sacrifier. La mémoire résidente est définie par un budget de cache que vous choisissez, pas par la taille du graphe.


Raccourcis

Pourquoi Slater existe

Une base de données graphe stocke les données sous forme de choses (nœuds) et de relations entre elles (arêtes), avec les relations comme citoyens de première classe. C'est ce qu'il vous faut lorsque vos questions portent sur des connexions plutôt que sur des lignes — « qui se trouve à moins de trois sauts de ce compte ? », « quelle est la chaîne complète de dépendances derrière cette compilation ? », « quels comptes partagent un appareil, une adresse et une carte ? » — les requêtes qui deviennent un marécage de jointures récursives en SQL mais se dégagent naturellement dans un graphe.

La plainte la plus courante à propos des bases de données graphe est qu'elles ne passent pas à l'échelle au-delà de ce que l'on peut tenir en RAM. Beaucoup d'entre elles (par ex. neo4j, Memgraph, FalkorDB, etc.) gardent tout le graphe en mémoire : un graphe de 40 Go demande 40 Go de mémoire — par instance. Vous voulez un réplica par région, par locataire ou par pod ? Multipliez la facture. Et au-delà d'une certaine taille, elles ne se chargent tout simplement pas : par ex. le graphe Wikidata de 90 millions de nœuds / 1,5 milliard d'arêtes nécessite ~64–128 Gio résidents, donc les moteurs en mémoire ne peuvent pas l'ouvrir du tout.

Slater est la réfutation. Plutôt que de charger le graphe en mémoire, il le compile une fois, hors ligne : slater-build transforme vos données en une image disque immuable et adressée par contenu, et n'importe quel nombre de serveurs Slater sert ensuite cette image via Bolt (donc vos drivers neo4j existants fonctionnent tels quels), en paginant les blocs à la demande et en ne gardant en mémoire qu'un budget de cache fixe. C'est ainsi que le même graphe de 90M nœuds se sert depuis quelques centaines de Mo de RAM — la taille du graphe et la facture mémoire sont découplées. Un graphe de 4 Go et un graphe de 400 Go coûtent la même RAM à servir, donc vous déployez des réplicas de lecture stateless bon marché et vous laissez le stockage, pas le tas, contenir le graphe.

Cela en fait un choix naturel pour les graphes de connaissances derrière du RAG, les graphes de recommandation et d'identité, les graphes de dépendances — tout ce qui est vaste et connecté et que vous voulez interroger à moindre coût et souvent. La recherche vectorielle native sur disque se trouve juste à côté du graphe, donc le même moteur sert aussi de couche de récupération pour les embeddings.

Compilé une fois ne signifie pas figé, cependant. Cette image est une base, pas un état final : une couche d'écriture optionnelle se superpose à elle, donc un graphe en direct peut être corrigé et étendu sans rien reconstruire.

Lectures et écritures

Le cœur est immuable ; le graphe ne l'est pas. Activez la couche inscriptible (delta.enabled) et vous écrivez via Bolt — corrigez une propriété, ajoutez un nœud, retirez une arête — et le changement est durable, sans reconstruction de l'image. Ce qui maintient le faible coût côté lecture, c'est là où vivent les écritures.

Les écritures s'accumulent dans une couche log-structured-merge (LSM) au-dessus du cœur immuable : un journal d'écriture anticipée et une table en mémoire, qui se déversent dans des segments delta immuables, repliés dans un nouveau cœur par une consolidation périodique. Ce que cela vous apporte :

  • Les lectures sur un graphe non écrit coûtent exactement ce qu'elles coûtaient avant. Un delta vide est une branche unique et prévisible, pas une fusion — le chemin de lecture est octet pour octet identique, que la couche inscriptible soit activée ou non.
  • Le coût de lecture d'une écriture varie avec la taille du delta, pas avec celle du graphe. Les réponses à l'échelle du graphe — count(*), les marginaux par label et par type de relation — restent des lectures de métadonnées même avec des écritures en attente : le delta conserve ses propres compteurs, donc un count(*) sur un cœur de 91,6M nœuds avec un demi-million d'écritures en attente répond encore en quelques dizaines de millisecondes sans toucher un seul bloc.
  • Accusé signifie durable. Un écrivain unique vide la file et ne renvoie SUCCESS qu'après le fsync qui couvre l'écriture. Regroupez vos écritures et elles deviennent économiques — un UNWIND d'écriture valide un fsync par lot plutôt que par ligne.
  • Écritures par clé métier, dans les deux dialectes. MERGE / MATCH … SET / DELETE (ainsi que CREATE / REMOVE, la suppression détachée, les écritures de relations) adressées sur la propriété d'identité d'un nœud — ou les instructions équivalentes de modification de données ISO GQL (INSERT / SET / REMOVE / DELETE), qui s'abaissent sur le même chemin. Corrigez, insérez, faites un upsert et retirez, sur les nœuds et les arêtes, adressés comme vos données le sont déjà.

Avec la couche désactivée — le défaut — Slater sert le cœur immuable pur et refuse les écritures. Voir La couche inscriptible pour le modèle complet.

Sur le nom. Slater porte le nom de l'agent de la CIA dans Archer (une excellente série) qui insiste pour n'être désigné que par un seul nom — « Just… Slater » — et l'un de mes personnages préférés. Voir la page wiki du personnage.

Ce que vous obtenez

  • RAM définie par votre budget de cache, pas par la taille de votre graphe — déployez autant de réplicas de lecture que vous voulez ; le graphe n'a jamais à tenir en mémoire.
  • Un remplacement direct pour le graphe — parle Bolt, donc n'importe quel driver neo4j standard (JS, Python, Go…) fonctionne sans modification. C'est du Cypher (plus une partie d'ISO GQL, lectures et écritures) ; rien de nouveau à apprendre.
  • Des écritures live et durables — une couche LSM optionnelle au-dessus du cœur immuable : MERGE / SET / DELETE par clé métier sur les nœuds et les arêtes, validées en groupe et durables par fsync, repliées dans un nouveau cœur par consolidation. Les lectures n'en paient pas le prix.
  • Déploiement par échange de fichiers — construisez hors ligne une nouvelle génération à hash de contenu, basculez atomiquement le pointeur current, et les serveurs la prennent en charge. Chaque bloc est vérifié par somme de contrôle, donc une image à moitié copiée est refusée plutôt que servie.
  • Recherche vectorielle intégrée — le plus proche voisin approximatif natif sur disque (cosinus, L2, ou KNN par produit scalaire) se trouve juste à côté de votre graphe, pour quand ce moteur est la couche de récupération derrière un pipeline RAG, et les embeddings sont inscriptibles sur place — aucune reconstruction hors ligne pour ajouter ou modifier un vecteur.
  • Verrouillé par conception — les autorisations de lecture et d'écriture sont indépendantes, plus un chiffrement au repos optionnel, Bolt TLS, des ACL hachées en argon2id, et un rootfs de conteneur en lecture seule pour les réplicas de lecture. Configurez une clé maîtresse et l'image sur disque est authentifiée en plus d'être chiffrée — son manifeste porte un MAC à clé, donc un attaquant ayant un accès en écriture au répertoire de données mais pas la clé ne peut pas forger un manifeste que le serveur acceptera. Sans clé, vous obtenez quand même le hash de contenu, qui détecte une image à moitié copiée ou corrompue — mais pas une image falsifiée délibérément. Quelle configuration achète quoi.

Fonctionnalités

FonctionnalitéCe que cela signifie pour vous
Mémoire bornée et prévisibleLa mémoire résidente suit trois budgets de cache que vous définissez, avec une surcharge bornée par entrée et par allocateur — elle ne croît pas avec la taille du graphe ; vous réglez le compromis performance/RAM au lieu de provisionner pour tout le graphe. Un allocateur jemalloc avec purge en arrière-plan rend la mémoire libérée au système d'exploitation après les rafales de requêtes lourdes, donc la taille résidente redescend vers son plancher d'inactivité au lieu de rester épinglée au maximum post-rafale.
Multi-locataire d'usineUn serveur héberge de nombreux graphes avec des autorisations de lecture par utilisateur — une isolation multi-bases que la plupart des bases de données graphe réservent à un niveau payant/entreprise.
Chiffrement au repos et en transitScellement XChaCha20-Poly1305 par bloc (la clé n'est jamais écrite sur disque) plus TLS optionnel (bolt+s://). Conforme au RGPD par construction. Le chiffrement est aussi ce qui offre l'intégrité authentifiée : le constructeur scelle le manifeste avec un MAC à clé, et un serveur détenant la clé le vérifie et refuse de servir une génération dont le manifeste est forgé, modifié, ou dont le MAC a été retiré. Une image non chiffrée (en clair) n'est protégée que par le hash de contenu non secret — complétude et corruption, pas falsification. Voir Ce que signifie l'intégrité dans chaque configuration.
Installation minusculeUn petit binaire strippé sur une base glibc distroless (sans shell/apt) — l'image multi-arch (amd64/arm64) pèse ~22 Mo, ou ~12 Mo pour le tag serveur seul slater:latest-lite ; TLS 100 % Rust, sans OpenSSL. Tirez et exécutez.
Conçu pour la publication périodiqueConstruisez un graphe hors ligne, servez-le en immuable, puis remplacez-le atomiquement par une nouvelle version sans temps d'arrêt — idéal pour les charges d'entrepôt de données / rafraîchissements planifiés.
Robuste sous chargeLe serveur et le constructeur hors ligne compilent tous deux avec #![forbid(unsafe_code)] — le seul unsafe du moteur vit dans la crate d'allocateur jemalloc auditée. Le cœur est immuable, donc les lectures ne prennent aucun verrou et n'attendent jamais un écrivain ; un écrivain unique sérialise les mutations derrière le seul chemin d'écriture. Pas de pauses GC, pas de courses de données. Une mauvaise requête ne peut pas faire tomber le serveur.
Fonctionne avec vos outils neo4jParle Bolt 5.4 / 4.4 / 4.1 — utilisez les drivers neo4j standard (JS, Python, Go, Java…), cypher-shell, ou les navigateurs de graphes sans modification.
Surface de requêtes Cypher richeUne large surface de lecture : MATCH/WHERE/WITH/UNION, sous-requêtes CALL {…}, plus de 70 fonctions & agrégations, valeurs temporelles et géospatiales, et regex.
Écritures live et durablesUne couche LSM à écrivain unique et optionnelle au-dessus du cœur immuable (delta.enabled) : MERGE / SET / DELETE / CREATE / REMOVE par clé métier sur les nœuds et les relations, UNWIND d'écriture par lots (un fsync par lot), et CALL slater.consolidate() — validation en groupe, durable par fsync, et repliée dans un nouveau cœur par consolidation. Le chemin de lecture est octet pour octet identique lorsque le delta est vide.
ISO GQL, en lecture et en écritureParle un sous-ensemble d'ISO GQL (ISO/IEC 39075) sur la même connexion Bolt — chemins quantifiés, restricteurs de chemin, sélecteurs de plus court chemin, expressions booléennes de label/type, FOR, CAST, un préfixe de dialecte GQL/CYPHER optionnel — et, avec la couche inscriptible activée, les instructions de modification de données de GQL (INSERT / SET / REMOVE / [DETACH] DELETE) s'abaissent sur le même chemin d'écriture durable. Cypher et GQL, lectures et écritures, dans un seul moteur.
Vecteurs + graphe dans un seul moteurRecherche vectorielle ANN native sur disque (Vamana + PQ ; cosinus / L2 / produit scalaire) pour les embeddings/RAG, plus des algorithmes de graphe (PageRank, BFS, betweenness, WCC…) — mémoire bornée même avec des millions de vecteurs. Les embeddings sont inscriptibles (une échelle d'écriture de type FreshDiskANN) : insérez / mettez à jour / supprimez un vecteur, visible en KNN immédiatement, replié dans la base sans reconstruction.
Sûr sur stockage réseauChaque fichier est hashé en contenu BLAKE3 et vérifié à l'ouverture ; les images déchirées ou à moitié copiées sont refusées, pas servies. Conçu pour les volumes NFS/distants (pas de surprises mmap).
Backends de stockage enfichablesServez le même format de génération depuis un système de fichiers local, un bucket S3 (compatible S3), ou un bucket Google Cloud Storage — publiez une fois, déployez des réplicas stateless — avec un niveau de cache SSD local optionnel devant le stockage d'objets. Voir Backends de stockage.

Deux binaires composent l'espace de travail :

BinaireRôle
slaterLe serveur Bolt en ligne (l'ENTRYPOINT du conteneur) : sert les lectures et, avec delta.enabled, le chemin d'écriture durable à écrivain unique.
slater-buildLe compilateur hors ligne : transforme un dump Cypher primitif en un répertoire de génération immuable et hashé par contenu.

Slater sépare la construction en masse du service : slater-build fait le travail lourd hors ligne — ingère vos données et les compile en une génération immuable — donc un graphe à froid n'est jamais assemblé sur le chemin réactif du service. Dans le serveur, la surface de lecture répond à une large partie de Cypher — correspondance de motifs, sous-requêtes WITH/UNION/CALL {…}, plus de 70 fonctions scalaires et d'agrégation, valeurs temporelles et géospatiales, algorithmes de graphe (algo.*), et KNN vectoriel natif sur disque (db.idx.vector.queryNodes) — tandis que la superposition delta de la couche inscriptible vit sous cette surface et ne coûte rien quand elle est vide, donc les lectures ne portent jamais la machinerie du côté écriture. Vous pouvez mettre à jour un graphe de deux façons : y écrire en direct via Bolt (voir La couche inscriptible), ou construire une nouvelle génération hors ligne et basculer atomiquement le pointeur current, que le serveur en cours d'exécution détecte via sa garde de génération (voir Garde de génération).

Documentation

Le manuel utilisateur complet se trouve dans docs/manual/ — un guide fonctionnalité par fonctionnalité qui explique, pour chaque capacité, ce que c'est, pourquoi cela existe, et comment l'utiliser, avec des exemples concrets que vous pouvez exécuter contre un graphe d'exemple fourni. Commencez par là pour tout ce qui dépasse cet aperçu.

Utilisation avec Docker

Slater est conçu pour être utilisé comme un déploiement Docker — c'est la façon attendue de l'utiliser. Des images multi-arch préconstruites (linux/amd64 + linux/arm64) sont publiées sur Docker Hub à hikarisystems/slater, taguées :latest et :vX.Y.Z à chaque version :```sh docker pull hikarisystems/slater:latest

Un guide d'utilisation, de configuration et d'exploitation uniquement basé sur les commandes Docker se trouve dans
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (et est reflété sur la page de présentation Docker Hub) —
**commencez par là si vous déployez.** En bref :```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

Pour plutôt construire l'image localement (par exemple pour le développement):```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

L'étape de construction installe `cmake`, `clang` et `libclang-dev` pour le backend `aws-lc-rs` de rustls ; `git` (déjà présent dans l'image de base) est requis pour la dépendance `hs-utils` en git+tag, que `.cargo/config.toml` récupère via la CLI git.

Les sections ci-dessous couvrent le format sur disque, la configuration, les ACL et un exemple pratique local (sans Docker).

## Comment ça fonctionne```
            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>
  • A generation is one immutable directory: a MANIFEST.json (symbol tables, index descriptors, an optional encryption header), columnar block files (node_props.blk, node_labels.blk, edge_props.blk, topology.csr.blk, vectors.f32.blk), range indexes (range/<name>.isam), above-threshold ANN indexes (vector/<label>.<prop>.{vamana,pq}), and a current text pointer.
  • Every block is zstd-compressed and BLAKE3-checksummed; with --encrypt each block is additionally sealed with XChaCha20-Poly1305 (AEAD at rest).
  • The server opens a generation by re-hashing every file against the manifest, so a half-copied / truncated image — a torn copy onto the data dir, which may be remote/network storage — is refused rather than served.
  • Reads flow through three bounded cache pools — a decompressed-block LRU, a vector-index pool (resident PQ codes + a Vamana-block LRU), and a result LRU — each with its own byte budget. Each pool weighs what it holds and evicts to stay under its budget, so RSS tracks the budgets to within bounded per-entry and allocator overhead instead of growing with the graph.

La couche d'écriture

Avec delta.enabled, la génération immuable devient le niveau inférieur entièrement compacté (le « cœur ») d'un petit arbre de fusion structuré en journal, et les écritures en direct s'appuient dessus :``` 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) └─────────────┘

* **Plancher de durabilité — le WAL.** Chaque mutation est sérialisée derrière un writer unique par graphe, ajoutée à un journal de pré-écriture par graphe, et `fsync`ée avant que le `SUCCESS` Bolt ne soit renvoyé — donc *acquittée ⇒ durable*, et une queue déchirée est ignorée lors de la relecture. Un `UNWIND` d'écriture par lot ajoute ses lignes et valide **un** `fsync` pour tout le lot. Le WAL est **uniquement sur disque local** (il n'est pas routé via le backend de stockage), ce qui rend un nœud *writer* stateful : il nécessite un volume local durable à `delta.walDir`. Les réplicas en lecture restent stateless.
* **Memtable → L0 → consolidation.** Les écritures s'accumulent dans une memtable en RAM (bornée par `delta.memtableBytes`) ; lorsqu'elle se remplit, elle est vidée dans un segment delta L0 immuable. Une **consolidation** fusionne `{core + delta}` en un nouveau core en sérialisant la vue fusionnée via `slater-build` et en échangeant `current` atomiquement — la même garde de hachage de contenu que toute génération publiée. Déclenchez-la à la main avec `CALL slater.consolidate()`, automatiquement à `delta.deltaCorePercent` de la taille du core (éventuellement limitée à une fenêtre hors pointe `delta.consolidateWindow`), ou laissez le limiteur `delta.deltaHardBytes` servir de garde-fou contre une croissance incontrôlée.
* **La surcouche se situe sous la surface de lecture.** L'exécuteur lit à travers une `ReadView` qui est soit le core nu (delta toujours vide), soit une vue fusionnée `(core, delta)` ; le moteur est monomorphisé sur cette vue, donc un delta vide se compile en une branche unique et prévisible, et le chemin en lecture seule est identique octet par octet. Les compteurs globaux du graphe (`count(*)`, effectifs par label/type de relation) sont servis depuis les compteurs vivants du delta, donc ils restent des lectures de métadonnées même avec des écritures en attente.
* **Une requête voit un instantané stable.** Elle fixe un tuple `(core, delta)` pour toute sa durée de vie. Il n'y a ni transactions multi-instructions ni rollback — une écriture est une correction durable, adressée par clé métier, et non une transaction OLTP.

La grammaire d'écriture exacte et les réglages se trouvent dans le tableau [Configuration](#environment--configuration) (`delta.*`) et dans l'[Exemple détaillé](#worked-example) ci-dessous.

### Index de plage (ISAM)

Un index de plage (`range/<name>.isam`, un par `(label, property)` indexé) permet à un `MATCH (n:Label {prop: v})` ou `WHERE n.prop <op> v` de résoudre les identifiants des nœuds correspondants **sans scanner le label**. Il s'agit d'une structure **[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method, méthode d'accès séquentiel indexé) — l'index classique *statique, trié et structuré en blocs*, ce qui est exactement la forme appropriée pour une génération immuable : il n'y a pas d'insertions à rééquilibrer, donc la simplicité d'ISAM apporte ce que la machinerie de mutation d'un B-tree ne ferait que compliquer.

* Les entrées `(value, entity_id)` sont triées par valeur et regroupées dans les mêmes blocs de 256 KiB compressés en zstd que tout le reste.
* Un petit **niveau supérieur résident** contient la première clé de chaque bloc (un index creux). Une recherche effectue une recherche binaire dans ce niveau supérieur en mémoire pour trouver l'*unique* bloc où une clé peut se trouver, lit + décompresse ce bloc et le scanne — donc une recherche d'égalité est **une lecture de bloc**, et un scan de plage parcourt la suite contiguë de blocs qu'il couvre. (C'est pourquoi une recherche indexée par `meshUi` prend quelques millisecondes alors que la même correspondance sur une propriété non indexée scanne tout le label.)
* Le planificateur la choisit via `NodeScan::RangeEq` / `RangeRange` ; un prédicat non indexé se rabat sur un balayage de label ou un scan complet, l'exécuteur revérifiant chaque prédicat dans les deux cas.

### Recherche vectorielle (Vamana + PQ) — cosinus, L2 et produit scalaire, lecture *et* écriture

Le KNN vectoriel (`db.idx.vector.queryNodes`) s'exécute sur des index **cosinus, L2 ou produit scalaire (MIPS)**. L'index de base est construit hors ligne avec deux chemins d'exécution, choisis par index par `--ann-threshold` (défaut : 50 000 vecteurs) :

* **Sous le seuil — force brute.** Les vecteurs `f32` complets se trouvent dans `vectors.f32.blk` ; une requête scanne le groupe de l'index et calcule la distance exacte dans la métrique de l'index. Simple et exact ; parfait quand l'ensemble de vecteurs est petit.
* **Au seuil ou au-dessus — Vamana + PQ**, le chemin ANN natif du disque qui maintient la mémoire résidente bornée quel que soit le nombre de vecteurs :
  * **[Vamana](https://arxiv.org/pdf/2401.11324)** est l'index de graphe issu de la lignée de travaux DiskANN : un graphe de proximité unique dont les arêtes sont élaguées (le degré sortant `--vamana-r` et le facteur de longue arête `--vamana-alpha`) afin qu'une *recherche gloutonne par faisceau* — démarrer au médoïde, sauter de façon répétée vers la requête, en conservant une liste de candidats de largeur `vectorQuery.beamWidth` — atteigne les vrais voisins d'un nœud en quelques sauts, c.-à-d. **peu de lectures aléatoires de blocs par requête**. Les blocs du graphe (`vector/<label>.<prop>.vamana`) sont chargés en mémoire via le cache vectoriel, pas retenus en bloc.
  * **[Quantification produit (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)** compresse chaque vecteur en un code court (`--pq-subspaces` × `--pq-bits`) : les dimensions sont divisées en sous-espaces, chacun étant regroupé indépendamment par k-means, et le vecteur est stocké comme le tuple des identifiants des centroïdes les plus proches. Ces codes (`vector/<label>.<prop>.pq`) sont assez petits pour rester **résidents**, de sorte que la recherche par faisceau évalue les candidats depuis la RAM et que seuls les quelques vecteurs complets choisis sont lus depuis le disque. Cet ensemble PQ résident est ce que le pool `cache.vectorCacheBytes` maintient.

**Embeddings modifiables — l'échelle d'écriture vectorielle (style [FreshDiskANN](https://arxiv.org/abs/2105.09613)).**
Un embedding indexé est une valeur modifiable de première classe. `SET n.embedding = vecf32([…])` (et `REMOVE`) est enregistré dans le delta d'écriture et est **immédiatement visible en KNN avec un rang exact**, puis survit à un flush de segment, à une fusion et à une consolidation. Une requête fusionne jusqu'à trois niveaux — l'index de base scellé, un index par segment scellé et un **index RW** en mémoire (un Vamana mutable à chaud sur le delta d'écriture) — de sorte que la latence reste stable à mesure que les écritures s'accumulent, plutôt que de croître avec le nombre d'écritures en attente. Une suppression laisse un *trou* : le nœud cesse d'être renvoyé mais reste un point de passage navigationnel jusqu'à ce qu'une **consolidation de suppression** en arrière-plan l'extrait du graphe, de sorte que les suppressions cessent de coûter des IO de requête. Et parce que le graphe sur disque adresse ses voisins par position de mise en page plutôt que par identifiant de nœud, `CALL slater.consolidate()` transporte le Vamana **par référence** — lié en dur, octet pour octet — et ne réécrit qu'une petite colonne d'identifiants, intégrant les écritures vectorielles dans la base **sans** la reconstruction de graphe O(N·R·L). Les chiffres mesurés, avec leurs réserves, figurent dans le [rapport de performance](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).

## Backends de stockage (système de fichiers / S3 / GCS)

Chaque fichier de génération est ouvert via une abstraction **`ObjectStore`** plutôt que par `std::fs` directement, de sorte que le *même* format d'octets sur disque — blocs, index, manifeste, pointeur `current` — est servi à l'identique depuis n'importe quel backend ; seul *d'où viennent les octets* diffère, jamais les lecteurs, le moteur de requête ou les contrôles d'intégrité. Le chemin à chaud est constitué de lectures positionnelles (`read_exact_at`), qui se mappent sur un `pread` dans un fichier local et sur une requête HTTP de plage d'octets sur un object store — Slater ne fait jamais de mmap, donc le modèle explicite de lecture bornée est identique partout.

**Trois backends de première classe**, sélectionnés par `dataBackend.kind`. Le système de fichiers est le défaut simple ; **Amazon S3 et Google Cloud Storage sont des backends object store égaux et entièrement pris en charge** — l'image publiée est livrée avec les deux compilés, donc chacun ne nécessite que de la configuration, et une génération construite une fois peut être servie depuis n'importe lequel d'entre eux (même migrée `fs` → S3 → GCS) sans reconstruction.

| `dataBackend.kind` | Lecture positionnelle | Intégrité à l'ouverture | Identifiants |
| --- | --- | --- | --- |
| `fs` *(défaut)* | `pread` | re-hachage BLAKE3 complet de chaque fichier | — |
| `s3` | HTTP `Range` GET | **SHA-256** côté serveur via `HEAD` (→ re-hachage BLAKE3 du corps si absent) | clés de configuration, chaîne AWS ou rôle IAM |
| `gcs` | lecture de plage HTTP | **CRC32C** côté serveur via `get_object` (→ re-hachage BLAKE3 du corps si absent) | ADC / Workload Identity, ou JSON de compte de service |

Les deux object stores vérifient l'intégrité à partir de la **somme de contrôle que le store calcule et conserve déjà**, récupérée comme métadonnée d'objet : `slater-build` envoie la somme de contrôle au téléversement (le store valide les octets par rapport à elle et la stocke), et le serveur la relit à l'ouverture et la compare au manifeste — une requête de métadonnée par fichier, sans téléchargement du corps. Elle se fait au niveau du contenu et est identique dans l'esprit entre S3 (SHA-256) et GCS (CRC32C). Lorsqu'un objet ne porte **aucune** somme de contrôle stockée par le serveur (copié hors bande, ou téléversé avec un autre paramètre par défaut), le serveur **re-hache le corps de l'objet par rapport au BLAKE3 du manifeste** plutôt que de se fier à sa longueur en octets — un contrôle d'intégrité demandé n'est jamais silencieusement réduit à une comparaison de taille. Les générations publiées par Slater portent toujours la somme de contrôle, elles restent donc sur le chemin métadonnées peu coûteux.

Ce que cette colonne vérifie, sur chaque backend, c'est que les fichiers **correspondent au manifeste**.
Que le manifeste lui-même puisse être digne de confiance est une question distincte, et c'est la clé maîtresse qui y répond : avec une clé configurée, le manifeste porte un MAC à clé que le serveur vérifie avant de se fier à un quelconque champ (y compris ces hachages), donc un manifeste réécrit pour décrire des fichiers altérés est refusé ; sans clé, la comparaison est sans clé partout, et quelqu'un qui peut écrire dans le répertoire de données peut réécrire un fichier et le manifeste ensemble. Voir
[Ce que signifie l'intégrité dans chaque configuration](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration).
Le contrôle lui-même peut être désactivé avec `dataBackend.verifyIntegrity: false`, ce qui l'échange contre une ouverture plus rapide.

### Système de fichiers (`fs`)

Le backend par défaut, dont la racine est `dataBackend.fs.dir`. Le bon choix pour la plupart des déploiements : une génération sur un SSD local (ou un montage NFS/EBS) servie en lecture seule. L'intégrité est un re-hachage BLAKE3 complet de chaque fichier à l'ouverture.

### Amazon S3 (`s3`)

Un bucket S3 ou compatible S3 (AWS, MinIO, localstack). Les identifiants proviennent **d'abord** de la configuration (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, plus `awsSessionToken` pour des identifiants STS temporaires) et se replient sur la chaîne AWS standard (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env, profil partagé, ou rôle d'instance/IRSA) lorsqu'ils sont laissés vides.```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)

Un bucket GCS, atteint via l'API JSON. L'authentification est native GCP : par défaut, elle résout les Application Default Credentials — GKE Workload Identity, le serveur de métadonnées GCE, ou une clé gcloud / GOOGLE_APPLICATION_CREDENTIALS. Définissez dataBackend.gcs.credentialsPath (un fichier de clé JSON de compte de service) ou credentialsJson en ligne pour une clé explicite. dataBackend.gcs.endpoint pointe vers un émulateur fake-gcs-server, et dataBackend.gcs.anonymous=true active l'accès non authentifié uniquement pour cet émulateur — jamais contre un vrai 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

Dans tous les cas, slater-build écrit la génération terminée dans --data-dir d'abord (sa zone de staging locale) et en plus la téléverse vers le bucket ; le pointeur current distant est écrit en dernier, de sorte qu'un nœud de service ne voit jamais une génération à moitié publiée.

Quand utiliser un stockage objet (S3 ou GCS)

Utilisez s3 ou gcs lorsque vous voulez des générations dans un stockage objet central et durable plutôt que sur le disque d'un nœud — typiquement : publier une fois et diffuser vers de nombreuses répliques de serveurs sans état et sans disque qui lisent toutes le même bucket ; découpler l'hôte de build des hôtes de service ; ou s'appuyer sur la durabilité/le versionnage/le cycle de vie du stockage au lieu de gérer des volumes. Le compromis est la latence : un bloc froid nécessite un aller-retour réseau (~10–50 ms) au lieu d'une lecture locale (~0.1 ms). Slater masque l'essentiel grâce au cache de blocs en mémoire, à la lecture anticipée concurrente, et au cache disque optionnel ci-dessous. Si vos générations sont déjà sur un stockage local rapide et que vous n'avez pas besoin du modèle de bucket central, fs est plus simple et plus rapide.

Cache de blocs sur disque local (second niveau du stockage objet)

Le BlockCache en mémoire est délibérément petit (la RSS bornée est la garantie principale), donc sur un ensemble de travail plus grand que la RAM, les mêmes blocs seraient re-récupérés depuis le stockage objet à chaque éviction. Un second niveau de cache SSD local optionnel corrige cela : un bloc évincé de la RAM est servi depuis le disque local (~0.1 ms) au lieu d'un nouvel GET objet, survivant à l'éviction de la mémoire et réduisant le nombre/coût des requêtes au stockage objet — rapprochant un nœud adossé au stockage objet des performances d'un système de fichiers local une fois chaud. Il est opt-in pour s3 et gcs, activé en définissant dataBackend.<s3|gcs>.diskCacheBytes > 0 et un diskCacheDir inscriptible.

  • Il met en cache les octets scellés exactement tels que récupérés — déjà compressés, et (pour les générations --encrypt) toujours scellés AEAD — en dessous du déchiffrement/décompression. La couche de cache ne détient jamais la clé de chiffrement et ne rechiffre jamais, donc l'état au repos est préservé gratuitement : une génération chiffrée arrive sur le disque toujours scellée.
  • Les écritures sont en différé (write-behind) : un échec renvoie les octets récupérés à la requête immédiatement, puis un thread d'arrière-plan effectue l'écriture disque et le nettoyage LRU, de sorte que le chemin de requête ne bloque jamais sur les E/S disque. L'éviction maintient le cache dans son budget d'octets ; une somme de contrôle par fichier vérifiée à chaque lecture auto-répare un fichier corrompu en un échec (→ re-récupération depuis le stockage objet).
  • diskCacheDir doit pointer vers un vrai volume inscriptible — jamais tmpfs (tmpfs est de la RAM et annulerait la garantie de RSS bornée). L'index en mémoire qui le suit coûte un peu de RAM (~dizaines d'octets par bloc mis en cache), ce qui compte contre votre plafond RSS — dimensionnez le répertoire ≫ le cache de blocs en mémoire.
  • L'autre coût RAM de ce niveau est la file d'écriture en différé, qui met en attente les blocs sur le chemin du disque. Elle est bornée à blockCacheBytes / 8 (plancher sur diskCacheBytes) — 8 MiB par défaut — et elle se réduit plutôt que de croître, donc un balayage à froid ne peut pas la gonfler ; un bloc éliminé se re-récupère simplement à son prochain échec. Elle ne nécessite aucune configuration : elle évolue avec blockCacheBytes, donc le niveau disque n'ajoute aucun nouveau nombre au budget RSS au-delà de son index.

Points de montage

Une réplique en lecture s'exécute avec un système de fichiers racine en lecture seule et un utilisateur non-root (appuser:1000) — tout ce dont elle a besoin est monté en lecture seule. Un writer (delta.enabled) a en outre besoin d'un volume durable et inscriptible pour son WAL.

CheminObjectifRemarques
/dataLes générations du graphe (<graph>/<uuid>/… + current).Lecture seule pour les répliques ; produit par slater-build. Peut résider sur un stockage distant/réseau (ex. NFS), donc les lectures ne sont pas supposées bénéficier de latences rapides de SSD local.
/sandboxSurcharge de configuration par environnement + secrets./sandbox/config.json est fusionné en profondeur par-dessus le config.json intégré ; contient aussi acl.json, le matériel PEM TLS, le fichier de clé au repos.
/tmp, /runEspace temporaire (tmpfs).Une réplique en lecture n'écrit jamais sur disque par défaut.
(writer) delta.walDirLe journal d'écriture anticipée + les segments delta L0, lorsque delta.enabled.Inscriptible, et un volume réel durable — jamais tmpfs (c'est le plancher de durabilité). Un chemin relatif se résout sous le répertoire de données ; donnez ici au writer son propre volume persistant.
(optional) disk cacheLe cache de blocs sur disque local, lorsque dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0.Inscriptible, et un volume réel — pas tmpfs. Utilisé par les backends s3 et gcs ; voir Backends de stockage.

Environnement / configuration

La configuration est chargée par le chargeur en couches standard de la maison : le config.json intégré, puis /sandbox/config.json fusionné en profondeur par-dessus, puis les surcharges d'environnement KEY__sub (double underscore pour l'imbrication ; les clés correspondent à la configuration camelCase).

Chaque paramètre de configuration — sa clé camelCase, la surcharge d'environnement KEY__sub, sa valeur par défaut et ce qu'il fait — est répertorié dans la Référence de configuration. Les paramètres les plus réglés sont les budgets de cache (cache.*), les garde-fous de requête (query.*), les plafonds de connexion (server.*), le backend de stockage (dataBackend.*) et la couche inscriptible (delta.*).

La mémoire résidente suit blockCacheBytes + vectorCacheBytes + resultCacheBytes à l'intérieur d'une marge bornée de surcharge par entrée et d'allocateur — chaque pool pèse son propre contenu (chaînes et conteneurs par capacité allouée) et évince pour rester sous le budget, mais la comptabilité par entrée et l'arrondi des classes de taille de l'allocateur s'ajoutent au nombre que vous définissez — plus une petite surcharge fixe (et jusqu'à degreeColumnBytes pour la colonne de degrés lazy, une fois le chemin rapide de somme de degrés count(endpoint) sollicité). C'est indépendant de la taille du graphe — c'est la garantie principale, exercée par le test d'intégration rss_stays_bounded_under_sustained_knn_load, qui maintient la croissance RSS pic-vs-chaud bien dans les budgets additionnés. Les tampons par connexion vivent en dehors des budgets de cache, donc la garantie ne tient sous charge adverse que parce que server.maxConnections borne combien peuvent exister à la fois.

Posture réseau

Slater est un point d'accès de réplique en lecture ; le contrôle principal de la sécurité des connexions est le réseau, pas le binaire. Liez-le à une interface privée, restreignez les plages sources au niveau réseau (groupes de sécurité / NetworkPolicy), et — s'il fait face à autre chose que des clients de confiance — placez devant lui un proxy L4 limitant les connexions (HAProxy maxconn + une stick-table par source, ou nftables connlimit + hashlimit). Cela se situe avant que le descripteur de fichier ne soit remis au processus, c'est donc la limite la plus robuste.

Les limites internes au binaire ci-dessus (maxConnections, maxPreAuthConnections, maxConnectionsPerIp, les plafonds d'octets différentiels et loginTimeoutMs) sont de la défense en profondeur : elles sont activées par défaut et généreuses, donc invisibles pour une population de clients légitimes, mais elles font tenir la garantie de RSS bornée même lorsque le proxy est oublié. Voir docs/HARDENING.md pour la posture défensive complète, et THREAT_MODEL.md / SECURITY_WORKLIST.md pour le détail canonique.

Garde de génération

Slater interroge le pointeur current de chaque graphe toutes les generationPollMs (poll, pas inotify — le répertoire de données peut être un stockage distant/réseau comme NFS, où les événements de changement de système de fichiers ne sont pas fiables). Lorsqu'il change :

  • reloadStrategy=exit (défaut) : le serveur journalise une erreur fatale et se termine avec un code non nul, afin que l'orchestrateur le redémarre proprement sur la nouvelle génération.
  • reloadStrategy=swap : le serveur ouvre et valide la nouvelle génération (même garde de hachage de contenu qu'au démarrage), l'échange atomiquement, et laisse les requêtes en cours se terminer sur l'ancienne. Une nouvelle image corrompue/incomplète est refusée et l'ancienne génération continue de servir.

ACL

acl.json associe les utilisateurs à des hachages de mot de passe argon2id et à des autorisations read / write par graphe. Générez un hachage (ne stockez jamais de texte en clair) avec :```sh slater hash-password 's3cret' # prints a $argon2id$… string for acl.json

Un `acl.json` de démarrage est fourni à la racine du dépôt ; sa forme est :```json
{
  "users": {
    "reporting": {
      "passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
      "grants": {
        "people": ["read"],
        "products": ["read", "write"]
      }
    }
  }
}
  • users — une entrée par connexion, indexée par nom d'utilisateur.

  • passwordArgon2id — la chaîne $argon2id$… issue de slater hash-password (jamais en clair ; le fichier lui-même est du JSON simple et réside sur un stockage partagé).

  • grants — listes de capacités par graphe. Deux permissions sont significatives :

    • read — interroger le graphe. Un graphe absent des grants d'un utilisateur lui est invisible.
    • write — muter le graphe via la couche inscriptible (delta.enabled) : les instructions MERGE / SET / DELETE et CALL slater.consolidate().

    Elles sont indépendantes : une permission read ne confère aucun accès en écriture. Activer la couche inscriptible ne peut donc pas transformer vos lecteurs existants en écrivains. Un écrivain a besoin des deux — ["read", "write"] — car résoudre une clé métier pour l'écrire est une lecture. Les chaînes de permission non reconnues sont ignorées (elles n'accordent rien).

Montez-le en lecture seule au chemin nommé par aclPath (défaut /config/acl.json). Le serveur le recharge à chaque permutation chaude de génération, et l'estampille ACL au repos est revérifiée à chaque rechargement (voir requireAclStamp).

Health check

Le binaire slater fait office de son propre probe de vivacité : slater healthcheck [host] [port] effectue une poignée de main Bolt (pas une requête HTTP) avec le serveur et se termine par 0 s'il négocie une version de protocole, 1 sinon — en utilisant par défaut localhost et le port Bolt configuré. C'est ce que la HEALTHCHECK du conteneur exécute, afin que les orchestrateurs voient un serveur réellement prêt pour Bolt, et pas seulement un socket ouvert :```sh slater healthcheck localhost 7687 # exit 0 = healthy docker exec slater /app/slater healthcheck # inside the container

## One-shot query

Pour les scripts, les contrôles CI et les recherches rapides, `slater query` monte
la génération actuelle d'un graphe, exécute une seule requête Cypher en lecture seule
en processus, affiche le résultat sous forme d'objet JSON, puis se termine — sans
serveur, sans connexion Bolt. Il respecte la même configuration que le serveur
(backend de stockage, clé de chiffrement, budgets de requête).```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

transportant le coût `cost` (éléments facturés), `resultCount`, `execMs` et
`limitRowCount` (uniquement lorsque la requête spécifie un `LIMIT`) — jamais le texte de la requête
ni aucune valeur de résultat. Le code de sortie est `0` en cas de succès, `1` en cas d’erreur
d’analyse/ouverture/exécution (message sur stderr).

## Exporter un graphe (`slater dump`)

`slater dump` exporte un graphe depuis un serveur **en cours d’exécution** sous forme de Cypher `MERGE`
à clé métier — le même dialecte que `slater-build` ingère — afin qu’un graphe effectue un aller-retour
(dump → `slater-build` → nouvelle génération) pour la migration ou la sauvegarde texte. Contrairement à
`slater query`, il se connecte via **Bolt**, s’authentifie et respecte les ACL par graphe,
il n’a donc besoin d’aucun accès disque au serveur. Le mot de passe est lu depuis
`SLATER_DUMP_PASSWORD` ou stdin (jamais une option, pour le tenir hors de `ps`/de l’historique).```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

La clé d'identité de chaque étiquette est la propriété portée par son index de plage ; remplacez-la avec --key Label=prop (répétable) ou un --pk <field> global. CREATE INDEX Le DDL est émis en premier afin que la reconstruction recrée les index. Un nœud multi-étiquettes conserve chaque étiquette — il est émis sous la forme MERGE (n:Ident:Other {key: v}), avec l'étiquette d'identité (celle qui fournit la clé métier) en premier et le reste trié ; le merge est basé uniquement sur l'étiquette d'identité, donc les étiquettes restantes sont écrites sur le nœud sans en créer un autre. Les étiquettes, les types de relations et les clés de propriété contenant des caractères spéciaux sont entourées de backticks lors de l'émission, de sorte que les noms inhabituels effectuent un aller-retour fidèle et ne peuvent pas injecter de Cypher dans le reconstruction. Les vecteurs (et autres valeurs sans représentation littérale Cypher) ne peuvent pas être transportés dans un dump MERGE et sont abandonnés avec un avertissement sur stderr. Le code de sortie est 0 en cas de succès, 1 en cas d'erreur.

Exemple concret

Une démonstration complète et exécutable — créer un graphe, le servir, se connecter avec les pilotes JavaScript et Python de neo4j, et y écrire — se trouve dans les pages Quickstart et Écriture de données du manuel, en utilisant le graphe d'exemple fourni dans docs/manual/examples/.

Développement```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

### Les backends de stockage d'objets sont des fonctionnalités cargo optionnelles

Un simple `cargo build` produit un binaire **système de fichiers uniquement** — les
backends `s3` et `gcs` sont masqués derrière des fonctionnalités cargo afin que la
compilation par défaut reste légère (pas de SDK AWS ou Google, pas d'exécution asynchrone).
Activez celui dont vous avez besoin sur **les deux** `slater` (serve) et `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

Chaque crate expose des fonctionnalités s3 / gcs correspondantes, transmises à graph-format/{s3,gcs}. Demander un backend à l’exécution (dataBackend.kind=s3|gcs, ou slater-build --publish-{s3,gcs}-*) sans que sa fonctionnalité soit compilée échoue rapidement avec une erreur claire « construit sans la fonctionnalité … ». L’image Docker publiée active les deux (Dockerfile CARGO_FEATURES), donc les images préconstruites n’ont besoin d’aucun drapeau supplémentaire — cela ne concerne que la compilation depuis les sources. Les tests d’intégration sont également conditionnés : --features s3 --test s3_minio, --features gcs --test gcs_emulator (un fake-gcs-server), et --features gcs --test gcs_real (vrai GCS via ADC) ; chacun est ignoré sauf si ses variables d’environnement SLATER_* sont définies.

Voir docs/PLAN.md, docs/PROGRESS.md et docs/DECISIONS.md pour la conception, le registre des jalons et le journal des décisions.

Performance

Jusqu’à six moteurs, une suite mono-client, des graphes allant d’un jouet de 62k nœuds à Wikidata 91.6M nœuds / 1.5B arêtes. Chaque moteur est mesuré isolément (tous les autres conteneurs arrêtés — RSS et latence reflètent sa propre empreinte). Les tableaux de latence ci-dessous ont été re-mesurés sur Slater 0.21.0 (la version en écriture) : les graphes petits/moyens (MeSH, EU-AI-Act) fraîchement, et le graphe 91.6M dans le cadre d’un nouveau passage même-machine, ancres partagées slater-vs-Neo4j (voir ce tableau). Les chiffres de mémoire résidente proviennent du passage précédent (mesurés via cgroup du conteneur ; le chemin de lecture est octet-pour-octet identique, la couche inscriptible étant inactive). Les chiffres des autres moteurs proviennent de la campagne inter-moteurs établie (leurs versions/performances sont inchangées). Tous les chiffres sont des médianes (ms) ou des pics de mémoire résidente (MiB). Dans tous les cas, plus bas = mieux ; gras = meilleur de la ligne. slater a été exécuté sur son backend système de fichiers local (fs) ; les backends S3 et GCS échangent la latence de lecture locale contre des allers-retours vers le stockage d’objets (atténués par les caches en mémoire et le niveau facultatif de cache disque local), ces chiffres caractérisent donc le moteur, et non un déploiement sur stockage réseau.

moteurclasselimite mémoire
slatersur disque, paginéquery.maxIntermediate plafonne automatiquement le jeu de travail
Neo4j 5sur disque, JVM~2 GiB de heap + off-heap, engagés quelle que soit la requête
Memgraph · FalkorDBen mémoiretout le graphe résident en RAM
ArcadeDBen mémoire, JVMtout le graphe résident ; le plus lourd
LadybugDBembarqué, colonairepool de tampons manuel devant dépasser la requête

Les trois moteurs qui paginent depuis le disque — slater, Neo4j 5 et LadybugDB — chargent les cinq graphes. Le trio en mémoire (Memgraph · FalkorDB · ArcadeDB) ne peut pas du tout contenir le graphe à 1.5B arêtes (il nécessite ~64–128 GiB résidents), et l’importateur d’ArcadeDB ne peut pas non plus le terminer.

Mémoire résidente (MiB) — bornée alors que le graphe croît ~1,500×

Chaque chiffre est la mémoire de travail engagée — ce que l’OS ne peut pas récupérer. Chaque moteur sauf slater conserve son graphe en mémoire anonyme engagée (son propre heap, le cache de pages hors heap de Neo4j, ou un pool de tampons), donc son pic de RSS est son empreinte engagée. slater seul sert depuis le cache de pages OS récupérable de son stockage sur disque, donc son chiffre est le jeu de travail anonyme ; le cache de pages du stockage (pouvant être évincé sous pression — slater continue de servir) est exclu, et affiché en total entre parenthèses pour le graphe 91.6M. Gras = plus bas.

graphe (nœuds / arêtes)slaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
pole — 62k / 106k117461141401,556198
MeSH — 341k / 469k631,0833584551,631121
EU-AI-Act — 21k / 45k (+55 MiB vec)997292293121,948286
Wikidata — 91.6M / 1.5B584 (4,595 total)~2,900ne peut pas chargerne peut pas chargerne peut pas charger~652 †

slater est le plus bas à chaque échelle et croît ~50× alors que le graphe croît ~1,500× — son empreinte suit le jeu de travail de la requête, pas le graphe (environ 16–71 MiB au repos tout du long). Le trio en mémoire croît ~linéairement et ne peut pas charger le graphe 1.5B ; Neo4j engage un heap ~2 GiB quelle que soit la requête. († LadybugDB sur les formes bornées uniquement — ses traversées de hub / longueur variable / plus court chemin à 1.5B arêtes exigent que son pool de lecture soit porté à ≥2 GiB, contre le plafond automatique maxIntermediate de slater.) Les histogrammes valeur→compte générés à la compilation ajoutent une mémoire résidente négligeable — quelques Ko pour une colonne indexée à faible cardinalité, et zéro pour les graphes à clé unique comme Wikidata (wikidata_id dépasse le plafond de cardinalité de l’histogramme, donc aucun n’est stocké) — ces chiffres sont donc inchangés par cette fonctionnalité.

Latence (médiane ms) — le graphe tient en RAM (MeSH, 341k / 469k)

formeslaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
count(*) tous les nœuds0.4115.023.816.482.02.2
comptage par étiquette0.424.220.71.14.44.3
recherche ponctuelle indexée0.433.90.480.480.658.8
comptage idx-eq0.424.95.02.03812.5
1-saut (ancre indexée)1.285.81.214.13904.9
2-sauts (sans ancre)1.405.68.516.74446.4
group-by / count(DISTINCT)0.4547–5163–6431–394115.3
analyse complète CONTAINS0.435.424.11.716.34.1

slater domine les formes métadonnées / index / analyse (count, étiquette, idx-eq, analyse — ~0.4 ms, 10–200× les moteurs de service), la recherche ponctuelle indexée (0.43 ms, devançant désormais les 0.48 ms de la paire en mémoire), le multi-sauts sans ancre (2-sauts en 1.40 ms via l’analyse par type de relation, le plus rapide du lot), et — via un histogramme valeur→compte généré à la compilation sur la clé de regroupement indexée — le group-by / count(DISTINCT) sur toute l’étiquette (0.45 ms, devant les 5.3 ms colonnaires de LadybugDB). Les serveurs en mémoire ne conservent que le 1-saut brut (Memgraph 1.21 ms contre 1.28 ms pour slater). (pole 62k/106k donne le même résultat : slater seul le plus rapide sur count/analyse ~0.4 ms, ~1.3–2.6 ms sur les sauts.)

Latence (médiane ms) — vecteurs (EU-AI-Act kNN, 15k × 1024-dim)

formeslaterNeo4j 5MemgraphFalkorDBLadybugDB
kNN top-10 Concept2.98.61.91.22.8
kNN top-10 Chunk2.45.71.91.53.2

slater répond aux kNN avec une analyse par force brute exacte (ces ensembles sont sous son seuil ANN de 50k vecteurs) alors que les autres utilisent un HNSW approximatif résident — les résultats de slater sont donc exacts (rappel 1.0). Un noyau de distance SIMD plus une matrice de vecteurs résidente et pré-normalisée ont fait passer Concept de ~23 → ~2.9 ms et Chunk de ~10 → ~2.4 ms ; slater bat désormais Neo4j et LadybugDB et se situe à ~1.4× de Memgraph, ne cédant qu’à FalkorDB — tout en restant exact.

Échelle d’écriture vectorielle — insertion / mise à jour / suppression sans reconstruction

Les tableaux ci-dessus sont des comparaisons lectures inter-moteurs. Le chemin d’écriture vectorielle (l’échelle d’écriture de type FreshDiskANN sur la base Vamana statique) n’a pas d’équivalent inter-moteurs — aucun autre moteur ici ne fait de l’ANN natif disque, inscriptible — les chiffres ci-dessous sont donc des benchmarks composants mono-moteur sur un jeu de données synthétique de type embeddings (une variété de bas rang, dim 768, normes inégales), intégré sous crates/slater/benches/ et documenté en détail — avec la méthodologie et toutes les mises en garde — dans docs/PERF-REPORT.md. Le rappel est toujours mesuré contre une force brute exacte sur l’ensemble vivant, jamais un index contre un autre. L’échelle ici est représentative et n’est extrapolée que lorsque la métrique est linéaire en taille.

propriétémesurepourquoi c’est important
Latence KNN vs écritures en attenteIndex RW ~1.5–2 ms, stable jusqu’à 50k en attente ; la surcouche pré-index en force brute 1.9 → 115 ms (linéaire en delta) — 61× à 50kla latence des requêtes ne se dégrade pas à mesure que les écritures s’accumulent entre les consolidations
Insertion d’embedding~1.5–2 ms par vecteur dans l’index vivantune écriture est visible immédiatement en KNN ; le budget de reconstruction du delta est ≈ 2 ms × le plafond du delta
IO de suppression à iso-rappel2.9× moins de récupérations de nœuds par requête à 67 % de suppressions, 5.2× à 80 % (rappel ≥ 0.90)un graphe consolidé ne paie aucune taxe de lecture pour les vecteurs supprimés
Consolidation, permutation pureO(1) — le .vamana est lié en dur, octet-pour-octet identique, seule la colonne d’identifiants est réécriteintégrer les écritures vectorielles dans la base évite la reconstruction O(N·R·L)
Rappel sur toute l’échelleconsolidé ≥ base pour cosinus, L2 et produit scalairel’échelle d’écriture préserve le rappel à chaque barreau

Le seul chiffre qui mérite l’encadré perf dédié est le débit de réécriture de consolidation par chemin lent — lorsqu’une consolidation porte des suppressions ou de nouveaux vecteurs plutôt qu’une pure permutation, il s’agit d’une recompression séquentielle limitée par zstd mono-thread et le disque local, le MiB/s absolu est donc spécifique à l’environnement (le rapport montre la forme et explique la plage environnementale).

Latence (médiane ms) — graphe ≫ RAM (Wikidata 91.6M / 1.5B)

Les moteurs en mémoire (Memgraph / FalkorDB / ArcadeDB) ne peuvent pas charger ce graphe du tout (~64–128 GiB résidents). Seuls slater et Neo4j 5 le peuvent. Il s’agit d’un nouveau passage même-machine, même jour, sur un ensemble d’ancres fixes partagées — chaque requête touche les nœuds identiques sur les deux moteurs, la confrontation est donc équitable (un pool commun wikidata_id d’ancres de degré modéré ; voir la note ci-dessous pour savoir pourquoi c’est important). slater est présenté aux deux fanouts (query.maxFanout 1 = défaut orienté débit, 8 = le réglage de latence qui chevauche les lectures de blocs froids). Gras = meilleur de la ligne.

formeslater (fan 1)slater (fan 8)Neo4j 5
count(*) tous les nœuds0.410.413606
recherche ponctuelle (indexée)0.720.496.3
degré (comptage 1-saut)0.430.446.0
voisins 1-saut9.84.510.1
2-sauts372334.5
3-sauts322574
longueur variable *1..2 distinct985105647

Le portrait honnête : slater domine les formes métadonnées / indexcount(*) est servi par les métadonnées (0.41 ms contre l’analyse disque de 3.6 s de Neo4j, ~8800×), et la recherche ponctuelle / le degré / le 3-sauts tournent ~2–10× plus vite — est à égalité avec Neo4j sur 1–2 sauts (fanout 8 prend l’avantage sur les lectures à froid), mais perd nettement sur var-length *1..2 distinct (≈1 s contre 47 ms pour Neo4j) : l’expansion distincte en longueur variable de slater est nettement plus lente ici, une vraie faiblesse qui mérite sa propre investigation. Le tout avec quelques centaines de Mo de RSS contre le heap ~2 GiB engagé de Neo4j.

À propos des ancres. Ces chiffres de traversées dépendent fortement des nœuds de départ — un nœud à un lien d’un méga-hub Wikidata (« human », « country ») a un voisinage 2-sauts de plusieurs millions d’éléments, le coût des longueurs variables / sauts varie donc de plusieurs ordres de grandeur selon le choix de l’ancre. L’édition précédente de ce tableau échantillonnait le propre « premier N par analyse » de chaque moteur, ce qui n’est ni stable ni comparable ; ce passage fixe un unique ensemble d’ancres partagé et borné en degré pour les deux moteurs. (shortestPath est omis de ce passage — entre deux ancres arbitraires, il dépend de l’existence d’un chemin et sa variance est trop élevée pour une médiane pertinente.)

Multi-sauts count(*) — mémoire découplée de la taille du résultat

Un multi-sauts RETURN count(*) sans plafond compte pendant l’expansion au lieu de matérialiser les lignes correspondantes. Mêmes ancres hub sur le graphe 91.6M, maxIntermediate=20M :

3-hop count(*) @ 91.6Mfanout=1fanout=8
latence / pic du jeu de travail554 ms / 0.66 GiB298 ms / 1.9 GiB

Le comptage ne contient que O(1) lignes. La comptabilisation est inchangée, donc un comptage de méga-hub déclenche toujours maxIntermediate sur le calcul (lectures d’adjacence), borné comme avant.

Parallélisme par requête (maxFanout)

Augmenter query.maxFanout fait se chevaucher les lectures de blocs froides et liées aux E/S d’une requête sur plusieurs cœurs — cela aide les formes liées au disque avec un grand jeu de travail froid et reste sans effet sur les formes chaudes. Sur le graphe 1.5B : shortestPath ≤6 918 → 608 ms (1.5×, plus grande recherche 6,269 → 2,350 ms, 2.7×) ; comptage 3-sauts 547 → 298 ms. maxFanout=1 est la valeur par défaut (orientée débit) ; 8 est le réglage de latence, au prix d’une mémoire de travail transitoire plus élevée.

Là où slater gagne / perd

dimensionslatermeilleur du lotverdict
mémoire résidente, à toute échelle11–584 MiB (62k → 91.6M)en mémoire 1.5–2.7 GiB ; ne peut pas charger 1.5Bslater
count / métadonnées / analyse~0.4 msmoteurs de service 5–80 msslater (10–200×)
recherche ponctuelle indexée0.43 ms (MeSH)Memgraph · FalkorDB 0.48 msslater (devance la paire en mémoire)
multi-sauts sans ancre (lignes)1.40 ms (2-sauts MeSH)Neo4j 5.6 msslater (analyse par type de relation)
agrégation (group-by / DISTINCT)0.45 msLadybugDB 5 ms (colonnaire)slater (histogramme à la compilation)
kNN2.4–2.9 ms (exact)FalkorDB 1.2 ms (HNSW)bat Neo4j/Ladybug ; ~1.4× de Memgraph ; exact
91.6M métadonnées / point / degré / 3-sauts0.4–32 msNeo4j 6–3,600 msslater (2–8800×)
91.6M 1–2-sauts4.5–23 ms (fan 8)Neo4j 10–35 ms~à égalité
91.6M longueur variable *1..2 distinct~1 sNeo4j 47 msNeo4j (une vraie faiblesse de slater)
multi-sauts count(*) à l’échelle0.3–0.6 GiBles moteurs en mémoire matérialisent l’ensemble de lignesslater, borné

Les tableaux complets par moteur (pole, MeSH, EU-AI-Act + le réglage RAM↔latence blockCacheBytes, Wikidata 1M & 91.6M) se trouvent dans perf/cross-engine-hs/README.md ; le nouveau passage slater uniquement (les deux fanouts, chaque jeu de données) est dans perf/PERF_CURRENT_STATUS.md.

Concurrence et brown-out (tests de charge)

Les benchmarks ci-dessus sont mono-client. L’axe complémentaire — le comportement sous de nombreux clients concurrents — dispose de son propre harnais, perf/loadtest/ : un pilote Locust sur Bolt plus un coordinateur qui fait monter la charge, lit CALL slater.diagnostics(), trouve le coude de capacité et nomme le facteur limitant (méthode complète dans docs/LOAD-TESTING.md). Les points marquants d’une exécution avec cache de 256 MiB sur le graphe Wikidata-1M (une machine 16 cœurs) :

résultatmesure
Tient 1000 clients concurrents, zéro échecle débit culmine à ~2.5k rps ; le coude de latence apparaît vers 750 clients (p99 51 → 750 ms) — mise en file sous contention de cœurs, pas un plafond dur (exécution unique, WSL2)
Cache de blocs borné et efficace100 % de taux de succès, 0 éviction, 50 Mo résidents pour un jeu de travail adapté au cache
RSS maintenu sous charge soutenuel’allocateur jemalloc maintient le RSS à ~0.6 Go sur une rampe wiki_cache_churn de 100→500 clients — lié au cache et stable, sans réglage MALLOC_* (l’ancien MALLOC_ARENA_MAX=2 + seuil de purge est retiré) ; sa purge en arrière-plan ramène aussi le niveau haut post-pic au lieu de le laisser épinglé
Mémoire agrégée bornéeà l’échelle du serveur, query.maxIntermediateGlobal + expansion comptabilisée par adjacence contiennent l’inondation 2-sauts wiki_budget à 1000 clients sans OOM (RSS ~0.6 Go ; le gardien écarte ~60 % des requêtes hub comme erreurs de budget réessayables)

Les deux problèmes mémoire que le test de charge a révélés sont désormais résolus ; tout est suivi dans le document de test de charge.

Licence

Sous licence Apache, version 2.0. Voir LICENSE pour le texte complet et NOTICE pour les attributions. Sauf indication contraire explicite, toute contribution soumise intentionnellement pour inclusion dans ce travail, telle que définie par la licence Apache 2.0, sera licenciée comme ci-dessus, sans termes ni conditions supplémentaires.

SPDX-License-Identifier: Apache-2.0

Catégories