
slater v0.24.3
Graphdb de bajo consumo de memoria con soporte Bolt+tls, cifrado en reposo y vectores, diseñado para casos de uso de grafos con réplica local.
Slater
Versión actual: v0.24.4 — todas las versiones.
En una frase: Slater sirve grafos que no caben en memoria — cientos de millones de nodos y miles de millones de aristas en unos pocos cientos de MB de RAM — a través de Bolt estándar, de modo que cualquier driver de neo4j funciona sin cambios, con búsqueda vectorial nativa en disco junto al grafo, y acepta escrituras en vivo y duraderas sin renunciar a ello. La memoria residente la fija un presupuesto de caché que tú eliges, no el tamaño del grafo.
Atajos
Por qué existe Slater
Una base de datos de grafos almacena datos como cosas (nodos) y las relaciones entre ellas (aristas), con las relaciones como ciudadanos de primera clase. Es lo que quieres cuando tus preguntas son sobre conexiones más que sobre filas — «¿quién está a menos de tres saltos de esta cuenta?», «¿cuál es toda la cadena de dependencias detrás de esta compilación?», «¿qué cuentas comparten un dispositivo, una dirección y una tarjeta?» — las consultas que se convierten en un pantano de joins recursivos en SQL pero salen de forma natural en un grafo.
La queja más común sobre las bases de datos de grafos es que no escalan más allá de lo que cabe en RAM. Muchas de ellas (p. ej., neo4j, Memgraph, FalkorDB, etc.) mantienen todo el grafo en memoria: un grafo de 40 GB quiere 40 GB de memoria — por instancia. ¿Quieres una réplica por región, por inquilino o por pod? Multiplica la factura. Y a partir de cierto tamaño simplemente no cargan: p. ej., el grafo de Wikidata de 90 millones de nodos / 1.500 millones de aristas necesita ~64–128 GiB residentes, así que los motores en memoria no pueden abrirlo en absoluto.
Slater es la refutación. En lugar de cargar el grafo en memoria, lo compila una sola vez, sin conexión: slater-build convierte tus datos en una imagen inmutable en disco con direccionamiento por contenido, y cualquier número de servidores Slater sirven esa imagen a través de Bolt (de modo que tus drivers neo4j existentes funcionan sin cambios), paginando bloques bajo demanda y manteniendo solo un presupuesto de caché fijo en memoria. Así es como el mismo grafo de 90 millones de nodos se sirve desde unos pocos cientos de MB de RAM — el tamaño del grafo y la factura de memoria están desacoplados. Un grafo de 4 GB y uno de 400 GB cuestan la misma RAM de servir, de modo que puedes desplegar réplicas de lectura baratas y sin estado y dejar que el almacén, no el montón, sostenga el grafo.
Eso lo convierte en un ajuste natural para grafos de conocimiento detrás de RAG, grafos de recomendación e identidad, grafos de dependencias — cualquier cosa grande y conectada que quieras consultar de forma barata y frecuente. La búsqueda vectorial nativa en disco vive justo al lado del grafo, así que el mismo motor es también la capa de recuperación para los embeddings.
Compilado una vez no significa, sin embargo, que quede congelado. Esa imagen es una base, no un estado final: una capa de escritura opcional se sitúa encima, de modo que un grafo en vivo puede corregirse y ampliarse sin reconstruir nada.
Lecturas y escrituras
El núcleo es inmutable; el grafo no lo es. Activa la capa de escritura (delta.enabled) y escribe a través de Bolt — corrige una propiedad, añade un nodo, retira una arista — y el cambio se registra de forma duradera, sin reconstrucción de la imagen. Lo que lo mantiene barato en el lado de la lectura es dónde viven las escrituras.
Las escrituras se acumulan en una capa de fusión estructurada por registros (LSM) sobre el núcleo inmutable: un registro de escritura anticipada (write-ahead log) y una tabla en memoria, que se vuelcan en segmentos delta inmutables y se reincorporan a un núcleo nuevo mediante una consolidación periódica. Lo que eso te aporta:
- Las lecturas sobre un grafo sin escribir cuestan exactamente lo que costaban antes. Un delta vacío es una única rama predecible, no una fusión — la ruta de lectura es idéntica byte a byte esté o no activada la capa de escritura.
- El coste de lectura de una escritura escala con el tamaño del delta, no con el tamaño del grafo. Las respuestas de todo el grafo —
count(*), las marginales de etiquetas y tipos de relación — siguen siendo lecturas de metadatos incluso con escrituras pendientes: el delta mantiene sus propios contadores, así que uncount(*)sobre un núcleo de 91.6M de nodos con medio millón de escrituras pendientes sigue respondiendo en decenas de milisegundos sin tocar un solo bloque. - Reconocido significa duradero. Un escritor único drena la cola y devuelve
SUCCESSsolo después delfsyncque cubre la escritura. Agrupa tus escrituras y son baratas — unUNWINDde escritura confirma unfsyncpor lote en lugar de por fila. - Escrituras por clave de negocio, en cualquiera de los dos dialectos.
MERGE/MATCH … SET/DELETE(yCREATE/REMOVE, detach delete, escrituras de relaciones) con clave en la propiedad de identidad de un nodo — o las sentencias equivalentes de modificación de datos de ISO GQL (INSERT/SET/REMOVE/DELETE), que bajan al mismo camino. Corrige, inserta, actualiza o retira, sobre nodos y aristas, direccionado como ya están tus datos.
Con la capa desactivada — el valor por defecto — Slater sirve el núcleo inmutable puro y rechaza escrituras. Consulta La capa de escritura para ver el modelo completo.
Sobre el nombre. Slater lleva el nombre del agente de la CIA en Archer (una gran serie) que insiste en usar un solo nombre — «Solo… Slater» — y uno de mis personajes favoritos de la serie. Consulta la página wiki del personaje.
Qué obtienes
- RAM fijada por tu presupuesto de caché, no por el tamaño de tu grafo — despliega tantas réplicas de lectura como quieras; el grafo nunca tiene que caber en memoria.
- Un sustituto directo para el grafo — habla Bolt, así que cualquier driver neo4j estándar (JS, Python, Go…) funciona sin cambios. Es Cypher (más una parte de ISO GQL, lecturas y escrituras); no hay nada nuevo que aprender.
- Escrituras en vivo y duraderas — una capa LSM opcional sobre el núcleo inmutable:
MERGE/SET/DELETEpor clave de negocio sobre nodos y aristas, con commit en grupo y durabilidad porfsync, reincorporadas a un núcleo nuevo mediante consolidación. Las lecturas no lo pagan. - Despliegue por intercambio de archivos — construye una nueva generación con hash de contenido sin conexión, cambia atómicamente el puntero
current, y los servidores la recogen. Cada bloque tiene checksum, de modo que una imagen copiada a medias se rechaza en lugar de servirse. - Búsqueda vectorial integrada — la búsqueda aproximada del vecino más cercano nativa en disco (coseno, L2 o KNN por producto escalar) se sitúa justo al lado de tu grafo, para cuando esto es la capa de recuperación detrás de un pipeline RAG, y los embeddings se pueden escribir en el sitio — sin reconstrucción sin conexión para añadir o cambiar un vector.
- Asegurado por diseño — los permisos de lectura y escritura son independientes, más cifrado opcional en reposo, TLS para Bolt, ACL con hash argon2id y un rootfs de contenedor de solo lectura para las réplicas de lectura. Configura una clave maestra y la imagen en disco queda autenticada además de cifrada — su manifiesto lleva un MAC con clave, de modo que un atacante con acceso de escritura al directorio de datos pero sin la clave no puede forjar un manifiesto que el servidor acepte. Sin clave sigues teniendo el hash de contenido, que detecta una imagen copiada a medias o corrupta — pero no una deliberada. Qué compra cada configuración.
Características
| Característica | Qué significa para ti |
|---|---|
| Memoria acotada y predecible | La memoria residente sigue tres presupuestos de caché que tú fijas, dentro de una sobrecarga acotada por entrada y por asignador — no crece con el tamaño del grafo; ajustas la compensación rendimiento/RAM en lugar de aprovisionar para todo el grafo. Un asignador jemalloc con purga en segundo plano devuelve la memoria liberada al sistema operativo después de ráfagas de consultas intensas, de modo que el tamaño residente vuelve a caer hacia su suelo de inactividad en lugar de quedarse fijado en el máximo posterior a la ráfaga. |
| Multiinquilino listo para usar | Un solo servidor aloja muchos grafos con permisos de lectura por usuario — aislamiento multibase de datos que la mayoría de las bases de datos de grafos reservan para un nivel de pago/empresa. |
| Cifrado en reposo y en tránsito | Sellado por bloque con XChaCha20-Poly1305 (la clave nunca se escribe en disco) más TLS opcional (bolt+s://). Respetuoso con el RGPD por construcción. El cifrado es también lo que compra la integridad autenticada: el constructor sella el manifiesto con un MAC con clave, y un servidor que tiene la clave lo verifica y se niega a servir una generación cuyo manifiesto haya sido falsificado, alterado o despojado de su MAC. Una imagen sin clave (en claro) está protegida solo por el hash de contenido sin clave — completitud y corrupción, no manipulación. Consulta Qué significa la integridad en cada configuración. |
| Instalación minúscula | Un pequeño binario sin símbolos sobre una base distroless glibc (sin shell/apt) — la imagen multiarquitectura (amd64/arm64) se descarga en ~22 MB, o ~12 MB para la etiqueta solo servidor slater:latest-lite; TLS en Rust puro, sin OpenSSL. Descarga y ejecuta. |
| Pensado para publicación periódica | Construye un grafo sin conexión, sírvelo inmutable y luego intercambia atómicamente una nueva versión con cero tiempo de inactividad — ideal para cargas de trabajo de data warehouse / actualización programada. |
| Robusto bajo carga | El servidor y el constructor sin conexión compilan ambos con #![forbid(unsafe_code)] — el único unsafe del motor vive en el crate auditado del asignador jemalloc. El núcleo es inmutable, así que las lecturas no toman bloqueos y nunca esperan a un escritor; un único escritor serializa las mutaciones únicamente a través de la ruta de escritura. Sin pausas de GC, sin carreras de datos. Una consulta mala no puede tumbar el servidor. |
| Funciona con tus herramientas neo4j | Habla Bolt 5.4 / 4.4 / 4.1 — usa los drivers neo4j estándar (JS, Python, Go, Java…), cypher-shell o los navegadores de grafos sin cambios. |
| Amplia superficie de consultas Cypher | Una amplia superficie de lectura: subconsultas MATCH/WHERE/WITH/UNION, CALL {…}, más de 70 funciones y agregaciones, valores temporales y geoespaciales, y regex. |
| Escrituras en vivo y duraderas | Una capa LSM opcional de escritor único sobre el núcleo inmutable (delta.enabled): MERGE / SET / DELETE / CREATE / REMOVE por clave de negocio sobre nodos y relaciones, UNWIND de escritura por lotes (un fsync por lote) y CALL slater.consolidate() — con commit en grupo, durabilidad por fsync, y reincorporadas a un núcleo nuevo mediante consolidación. La ruta de lectura es idéntica byte a byte cuando el delta está vacío. |
| ISO GQL, lectura y escritura | Habla un subconjunto de ISO GQL (ISO/IEC 39075) sobre la misma conexión Bolt — caminos cuantificados, restrictores de camino, selectores de camino más corto, expresiones booleanas de etiqueta/tipo, FOR, CAST, un prefijo de dialecto opcional GQL/CYPHER — y, con la capa de escritura activada, las sentencias de modificación de datos de GQL (INSERT / SET / REMOVE / [DETACH] DELETE) bajan a la misma ruta de escritura duradera. Cypher y GQL, lecturas y escrituras, en un solo motor. |
| Vectores + grafo en un solo motor | Búsqueda vectorial ANN nativa en disco (Vamana + PQ; coseno / L2 / producto escalar) para embeddings/RAG, más algoritmos de grafos (PageRank, BFS, betweenness, WCC…) — memoria acotada incluso con millones de vectores. Los embeddings son escribibles (una escalera de escritura estilo FreshDiskANN): inserta / actualiza / elimina un vector, visible para KNN al instante, reincorporado a la base sin reconstrucción. |
| Seguro en almacenamiento de red | Cada archivo tiene hash de contenido BLAKE3 y se verifica al abrirlo; las imágenes truncadas o copiadas a medias se rechazan, no se sirven. Diseñado para volúmenes NFS/remotos (sin sorpresas de mmap). |
| Backends de almacenamiento conectables | Sirve el mismo formato de generación desde un sistema de archivos local, un bucket S3 (compatible con S3) o un bucket de Google Cloud Storage — publica una vez, despliega a réplicas sin estado — con una capa de caché opcional en SSD local delante del almacén de objetos. Consulta Backends de almacenamiento. |
Dos binarios componen el espacio de trabajo:
| Binario | Rol |
|---|---|
slater | El servidor Bolt en línea (el ENTRYPOINT del contenedor): sirve lecturas y, con delta.enabled, la ruta de escritura duradera de escritor único. |
slater-build | El compilador sin conexión: convierte un volcado de Cypher primitivo en un directorio de generación inmutable con hash de contenido. |
Slater separa la construcción masiva del servicio: slater-build hace el trabajo pesado sin conexión — ingiriendo tus datos y compilándolos en una generación inmutable — de modo que un grafo en frío nunca se ensambla en la ruta crítica del servicio. Dentro del servidor, la superficie de lectura responde a una amplia porción de Cypher — coincidencia de patrones, subconsultas WITH/UNION/CALL {…}, más de 70 funciones escalares y de agregación, valores temporales y geoespaciales, algoritmos de grafos (algo.*) y KNN vectorial nativo en disco (db.idx.vector.queryNodes) — mientras que la superposición delta de la capa de escritura se sitúa por debajo de esa superficie y tiene coste cero cuando está vacía, de modo que las lecturas nunca cargan con la maquinaria del lado de escritura. Puedes actualizar un grafo de dos maneras: escribir en él en vivo a través de Bolt (consulta La capa de escritura), o construir una nueva generación sin conexión e intercambiar atómicamente el puntero current, que el servidor en ejecución detecta mediante su guardia de generación (consulta Guardia de generación).
Documentación
El manual de usuario completo se encuentra en docs/manual/ — una guía característica por característica que explica, para cada capacidad, qué es, por qué existe y cómo usarla, con ejemplos prácticos que puedes ejecutar contra un grafo de muestra incluido. Empieza ahí para cualquier cosa más allá de esta visión general.
- ¿Nuevo aquí? Inicio rápido construye y sirve un grafo en cinco pasos.
- ¿Escribiendo consultas? Consultas, Funciones y expresiones, Procedimientos y algoritmos, Búsqueda vectorial, Escritura de datos.
- ¿Construyendo grafos? Construcción de grafos y la Referencia de la CLI de build.
- ¿Operando Slater? Despliegue, Almacenamiento, Referencia de configuración, Seguridad, Ajuste de rendimiento.
Ejecución con Docker
Slater está diseñado para ejecutarse como un despliegue Docker — esa es la forma esperada de usarlo. Las imágenes multiarquitectura precompiladas (linux/amd64 + linux/arm64) se publican en Docker Hub en hikarisystems/slater, con las etiquetas :latest y :vX.Y.Z en cada versión:```sh
docker pull hikarisystems/slater:latest
Una guía de uso, configuración y operaciones solo con comandos de Docker se encuentra en
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) (y se refleja en la página de descripción general de Docker Hub) —
**empieza ahí si vas a desplegar.** En resumen:```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
Para construir la imagen localmente en su lugar (por ejemplo, para desarrollo):```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
La etapa de construcción instala `cmake`, `clang` y `libclang-dev` para el backend
`aws-lc-rs` de rustls; `git` (ya presente en la imagen base) es necesario para la
dependencia git+tag de `hs-utils`, que `.cargo/config.toml` obtiene mediante la CLI de git.
Las secciones siguientes cubren el formato en disco, la configuración, las ACLs y un
ejemplo práctico local (no Docker).
## Cómo funciona```
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 generación es un directorio inmutable: un
MANIFEST.json(tablas de símbolos, descriptores de índice, una cabecera de cifrado opcional), archivos de bloques columnares (node_props.blk,node_labels.blk,edge_props.blk,topology.csr.blk,vectors.f32.blk), índices de rango (range/<name>.isam), índices ANN por encima del umbral (vector/<label>.<prop>.{vamana,pq}) y un puntero de textocurrent. - Cada bloque está comprimido con zstd y tiene checksum BLAKE3; con
--encrypt, cada bloque se sella además con XChaCha20-Poly1305 (AEAD en reposo). - El servidor abre una generación recalculando el hash de cada archivo contra el manifiesto, de modo que una imagen a medio copiar / truncada — una copia incompleta en el directorio de datos, que puede ser almacenamiento remoto/de red — se rechaza en lugar de servirse.
- Las lecturas fluyen a través de tres grupos de caché acotados — un LRU de bloques descomprimidos, un grupo de índices vectoriales (códigos PQ residentes + un LRU de bloques Vamana) y un LRU de resultados — cada uno con su propio presupuesto de bytes. Cada grupo pesa lo que contiene y expulsa para mantenerse por debajo de su presupuesto, de modo que la RSS sigue los presupuestos dentro de sobrecarga acotada por entrada y del asignador, en lugar de crecer con el grafo.
La capa de escritura
Con delta.enabled, la generación inmutable se convierte en el nivel inferior totalmente compactado
(el "núcleo") de un pequeño árbol de fusión por registros estructurados (LSM), y las escrituras en vivo se montan
encima de él:```
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)
└─────────────┘
* **Suelo de durabilidad — el WAL.** Cada mutación se serializa tras un único
escritor por grafo, se añade a un registro de escritura anticipada por grafo y se hace `fsync` antes de que se devuelva el `SUCCESS` de Bolt — por lo que *reconocido ⇒ duradero*, y una cola rasgada se descarta en la reproducción. Un `UNWIND` de escritura por lotes añade sus filas y confirma **un** `fsync` para todo el lote. El WAL es **solo disco local** (no se enruta a través del backend de almacenamiento), lo que hace que un nodo *escritor* tenga estado: necesita un volumen local duradero en `delta.walDir`. Las réplicas de lectura permanecen sin estado.
* **Memtable → L0 → consolidación.** Las escrituras se acumulan en una memtable en RAM (limitada por `delta.memtableBytes`); cuando se llena, se vacía a un segmento delta L0 inmutable. Una **consolidación** pliega `{core + delta}` en un nuevo core serializando la vista combinada de nuevo a través de `slater-build` e intercambiando `current` atómicamente — la misma protección de hash de contenido que cualquier generación publicada. Actívala manualmente con `CALL slater.consolidate()`, automáticamente al `delta.deltaCorePercent` del tamaño del core (opcionalmente restringido a una ventana de consolidación fuera de pico `delta.consolidateWindow`), o deja que el `delta.deltaHardBytes` limite el crecimiento desbocado.
* **La superposición se sitúa bajo la superficie de lectura.** El ejecutor lee a través de una `ReadView` que es o bien el core desnudo (delta siempre vacío) o una vista combinada `(core, delta)`; el motor está monomorfizado sobre ella, de modo que un delta vacío compila a una única rama predecible y la ruta de solo lectura es byte-idéntica. Los contadores de todo el grafo (`count(*)`, marginales de etiqueta/tipo de relación) se sirven desde los contadores vivos propios del delta, por lo que siguen siendo lecturas de metadatos incluso con escrituras pendientes.
* **Una consulta ve una instantánea estable.** Fija una tupla `(core, delta)` durante toda su vida. No hay transacciones de múltiples sentencias ni rollback — una escritura es una corrección duradera dirigida por clave de negocio, no una transacción OLTP.
La gramática exacta de escritura y los parámetros están en la tabla de [Configuración](#environment--configuration) (`delta.*`) y en el [Ejemplo práctico](#worked-example) a continuación.
### Índices de rango (ISAM)
Un índice de rango (`range/<name>.isam`, uno por `(label, property)` indexado) permite que un `MATCH (n:Label {prop: v})` o `WHERE n.prop <op> v` resuelva los ids de nodo coincidentes **sin escanear la etiqueta**. Es una estructura **[ISAM](https://en.wikipedia.org/wiki/ISAM)** (Indexed Sequential Access Method, método de acceso secuencial indexado) — el clásico índice *estático, ordenado y estructurado en bloques*, que es exactamente la forma correcta para una generación inmutable: no hay inserciones que reequilibrar, por lo que la simplicidad de ISAM proporciona lo que la maquinaria de mutación de un B-tree solo complicaría.
* Las entradas `(value, entity_id)` se ordenan por valor y se empaquetan en los mismos bloques de 256 KiB comprimidos con zstd que todo lo demás.
* Un pequeño **nivel superior residente** contiene la primera clave de cada bloque (un índice disperso). Una búsqueda hace una búsqueda binaria en ese nivel superior en memoria para encontrar el *único* bloque en el que puede estar una clave, lee + descomprime ese bloque y lo escanea — por lo que una búsqueda por igualdad es **una lectura de bloque**, y un escaneo de rango recorre la serie contigua de bloques que abarca. (Por eso una búsqueda indexada por `meshUi` tarda milisegundos de un solo dígito mientras que la misma coincidencia sobre una propiedad no indexada escanea toda la etiqueta.)
* El planificador la elige mediante `NodeScan::RangeEq` / `RangeRange`; un predicado no indexado cae en un barrido de etiqueta o un escaneo completo, con el ejecutor re-verificando cada predicado en cualquier caso.
### Búsqueda vectorial (Vamana + PQ) — coseno, L2 y producto punto, lectura *y* escritura
El KNN vectorial (`db.idx.vector.queryNodes`) se ejecuta sobre índices **de coseno, L2 o producto punto (MIPS)**. El índice base se construye fuera de línea con dos rutas de ejecución, elegidas por índice mediante `--ann-threshold` (por defecto 50 000 vectores):
* **Por debajo del umbral — fuerza bruta.** Los vectores `f32` completos viven en `vectors.f32.blk`; una consulta escanea el grupo del índice y calcula la distancia exacta en la métrica del índice. Simple y exacto; adecuado cuando el conjunto de vectores es pequeño.
* **En o por encima del umbral — Vamana + PQ**, la ruta ANN nativa de disco que mantiene acotada la memoria residente sin importar cuántos vectores haya:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** es el índice de grafo de la línea de trabajo DiskANN: un único grafo de proximidad cuyas aristas se podan (el grado de salida `--vamana-r` y el factor de arista larga `--vamana-alpha`) de modo que una *búsqueda de haz codiciosa* — comenzar en el medoide, saltar repetidamente hacia la consulta, manteniendo una lista de candidatos de ancho `vectorQuery.beamWidth` — alcanza los verdaderos vecinos de un nodo en pocos saltos, es decir, **pocas lecturas de bloque aleatorias por consulta**. Los bloques del grafo (`vector/<label>.<prop>.vamana`) se paginan mediante la caché de vectores, no se mantienen completos.
* **[Cuantificación de producto (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)** comprime cada vector en un código corto (`--pq-subspaces` × `--pq-bits`): las dimensiones se dividen en subespacios, cada uno agrupado independientemente con k-means, y el vector se almacena como la tupla de ids de centroide más cercano. Estos códigos (`vector/<label>.<prop>.pq`) son lo bastante pequeños para mantenerlos **residentes**, por lo que la búsqueda de haz puntúa a los candidatos desde RAM y solo los pocos vectores completos elegidos se leen del disco. Ese conjunto PQ residente es lo que fija el pool de `cache.vectorCacheBytes`.
**Embeddings escribibles — la escalera de escritura vectorial (estilo [FreshDiskANN](https://arxiv.org/abs/2105.09613)).**
Un embedding indexado es un valor escribible de primera clase. `SET n.embedding = vecf32([…])` (y `REMOVE`) aterriza en el delta de escritura y es **inmediatamente visible para KNN con rango exacto**, luego sobrevive a un vaciado de segmento, una fusión y una consolidación. Una consulta fusiona hasta tres niveles — el índice base sellado, un índice sellado por segmento y un **índice RW** en memoria (un Vamana mutable en vivo sobre el delta de escritura) — por lo que la latencia permanece plana a medida que se acumulan las escrituras en lugar de crecer con el número de escrituras pendientes. Un borrado deja un *hueco*: el nodo deja de devolverse pero permanece como punto de navegación hasta que una **consolidación de borrado** en segundo plano lo saca del grafo, por lo que los borrados dejan de costar IO de consulta. Y debido a que el grafo en disco direcciona a sus vecinos por posición de diseño en lugar de por id de nodo, `CALL slater.consolidate()` transporta el Vamana **por referencia** — enlazado duro, byte-idéntico — y reescribe solo una pequeña columna de ids, plegando las escrituras vectoriales en la base **sin** la reconstrucción del grafo O(N·R·L). Los números medidos, con salvedades, están en el [informe de rendimiento](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).
## Backends de almacenamiento (filesystem / S3 / GCS)
Cada archivo de generación se abre a través de una abstracción **`ObjectStore`** en lugar de `std::fs` directamente, por lo que el *mismo* formato de bytes en disco — bloques, índices, manifiesto, puntero `current` — se sirve sin cambios desde cualquier backend; solo difiere *de dónde vienen los bytes*, nunca los lectores, el motor de consultas ni las comprobaciones de integridad. La ruta crítica son las lecturas posicionales (`read_exact_at`), que se asignan a un `pread` en un archivo local y a una petición HTTP de rango de bytes en un object store — Slater nunca usa mmap, por lo que el modelo explícito de lectura acotada es idéntico en todas partes.
**Tres backends de primera clase**, seleccionados por `dataBackend.kind`. El sistema de archivos es el sencillo predeterminado; **Amazon S3 y Google Cloud Storage son backends de object store iguales y totalmente compatibles** — la imagen publicada incluye ambos compilados, por lo que cada uno es solo configuración, y una generación construida una vez puede servirse desde cualquiera de ellos (incluso migrada `fs` → S3 → GCS) sin reconstrucción.
| `dataBackend.kind` | Lectura posicional | Integridad al abrir | Credenciales |
| --- | --- | --- | --- |
| `fs` *(default)* | `pread` | re-hash BLAKE3 completo de cada archivo | — |
| `s3` | HTTP `Range` GET | **SHA-256** del servidor vía `HEAD` (→ re-hash BLAKE3 del cuerpo si está ausente) | claves de configuración, cadena AWS o rol IAM |
| `gcs` | lectura por rango HTTP | **CRC32C** del servidor vía `get_object` (→ re-hash BLAKE3 del cuerpo si está ausente) | ADC / Workload Identity, o JSON de cuenta de servicio |
Ambos object stores verifican la integridad desde el **checksum que el store ya calcula y conserva**, obtenido como metadatos del objeto: `slater-build` envía el checksum en la subida (el store valida los bytes contra él y lo almacena), y el servidor lo lee al abrir y lo compara con el manifiesto — una petición de metadatos por archivo, sin descargar el cuerpo. Es de grado de contenido e idéntico en espíritu entre S3 (SHA-256) y GCS (CRC32C). Cuando un objeto no lleva **ningún** checksum almacenado por el servidor (copiado fuera de banda, o subido con un valor predeterminado diferente), el servidor **re-hashea el cuerpo del objeto contra el BLAKE3 del manifiesto** en lugar de confiar en su longitud de bytes — una comprobación de integridad solicitada nunca se degrada silenciosamente a una comparación de tamaño. Las generaciones publicadas por Slater siempre llevan el checksum, por lo que permanecen en la ruta de metadatos barata.
Lo que esta columna comprueba, en cada backend, es que los archivos **coinciden con el manifiesto**. Si el propio manifiesto puede ser confiable es una cuestión aparte, y es la clave maestra la que la responde: con una clave configurada, el manifiesto lleva un MAC con clave que el servidor verifica antes de confiar en cualquier campo (incluidos estos hashes), por lo que un manifiesto reescrito para describir archivos manipulados es rechazado; sin clave, la comparación no tiene clave en todo el proceso, y alguien que pueda escribir en el directorio de datos puede reescribir un archivo y el manifiesto juntos. Véase [Qué significa la integridad en cada configuración](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration). La comprobación en sí puede desactivarse con `dataBackend.verifyIntegrity: false`, lo que la cambia por una apertura más rápida.
### Sistema de archivos (`fs`)
El predeterminado, con raíz en `dataBackend.fs.dir`. La elección correcta para la mayoría de los despliegues: una generación en un SSD local (o un montaje NFS/EBS) servida en solo lectura. La integridad es un re-hash BLAKE3 completo de cada archivo al abrirlo.
### Amazon S3 (`s3`)
Un bucket S3 o compatible con S3 (AWS, MinIO, localstack). Las credenciales provienen **primero** de la configuración (`dataBackend.s3.awsAccessKey` / `awsSecretKey`, más `awsSessionToken` para credenciales STS temporales) y recurren a la cadena estándar de AWS (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` variables de entorno, perfil compartido o rol de instancia/IRSA) cuando se dejan vacías.```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 de GCS, accedido a través de la API JSON. La autorización es nativa de GCP: por defecto
resuelve Application Default Credentials — GKE Workload Identity, el
servidor de metadatos de GCE, o una clave gcloud / GOOGLE_APPLICATION_CREDENTIALS. Establece
dataBackend.gcs.credentialsPath (un archivo de clave JSON de cuenta de servicio) o en línea
credentialsJson para una clave explícita. dataBackend.gcs.endpoint apunta a un
emulador fake-gcs-server, y dataBackend.gcs.anonymous=true habilita
acceso no autenticado solo para ese emulador — nunca contra GCS real.```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
En todos los casos slater-build escribe la generación terminada primero en --data-dir (su área de staging local) y además la sube al bucket; el puntero remoto current se escribe al final, por lo que un nodo de servicio nunca ve una generación a medio publicar.
Cuándo usar un almacén de objetos (S3 o GCS)
Recurre a s3 o gcs cuando quieras generaciones en un almacenamiento de objetos central y duradero en lugar de en el disco de un nodo — normalmente: publicar una vez y distribuir a muchas réplicas de servidores sin estado y sin disco que leen todas el mismo bucket; desacoplar el host de compilación de los hosts de servicio; o apoyarte en la durabilidad/versionado/ciclo de vida del almacén en lugar de gestionar volúmenes. La contrapartida es la latencia: un bloque frío es un viaje de ida y vuelta por red (~10–50 ms) en lugar de una lectura local (~0,1 ms). Slater oculta la mayor parte con la caché de bloques en memoria, la lectura anticipada concurrente y la caché de disco opcional que se describe más abajo. Si tus generaciones ya están en un almacenamiento local rápido y no necesitas el modelo de bucket central, fs es más simple y rápido.
Caché de bloques en disco local (segundo nivel del almacén de objetos)
La BlockCache en memoria es deliberadamente pequeña (la RSS acotada es la garantía principal), por lo que, en un conjunto de trabajo mayor que la RAM, los mismos bloques se volverían a obtener del almacén de objetos en cada expulsión. Un segundo nivel de caché opcional en SSD local soluciona eso: un bloque expulsado de la RAM se sirve desde el disco local (~0,1 ms) en lugar de una nueva GET de objeto, sobreviviendo a la expulsión de memoria y reduciendo el recuento/coste de solicitudes al almacén de objetos — acercando un nodo respaldado por almacén de objetos al rendimiento del sistema de archivos local una vez caliente. Es opt-in tanto para s3 como para gcs, habilitado al configurar dataBackend.<s3|gcs>.diskCacheBytes > 0 y un diskCacheDir escribible.
- Almacena en caché los bytes sellados exactamente tal como se obtuvieron — ya comprimidos y (para generaciones con
--encrypt) todavía sellados con AEAD — por debajo del descifrado/descompresión. La capa de caché nunca retiene la clave de cifrado ni vuelve a cifrar, por lo que el estado en reposo se conserva sin coste adicional: una generación cifrada llega al disco todavía sellada. - Las escrituras son write-behind: un fallo devuelve los bytes obtenidos a la consulta inmediatamente, y luego un hilo en segundo plano hace la escritura en disco y el recorte LRU, por lo que la ruta de consulta nunca se bloquea en E/S de disco. La expulsión mantiene la caché dentro de su presupuesto de bytes; una suma de verificación por archivo verificada en cada lectura autocura un archivo de caché corrupto convirtiéndolo en un fallo (→ reobtener del almacén de objetos).
diskCacheDirdebe apuntar a un volumen real escribible — nunca atmpfs(tmpfs es RAM y anularía la garantía de RSS acotada). El índice en memoria que lo rastrea cuesta un poco de RAM (~decenas de bytes por bloque en caché), que cuenta contra tu límite de RSS — dimensiona el directorio ≫ la caché de bloques en memoria.- El otro coste de RAM de este nivel es la cola write-behind, que prepara los bloques en su camino al disco. Está acotada en
blockCacheBytes / 8(con un mínimo dediskCacheBytes) — 8 MiB por defecto — y descarta en lugar de crecer, de modo que un escaneo en frío no puede inflarla; un bloque descartado simplemente se vuelve a obtener en su siguiente fallo. No necesita configuración: escala conblockCacheBytes, por lo que el nivel de disco no añade ningún número nuevo al presupuesto de RSS más allá de su índice.
Montajes
Una réplica de lectura se ejecuta con un sistema de archivos raíz de solo lectura y un usuario no root (appuser:1000) — todo lo que necesita está montado en solo lectura. Un escritor (delta.enabled) necesita además un volumen duradero y escribible para su WAL.
| Path | Purpose | Notes |
|---|---|---|
/data | Las generaciones del grafo (<graph>/<uuid>/… + current). | Solo lectura para las réplicas; generado por slater-build. Puede vivir en almacenamiento remoto/de red (p. ej., NFS), por lo que no se asume que las lecturas tengan latencias rápidas de SSD local. |
/sandbox | Superposición de configuración por entorno + secretos. | /sandbox/config.json se fusiona en profundidad sobre el config.json incorporado; también contiene acl.json, material PEM TLS y el archivo de clave en reposo. |
/tmp, /run | Área de trabajo (tmpfs). | Una réplica de lectura nunca escribe en disco por defecto. |
(writer) delta.walDir | El registro de escritura anticipada (write-ahead log) + segmentos delta L0, cuando delta.enabled. | Escribible, y un volumen real y duradero — nunca tmpfs (es el suelo de durabilidad). Una ruta relativa se resuelve bajo el directorio de datos; da aquí a un escritor su propio volumen persistente. |
| (optional) disk cache | La caché de bloques en disco local, cuando dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0. | Escribible, y un volumen real — no tmpfs. Usado por los backends s3 y gcs; consulta Backends de almacenamiento. |
Entorno / configuración
La configuración se carga mediante el cargador en capas estándar de la casa: el config.json incorporado, luego /sandbox/config.json fusionado en profundidad sobre él, y después las anulaciones de entorno KEY__sub (doble guion bajo para anidar; las claves coinciden con la configuración camelCase).
Cada parámetro de configuración — su clave camelCase, la anulación de entorno KEY__sub, su valor por defecto y qué hace — está tabulado en la Referencia de configuración. Los parámetros más ajustados son los presupuestos de caché (cache.*), los guardas de consulta (query.*), los límites de conexiones (server.*), el backend de almacenamiento (dataBackend.*) y la capa de escritura (delta.*).
La memoria residente sigue
blockCacheBytes + vectorCacheBytes + resultCacheBytes dentro de una sobrecarga acotada por entrada y del asignador — cada grupo pesa su propio contenido (cadenas y contenedores por capacidad asignada) y expulsa para mantenerse bajo el presupuesto, pero el mantenimiento de registros por entrada y el redondeo de clases de tamaño del asignador se sitúan sobre el número que configuras — más una pequeña sobrecarga fija (y hasta degreeColumnBytes para la columna de grados lazy, una vez que se ejercita la ruta rápida de suma de grados count(endpoint)). Es independiente del tamaño del grafo — esa es la garantía principal, ejercitada por la prueba de integración rss_stays_bounded_under_sustained_knn_load, que mantiene el crecimiento de RSS pico-frente-a-caliente bien dentro de los presupuestos sumados. Los búferes por conexión viven fuera de los presupuestos de caché, por lo que la garantía se mantiene bajo carga adversaria solo porque server.maxConnections limita cuántos pueden existir a la vez.
Postura de red
Slater es el manejador de réplica de lectura; el control principal de seguridad de conexiones es la red, no el binario. Enlázalo a una interfaz privada, restringe los rangos de origen en la capa de red (grupos de seguridad / NetworkPolicy) y — si se enfrenta a cualquier cosa que no sean clientes de confianza — ponle delante un proxy L4 que limite conexiones (HAProxy maxconn + una stick-table por origen, o nftables connlimit + hashlimit). Eso se sitúa antes de que el descriptor de archivo llegue al proceso, por lo que es el límite más robusto.
Los límites dentro del binario mencionados arriba (maxConnections, maxPreAuthConnections, maxConnectionsPerIp, los topes de bytes diferenciales y loginTimeoutMs) son defensa en profundidad: están activados por defecto y son generosos para resultar invisibles a una población de clientes legítima, pero hacen que la garantía de RSS acotada se mantenga incluso cuando se olvida el proxy. Consulta docs/HARDENING.md para conocer la postura defensiva completa y THREAT_MODEL.md / SECURITY_WORKLIST.md para el detalle canónico.
Guardia de generación
Slater consulta el puntero current de cada grafo cada generationPollMs (sondeo, no inotify — el directorio de datos puede ser almacenamiento remoto/de red como NFS, donde los eventos de cambio del sistema de archivos no son fiables). Cuando cambia:
reloadStrategy=exit(por defecto): el servidor registra un error fatal y termina con código distinto de cero para que el orquestador lo reinicie limpiamente contra la nueva generación.reloadStrategy=swap: el servidor abre y valida la nueva generación (la misma comprobación de hash de contenido que en el arranque), la intercambia atómicamente y deja que las consultas en vuelo terminen con la antigua. Se rechaza una nueva imagen corrupta/incompleta y la generación antigua sigue sirviendo.
ACL
acl.json asigna a los usuarios hashes de contraseña argon2id y permisos read / write por grafo. Genera un hash (nunca almacenes texto en claro) con:```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
Un `acl.json` de inicio se incluye en la raíz del repositorio; su forma es:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
-
users— una entrada por inicio de sesión, indexada por nombre de usuario. -
passwordArgon2id— la cadena$argon2id$…deslater hash-password(nunca en texto claro; el propio archivo es JSON plano y reside en almacenamiento compartido). -
grants— listas de capacidades por grafo. Dos permisos son significativos:read— consultar el grafo. Un grafo ausente de losgrantsde un usuario es invisible para él.write— mutar el grafo a través de la capa de escritura (delta.enabled): las sentenciasMERGE/SET/DELETEyCALL slater.consolidate().
Son independientes: un grant de
readno confiere acceso de escritura. Activar la capa de escritura, por tanto, no puede promover a tus lectores existentes a escritores. Un escritor necesita ambos —["read", "write"]— porque resolver una clave de negocio para escribirla es una lectura. Las cadenas de permiso no reconocidas se ignoran (no conceden nada).
Móntalo de solo lectura en la ruta indicada por aclPath (por defecto /config/acl.json).
El servidor lo recarga en cada intercambio en caliente de generación, y el sello ACL en reposo se
vuelve a comprobar en cada recarga (ver requireAclStamp).
Comprobación de salud
El binario slater hace las veces de su propia sonda de actividad: slater healthcheck [host] [port] realiza un handshake Bolt (no una petición HTTP) contra el servidor y
sale con 0 si negocia una versión de protocolo, o 1 en caso contrario — usando por defecto
localhost y el puerto Bolt configurado. Esto es lo que ejecuta el
HEALTHCHECK del contenedor, por lo que los orquestadores ven un servidor realmente listo para Bolt, no solo un
socket abierto:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## Consulta de una sola ejecución
Para scripting, comprobaciones en CI y búsquedas rápidas, `slater query` monta la
generación actual de un grafo, ejecuta una única consulta Cypher de solo lectura
en el proceso, imprime el resultado como un objeto JSON y sale — sin servidor, sin
conexión Bolt. Respeta la misma configuración que el servidor (backend de
almacenamiento, clave de cifrado, presupuestos de consulta):```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"]]}
Los nodos y las relaciones se expanden a sus etiquetas/tipos y propiedades. Usa -q
cuando quieras una salida analizable por máquina (el JSON resultante es lo único en
stdout); omítelo para una ejecución orientada al operador con registros. Sin -q se
registra un resumen solo de métricas tras cada ejecución — p. ej.```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
carrying the query `cost` (elementos cobrados), `resultCount`, `execMs`, y
`limitRowCount` (solo cuando la consulta especifica un `LIMIT`) — nunca el texto de la consulta
ni ningún valor de resultado. El estado de salida es `0` en caso de éxito, `1` en caso de error
de análisis/apertura/ejecución (mensaje en stderr).
## Exportar un grafo (`slater dump`)
`slater dump` exporta un grafo desde un servidor **en ejecución** como Cypher `MERGE`
basado en clave de negocio — el mismo dialecto que ingiere `slater-build` — de modo que un grafo
pueda hacer un viaje de ida y vuelta (dump → `slater-build` → nueva generación) para migración
o copia de seguridad de texto. A diferencia de `slater query`, se conecta a través de **Bolt**,
se autentica y respeta las ACL por grafo, por lo que no necesita acceso al disco del servidor.
La contraseña se lee desde `SLATER_DUMP_PASSWORD` o stdin (nunca mediante una bandera, lo que
la mantiene fuera de `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 clave de identidad de cada etiqueta es la propiedad sobre la que se define su índice de rango; anúlala
con --key Label=prop (repetible) o con un --pk <field> global. El DDL CREATE INDEX
se emite primero para que la reconstrucción recree los índices. Un nodo
con varias etiquetas conserva todas las etiquetas: se emite como MERGE (n:Ident:Other {key: v}), con
la etiqueta de identidad (la que suministra la clave de negocio) primero y el resto
ordenadas; el merge se basa únicamente en la etiqueta de identidad, por lo que las etiquetas finales
se escriben en el nodo sin crear otro. Las etiquetas, los tipos de relación
y las claves de propiedad que contengan caracteres especiales se encierran entre comillas invertidas al
emitirlos, de modo que los nombres inusuales se conservan fielmente y no pueden inyectar Cypher en la
reconstrucción. Los vectores (y otros
valores sin representación literal en Cypher) no pueden incluirse en un volcado MERGE y se descartan
con una advertencia en stderr. El estado de salida es 0 en caso de éxito, 1 en caso de error.
Ejemplo práctico
Un tutorial completo y ejecutable — crear un grafo, servirlo, conectarse con los controladores de neo4j JavaScript y Python, y escribir en él — está en las páginas Quickstart y Writing data del manual, utilizando el grafo de ejemplo incluido en docs/manual/examples/.
Desarrollo```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
### Los backends de almacenamiento de objetos son características opcionales de cargo
Un `cargo build` simple produce un binario **solo de sistema de archivos** — los backends `s3` y `gcs` están detrás de características de cargo para que la compilación predeterminada siga siendo pequeña (sin SDK de AWS ni de Google, sin runtime asíncrono). Habilita el que necesites en **ambos** `slater` (servir) y `slater-build` (publicar):```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
Cada crate expone características s3 / gcs equivalentes que se reenvían a
graph-format/{s3,gcs}. Solicitar un backend en tiempo de ejecución
(dataBackend.kind=s3|gcs, o slater-build --publish-{s3,gcs}-*) sin su
característica compilada falla rápidamente con un error claro de "compilado sin la característica …".
La imagen Docker publicada habilita ambas (CARGO_FEATURES del Dockerfile), por lo que
las imágenes precompiladas no necesitan indicadores adicionales — esto solo importa al compilar desde el código fuente.
Las pruebas de integración también están condicionadas: --features s3 --test s3_minio,
--features gcs --test gcs_emulator (un fake-gcs-server), y --features gcs --test gcs_real (GCS real mediante ADC); cada una se omite salvo que sus variables de entorno SLATER_* estén
definidas.
Consulta docs/PLAN.md, docs/PROGRESS.md y docs/DECISIONS.md para el diseño,
el registro de hitos y el registro de decisiones.
Rendimiento
Hasta seis motores, una suite de un solo cliente, grafos desde un juguete de 62k nodos hasta Wikidata
91.6M de nodos / 1.5B de aristas. Cada motor se mide de forma aislada (todos los demás contenedores
detenidos — RSS y latencia son su propia huella). Las tablas de latencia siguientes se
volvieron a medir en Slater 0.21.0 (la compilación con escritura): los grafos pequeños/medianos (MeSH, EU-AI-Act)
de nuevo, y el grafo de 91.6M como una nueva pasada misma máquina, anclas compartidas de slater contra Neo4j (consulta
esa tabla). Las cifras de memoria residente se trasladan de la pasada anterior (medidas mediante
cgroup del contenedor; la ruta de lectura es byte-idéntica con la capa de escritura inactiva). Los números
de los demás motores provienen de la ejecución comparativa entre motores ya establecida (sus versiones/rendimiento no han cambiado).
Todas las cifras son medianas (ms) o picos de memoria residente (MiB). Menor es mejor en todos los casos; negrita =
mejor de la fila. slater se ejecutó en su backend de sistema de archivos local (fs); los backends S3 y GCS intercambian la latencia
de lectura local por idas y vueltas al almacén de objetos (mitigadas por las cachés en memoria y el nivel opcional de caché
en disco local), por lo que estas cifras caracterizan al motor, no a un despliegue con almacenamiento en red.
| motor | clase | límite de memoria |
|---|---|---|
| slater | respaldado por disco, paginado | query.maxIntermediate limita el conjunto de trabajo automáticamente |
| Neo4j 5 | respaldado por disco, JVM | ~2 GiB de heap + off-heap, comprometidos independientemente de la consulta |
| Memgraph · FalkorDB | en memoria | todo el grafo residente en RAM |
| ArcadeDB | en memoria, JVM | todo el grafo residente; el más pesado |
| LadybugDB | embebido, columnar | pool de búferes manual que debe superar la consulta |
Los tres motores que paginan desde disco — slater, Neo4j 5 y LadybugDB — cargan los cinco grafos. El trío en memoria (Memgraph · FalkorDB · ArcadeDB) no puede contener el grafo de 1.5B de aristas en absoluto (necesita ~64–128 GiB residentes), y el importador de ArcadeDB tampoco puede terminarlo.
Memoria residente (MiB) — acotada a medida que el grafo crece ~1,500×
Cada cifra es memoria de trabajo comprometida — lo que el SO no puede reclamar. Todos los motores excepto slater mantienen su grafo en memoria anónima comprometida (heap propio, caché de páginas off-heap de Neo4j, o un pool de búferes), por lo que su RSS máximo es su huella comprometida. Solo slater sirve desde la caché de páginas del SO reclamable de su almacén en disco, por lo que su cifra es el conjunto de trabajo anónimo; la caché de páginas del almacén (desalojable bajo presión — slater sigue sirviendo) se excluye, y se muestra como total entre paréntesis para el grafo de 91.6M. Negrita = el más bajo.
| grafo (nodos / aristas) | 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 total) | ~2,900 | no puede cargar | no puede cargar | no puede cargar | ~652 † |
slater es el más bajo a cualquier escala y crece ~50× mientras el grafo crece ~1,500× — su
huella sigue el conjunto de trabajo de la consulta, no el grafo (inactivo ~16–71 MiB en todo momento). El
trío en memoria crece ~linealmente y no puede cargar el grafo de 1.5B; Neo4j compromete un heap de ~2 GiB
independientemente de la consulta. († LadybugDB solo en las formas acotadas — sus recorridos de hub / longitud variable /
camino más corto con 1.5B de aristas necesitan que su pool de lectura se eleve a ≥2 GiB, frente al límite automático
maxIntermediate de slater.) Los histogramas de valor→conteo en tiempo de compilación añaden memoria residente insignificante —
unos pocos KB para una columna indexada de baja cardinalidad, y cero para grafos de clave única como Wikidata
(wikidata_id supera el límite de cardinalidad del histograma, por lo que no se almacena ninguno) — por lo que estas cifras no
cambian con esa característica.
Latencia (ms mediana) — el grafo cabe en RAM (MeSH, 341k / 469k)
| forma | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|---|---|---|---|---|---|
| count(*) todos los nodos | 0.41 | 15.0 | 23.8 | 16.4 | 82.0 | 2.2 |
| conteo de etiquetas | 0.42 | 4.2 | 20.7 | 1.1 | 4.4 | 4.3 |
| búsqueda puntual indexada | 0.43 | 3.9 | 0.48 | 0.48 | 0.65 | 8.8 |
| conteo idx-eq | 0.42 | 4.9 | 5.0 | 2.0 | 381 | 2.5 |
| 1 salto (ancla indexada) | 1.28 | 5.8 | 1.21 | 4.1 | 390 | 4.9 |
| 2 saltos (sin anclar) | 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 |
escaneo completo CONTAINS | 0.43 | 5.4 | 24.1 | 1.7 | 16.3 | 4.1 |
slater domina las formas de metadatos / índice / escaneo (count, etiqueta, idx-eq, escaneo — ~0.4 ms, 10–200× los motores de servicio), la búsqueda puntual indexada (0.43 ms, superando ahora por poco los 0.48 ms del par en memoria), el multi-salto no anclado (2 saltos 1.40 ms mediante el escaneo por tipo de relación, el más rápido del campo), y — mediante un histograma de valor→conteo en tiempo de compilación sobre la clave de agrupación indexada — el group-by / count(DISTINCT) de etiqueta completa (0.45 ms, por delante de los 5.3 ms de LadybugDB (columnar)). Los servidores en memoria mantienen solo el 1 salto puro (Memgraph 1.21 ms frente a los 1.28 ms de slater). (pole 62k/106k se ve igual: slater es el único más rápido en count/escaneo ~0.4 ms, y ~1.3–2.6 ms en saltos.)
Latencia (ms mediana) — vectores (EU-AI-Act kNN, 15k × 1024-dim)
| forma | 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 responde a kNN con un escaneo exacto de fuerza bruta (estos conjuntos están por debajo de su umbral ANN de 50k vectores) mientras que los demás usan un HNSW residente aproximado — por lo que los resultados de slater son exactos (recall 1.0). Un núcleo de distancia SIMD + una matriz de vectores residente y pre-normalizada llevó a Concept de ~23 → ~2.9 ms y a Chunk de ~10 → ~2.4 ms, por lo que slater ahora supera a Neo4j y LadybugDB y queda a ~1.4× de Memgraph, solo por detrás de FalkorDB — siendo exacto.
Escalera de escritura de vectores — insertar / actualizar / eliminar sin reconstrucción
Las tablas anteriores son comparaciones de lectura entre motores. La ruta de escritura de vectores (la
escalera de escritura de estilo FreshDiskANN sobre la base
Vamana estática) no tiene equivalente entre motores — ningún otro motor aquí ofrece ANN nativo en disco y escribible —
por lo que los números siguientes son benchmarks de componentes de un solo motor sobre un
fixture sintético tipo embedding (una variedad de bajo rango, dim 768, normas desiguales), incluido en
crates/slater/benches/ y documentado en detalle — con la
metodología y todas las salvedades — en docs/PERF-REPORT.md. El recall se
mide siempre contra una fuerza bruta exacta sobre el conjunto vivo, nunca un índice contra
otro. La escala aquí es representativa y se extrapola solo donde la métrica es lineal con el tamaño.
| propiedad | medido | por qué importa |
|---|---|---|
| Latencia de KNN frente a escrituras pendientes | índice RW ~1.5–2 ms, plano hasta 50k pendientes; la superposición pre-índice de fuerza bruta 1.9 → 115 ms (lineal en delta) — 61× a 50k | la latencia de consulta no se degrada a medida que las escrituras se acumulan entre consolidaciones |
| Inserción de embedding | ~1.5–2 ms por vector en el índice vivo | una escritura es visible para KNN de inmediato; el presupuesto de reconstrucción delta es ≈ 2 ms × el límite delta |
| IO de borrado a recall equivalente | 2.9× menos recuperaciones de nodos por consulta con 67 % borrado, 5.2× con 80 % (recall ≥ 0.90) | un grafo consolidado no paga ningún impuesto de lectura por los vectores borrados |
| Consolidación, permutación pura | O(1) — el .vamana está enlazado físicamente byte-idéntico, solo se reescribe la columna de id | plegar las escrituras de vectores en la base omite la reconstrucción O(N·R·L) |
| Recall a lo largo de la escalera | consolidado ≥ base para coseno, L2 y producto punto | la escalera de escritura preserva el recall en cada peldaño |
La única cifra que requiere el banco de pruebas de rendimiento dedicado es el rendimiento de reescritura de consolidación por la ruta lenta — cuando una consolidación incluye borrados o vectores nuevos en lugar de una permutación pura, es una recompresión secuencial limitada por zstd de un solo hilo y el disco local, por lo que el MiB/s absoluto depende del entorno (el informe muestra la forma y explica el rango según el entorno).
Latencia (ms mediana) — grafo ≫ RAM (Wikidata 91.6M / 1.5B)
Los motores en memoria (Memgraph / FalkorDB / ArcadeDB) no pueden cargar este grafo en absoluto
(~64–128 GiB residentes). Solo slater y Neo4j 5 lo hacen. Esta es una pasada nueva, misma máquina y mismo día,
contra un conjunto de anclas fijo y compartido — cada consulta alcanza los mismos nodos en ambos motores,
por lo que el cara a cara es comparable (un conjunto común de anclas wikidata_id de grado moderado;
consulta la nota siguiente sobre por qué importa). slater se muestra con ambos fanouts (query.maxFanout 1 =
predeterminado de rendimiento, 8 = el control de latencia que solapa las lecturas de bloques fríos). Negrita = mejor de la fila.
| forma | slater (fan 1) | slater (fan 8) | Neo4j 5 |
|---|---|---|---|
| count(*) todos los nodos | 0.41 | 0.41 | 3606 |
| búsqueda puntual (indexada) | 0.72 | 0.49 | 6.3 |
| grado (conteo de 1 salto) | 0.43 | 0.44 | 6.0 |
| vecinos a 1 salto | 9.8 | 4.5 | 10.1 |
| 2 saltos | 37 | 23 | 34.5 |
| 3 saltos | 32 | 25 | 74 |
longitud variable *1..2 distinct | 985 | 1056 | 47 |
La imagen honesta: slater domina las formas de metadatos / índice — count(*) se
sirve desde metadatos (0.41 ms frente al escaneo de disco de 3.6 s de Neo4j, ~8800×), y la búsqueda puntual / grado /
3 saltos son ~2–10× más rápidos — está a la par con Neo4j en 1–2 saltos (el fanout 8 se adelanta en lecturas en frío),
pero pierde var-length *1..2 distinct de forma decisiva (≈1 s frente a los 47 ms de Neo4j): la
expansión de longitud variable con distinct de slater es materialmente más lenta aquí, una debilidad real que merece
investigación propia. Todo ello con unos pocos cientos de MB de RSS frente al heap comprometido de ~2 GiB de Neo4j.
Sobre las anclas. Estos números de recorridos dependen en gran medida de qué nodos uses como punto de partida — un nodo a un enlace de un mega-hub de Wikidata ("human", "country") tiene una vecindad de 2 saltos de millones de nodos, por lo que el coste de longitud variable / saltos varía en órdenes de magnitud según la elección de la ancla. La edición anterior de esta tabla muestreaba el "primeros N por escaneo" propio de cada motor, que no es ni estable ni comparable; esta pasada fija un único conjunto de anclas compartido y acotado por grado para ambos motores. (shortestPath se omite en esta pasada — entre dos anclas arbitrarias depende de la existencia de la ruta y tiene demasiada varianza para dar una mediana significativa.)
Multi-salto count(*) — memoria desacoplada del tamaño del resultado
El RETURN count(*) multi-salto sin límite cuenta durante la expansión en lugar de materializar
las filas coincidentes. Mismas anclas de hub en el grafo de 91.6M, maxIntermediate=20M:
| 3 saltos count(*) @ 91.6M | fanout=1 | fanout=8 |
|---|---|---|
| latencia / pico de conjunto de trabajo | 554 ms / 0.66 GiB | 298 ms / 1.9 GiB |
El conteo mantiene O(1) filas. El cargo no cambia, por lo que un conteo de mega-hub sigue haciendo saltar
maxIntermediate en cómputo (lecturas de adyacencia), acotado como antes.
Paralelismo por consulta (maxFanout)
Elevar query.maxFanout superpone las lecturas de bloques en frío, ligadas a E/S de una consulta entre núcleos —
ayuda a las formas ligadas a disco con conjuntos de trabajo fríos grandes y es plano en las formas en caliente. En el grafo
de 1.5B: shortestPath ≤6 918 → 608 ms (1.5×, la búsqueda más grande 6,269 → 2,350 ms, 2.7×);
el conteo de 3 saltos 547 → 298 ms. maxFanout=1 es el valor predeterminado (orientado al rendimiento); 8 es el
control de latencia, a costa de más memoria de trabajo transitoria.
Dónde gana / pierde slater
| dimensión | slater | lo mejor de la competencia | veredicto |
|---|---|---|---|
| memoria residente, cualquier escala | 11–584 MiB (62k → 91.6M) | en memoria 1.5–2.7 GiB; no puede cargar 1.5B | slater |
| count / metadatos / escaneo | ~0.4 ms | motores de servicio 5–80 ms | slater (10–200×) |
| búsqueda puntual indexada | 0.43 ms (MeSH) | Memgraph · FalkorDB 0.48 ms | slater (supera por poco al par en memoria) |
| multi-salto no anclado (filas) | 1.40 ms (2 saltos MeSH) | Neo4j 5.6 ms | slater (escaneo por tipo de relación) |
| agregación (group-by / DISTINCT) | 0.45 ms | LadybugDB 5 ms (columnar) | slater (histograma en tiempo de compilación) |
| kNN | 2.4–2.9 ms (exacto) | FalkorDB 1.2 ms (HNSW) | supera a Neo4j/Ladybug; a ~1.4× de Memgraph; exacto |
| 91.6M metadatos / punto / grado / 3 saltos | 0.4–32 ms | Neo4j 6–3,600 ms | slater (2–8800×) |
| 91.6M 1–2 saltos | 4.5–23 ms (fan 8) | Neo4j 10–35 ms | ~a la par |
91.6M longitud variable *1..2 distinct | ~1 s | Neo4j 47 ms | Neo4j (una debilidad real de slater) |
count(*) multi-salto a escala | 0.3–0.6 GiB | los motores en memoria materializan el conjunto de filas | slater, acotado |
Las tablas completas por motor (pole, MeSH, EU-AI-Act + el control RAM↔latencia blockCacheBytes,
Wikidata 1M y 91.6M) están en
perf/cross-engine-hs/README.md; la nueva pasada solo de slater
(ambos fanouts, todos los conjuntos de datos) está en perf/PERF_CURRENT_STATUS.md.
Concurrencia y brown-out (pruebas de carga)
Los benchmarks anteriores son de un solo cliente. El eje complementario — comportamiento con muchos
clientes concurrentes — tiene su propio arnés, perf/loadtest/: un controlador
Locust sobre Bolt más un coordinador que incrementa la carga, lee CALL slater.diagnostics(),
encuentra la rodilla de capacidad y nombra el limitador (método completo en
docs/LOAD-TESTING.md). Titulares de una ejecución con caché de 256 MiB en el
grafo Wikidata-1M (una máquina de 16 núcleos):
| resultado | medición |
|---|---|
| Resiste 1000 clientes concurrentes, cero fallos | el rendimiento alcanza un pico de ~2.5k rps; la rodilla de latencia aparece alrededor de 750 clientes (p99 51 → 750 ms) — colas bajo contención de núcleos, no un límite duro (ejecución única, WSL2) |
| Caché de bloques acotada y eficaz | 100% de aciertos, 0 desalojos, 50 MB residentes para un conjunto de trabajo que cabe en la caché |
| RSS mantenido bajo carga sostenida | el asignador jemalloc mantiene el RSS en ~0.6 GB a lo largo de una rampa wiki_cache_churn de 100→500 clientes — ligado a la caché y estable, sin ningún ajuste MALLOC_* (el antiguo MALLOC_ARENA_MAX=2 + umbral de recorte está retirado); su purga en segundo plano también devuelve el máximo posterior a la ráfaga en lugar de dejarlo fijado |
| Memoria agregada acotada | el query.maxIntermediateGlobal de todo el servidor + la expansión con cargo de adyacencia contienen la inundación de 2 saltos wiki_budget con 1000 clientes sin OOM (RSS ~0.6 GB; el guardián descarta ~60% de las consultas de hub como errores de presupuesto reintentables) |
Ambos problemas de memoria que la prueba de carga sacó a la luz están ahora cerrados; todos se registran en el documento de pruebas de carga.
Licencia
Licenciado bajo la Apache License, versión 2.0. Consulta LICENSE para el
texto completo y NOTICE para la atribución. Salvo que indiques explícitamente
otra cosa, cualquier contribución enviada intencionalmente para su inclusión en este trabajo,
según se define en la licencia Apache 2.0, se licenciará como se indica arriba, sin
términos ni condiciones adicionales.
SPDX-License-Identifier: Apache-2.0