
slater v0.25.1
Database a grafi a basso consumo di memoria con supporto Bolt+TLS, crittografia at-rest e vettori, progettato per casi d'uso di repliche locali di grafi.
Slater
Versione attuale: v0.25.2 — tutte le release.
In una riga: Slater serve grafi che non stanno in memoria — centinaia di milioni di nodi e miliardi di relazioni in poche centinaia di MB di RAM — tramite il Bolt standard, quindi qualsiasi driver neo4j funziona senza modifiche, con la ricerca vettoriale nativa su disco accanto al grafo, e accetta scritture live e durevoli senza rinunciare a tutto questo. La memoria residente è determinata da un budget di cache che scegli tu, non dalla dimensione del grafo.
Scorciatoie
Perché Slater esiste
Un database a grafo memorizza i dati come cose (nodi) e relazioni tra loro (archi), con le relazioni trattate come cittadini di prima classe. È ciò che ti serve quando le domande riguardano connessioni piuttosto che righe — "chi è entro tre salti da questo conto?", "qual è l'intera catena di dipendenze dietro questa build?", "quali conti condividono un dispositivo, un indirizzo e una carta?" — le query che in SQL diventano una palude di join ricorsivi ma che in un grafo emergono naturalmente.
La lamentela più comune sui database a grafo è che non scalano oltre ciò che puoi tenere in RAM. Molti di essi (ad es. neo4j, Memgraph, FalkorDB, ecc.) mantengono l'intero grafo residente: un grafo da 40 GB richiede 40 GB di memoria — per istanza. Vuoi una replica per regione, per tenant o per pod? Moltiplica il costo. E oltre una certa dimensione semplicemente non si caricano: ad esempio il grafo Wikidata da 90 milioni di nodi / 1,5 miliardi di archi richiede ~64–128 GiB residenti, quindi i motori in memoria non riescono ad aprirlo affatto.
Slater è la confutazione. Invece di caricare il grafo in memoria, lo compila una volta, offline: slater-build trasforma i tuoi dati in un'immagine su disco immutabile e indirizzata al contenuto, e un numero qualsiasi di server Slater serve poi quell'immagine tramite Bolt (così i tuoi driver neo4j esistenti funzionano e basta), caricando i blocchi on demand e tenendo residente solo un budget di cache fisso. È così che lo stesso grafo da 90 milioni di nodi viene servito da poche centinaia di MB di RAM — dimensione del grafo e costo di memoria sono disaccoppiati. Un grafo da 4 GB e uno da 400 GB costano la stessa RAM da servire, quindi puoi distribuire repliche di lettura economiche e senza stato e lasciare che sia lo storage, non l'heap, a contenere il grafo.
Questo lo rende un candidato naturale per i knowledge graph dietro RAG, grafi di raccomandazione e identità, grafi di dipendenze — qualsiasi cosa grande e interconnessa tu voglia interrogare in modo economico e frequente. La ricerca vettoriale nativa su disco vive proprio accanto al grafo, così lo stesso motore è anche il livello di retrieval per gli embedding.
Compilato una volta, però, non significa congelato. Quell'immagine è una base, non uno stato finale: un livello di scrittura opt-in si trova sopra di essa, quindi un grafo live può essere corretto ed esteso senza ricostruire nulla.
Letture e scritture
Il core è immutabile; il grafo no. Attiva il livello scrivibile (delta.enabled) e scrivi tramite Bolt — correggi una proprietà, aggiungi un nodo, ritira un arco — e la modifica atterra in modo durevole, senza ricostruire l'immagine. Ciò che mantiene bassi i costi sul lato lettura è dove vivono le scritture.
Le scritture si accumulano in un livello log-structured-merge (LSM) sopra il core immutabile: un write-ahead log e una tabella in memoria, che si riversano in segmenti delta immutabili, riassorbiti in un nuovo core da una consolidation periodica. Cosa ottieni:
- Le letture su un grafo non scritto costano esattamente quanto prima. Un delta vuoto è un unico ramo prevedibile, non un merge — il percorso di lettura è byte-identico a prescindere che il livello scrivibile sia attivo o meno.
- Il costo di lettura di una scrittura scala con la dimensione del delta, non con quella del grafo. Risposte sull'intero grafo —
count(*), le marginali su etichette e tipi di relazione — restano letture di metadati anche con scritture in sospeso: il delta mantiene i propri contatori, quindi uncount(*)su un core da 91,6 milioni di nodi con mezzo milione di scritture in attesa risponde comunque in decine di millisecondi senza toccare un singolo blocco. - Accusato significa durevole. Un singolo writer drena la coda e restituisce
SUCCESSsolo dopo l'fsyncche copre la scrittura. Raggruppa le tue scritture e diventano economiche — una scrittura-UNWINDcommitta unfsyncper batch, non per riga. - Scritture per business key, in entrambi i dialetti.
MERGE/MATCH … SET/DELETE(eCREATE/REMOVE, delete con detach, scritture di relazioni) basate sulla proprietà identità di un nodo — oppure le equivalenti istruzioni di modifica ISO GQL (INSERT/SET/REMOVE/DELETE), che si appoggiano allo stesso percorso. Correggi, inserisci, fai upsert e ritrai, su nodi e archi, indirizzati nel modo in cui i tuoi dati sono già strutturati.
Con il livello disattivato — il default — Slater serve il puro core immutabile e rifiuta le scritture. Vedi Il livello scrivibile per il modello completo.
Sul nome. Slater prende il nome dall'agente della CIA in Archer (una grande serie) che insiste sull'uso di un solo nome — "Just… Slater" — e uno dei miei personaggi preferiti della serie. Vedi la pagina wiki del personaggio.
Cosa ottieni
- RAM determinata dal tuo budget di cache, non dalla dimensione del grafo — distribuisci quante repliche di lettura vuoi; il grafo non deve mai stare in memoria.
- Un sostituto pronto per il grafo — parla Bolt, quindi qualsiasi driver neo4j standard (JS, Python, Go…) funziona senza modifiche. È Cypher (più una parte di ISO GQL, in lettura e scrittura); niente di nuovo da imparare.
- Scritture live e durevoli — un livello LSM opt-in sopra il core immutabile:
MERGE/SET/DELETEper business key su nodi e archi, con commit di gruppo e durabilità viafsync, riassorbito in un nuovo core dalla consolidation. Le letture non ne pagano il costo. - Deploy tramite sostituzione di file — costruisci offline una nuova generation con hash del contenuto, scambia atomicamente il puntatore
currente i server la recepiscono. Ogni blocco è checksumato, quindi un'immagine copiata a metà viene rifiutata, non servita. - Ricerca vettoriale integrata — approssimazione nearest-neighbour nativa su disco (cosine, L2 o dot KNN) sta proprio accanto al tuo grafo, per quando questo è il livello di retrieval dietro una pipeline RAG, e gli embedding sono scrivibili in place — nessuna ricostruzione offline per aggiungere o modificare un vettore.
- Bloccato per design — i permessi di lettura e scrittura sono indipendenti, più crittografia opzionale a riposo, Bolt TLS, ACL con hash argon2id e rootfs di container read-only per le repliche di lettura. Configura una master key e l'immagine su disco è autenticata oltre che cifrata — il suo manifest porta una MAC con chiave, quindi un attaccante con accesso in scrittura alla directory dati ma senza la chiave non può forgiare un manifest che il server accetti. Senza chiave ottieni comunque l'hash del contenuto, che intercetta un'immagine copiata a metà o corrotta — ma non una manomissione intenzionale. Quale configurazione acquista cosa.
Caratteristiche
| Funzione | Cosa significa per te |
|---|---|
| Memoria limitata e prevedibile | La memoria residente segue tre budget di cache che imposti tu, entro un overhead per-entry e dell'allocatore limitato — non cresce con la dimensione del grafo; regoli il compromesso prestazioni/RAM invece di dimensionare per l'intero grafo. Un allocatore jemalloc con purge in background restituisce la memoria liberata al sistema operativo dopo picchi intensivi di query, quindi la dimensione residente torna verso il suo minimo di idle invece di restare ancorata al livello massimo post-picco. |
| Multi-tenant subito funzionante | Un solo server ospita molti grafi con grant di lettura per utente — isolamento multi-database che molti DB a grafo riservano a un livello a pagamento/enterprise. |
| Crittografia a riposo e in transito | Sigillatura per-blocco con XChaCha20-Poly1305 (la chiave non viene mai scritta su disco) più TLS opzionale (bolt+s://). GDPR-friendly by construction. La cifratura è anche ciò che garantisce l'integrità autenticata: il builder sigilla il manifest con una MAC con chiave, e un server che possiede la chiave la verifica e rifiuta di servire una generation il cui manifest sia forgiato, alterato o spogliato della MAC. Un'immagine non cifrata (plaintext) è protetta solo dall'hash del contenuto non cifrato — completezza e corruzione, non manomissione. Vedi Cosa significa integrità in ogni configurazione. |
| Installazione minima | Un piccolo binario stripped su base glibc distroless (senza shell/apt) — l'immagine multi-arch (amd64/arm64) pesa circa 22 MB, o ~12 MB per il tag solo-server slater:latest-lite; TLS in puro Rust, niente OpenSSL. Pull e via. |
| Progettato per la pubblicazione periodica | Costruisci un grafo offline, servilo immutabile, poi sostituisci atomicamente una nuova versione con zero downtime — ideale per carichi di lavoro data-warehouse / aggiornamenti schedulati. |
| Robusto sotto carico | Il server e il builder offline compilano entrambi con #![forbid(unsafe_code)] — l'unico unsafe del motore vive nella crate auditata dell'allocatore jemalloc. Il core è immutabile, quindi le letture non usano lock e non aspettano mai uno scrittore; un singolo writer serializza le mutazioni dietro il solo percorso di scrittura. Nessuna pausa GC, nessuna data race. Una query sbagliata non può abbattere il server. |
| Funziona con i tuoi strumenti neo4j | Parla Bolt 5.4 / 4.4 / 4.1 — usa i driver neo4j standard (JS, Python, Go, Java…), cypher-shell o i browser per grafi senza modifiche. |
| Ampia superficie Cypher | Una vasta superficie di lettura: MATCH/WHERE/WITH/UNION, subquery CALL {…}, 70+ funzioni e aggregazioni, valori temporali e geospaziali, e regex. |
| Scritture live e durevoli | Un livello LSM opt-in a writer singolo sopra il core immutabile (delta.enabled): MERGE / SET / DELETE / CREATE / REMOVE per business key su nodi e relazioni, UNWIND di scrittura in batch (un fsync per batch) e CALL slater.consolidate() — commit di gruppo, durabilità via fsync e riassorbimento in un nuovo core tramite consolidation. Il percorso di lettura è byte-identico quando il delta è vuoto. |
| ISO GQL, lettura e scrittura | Parla un sottoinsieme di ISO GQL (ISO/IEC 39075) sulla stessa connessione Bolt — percorsi quantificati, restrictor di percorso, selettori di percorso più breve, espressioni booleane su etichette/tipi, FOR, CAST, un prefisso di dialetto opzionale GQL/CYPHER — e, con il livello scrivibile attivo, le istruzioni di modifica dati GQL (INSERT / SET / REMOVE / [DETACH] DELETE) si appoggiano allo stesso percorso di scrittura durevole. Cypher e GQL, letture e scritture, in un unico motore. |
| Vettori + grafo in un unico motore | Ricerca vettoriale ANN nativa su disco (Vamana + PQ; cosine / L2 / dot) per embedding/RAG, più algoritmi su grafi (PageRank, BFS, betweenness, WCC…) — memoria limitata anche con milioni di vettori. Gli embedding sono scrivibili (una write ladder stile FreshDiskANN): inserisci / aggiorna / elimina un vettore, visibile subito alla KNN, riassorbito nella base senza ricostruzione. |
| Sicuro su storage di rete | Ogni file è hashato con BLAKE3 e verificato all'apertura; immagini strappate o copiate a metà vengono rifiutate, non servite. Progettato per volumi NFS/remoti (nessuna sorpresa con mmap). |
| Backend di storage pluggabili | Servi lo stesso formato di generazione da un filesystem locale, un bucket S3 (o compatibile S3), oppure un bucket Google Cloud Storage — pubblica una volta, distribuisci a repliche senza stato — con un livello cache SSD locale opzionale davanti all'oggetto store. Vedi Backend di storage. |
Due binari compongono il workspace:
| Binario | Ruolo |
|---|---|
slater | Il server Bolt online (ENTRYPOINT del container): serve letture e, con delta.enabled, il percorso di scrittura durevole a writer singolo. |
slater-build | Il compilatore offline: trasforma un dump Cypher primitivo in una directory di generazione immutabile e con hash del contenuto. |
Slater separa la costruzione di massa dalla distribuzione: slater-build fa il lavoro pesante
offline — ingerendo i tuoi dati e compilandoli in una generazione immutabile — così un grafo freddo
non viene mai assemblato sul percorso critico del server. All'interno del server, la superficie di
lettura risponde a un'ampia fetta di Cypher — pattern matching, subquery WITH/UNION/CALL {…},
70+ funzioni scalari e aggregate, valori temporali e geospaziali, algoritmi su grafi (algo.*)
e KNN vettoriale nativo su disco (db.idx.vector.queryNodes) — mentre l'overlay delta del livello
scrivibile sta sotto quella superficie ed è a costo zero quando è vuoto, quindi le letture non si
trascinano mai la macchina delle scritture. Puoi aggiornare un grafo in due modi: scriverci live
tramite Bolt (vedi Il livello scrivibile), oppure costruire una nuova
generazione offline e scambiare atomicamente il puntatore current, che il server in esecuzione
rileva tramite la sua generation guard (vedi
Generation guard).
Documentazione
Il manuale utente completo vive in docs/manual/ —
una guida funzione per funzione che spiega, per ogni capacità, cos'è, perché
esiste e come usarla, con esempi pratici eseguibili su un grafo di esempio incluso.
Inizia da lì per qualsiasi cosa vada oltre questa panoramica.
- Nuovo qui? Quickstart costruisce e serve un grafo in cinque passi.
- Scrivi query? Querying, Funzioni ed espressioni, Procedure e algoritmi, Ricerca vettoriale, Scrivere dati.
- Costruisci grafi? Building graphs e il Build CLI reference.
- Operi Slater? Deployment, Storage, Riferimento di configurazione, Sicurezza, Ottimizzazione delle prestazioni.
Usare Slater come memory store per Graphiti
graphiti-slater è un adapter
che permette a Graphiti di memorizzare il suo
knowledge graph temporale in Slater, con un
docker-example/
eseguibile — inclusa l'esposizione a Claude Code come server MCP. Vedi quel repository per come
funziona e come eseguirlo.
Esecuzione con Docker
Slater è progettato per essere eseguito come deployment Docker — è il modo previsto
per usarlo. Immagini multi-arch precompilate (linux/amd64 + linux/arm64) sono
pubblicate su Docker Hub all'indirizzo
hikarisystems/slater,
con tag :latest e :vX.Y.Z a ogni release:```sh
docker pull hikarisystems/slater:latest
Una guida all'uso, alla configurazione e alle operazioni solo tramite comandi Docker si trova in
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (ed è rispecchiata nella pagina di panoramica di Docker Hub) —
**inizia da lì se stai effettuando il deploy.** In breve:```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
Per costruire l'immagine localmente invece (ad es. per lo sviluppo):```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
Lo stage di build installa `cmake`, `clang` e `libclang-dev` per il backend `aws-lc-rs` di rustls; `git` (già presente nell'immagine di base) è richiesto per la dipendenza git+tag di `hs-utils`, che `.cargo/config.toml` recupera tramite la CLI di git.
Le sezioni seguenti trattano il formato su disco, la configurazione, le ACL e un esempio pratico locale (non Docker).
## Come funziona```
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>
- Una generazione è una directory immutabile: un
MANIFEST.json(tabelle dei simboli, descrittori di indice, un'intestazione di cifratura opzionale), file di blocchi colonnari (node_props.blk,node_labels.blk,edge_props.blk,topology.csr.blk,vectors.f32.blk), indici di intervallo (range/<name>.isam), indici ANN sopra soglia (vector/<label>.<prop>.{vamana,pq}) e un puntatore testualecurrent. - Ogni blocco è compresso con zstd e dispone di checksum BLAKE3; con
--encryptogni blocco è inoltre sigillato con XChaCha20-Poly1305 (AEAD at rest). - Il server apre una generazione ricalcolando l'hash di ogni file rispetto al manifest, quindi un'immagine copiata a metà / troncata — una copia incompleta nella directory dati, che può essere uno storage remoto/di rete — viene rifiutata piuttosto che servita.
- Le letture passano attraverso tre pool di cache limitati — una LRU di blocchi decompressi, un pool di indici vettoriali (codici PQ residenti + una LRU di blocchi Vamana) e una LRU dei risultati — ciascuno con il proprio budget di byte. Ogni pool pesa ciò che contiene e rimuove elementi per restare sotto il budget, quindi la RSS segue i budget entro un overhead limitato per voce e per allocatore invece di crescere con il grafo.
Il livello scrivibile
Con delta.enabled, la generazione immutabile diventa il livello inferiore completamente compattato (il "core") di un piccolo albero log-structured-merge, e le scritture in tempo reale viaggiano sopra di esso:```
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)
└─────────────┘
* **Soglia minima di durabilità — il WAL.** Ogni mutazione è serializzata dietro un singolo
writer per grafo, accodata a un write-ahead log per grafo e sottoposta a `fsync` prima che
venga restituito il `SUCCESS` di Bolt — quindi *riconosciuto ⇒ durevole*, e una coda troncata
viene scartata al replay. Un write-`UNWIND` in batch accoda le sue righe e committa **un**
singolo `fsync` per l'intero batch. Il WAL è **solo su disco locale** (non viene instradato
attraverso il backend di storage), il che rende *stateful* un nodo *writer*: necessita di un
volume locale durevole in `delta.walDir`. Le repliche di lettura restano stateless.
* **Memtable → L0 → consolidamento.** Le scritture si accumulano in una memtable in-RAM
(limitata da `delta.memtableBytes`); quando si riempie, esegue il flush in un segmento delta
L0 immutabile. Un **consolidamento** fonde `{core + delta}` in un nuovo core serializzando la
vista unita di nuovo attraverso `slater-build` e scambiando `current` atomicamente — la stessa
protezione tramite content-hash di qualsiasi generazione pubblicata. Attivalo manualmente con
`CALL slater.consolidate()`, automaticamente al `delta.deltaCorePercent` della dimensione del
core (opzionalmente limitato a una finestra off-peak `delta.consolidateWindow`), oppure lascia
che il freno `delta.deltaHardBytes` blocchi la crescita incontrollata.
* **L'overlay si trova al di sotto della superficie di lettura.** L'executor legge attraverso una
`ReadView` che è o il core nudo (delta sempre vuoto) o una vista unita `(core, delta)`; il
motore è monomorfizzato su di essa, quindi un delta vuoto compila in un singolo ramo prevedibile
e il percorso di sola lettura è byte-identico. I contatori dell'intero grafo (`count(*)`,
marginali di label/reltype) sono serviti dai contatori live del delta stesso, quindi restano
letture di metadati anche con scritture in sospeso.
* **Una query vede uno snapshot stabile.** Ancora una tupla `(core, delta)` per tutta la sua vita.
Non esistono transazioni multi-statement né rollback — una scrittura è una correzione durevole,
indirizzata per chiave di business, non una transazione OLTP.
La grammatica di scrittura esatta e le manopole sono nella tabella
[Configurazione](#environment--configuration) (`delta.*`) e nell'[Esempio pratico](#worked-example) sotto.
### Indici di range (ISAM)
Un indice di range (`range/<name>.isam`, uno per ogni `(label, property)` indicizzata) consente a una
`MATCH (n:Label {prop: v})` o `WHERE n.prop <op> v` di risolvere gli id dei nodi corrispondenti
**senza scansionare la label**. È una struttura
**[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method)
— il classico indice *statico, ordinato, a blocchi*, che è esattamente la forma giusta per una
generazione immutabile: non ci sono inserimenti da ribilanciare, quindi la semplicità di ISAM
offre ciò che i meccanismi di mutazione di un B-tree complicherebbero soltanto.
* Le entry `(value, entity_id)` sono ordinate per valore e impacchettate negli stessi
blocchi zstd-compressi da 256 KiB come tutto il resto.
* Un piccolo **top-level residente** contiene la prima chiave di ogni blocco (un indice sparso).
Una ricerca esegue una binary search su quel top-level in memoria per trovare il *singolo* blocco
in cui può trovarsi una chiave, legge + decomprime quel blocco e lo scansiona — quindi una ricerca
di uguaglianza è **una lettura di un blocco**, e una scansione di range attraversa la sequenza
contigua di blocchi che copre. (È per questo che una ricerca indicizzata su `meshUi` impiega
pochi millisecondi, mentre la stessa corrispondenza su una proprietà non indicizzata scansiona
l'intera label.)
* Il planner la seleziona tramite `NodeScan::RangeEq` / `RangeRange`; un predicato non indicizzato
ricade in una scansione della label o in una scansione completa, con l'executor che ricontrolla
ogni predicato in entrambi i casi.
### Ricerca vettoriale (Vamana + PQ) — cosine, L2 e dot, lettura *e* scrittura
La KNN vettoriale (`db.idx.vector.queryNodes`) opera su indici **cosine, L2 o dot-product (MIPS)**.
L'indice di base viene costruito offline con due percorsi di esecuzione, scelti per ogni indice tramite
`--ann-threshold` (default 50 000 vettori):
* **Sotto la soglia — forza bruta.** I vettori `f32` completi vivono in
`vectors.f32.blk`; una query scansiona il gruppo dell'indice e calcola la distanza esatta nella
metrica dell'indice. Semplice ed esatto; adatto quando il set di vettori è piccolo.
* **Alla soglia o sopra — Vamana + PQ**, il percorso ANN nativo su disco che mantiene
limitata la memoria residente indipendentemente da quanti vettori ci sono:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** è l'indice a grafo della
linea di lavoro DiskANN: un singolo grafo di prossimità i cui archi sono potati (l'out-degree
`--vamana-r` e il fattore di archi lunghi `--vamana-alpha`) così che una *greedy
beam search* — si parte dal medoide, si salta ripetutamente verso la query mantenendo una
lista di candidati di larghezza `vectorQuery.beamWidth` — raggiunge i veri vicini di un nodo
in pochi salti, cioè **poche letture casuali di blocchi per query**. I blocchi
del grafo (`vector/<label>.<prop>.vamana`) vengono paginati attraverso la vector cache,
non tenuti interamente in memoria.
* **[Quantizzazione del prodotto (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)**
comprime ogni vettore in un codice breve (`--pq-subspaces` × `--pq-bits`): le
dimensioni vengono suddivise in sottospazi, ciascuno clusterizzato indipendentemente con k-means,
e il vettore è memorizzato come tupla degli id dei centroidi più vicini. Questi codici
(`vector/<label>.<prop>.pq`) sono abbastanza piccoli da rimanere **residenti**, così la
beam search valuta i candidati dalla RAM e solo i pochi vettori completi selezionati vengono
letti dal disco. Quel set PQ residente è ciò che il pool `cache.vectorCacheBytes` tiene ancorato.
**Embedding scrivibili — la scala di scrittura vettoriale (stile [FreshDiskANN](https://arxiv.org/abs/2105.09613)).**
Un embedding indicizzato è un valore scrivibile di prima classe. `SET n.embedding = vecf32([…])` (e `REMOVE`) finisce nel
delta di scrittura ed è **immediatamente visibile alla KNN con rango esatto**, poi sopravvive a un flush
di segmento, a un merge e a un consolidamento. Una query fonde fino a tre livelli — l'indice di base
sigillato, un indice per segmento sigillato e un **RW-index** in memoria (un Vamana mutabile live
sul delta di scrittura) — quindi la latenza rimane piatta man mano che le scritture si accumulano,
invece di crescere con il numero di scritture in sospeso. Una delete lascia un *buco*: il nodo smette
di essere restituito ma rimane un waypoint di navigazione finché una **consolidamento delle delete**
in background non lo rimuove dal grafo, così le delete smettono di costare IO alle query. E poiché il grafo
su disco indirizza i suoi vicini tramite la posizione nel layout piuttosto che con l'id del nodo,
`CALL slater.consolidate()` trasporta il Vamana **per riferimento** — hard-link, byte-identico — e riscrive
solo una piccola colonna di id, ripiegando le scritture vettoriali nella base **senza** la ricostruzione
del grafo O(N·R·L). I numeri misurati, con le relative avvertenze, sono nel
[rapporto sulle prestazioni](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).
## Backend di storage (filesystem / S3 / GCS)
Ogni file di generazione viene aperto tramite un'astrazione **`ObjectStore`** piuttosto che
direttamente con `std::fs`, quindi lo *stesso* formato di byte su disco — blocchi, indici,
manifest, puntatore `current` — viene servito invariato da qualsiasi backend; cambia solo
*da dove provengono i byte*, mai i reader, il motore di query o i controlli di integrità.
Il percorso caldo è costituito dalle letture posizionali (`read_exact_at`), che si mappano
su una `pread` su file locale e su una richiesta HTTP byte-range su un object store
— Slater non usa mai mmap, quindi il modello esplicito di letture limitate è identico
ovunque.
**Tre backend di prima classe**, selezionati tramite `dataBackend.kind`. Il filesystem è
il semplice default; **Amazon S3 e Google Cloud Storage sono backend object-store ugualmente supportati e completi**
— l'immagine pubblicata include entrambi compilati, quindi ciascuno è solo una questione di
configurazione, e una generazione costruita una volta può essere servita da qualsiasi di essi
(anche migrata `fs` → S3 → GCS) senza una ricostruzione.
| `dataBackend.kind` | Lettura posizionale | Integrità all'apertura | Credenziali |
| --- | --- | --- | --- |
| `fs` *(default)* | `pread` | ri-hash BLAKE3 completo di ogni file | — |
| `s3` | HTTP `Range` GET | server **SHA-256** via `HEAD` (→ ri-hash del body BLAKE3 se assente) | chiavi di config, catena AWS o ruolo IAM |
| `gcs` | lettura HTTP range | server **CRC32C** via `get_object` (→ ri-hash del body BLAKE3 se assente) | ADC / Workload Identity, o JSON del service account |
Entrambi gli object store verificano l'integrità a partire dal **checksum che lo store già calcola
e conserva**, recuperato come metadato dell'oggetto: `slater-build` invia il checksum in upload
(lo store valida i byte rispetto ad esso e lo memorizza), e il server lo rilegge all'apertura e lo
confronta con il manifest — una richiesta di metadati per file, nessun download del body. È basata
sul contenuto ed è identica nello spirito su S3 (SHA-256) e GCS (CRC32C). Quando un oggetto non ha
**nessun** checksum memorizzato dal server (copiato out-of-band, o caricato con un default diverso),
il server **ri-hasha il body dell'oggetto rispetto al BLAKE3 del manifest** piuttosto che fidarsi
della sua lunghezza in byte — un controllo di integrità richiesto non viene mai silenziosamente
declassato a un confronto di dimensioni. Le generazioni pubblicate da Slater portano sempre il
checksum, quindi restano sul percorso economico dei metadati.
Ciò che questa colonna verifica, su ogni backend, è che i file **corrispondano al manifest**.
Se il manifest stesso possa essere considerato attendibile è una questione separata, ed è la
chiave master a rispondere: con una chiave configurata, il manifest porta un MAC con chiave
che il server verifica prima di fidarsi di qualsiasi campo (inclusi questi hash), quindi un
manifest riscritto per descrivere file manomessi viene rifiutato; senza chiave il confronto è
senza chiave ovunque, e chiunque possa scrivere nella directory dei dati può riscrivere insieme
un file e il manifest. Vedi
[Cosa significa integrità in ogni configurazione](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration).
Il controllo stesso può essere disattivato con `dataBackend.verifyIntegrity: false`, che
lo scambia con un'apertura più rapida.
### Filesystem (`fs`)
Il default, con radice in `dataBackend.fs.dir`. La scelta giusta per la maggior parte
dei deployment: una generazione su SSD locale (o un mount NFS/EBS) servita in sola lettura.
L'integrità è un ri-hash BLAKE3 completo di ogni file all'apertura.
### Amazon S3 (`s3`)
Un bucket S3 o compatibile con S3 (AWS, MinIO, localstack). Le credenziali provengono **in primo luogo**
dalla config (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, più
`awsSessionToken` per credenziali STS temporanee) e ricadono sulla catena AWS standard
(`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env, profilo condiviso o
ruolo instance/IRSA) quando vengono lasciate vuote.```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, raggiunto tramite l'API JSON. L'autorizzazione è nativa di GCP: per impostazione predefinita
risolve le Application Default Credentials — GKE Workload Identity, il server di
metadati GCE, o una chiave gcloud / GOOGLE_APPLICATION_CREDENTIALS. Imposta
dataBackend.gcs.credentialsPath (un file di chiave JSON di un account di servizio) o inline
credentialsJson per una chiave esplicita. dataBackend.gcs.endpoint punta a un
emulatore fake-gcs-server, e dataBackend.gcs.anonymous=true abilita
l'accesso non autenticato solo per quell'emulatore — mai contro un GCS reale.```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
In ogni caso slater-build scrive la generazione completata in --data-dir per prima cosa
(la sua area di staging locale) e inoltre la carica sul bucket; il puntatore remoto
current viene scritto per ultimo, quindi un nodo di servizio non vede mai una
generazione pubblicata a metà.
Quando usare un object store (S3 o GCS)
Ricorri a s3 o gcs quando vuoi generazioni in uno storage a oggetti centrale e durevole
piuttosto che sul disco di un nodo — tipicamente: pubblica una volta e distribuisci a molte
repliche server stateless e senza disco che leggono tutte lo stesso bucket; disaccoppia
l'host di build dagli host di servizio; oppure affidati alla durabilità/versioning/ciclo di
vita dello store invece di gestire volumi. Il compromesso è la latenza: un blocco freddo è
un round-trip di rete (~10–50 ms) invece di una lettura locale (~0.1 ms). Slater nasconde
gran parte di questo con la cache di blocchi in memoria, la pre-lettura concorrente e la
cache su disco opzionale descritta sotto. Se le tue generazioni sono già su storage locale
veloce e non ti serve il modello a bucket centrale, fs è più semplice e veloce.
Cache di blocchi su disco locale (secondo livello per object store)
La BlockCache in memoria è volutamente piccola (la RSS limitata è la garanzia principale),
quindi su un working set più grande della RAM gli stessi blocchi verrebbero ri-scaricati
dall'object store a ogni spill. Un secondo livello di cache opzionale su SSD locale
risolve il problema: un blocco espulso dalla RAM viene servito dal disco locale (~0.1 ms)
invece che da un nuovo GET all'object store, sopravvivendo all'espulsione dalla memoria e
riducendo numero/costo delle richieste all'object store — portando un nodo basato su object
store vicino alle prestazioni del filesystem locale una volta caldo. È opt-in sia per
s3 sia per gcs, abilitato impostando dataBackend.<s3|gcs>.diskCacheBytes > 0 e una
diskCacheDir scrivibile.
- Memorizza i byte sigillati esattamente come recuperati — già compressi e (per le
generazioni
--encrypt) ancora sigillati AEAD — sotto la decrittazione/decompressione. Il livello di cache non detiene mai la chiave di cifratura e non ri-cifra mai, quindi lo stato at-rest è preservato gratuitamente: una generazione cifrata finisce su disco ancora sigillata. - Le scritture sono write-behind: un miss restituisce subito i byte recuperati alla query, poi un thread in background esegue la scrittura su disco e il trim LRU, quindi il percorso di query non si blocca mai su I/O disco. L'eviction mantiene la cache entro il proprio budget di byte; un checksum per file verificato a ogni lettura auto-ripara un file di cache corrotto trasformandolo in un miss (→ nuovo fetch dall'object store).
diskCacheDirdeve puntare a un volume reale scrivibile — maitmpfs(tmpfs è RAM e vanificherebbe la garanzia di RSS limitata). L'indice in memoria che la tiene traccia costa un po' di RAM (~decine di byte per blocco in cache), che concorre al tetto RSS — dimensiona la directory ≫ della cache di blocchi in memoria.- L'altro costo di RAM del livello è la coda write-behind, che mette in stage i blocchi
in viaggio verso il disco. È limitata a
blockCacheBytes / 8(con un minimo didiskCacheBytes) — 8 MiB con i valori predefiniti — e si riduce invece di crescere, quindi una scansione a freddo non può gonfiarla; un blocco eliminato viene semplicemente ri-scaricato al miss successivo. Non richiede configurazione: scala conblockCacheBytes, quindi il livello su disco non aggiunge alcun nuovo numero al budget RSS oltre al proprio indice.
Montaggi
Una read replica viene eseguita con un filesystem root in sola lettura e un utente
non-root (appuser:1000) — tutto ciò di cui ha bisogno è montato in sola lettura. Un
writer (delta.enabled) ha inoltre bisogno di un volume durevole e scrivibile per il
proprio WAL.
| Path | Scopo | Note |
|---|---|---|
/data | Le generazioni del grafo (<graph>/<uuid>/… + current). | Sola lettura per le repliche; prodotte da slater-build. Possono trovarsi su storage remoto/di rete (es. NFS), quindi le letture non sono considerate veloci come le latenze di un SSD locale. |
/sandbox | Overlay di configurazione per ambiente + segreti. | /sandbox/config.json viene deep-merged sopra il config.json integrato; contiene anche acl.json, materiale PEM TLS, il file della chiave at-rest. |
/tmp, /run | Scratch (tmpfs). | Una read replica non scrive mai su disco per impostazione predefinita. |
(writer) delta.walDir | Il write-ahead log + i segmenti delta L0, quando delta.enabled. | Scrivibile e un volume reale e durevole — mai tmpfs (è il livello minimo di durabilità). Un percorso relativo viene risolto sotto la data dir; assegna qui un volume persistente dedicato a un writer. |
| (opzionale) cache su disco | La cache di blocchi su disco locale, quando dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Scrivibile e un volume reale — non tmpfs. Usata dai backend s3 e gcs; vedi Storage backends. |
Ambiente / configurazione
La configurazione viene caricata dal loader stratificato standard di casa: il config.json
integrato, poi /sandbox/config.json deep-merged sopra, quindi le override di ambiente
KEY__sub (doppio underscore per l'annidamento; le chiavi corrispondono alla configurazione
camelCase).
Ogni parametro di configurazione — la sua chiave camelCase, l'override di ambiente
KEY__sub, il suo default e cosa fa — è tabulato nella Configuration reference. I parametri più regolati sono i budget di cache (cache.*), le protezioni delle query (query.*), i limiti di connessione (server.*), il backend di storage (dataBackend.*) e il livello scrivibile (delta.*).
La memoria residente tiene traccia di
blockCacheBytes + vectorCacheBytes + resultCacheBytes entro un overhead delimitato per
voce e dell'allocatore — ogni pool pesa i propri contenuti (stringhe e contenitori in base
alla capacità allocata) ed espelle per restare sotto budget, ma la contabilità per voce e
l'arrotondamento per classi di dimensione dell'allocatore si sommano al numero impostato —
più un piccolo overhead fisso (e fino a degreeColumnBytes per la colonna dei gradi lazy,
una volta che viene esercitato il percorso rapido count(endpoint) della somma dei gradi).
È indipendente dalla dimensione del grafo — questa è la garanzia principale, esercitata
dal test di integrazione rss_stays_bounded_under_sustained_knn_load, che mantiene la
crescita della RSS tra picco e warm ben all'interno dei budget sommati. I buffer per
connessione vivono fuori dai budget di cache, quindi la garanzia regge sotto carico
avversario solo perché server.maxConnections limita quanti possono esistere
contemporaneamente.
Postura di rete
Slater è un handle di read replica; il controllo di sicurezza principale delle
connessioni è la rete, non il binario. Collegalo a un'interfaccia privata, limita gli
intervalli di origine al livello di rete (security group / NetworkPolicy) e — se si trova di
fronte a qualcosa di diverso da client fidati — mettilo dietro un proxy L4 che limita le
connessioni (HAProxy maxconn + una stick-table per origine, oppure nftables connlimit +
hashlimit). Questo si colloca prima che il descrittore di file venga mai passato al
processo, quindi è il limite più robusto.
I limiti in-binario sopra elencati (maxConnections, maxPreAuthConnections,
maxConnectionsPerIp, i tetti differenziali di byte e loginTimeoutMs) sono difesa in
profondità: sono attivi per impostazione predefinita e generosi, così da essere invisibili
a una popolazione di client legittimi, ma fanno sì che la garanzia di RSS limitata regga
anche quando il proxy viene dimenticato. Vedi docs/HARDENING.md per
la postura difensiva completa e THREAT_MODEL.md / SECURITY_WORKLIST.md per il dettaglio
canonico.
Protezione della generazione
Slater interroga (poll) il puntatore current di ogni grafo ogni generationPollMs
(poll, non inotify — la data dir può trovarsi su storage remoto/di rete come NFS, dove gli
eventi di modifica del filesystem sono inaffidabili). Quando cambia:
reloadStrategy=exit(predefinita): il server registra un errore fatale ed esce con codice non zero così l'orchestratore lo riavvia in modo pulito sulla nuova generazione.reloadStrategy=swap: il server apre e valida la nuova generazione (stessa protezione tramite hash del contenuto dell'avvio), la sostituisce atomicamente e lascia che le query in corso finiscano sulla vecchia. Un'immagine nuova corrotta/incompleta viene rifiutata e la vecchia generazione continua a servire.
ACL
acl.json mappa gli utenti a hash di password argon2id e concessioni read / write
per grafo. Genera un hash (non memorizzare mai testo in chiaro) con:```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
Un `acl.json` iniziale è incluso nella radice del repository; la sua struttura è:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
-
users— una voce per login, indicizzata per nome utente. -
passwordArgon2id— la stringa$argon2id$…daslater hash-password(mai in chiaro; il file stesso è JSON semplice e risiede su storage condiviso). -
grants— elenchi di capacità per grafo. Due permessi sono significativi:read— interroga il grafo. Un grafo assente dai grants di un utente è invisibile per lui.write— modifica il grafo attraverso il livello scrivibile (delta.enabled): le istruzioniMERGE/SET/DELETEeCALL slater.consolidate().
Sono indipendenti: un grant
readnon conferisce alcun accesso in scrittura. Abilitare il livello scrivibile quindi non può promuovere i tuoi lettori esistenti a scrittori. Uno scrittore necessita di entrambi —["read", "write"]— perché risolvere una business key per scriverla è una lettura. Le stringhe di permesso non riconosciute vengono ignorate (non concedono nulla).
Montalo in sola lettura al percorso indicato da aclPath (default /config/acl.json).
Il server lo ricarica a ogni hot-swap di generazione, e il timbro ACL a riposo viene
riverificato a ogni ricaricamento (vedi requireAclStamp).
Health check
Il binario slater funge anche da sonda di liveness: slater healthcheck [host] [port] esegue un handshake Bolt (non una richiesta HTTP) verso il server e
restituisce 0 se negozia una versione del protocollo, altrimenti 1 — usando come default
localhost e la porta Bolt configurata. Questo è ciò che il container
HEALTHCHECK esegue, così gli orchestrator vedono un server realmente Bolt-ready, non solo un
socket aperto:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## Query monouso
Per scripting, controlli CI e ricerche rapide, `slater query` monta la
generazione corrente di un grafo, esegue una singola query Cypher in sola
lettura in-process, stampa il risultato come oggetto JSON ed esce — niente
server, nessuna connessione Bolt. Rispetta la stessa configurazione del server (backend di archiviazione, chiave di cifratura, budget delle query):```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"]]}
Nodi e relazioni si espandono nelle loro etichette/tipi e proprietà. Usa -q quando vuoi un output machine-parseable (l'unica cosa su stdout è il JSON risultante); omettilo per un'esecuzione rivolta all'operatore con log. Senza -q viene registrato un riepilogo di sole metriche dopo ogni esecuzione — ad es.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
carrying the query `cost` (elements charged), `resultCount`, `execMs`, and
`limitRowCount` (only when the query specifies a `LIMIT`) — never the query text
or any result value. Exit status is `0` on success, `1` on a parse/open/execute
error (message on stderr).
## Export a graph (`slater dump`)
`slater dump` exports a graph from a **running** server as business-key `MERGE`
Cypher — the same dialect `slater-build` ingests — so a graph round-trips
(dump → `slater-build` → new generation) for migration or text backup. Unlike
`slater query`, it connects over **Bolt**, authenticates, and honours per-graph
ACLs, so it needs no disk access to the server. The password is read from
`SLATER_DUMP_PASSWORD` or stdin (never a flag, keeping it out of `ps`/history).```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 chiave di identità di ogni etichetta è la proprietà indicizzata dal suo range index; è possibile sovrascriverla
con --key Label=prop (ripetibile) o con un --pk <field> globale. Il DDL CREATE INDEX
viene emesso per primo, così la ricostruzione ricrea gli indici. Un nodo con più etichette
conserva ogni etichetta — viene emesso come MERGE (n:Ident:Other {key: v}), con
l'etichetta di identità (quella che fornisce la business key) per prima e le restanti
ordinate; il MERGE usa come chiave unicamente l'etichetta di identità, quindi le etichette successive
vengono scritte sul nodo senza crearne un altro. Le etichette, i tipi di relazione
e le chiavi di proprietà che contengono caratteri speciali vengono racchiusi tra backtick in fase di
emissione, così i nomi insoliti fanno il round-trip fedelmente e non possono iniettare Cypher nella
ricostruzione. I vettori (e altri
valori senza una forma letterale Cypher) non possono essere inclusi in un dump MERGE e vengono scartati
con un avviso su stderr. Lo stato di uscita è 0 in caso di successo, 1 in caso di errore.
Esempio pratico
Una procedura completa ed eseguibile — creare un grafo, servirlo, connettersi con i driver neo4j JavaScript e Python e scrivere su di esso — si trova nelle pagine Avvio rapido e Scrittura dei dati del manuale, usando il grafo di esempio incluso in docs/manual/examples/.
Sviluppo```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
### I backend object-store sono funzionalità opt-in di cargo
Un semplice `cargo build` produce un binario **solo filesystem** — i backend `s3` e `gcs`
sono accessibili solo tramite le funzionalità opt-in di cargo, così la build predefinita resta leggera (niente SDK AWS
o Google, niente runtime asincrono). Abilita quello che ti serve su **entrambi** `slater`
(serve) e `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
Ogni crate espone le corrispondenti feature s3 / gcs che inoltrano a
graph-format/{s3,gcs}. Richiedere un backend a runtime
(dataBackend.kind=s3|gcs, o slater-build --publish-{s3,gcs}-*) senza che la relativa
feature sia compilata fallisce rapidamente con un errore chiaro "built without the … feature".
L'immagine Docker pubblicata abilita entrambe le feature (Dockerfile CARGO_FEATURES), quindi
le immagini precompilate non richiedono flag aggiuntivi — questo conta solo quando si compila dai sorgenti.
Anche i test di integrazione sono condizionati allo stesso modo: --features s3 --test s3_minio,
--features gcs --test gcs_emulator (un fake-gcs-server) e --features gcs --test gcs_real (GCS reale via ADC); ciascuno viene saltato a meno che le sue variabili d'ambiente SLATER_* siano
impostate.
Vedi docs/PLAN.md, docs/PROGRESS.md e docs/DECISIONS.md per il progetto,
il registro delle milestone e il log delle decisioni.
Performance
Fino a sei motori, una suite a client singolo, grafi da un dataset dimostrativo da 62k nodi a Wikidata
91.6M nodi / 1.5B archi. Ogni motore viene misurato in isolamento (ogni altro container
fermo — RSS e latenza sono il suo footprint). Le tabelle di latenza qui sotto sono state
rimisurate su Slater 0.21.0 (la build scrivibile): i grafi piccoli/medi (MeSH, EU-AI-Act)
di recente, e il grafo da 91.6M come una nuova passata stessa-macchina, anchor condiviso slater-vs-Neo4j (vedi
quella tabella). I valori di memoria residente vengono riportati dalla passata precedente (misurati tramite
cgroup del container; il percorso di lettura è byte-identico con il layer scrivibile a riposo). Gli
altri motori hanno i numeri della consolidata esecuzione cross-engine (versioni/prestazioni invariate).
Tutti i valori sono mediane (ms) o picco di memoria residente (MiB). Ovunque più basso è meglio; grassetto =
migliore nella riga. slater è stato eseguito sul suo backend filesystem locale (fs); i backend S3 e GCS scambiano la latenza di lettura locale
con i round-trip verso l'object store (mitigati dalle cache in memoria e dal livello opzionale di cache su disco
locale), quindi questi valori caratterizzano il motore, non un deployment con storage di rete.
| motore | classe | limite di memoria |
|---|---|---|
| slater | su disco, paginato | query.maxIntermediate limita automaticamente il working set |
| Neo4j 5 | su disco, JVM | ~2 GiB heap + off-heap, impegnati indipendentemente dalla query |
| Memgraph · FalkorDB | in memoria | intero grafo residente in RAM |
| ArcadeDB | in memoria, JVM | intero grafo residente; il più pesante |
| LadybugDB | embedded, colonnare | buffer pool manuale che deve superare la query |
I tre motori che paginano dal disco — slater, Neo4j 5 e LadybugDB — caricano tutti e cinque i grafi. Il trio in memoria (Memgraph · FalkorDB · ArcadeDB) non riesce proprio a contenere il grafo da 1.5B archi (servono ~64–128 GiB residenti), e l'importatore di ArcadeDB non riesce a completarlo.
Memoria residente (MiB) — limitata mentre il grafo cresce di ~1,500×
Ogni valore è memoria di lavoro impegnata — ciò che l'OS non può recuperare. Ogni motore eccetto slater mantiene il proprio grafo in memoria anonima impegnata (heap propria, page cache off-heap di Neo4j o un buffer pool), quindi il suo RSS di picco è il suo footprint impegnato. Solo slater serve dal page cache di OS recuperabile del suo store su disco, quindi il suo valore è il working set anonimo; il page cache dello store (espellibile sotto pressione — slater continua a servire) è escluso e mostrato come totale tra parentesi per il grafo da 91.6M. Grassetto = valore più basso.
| grafo (nodi / archi) | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| pole — 62k / 106k | 11 | 746 | 114 | 140 | 1,556 | 198 |
| MeSH — 341k / 469k | 63 | 1,083 | 358 | 455 | 1,631 | 121 |
| EU-AI-Act — 21k / 45k (+55 MiB vec) | 99 | 729 | 229 | 312 | 1,948 | 286 |
| Wikidata — 91.6M / 1.5B | 584 (4,595 totale) | ~2,900 | non caricabile | non caricabile | non caricabile | ~652 † |
slater è il più basso a ogni scala e cresce di ~50× mentre il grafo cresce di ~1,500× — il suo
footprint segue il query working set, non il grafo (idle ~16–71 MiB per tutto il tempo). Il
trio in memoria cresce ~linearmente e non può caricare il grafo da 1.5B; Neo4j impegna un heap di ~2 GiB
indipendentemente dalla query. († LadybugDB solo sui pattern limitati — le sue traversal hub / var-length /
shortestPath su 1.5B archi richiedono che il read pool sia alzato a ≥2 GiB, contro il cap automatico
maxIntermediate di slater.) Gli istogrammi valore→conteggio generati in build aggiungono memoria residente trascurabile —
pochi KB per una colonna indicizzata a bassa cardinalità e zero per grafi a chiave unica come Wikidata
(wikidata_id supera il limite di cardinalità dell'istogramma, quindi non ne viene memorizzato nessuno) — quindi questi valori sono
invariati da quella feature.
Latenza (ms mediana) — il grafo sta in RAM (MeSH, 341k / 469k)
| tipo | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| count(*) di tutti i nodi | 0.41 | 15.0 | 23.8 | 16.4 | 82.0 | 2.2 |
| conteggio etichette | 0.42 | 4.2 | 20.7 | 1.1 | 4.4 | 4.3 |
| point lookup indicizzato | 0.43 | 3.9 | 0.48 | 0.48 | 0.65 | 8.8 |
| conteggio idx-eq | 0.42 | 4.9 | 5.0 | 2.0 | 381 | 2.5 |
| 1-hop (anchor indicizzato) | 1.28 | 5.8 | 1.21 | 4.1 | 390 | 4.9 |
| 2-hop (senza anchor) | 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 |
full-scan CONTAINS | 0.43 | 5.4 | 24.1 | 1.7 | 16.3 | 4.1 |
slater domina i tipi metadata / indice / scan (count, label, idx-eq, scan — ~0.4 ms, 10–200× rispetto ai motori server), il point lookup indicizzato (0.43 ms, ora davanti alla coppia in memoria con 0.48 ms), il multi-hop senza anchor (2-hop 1.40 ms tramite lo scan per tipo di relazione, il più veloce in campo), e — tramite un istogramma valore→conteggio in fase di build sulla chiave di raggruppamento indicizzata — il group-by / count(DISTINCT) su tutta l'etichetta (0.45 ms, davanti ai 5.3 ms colonnari di LadybugDB). I server in memoria tengono solo il 1-hop puro (Memgraph 1.21 ms contro gli 1.28 ms di slater). (pole 62k/106k si presenta allo stesso modo: slater unico più veloce su count/scan ~0.4 ms, ~1.3–2.6 ms sugli hop.)
Latenza (ms mediana) — vettori (EU-AI-Act kNN, 15k × 1024-dim)
| tipo | 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 risponde alla kNN con uno scan esatto brute-force (questi set sono sotto la sua soglia ANN di 50k vettori) dove gli altri usano un HNSW residente approssimato — quindi i risultati di slater sono esatti (recall 1.0). Un kernel di distanza SIMD + una matrice vettoriale residente pre-normalizzata hanno portato Concept da ~23 → ~2.9 ms e Chunk da ~10 → ~2.4 ms, quindi slater ora supera Neo4j e LadybugDB e si attesta entro ~1.4× da Memgraph, dietro solo a FalkorDB — restando esatto.
Scala di scrittura vettoriale — insert / update / delete senza rebuild
Le tabelle sopra sono confronti di lettura cross-engine. Il percorso di scrittura vettoriale (la
scala di scrittura in stile FreshDiskANN sopra la base Vamana statica)
non ha controparte cross-engine — nessun altro motore qui fa ANN scrivibile nativo su disco —
quindi i numeri seguenti sono benchmark di componente a motore singolo su un fixture sintetico
simile a embedding (una varietà a basso rango, dim 768, norme disuguali), committato in
crates/slater/benches/ e documentato per intero — con la
metodologia e tutte le avvertenze — in docs/PERF-REPORT.md. La recall è
sempre misurata contro un brute force esatto sul set vivo, mai un indice contro
un altro. La scala qui è rappresentativa ed estrapolata solo dove la metrica è lineare nella dimensione.
| proprietà | misurato | perché è importante |
|---|---|---|
| Latenza KNN vs scritture in attesa | RW-index ~1.5–2 ms, piatto fino a 50k pendenti; l'overlay brute-force pre-indice 1.9 → 115 ms (lineare nel delta) — 61× a 50k | la latenza delle query non degrada mentre le scritture si accumulano tra le consolidazioni |
| Inserimento embedding | ~1.5–2 ms per vettore nell'indice live | una scrittura è subito visibile alla KNN; il budget di delta-rebuild è ≈ 2 ms × il limite del delta |
| IO di delete a parità di recall | 2.9× in meno di fetch di nodi per query con 67 % di cancellati, 5.2× all'80 % (recall ≥ 0.90) | un grafo consolidato non paga alcuna tassa di lettura per i vettori cancellati |
| Consolidamento, pura permutazione | O(1) — il .vamana è un hard link byte-identico, viene riscritta solo la colonna degli id | incorporare le scritture vettoriali nella base salta la rebuild O(N·R·L) |
| Recall lungo la scala di scrittura | consolidato ≥ base per cosine, L2 e dot | la scala di scrittura preserva la recall a ogni piolo |
L'unico dato che richiede un box dedicato alle prestazioni è la throughput di riscrittura del consolidamento in slow-path — quando un consolidamento trasporta eliminazioni o nuovi vettori invece di una pura permutazione, si tratta di una ricompressione sequenziale limitata dalla zstd single-thread e dal disco locale, quindi i MiB/s assoluti dipendono dall'ambiente (il report mostra l'andamento e spiega l' intervallo ambientale).
Latenza (ms mediana) — grafo ≫ RAM (Wikidata 91.6M / 1.5B)
I motori in memoria (Memgraph / FalkorDB / ArcadeDB) non riescono proprio a caricare questo grafo
(~64–128 GiB residenti). Solo slater e Neo4j 5 ci riescono. Questa è una nuova passata stessa-macchina, stesso-giorno
contro un set di anchor fisso e condiviso — ogni query colpisce i nodi identici su entrambi i motori,
quindi il confronto diretto è alla pari (un pool comune di wikidata_id come anchor di grado moderato;
vedi la nota sotto sul perché conta). slater è mostrato con entrambi i fanout (query.maxFanout 1 =
default di throughput, 8 = la manopola della latenza che sovrappone le letture di blocchi freddi). Grassetto = migliore nella riga.
| tipo | slater (fan 1) | slater (fan 8) | Neo4j 5 |
|---|---|---|---|
| count(*) di tutti i nodi | 0.41 | 0.41 | 3606 |
| point lookup (indicizzato) | 0.72 | 0.49 | 6.3 |
| grado (conteggio 1-hop) | 0.43 | 0.44 | 6.0 |
| vicini 1-hop | 9.8 | 4.5 | 10.1 |
| 2-hop | 37 | 23 | 34.5 |
| 3-hop | 32 | 25 | 74 |
var-length *1..2 distinct | 985 | 1056 | 47 |
Il quadro onesto: slater domina i tipi metadata / indice — count(*) è
servito dai metadati (0.41 ms contro lo scan su disco di 3.6 s di Neo4j, ~8800×), e point-lookup / grado /
3-hop girano ~2–10× più veloci — è alla pari con Neo4j su 1–2-hop (il fanout 8 si porta avanti sulle letture fredde),
ma perde nettamente su var-length *1..2 distinct (≈1 s contro i 47 ms di Neo4j): l'
espansione distinct a lunghezza variabile di slater è sensibilmente più lenta qui, una debolezza reale che merita un'indagine
dedicata. Tutto questo con poche centinaia di MB di RSS contro l'heap committed di ~2 GiB di Neo4j.
Sugli anchor. Questi valori di traversal dipendono fortemente da quali nodi si parte — un nodo a un link da un mega-hub di Wikidata ("human", "country") ha un intorno di 2-hop con milioni di elementi, quindi il costo di var-length/hop oscilla di ordini di grandezza con la scelta dell'anchor. La precedente edizione di questa tabella campionava i "primi N per scan" propri di ogni motore, che non è né stabile né confrontabile; questa passata fissa un unico set di anchor condiviso e limitato per grado per entrambi i motori. (shortestPath è omesso da questa passata — tra due anchor arbitrari dipende dall'esistenza del percorso ed è troppo variabile per avere una mediana significativa.)
Multi-hop count(*) — memoria disaccoppiata dalla dimensione del risultato
Il multi-hop senza limite RETURN count(*) conta durante l'espansione invece di materializzare
le righe corrispondenti. Stessi anchor hub sul grafo da 91.6M, maxIntermediate=20M:
| count(*) 3-hop @ 91.6M | fanout=1 | fanout=8 |
|---|---|---|
| latenza / working set di picco | 554 ms / 0.66 GiB | 298 ms / 1.9 GiB |
Il conteggio mantiene O(1) righe. La contabilizzazione è invariata, quindi un conteggio su mega-hub fa ancora scattare
maxIntermediate sul compute (letture di adiacenza), limitato come prima.
Parallelismo per query (maxFanout)
Alzare query.maxFanout sovrappone le letture di blocchi freddi e legati all'I/O di una query su più core —
aiuta i tipi disk-bound con working set freddo grande ed è piatto sui tipi caldi. Sul grafo da 1.5B:
shortestPath ≤6 918 → 608 ms (1.5×, ricerca più grande 6,269 → 2,350 ms, 2.7×);
count 3-hop 547 → 298 ms. maxFanout=1 è il default (orientato alla throughput); 8 è la
manopola della latenza, con più memoria di lavoro transitoria.
Dove slater vince / resta indietro
| dimensione | slater | migliore in campo | verdetto |
|---|---|---|---|
| memoria residente, a qualsiasi scala | 11–584 MiB (62k → 91.6M) | in-memory 1.5–2.7 GiB; non carica 1.5B | slater |
| count / metadata / scan | ~0.4 ms | motori server 5–80 ms | slater |
| point lookup indicizzato | 0.43 ms (MeSH) | Memgraph · FalkorDB 0.48 ms | slater (supera di poco la coppia in memoria) |
| multi-hop senza anchor (righe) | 1.40 ms (MeSH 2-hop) | Neo4j 5.6 ms | slater (scan per tipo di relazione) |
| aggregazione (group-by / DISTINCT) | 0.45 ms | LadybugDB 5 ms (colonnare) | slater (istogramma in fase di build) |
| kNN | 2.4–2.9 ms (esatto) | FalkorDB 1.2 ms (HNSW) | supera Neo4j/Ladybug; entro ~1.4× da Memgraph; esatto |
| 91.6M metadata / point / grado / 3-hop | 0.4–32 ms | Neo4j 6–3,600 ms | slater (2–8800×) |
| 91.6M 1–2-hop | 4.5–23 ms (fan 8) | Neo4j 10–35 ms | ~alla pari |
var-length *1..2 distinct su 91.6M | ~1 s | Neo4j 47 ms | Neo4j (un vero punto debole di slater) |
multi-hop count(*) su larga scala | 0.3–0.6 GiB | i motori in memoria materializzano il set di righe | slater, limitato |
Le tabelle complete per motore (pole, MeSH, EU-AI-Act + la manopola RAM↔latenza blockCacheBytes,
Wikidata 1M e 91.6M) sono in
perf/cross-engine-hs/README.md; la nuova passata solo-slater
(entrambi i fanout, tutti i dataset) è in perf/PERF_CURRENT_STATUS.md.
Concorrenza & brown-out (load testing)
I benchmark sopra sono a client singolo. L'asse complementare — comportamento sotto molti
client concorrenti — ha il proprio harness, perf/loadtest/: un driver
Locust su Bolt più un coordinatore che aumenta il carico, legge CALL slater.diagnostics(),
trova il punto di flesso della capacità e indica il fattore limitante (metodo completo in
docs/LOAD-TESTING.md). I punti salienti di un'esecuzione con cache da 256 MiB sul
grafo Wikidata-1M (una macchina a 16 core):
| risultato | misurazione |
|---|---|
| Regge fino a 1000 client concorrenti, zero errori | throughput di picco ~2.5k rps; il punto di flesso della latenza si manifesta intorno ai 750 client (p99 51 → 750 ms) — accodamento sotto contesa dei core, non un tetto rigido (singola esecuzione, WSL2) |
| Block cache limitata ed efficace | 100% di hit rate, 0 espulsioni, 50 MB residenti per un working set che sta nella cache |
| RSS mantenuto sotto carico sostenuto | l'allocatore jemalloc mantiene la RSS a ~0.6 GB in una rampa da 100→500 client wiki_cache_churn — legato alla cache e stabile, senza alcun tuning MALLOC_* (il precedente MALLOC_ARENA_MAX=2 + soglia di trim è stato ritirato); la sua purga in background fa anche rientrare il picco post-burst invece di lasciarlo ancorato |
| Memoria aggregata limitata | il query.maxIntermediateGlobal a livello di server + l'espansione con addebito di adiacenza tengono l'inondazione 2-hop wiki_budget a 1000 client senza OOM (RSS ~0.6 GB; la guardia scarta ~60% delle query hub come errori di budget ritentabili) |
Entrambi i problemi di memoria emersi dal load test sono ora chiusi; tutti tracciati nel documento di load testing.
Licenza
Concesso in licenza ai sensi della Apache License, Versione 2.0. Vedi LICENSE per il
testo completo e NOTICE per l'attribuzione. Salvo dichiarazione esplicita
contraria, ogni contributo inviato intenzionalmente per essere incluso in quest'opera,
come definito dalla licenza Apache 2.0, sarà concesso in licenza come sopra, senza
termini o condizioni aggiuntivi.
SPDX-License-Identifier: Apache-2.0