Volver a actualizaciones
Nuevo releaseAug 16, 2026

slater v0.25.1

Graphdb de bajo consumo de memoria con soporte para Bolt+tls, cifrado en reposo y vectores, diseñado para casos de uso de réplicas locales de grafos.

Compartir

Slater

CI Release

Versión actual: v0.25.2todas las versiones.

En una línea: 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 del Bolt estándar, por lo 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 eliges tú, no el tamaño del grafo.


Accesos directos

Por qué existe Slater

Una base de datos de grafos almacena los datos como cosas (nodos) y las relaciones entre ellas (aristas), tratando las relaciones como ciudadanos de primera clase. Eso es lo que quieres cuando tus preguntas tratan sobre conexiones más que sobre filas: «¿quién está a menos de tres saltos de esta cuenta?», «¿cuál es la cadena completa 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 que en un grafo surgen de forma natural.

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 residente: un grafo de 40 GB necesita 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: por ejemplo, el grafo de Wikidata de 90 millones de nodos / 1.500 millones de aristas necesita ~64–128 GiB residentes, por lo que los motores en memoria no pueden abrirlo en absoluto.

Slater es la respuesta a esto. En lugar de cargar el grafo en memoria, lo compila una vez, fuera de línea: 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 (por lo que tus drivers de neo4j existentes funcionan sin más), paginando bloques bajo demanda y manteniendo residente solo un presupuesto de caché fijo. Así es como el mismo grafo de 90M 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 un grafo de 400 GB cuestan la misma RAM para servirse, de modo que puedes desplegar réplicas de lectura baratas y sin estado, y dejar que sea el almacenamiento, no el montón, quien 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, por lo que el mismo motor es también la capa de recuperación para los embeddings.

Compilado una vez no significa congelado, sin embargo. Esa imagen es una base, no un estado final: una capa de escritura opcional se sitúa sobre ella, 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 reconstruir 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 (WAL) y una tabla en memoria, que se vuelcan en segmentos delta inmutables y se pliegan de nuevo en un núcleo fresco mediante una consolidación periódica. Lo que esto te aporta:

  • Las lecturas sobre un grafo sin escrituras cuestan exactamente lo que costaban antes. Un delta vacío es una única rama predecible, no una fusión: la ruta de lectura es byte-idéntica tanto si la capa de escritura está activada como si no.
  • El coste de lectura de una escritura escala con el tamaño del delta, no con el del grafo. Las respuestas de todo el grafo — count(*), los marginales de etiquetas y tipos de relación — siguen siendo lecturas de metadatos incluso con escrituras pendientes: el delta mantiene sus propios contadores, por lo que un count(*) 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 único escritor drena la cola y devuelve SUCCESS solo después del fsync que cubre la escritura. Agrupa tus escrituras y resultan baratas: un UNWIND de escritura confirma un fsync por lote, no por fila.
  • Escrituras por clave de negocio, en cualquiera de los dos dialectos. MERGE / MATCH … SET / DELETE (y CREATE / REMOVE, borrado con detach, 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 caen en la misma ruta. Corregir, insertar, actualizar (upsert) y retirar, sobre nodos y aristas, dirigidos como tus datos ya lo están.

Con la capa desactivada — el valor predeterminado — Slater sirve el núcleo inmutable puro y rechaza las escrituras. Consulta La capa de escritura para 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.

Lo que 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 reemplazo directo para el grafo — habla Bolt, por lo que cualquier driver estándar de neo4j (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 / DELETE por clave de negocio sobre nodos y aristas, con commit en grupo y durabilidad fsync, replegada en un núcleo fresco mediante consolidación. Las lecturas no pagan por ello.
  • Despliegue mediante intercambio de archivos — compila fuera de línea una nueva generación con hash de contenido, cambia atómicamente el puntero current, y los servidores la retoman. Cada bloque está checksumeado, por lo que una imagen copiada a medias se rechaza en lugar de servirse.
  • Búsqueda vectorial integrada — búsqueda aproximada del vecino más cercano (ANN) nativa en disco (coseno, L2 o producto escalar KNN) 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 lugar — sin reconstrucción fuera de línea 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, Bolt sobre TLS, ACL con hash argon2id y un rootfs de contenedor de solo lectura para 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, por lo 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ísticaLo que significa para ti
Memoria acotada y predecibleLa memoria residente sigue tres presupuestos de caché que fijas, dentro de un overhead acotado por entrada y por asignador — no crece con el tamaño del grafo; ajustas la relació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 tras ráfagas intensivas de consultas, de modo que la memoria residente vuelve a caer hacia su mínimo en reposo en lugar de quedarse anclada en el máximo posterior a la ráfaga.
Multiinquilino listo para usarUn solo servidor aloja muchos grafos con permisos de lectura por usuario: aislamiento multi-base 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ánsitoSellado por bloque con XChaCha20-Poly1305 (la clave nunca se escribe en disco) más TLS opcional (bolt+s://). Amigable con el RGPD por construcción. El cifrado es también lo que compra la integridad autenticada: el builder sella el manifiesto con un MAC con clave, y un servidor que posee la clave lo verifica y se niega a servir una generación cuyo manifiesto sea forjado, alterado o al que se le haya arrancado el MAC. Una imagen sin clave (en texto plano) está protegida solo por el hash de contenido sin clave: integridad y corrupción, no manipulación. Consulta Qué significa la integridad en cada configuración.
Instalación diminutaUn binario pequeño y depurado sobre una base glibc distroless (sin shell/apt): la imagen multiarquitectura (amd64/arm64) pesa ~22 MB, o ~12 MB para la etiqueta slater:latest-lite solo servidor; TLS 100% Rust, sin OpenSSL. Tira y ejecuta.
Pensado para publicación periódicaCompila un grafo fuera de línea, sírvelo inmutable y luego intercambia atómicamente una nueva versión sin tiempo de inactividad: ideal para cargas de trabajo de data warehouse / refresco programado.
Robusto bajo cargaTanto el servidor como el builder fuera de línea compilan con #![forbid(unsafe_code)] — el único unsafe del motor vive en el crate auditado del asignador jemalloc. El núcleo es inmutable, por lo que las lecturas no toman bloqueos y nunca esperan a un escritor; un único escritor serializa las mutaciones detrá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 neo4jHabla Bolt 5.4 / 4.4 / 4.1: usa los drivers estándar de neo4j (JS, Python, Go, Java…), cypher-shell o los navegadores de grafos sin cambios.
Amplia superficie de consulta CypherUna amplia superficie de lectura: MATCH/WHERE/WITH/UNION, subconsultas CALL {…}, más de 70 funciones y agregaciones, valores temporales y geoespaciales, y regex.
Escrituras en vivo y duraderasUna capa LSM opcional de un solo escritor 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 fsync y replegado en un núcleo fresco mediante consolidación. La ruta de lectura es byte-idéntica cuando el delta está vacío.
ISO GQL, lectura y escrituraHabla 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 etiquetas/tipos, 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) caen en la misma ruta de escritura duradera. Cypher y GQL, lecturas y escrituras, en un solo motor.
Vectores + grafo en un solo motorBúsqueda vectorial ANN nativa en disco (Vamana + PQ; coseno / L2 / producto escalar) para embeddings/RAG, más algoritmos de grafo (PageRank, BFS, betweenness, WCC…) — memoria acotada incluso con millones de vectores. Los embeddings son escribibles (escalera de escritura estilo FreshDiskANN): insertar / actualizar / borrar un vector, visible al instante para KNN, replegado en la base sin reconstrucción.
Seguro en almacenamiento de redCada archivo tiene hash de contenido BLAKE3 y se verifica al abrir; las imágenes rotas o copiadas a medias se rechazan, no se sirven. Diseñado para volúmenes NFS/remotos (sin sorpresas de mmap).
Backends de almacenamiento conectablesSirve 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 en réplicas sin estado — con una capa de caché SSD local opcional frente al almacén de objetos. Consulta Backends de almacenamiento.

Dos binarios componen el workspace:

BinarioFunción
slaterEl servidor Bolt en línea (el ENTRYPOINT del contenedor): sirve lecturas y, con delta.enabled, la ruta de escritura duradera de un solo escritor.
slater-buildEl compilador fuera de línea: 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 fuera de línea — 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 caliente del servidor. 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 grafo (algo.*) y KNN vectorial nativo en disco (db.idx.vector.queryNodes) — mientras que el overlay delta de la capa de escritura se sitúa por debajo de esa superficie y cuesta cero cuando está vacío, de modo que las lecturas nunca cargan con la maquinaria 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 fuera de línea e intercambiar atómicamente el puntero current, que el servidor en ejecución retoma mediante su guardia de generación (consulta Guardia de generación).

Documentación

El manual de usuario completo vive 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.

Usar Slater como almacén de memoria de Graphiti

graphiti-slater es un adaptador que permite a Graphiti almacenar su grafo de conocimiento temporal en Slater, con un docker-example/ ejecutable — incluida su exposición a Claude Code como servidor MCP. Consulta ese repositorio para ver cómo funciona y cómo ejecutarlo.

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, etiquetadas como :latest y :vX.Y.Z en cada lanzamiento:```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 está duplicada 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 (p. ej., 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; se requiere `git` (ya presente en la imagen base) para la dependencia git+tag `hs-utils`, que `.cargo/config.toml` obtiene mediante la CLI de git.

Las siguientes secciones cubren el formato en disco, la configuración, las ACL y un ejemplo práctico local (sin 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, un encabezado 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 texto current.
  • 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 re-haciendo el hash de cada archivo contra el manifiesto, de modo que una imagen copiada a medias / truncada — una copia rota en el directorio de datos, que puede ser almacenamiento remoto/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 pondera lo que contiene y expulsa para mantenerse dentro de su presupuesto, de modo que la RSS sigue los presupuestos con un overhead acotado 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 estructurado por registros, 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) └─────────────┘

* **Garantía mínima de durabilidad — el WAL.** Cada mutación se serializa detrás de un único
  escritor por grafo, se anexa a un log de escritura anticipada por grafo y se hace
  `fsync` antes de que se devuelva el `SUCCESS` de Bolt — así que *confirmado ⇒ durable*,
  y una cola truncada se descarta en la reproducción. Un `UNWIND` de escritura por lotes
  anexa 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* sea con 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 (acotada por `delta.memtableBytes`); cuando se llena, se vacía a un segmento
  delta L0 inmutable. Una **consolidación** fusiona `{core + delta}` en un nuevo core
  serializando la vista combinada de vuelta a través de `slater-build` e intercambiando
  `current` atómicamente — la misma protección por hash de contenido que cualquier
  generación publicada. Puedes dispararla manualmente con `CALL slater.consolidate()`,
  automáticamente al alcanzar `delta.deltaCorePercent` del tamaño del core
  (opcionalmente restringido a una ventana fuera de horas punta `delta.consolidateWindow`),
  o deja que el tope `delta.deltaHardBytes` frene el crecimiento descontrolado.
* **La capa superpuesta queda por debajo de 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 fusionada `(core, delta)`; el motor está monomorfizado sobre ella, de modo
  que un delta vacío se 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 en vivo del propio delta, así 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 multi-sentencia ni rollback — una escritura es una
  corrección duradera direccionada por clave de negocio, no una transacción OLTP.

La gramática exacta de escritura y los parámetros están en la tabla
[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 cada `(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)
— el clásico índice *estático, ordenado y estructurado en bloques*, que es
exactamente la forma adecuada para una generación inmutable: no hay inserciones que
rebalancear, así que la simplicidad del ISAM aporta 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 aplica búsqueda binaria sobre ese nivel superior
  en memoria para encontrar el *único* bloque en el que puede estar una clave, lee +
  descomprime ese bloque y lo escanea — de modo 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 lo elige mediante `NodeScan::RangeEq` / `RangeRange`; un predicado
  no indexado cae en un barrido de etiqueta o en un escaneo completo, y el ejecutor
  vuelve a comprobar 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`) opera sobre índices **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 de 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 voraz* — comienza en el medoide, salta repetidamente hacia
    la consulta, manteniendo una lista de candidatos de ancho `vectorQuery.beamWidth` —
    alcanza los vecinos reales de un nodo en pocos saltos, es decir, **pocas lecturas
    aleatorias de bloque por consulta**. Los bloques del grafo
    (`vector/<label>.<prop>.vamana`) se paginan a través de la caché de vectores, no se
    mantienen enteros en memoria.
  * **[Cuantizació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 se agrupa de forma independiente
    con k-means, y el vector se almacena como la tupla de ids del centroide más cercano.
    Estos códigos (`vector/<label>.<prop>.pq`) son lo bastante pequeños como para
    mantenerlos **residentes**, de modo que la búsqueda de haz puntúa a los candidatos
    desde la RAM y solo los pocos vectores completos elegidos se leen del disco. Ese
    conjunto PQ residente es lo que fija el pool `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
**visible inmediatamente para KNN con rango exacto**, luego sobrevive a un vaciado de
segmento, a una fusión y a una consolidación. Una consulta fusiona hasta tres niveles —
el índice base sellado, un índice por segmento sellado y un **RW-index** en memoria
(un Vamana mutable vivo sobre el delta de escritura) — de modo que la latencia se
mantiene plana a medida que se acumulan las escrituras, en lugar de crecer con el
número de escrituras pendientes. Un borrado deja un *agujero*: el nodo deja de
devolverse pero permanece como punto de navegación hasta que una **consolidación de
borrado** en segundo plano lo extrae del grafo, de modo 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 layout en lugar de por id de nodo, `CALL slater.consolidate()` transporta
el Vamana **por referencia** — con enlace físico, byte-idéntico — y reescribe solo una
pequeña columna de ids, incorporando las escrituras vectoriales en la base **sin** la
reconstrucción del grafo O(N·R·L). Las cifras medidas, con sus advertencias, están en
el [informe de rendimiento](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md).

## Backends de almacenamiento (sistema de archivos / S3 / GCS)

Cada archivo de generación se abre a través de una abstracción **`ObjectStore`** en
lugar de `std::fs` directamente, de modo 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 corresponden con un `pread` en un archivo local
y con una solicitud HTTP de rango de bytes en un almacén de objetos — Slater nunca usa
mmap, así 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 predeterminado simple; **Amazon S3 y Google Cloud Storage son
backends de almacén de objetos en igualdad de condiciones y con soporte completo** —
la imagen publicada incluye ambos ya compilados, así 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 reconstruir.

| `dataBackend.kind` | Lectura posicional | Integridad al abrir | Credenciales |
| --- | --- | --- | --- |
| `fs` *(predeterminado)* | `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 de AWS o rol IAM |
| `gcs` | lectura de 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 almacenes de objetos verifican la integridad a partir del **checksum que el
almacén ya calcula y conserva**, recuperado como metadatos del objeto: `slater-build`
envía el checksum en la subida (el almacén valida los bytes contra él y lo guarda), y
el servidor lo lee al abrir y lo compara con el manifiesto — una solicitud de
metadatos por archivo, sin descargar el cuerpo. Es a nivel de contenido e idéntico en
espíritu entre S3 (SHA-256) y GCS (CRC32C). Cuando un objeto no lleva **ningún**
checksum almacenado en el servidor (copiado externamente, o subido con un valor
predeterminado diferente), el servidor **recalcula el hash del 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ños. 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), de modo que un manifiesto reescrito para describir archivos manipulados
es rechazado; sin clave, la comparación carece de protección por clave en todo momento,
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 adecuada 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 abrir.

### 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 las Application Default Credentials — GKE Workload Identity, el servidor de metadatos de GCE, o una clave de gcloud / GOOGLE_APPLICATION_CREDENTIALS. Configura dataBackend.gcs.credentialsPath (un archivo JSON de clave de cuenta de servicio) o credentialsJson en línea para una clave explícita. dataBackend.gcs.endpoint apunta a un emulador fake-gcs-server, y dataBackend.gcs.anonymous=true habilita el 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 en --data-dir primero (su área de preparación 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 publicada a medias.

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 — típicamente: publicar una vez y distribuir a muchas réplicas de servidor sin estado y sin disco que lean 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 de 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 almacenamiento local rápido y no necesitas el modelo de bucket central, fs es más simple y más rápido.

Caché de bloques en disco local (segundo nivel del almacén de objetos)

La BlockCache en memoria es deliberadamente pequeña (RSS acotado es la garantía principal), así que en un conjunto de trabajo mayor que la RAM los mismos bloques se volverían a buscar en el almacén de objetos en cada expulsión. Un segundo nivel de caché opcional en SSD local lo soluciona: un bloque expulsado de la RAM se sirve desde el disco local (~0.1 ms) en lugar de un nuevo GET al almacén de objetos, sobrevive a la expulsión de la memoria y reduce el recuento/coste de peticiones 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, y se habilita estableciendo 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 tiene la clave de cifrado y nunca vuelve a cifrar, por lo que el estado en reposo se conserva gratuitamente: una generación cifrada llega al disco todavía sellada.
  • Las escrituras son de escritura diferida (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 la poda LRU, de modo 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 (→ volver a buscar en el almacén de objetos).
  • diskCacheDir debe apuntar a un volumen real escribible — nunca tmpfs (tmpfs es RAM y anularía la garantía de RSS acotado). 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 de escritura diferida, que prepara los bloques en su camino al disco. Está limitada a blockCacheBytes / 8 (con un mínimo de diskCacheBytes) — 8 MiB en el valor predeterminado — y reduce su tamaño en lugar de crecer, de modo que un escaneo en frío no puede inflarla; un bloque reducido simplemente se vuelve a buscar en el siguiente fallo. No necesita configuración: escala con blockCacheBytes, 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.

RutaPropósitoNotas
/dataLas generaciones del grafo (<graph>/<uuid>/… + current).Solo lectura para las réplicas; producido por slater-build. Puede residir en almacenamiento remoto/de red (p. ej. NFS), por lo que no se asume que las lecturas tengan latencias rápidas de SSD local.
/sandboxSuperposición de configuración por entorno + secretos./sandbox/config.json se fusiona en profundidad sobre el config.json integrado; también contiene acl.json, material PEM TLS y el archivo de clave en reposo.
/tmp, /runÁrea temporal (tmpfs).Una réplica de lectura nunca escribe en disco por defecto.
(escritor) delta.walDirEl registro de escritura anticipada + segmentos delta L0, cuando delta.enabled.Escribible, y un volumen real y duradero — nunca tmpfs (es el piso de durabilidad). Una ruta relativa se resuelve bajo el directorio de datos; dale a un escritor su propio volumen persistente aquí.
(opcional) caché de discoLa caché de bloques en disco local, cuando dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0.Escribible, y un volumen real — no tmpfs. Usada por los backends s3 y gcs; ver Backends de almacenamiento.

Entorno / configuración

La configuración se carga con el cargador en capas estándar de la casa: el config.json integrado, luego /sandbox/config.json fusionado en profundidad sobre él, y luego las anulaciones de entorno KEY__sub (doble guion bajo para anidamiento; las claves coinciden con la configuración camelCase).

Cada perilla de configuración — su clave camelCase, la anulación de entorno KEY__sub, su valor predeterminado y qué hace — está tabulada en la Referencia de configuración. Las perillas más ajustadas son los presupuestos de caché (cache.*), las protecciones de consulta (query.*), los límites de conexión (server.*), el backend de almacenamiento (dataBackend.*) y la capa escribible (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 encima del número que estableces — más una pequeña sobrecarga fija (y hasta degreeColumnBytes para la columna de grado 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-vs-caliente muy 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 adversa solo porque server.maxConnections limita cuántos pueden existir a la vez.

Postura de red

Slater es un identificador de réplica de lectura; el control principal de seguridad de conexión es la red, no el binario. Conéctalo a una interfaz privada, restringe los rangos de origen en la capa de red (grupos de seguridad / NetworkPolicy) y — si se enfrenta a algo que no sean clientes de confianza — colócalo detrás de un proxy L4 con límite de conexiones (HAProxy maxconn + una stick-table por origen, o nftables connlimit + hashlimit). Eso se sitúa antes de que el descriptor de archivo se entregue al proceso, por lo que es el límite más robusto.

Los límites dentro del binario mencionados arriba (maxConnections, maxPreAuthConnections, maxConnectionsPerIp, los límites de bytes diferenciales y loginTimeoutMs) son defensa en profundidad: están activados por defecto y son generosos para que sean invisibles para una población de clientes legítima, pero hacen que la garantía de RSS acotado se mantenga incluso cuando se olvida el proxy. Consulta docs/HARDENING.md para la postura defensiva completa, y THREAT_MODEL.md / SECURITY_WORKLIST.md para el detalle canónico.

Protección de generación

Slater sondea 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 (predeterminado): el servidor registra un error fatal y sale 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 protección de hash de contenido que en el arranque), la intercambia atómicamente y deja que las consultas en curso terminen con la anterior. Una nueva imagen corrupta/incompleta se rechaza y la generación anterior sigue sirviendo.

ACL

acl.json asigna usuarios a hashes de contraseña argon2id y permisos read / write por grafo. Genera un hash (nunca almacenes texto plano) 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 estructura 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 login, indexada por nombre de usuario.

  • passwordArgon2id — la cadena $argon2id$… de slater hash-password (nunca en texto claro; el archivo en sí 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 los grants de un usuario es invisible para él.
    • write — mutar el grafo a través de la capa de escritura (delta.enabled): las sentencias MERGE / SET / DELETE y CALL slater.consolidate().

    Son independientes: un permiso de read no confiere acceso de escritura. Activar la capa de escritura, por tanto, no puede convertir a tus lectores existentes en escritores. Un escritor necesita ambos — ["read", "write"] — porque resolver una clave de negocio para escribirla es una lectura. Las cadenas de permisos no reconocidas se ignoran (no conceden nada).

Monta el archivo en modo solo lectura en la ruta indicada por aclPath (por defecto /config/acl.json). El servidor lo recarga en cada hot-swap de generación, y el sello ACL en reposo se vuelve a comprobar en cada recarga (ver requireAclStamp).

Chequeo de salud

El binario slater hace las veces de su propia sonda de vida: slater healthcheck [host] [port] realiza un handshake de Bolt (no una petición HTTP) contra el servidor y sale con 0 si negocia una versión de protocolo, 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 única

Para scripting, comprobaciones de 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 mismo proceso,
imprime el resultado como un objeto JSON y finaliza — 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. Use -q cuando desee una salida procesable por máquina (el JSON resultante es lo único en stdout); omítalo para una ejecución orientada al operador con registros. Sin -q, se registra un resumen solo de métricas después de cada ejecución — p. ej.```text INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10

que contiene el `cost` de la consulta (elementos cargados), `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 un
error de parseo/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` de clave de negocio
— el mismo dialecto que consume `slater-build` — de modo que un grafo hace un viaje de ida y vuelta
(dump → `slater-build` → nueva generación) para migración o respaldo de texto. A diferencia de
`slater query`, se conecta mediante **Bolt**, se autentica y respeta las
ACLs por grafo, por lo que no necesita acceso al disco del servidor. La contraseña se lee de
`SLATER_DUMP_PASSWORD` o stdin (nunca de una bandera, manteniéndola fuera de `ps`/historial).```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 que porta su índice de rango; se puede sobrescribir con --key Label=prop (repetible) o con una --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; la clave del MERGE es únicamente 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 entrecomillan con comillas invertidas al emitirse, de modo que los nombres inusuales se conservan fielmente en el viaje de ida y vuelta 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 código de salida es 0 en caso de éxito y 1 en caso de error.

Ejemplo práctico

Un tutorial completo y ejecutable — crear un grafo, servirlo, conectarse con los drivers de neo4j JavaScript y Python y escribir en él — se encuentra en las páginas Quickstart y Escritura de datos 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 object-store 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 o 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

Each crate expone características s3 / gcs correspondientes que 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 que su característica esté compilada falla rápidamente con un claro error de "compilado sin la característica …". La imagen Docker publicada habilita ambas (CARGO_FEATURES en el 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 a menos 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 nodos / 1.5B aristas. Cada motor se mide de forma aislada (todos los demás contenedores detenidos — la RSS y la latencia son su propia huella). Las tablas de latencia siguientes se re-midieron en Slater 0.21.0 (la versión escribible): los grafos pequeños/medianos (MeSH, EU-AI-Act) de nuevo, y el grafo de 91.6M como una pasada nueva en la misma máquina, con anclajes compartidos de slater frente a Neo4j (véase esa tabla). Las cifras de memoria residente se arrastran de la pasada anterior (medidas mediante el cgroup del contenedor; la ruta de lectura es byte-idéntica con la capa escribible inactiva). Las cifras de los otros motores provienen de la ejecución comparativa entre motores ya establecida (sus versiones/rendimiento no han cambiado). Todas las cifras son medianas (ms) o pico de memoria residente (MiB). En todo, menor es mejor; 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 de almacenamiento en red.

motorclaselímite de memoria
slaterrespaldado por disco, paginadoquery.maxIntermediate limita automáticamente el conjunto de trabajo
Neo4j 5respaldado por disco, JVM~2 GiB de heap + off-heap, comprometidos independientemente de la consulta
Memgraph · FalkorDBen memoriatodo el grafo residente en RAM
ArcadeDBen memoria, JVMtodo el grafo residente; el más pesado
LadybugDBembebido, columnarpool 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 retener en absoluto el grafo de 1.5B aristas (necesita ~64–128 GiB residentes), y el importador de ArcadeDB tampoco puede terminarlo.

Memoria residente (MiB) — acotada mientras 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 pico de RSS 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)slaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
pole — 62k / 106k117461141401,556198
MeSH — 341k / 469k631,0833584551,631121
EU-AI-Act — 21k / 45k (+55 MiB vec)997292293121,948286
Wikidata — 91.6M / 1.5B584 (4,595 total)~2,900no puede cargarseno puede cargarseno puede cargarse~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 hub / longitud variable / shortestPath con 1.5B aristas necesitan que su pool de lectura se eleve a ≥2 GiB, frente al límite automático maxIntermediate de slater.) Los histogramas valor→recuento 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 (mediana en ms) — el grafo cabe en RAM (MeSH, 341k / 469k)

formaslaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
count(*) de todos los nodos0.4115.023.816.482.02.2
conteo por etiqueta0.424.220.71.14.44.3
búsqueda puntual indexada0.433.90.480.480.658.8
conteo idx-eq0.424.95.02.03812.5
1 salto (anclaje indexado)1.285.81.214.13904.9
2 saltos (sin anclaje)1.405.68.516.74446.4
group-by / count(DISTINCT)0.4547–5163–6431–394115.3
escaneo completo CONTAINS0.435.424.11.716.34.1

slater domina las formas de metadatos / índice / escaneo (count, label, idx-eq, scan — ~0.4 ms, 10–200× los motores de servicio), la búsqueda puntual indexada (0.43 ms, ahora superando por poco los 0.48 ms del par en memoria), el multi-salto sin anclaje (2 saltos 1.40 ms mediante el escaneo por tipo de relación, el más rápido del campo), y — mediante un histograma valor→recuento 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 columnares de LadybugDB). Los servidores en memoria conservan solo el 1 salto crudo (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/scan ~0.4 ms, ~1.3–2.6 ms en saltos.)

Latencia (mediana en ms) — vectores (kNN de EU-AI-Act, 15k × 1024 dimensiones)

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

slater responde al 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 prenormalizada 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 está dentro de ~1.4× de Memgraph, solo por detrás de FalkorDB — y 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 FreshDiskANN-style write ladder sobre la base Vamana estática) no tiene contrapartida entre motores — ningún otro motor aquí hace ANN escribible nativo de disco — por lo que los números siguientes son puntos de referencia de componente de un solo motor sobre un fixture sintético similar a embeddings (una variedad de bajo rango, dim 768, normas desiguales), incluido en crates/slater/benches/ y documentado por completo — con la metodología y todas las salvedades — en docs/PERF-REPORT.md. El recall siempre se mide contra una fuerza bruta exacta sobre el conjunto vivo, nunca un índice contra otro. La escala aquí es representativa y solo se extrapola cuando la métrica es lineal respecto al tamaño.

propiedadmedidopor qué importa
Latencia 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 50kla latencia de consulta no se degrada mientras las escrituras se acumulan entre consolidaciones
Inserción de embedding~1.5–2 ms por vector en el índice vivouna escritura es visible para KNN de inmediato; el presupuesto de reconstrucción delta es ≈ 2 ms × el límite de delta
IO de borrado con recall iso2.9× menos recuperaciones de nodo por consulta con 67 % de borrados, 5.2× con 80 % (recall ≥ 0.90)un grafo consolidado no paga impuesto de lectura por vectores borrados
Consolidación, permutación puraO(1) — el .vamana está con enlace físico, byte-idéntico, solo se reescribe la columna de idsplegar escrituras de vectores en la base omite la reconstrucción O(N·R·L)
Recall a lo largo de la escaleraconsolidado ≥ base para coseno, L2 y producto puntola escalera de escritura preserva el recall en cada peldaño

La única cifra que quiere el recuadro de rendimiento dedicado es el rendimiento de reescritura de consolidación por la ruta lenta — cuando una consolidación lleva 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 ambiental).

Latencia (mediana en ms) — 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 lo hacen slater y Neo4j 5. Esta es una pasada nueva en la misma máquina y el mismo día contra un conjunto de anclajes fijo y compartido — cada consulta alcanza los mismos nodos en ambos motores, por lo que el cara a cara es comparable (un pool común de anclajes wikidata_id de grado moderado; véase la nota más abajo sobre por qué importa). slater se muestra con ambos fanouts (query.maxFanout 1 = predeterminado de rendimiento, 8 = el dial de latencia que solapa las lecturas de bloques fríos). Negrita = mejor de la fila.

formaslater (fan 1)slater (fan 8)Neo4j 5
count(*) de todos los nodos0.410.413606
búsqueda puntual (indexada)0.720.496.3
grado (conteo de 1 salto)0.430.446.0
vecinos a 1 salto9.84.510.1
2 saltos372334.5
3 saltos322574
longitud variable *1..2 distinct985105647

El panorama honesto: slater domina las formas de metadatos / índicecount(*) 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 frías), pero pierde de forma decisiva en var-length *1..2 distinct (≈1 s frente a los 47 ms de Neo4j): la expansión distinct de longitud variable de slater es materialmente más lenta aquí, una debilidad real que merece su propia investigación. Todo ello con unos pocos cientos de MB de RSS frente al heap comprometido de ~2 GiB de Neo4j.

Sobre los anclajes. Estos números de recorridos dependen en gran medida de qué nodos se utilicen como punto de partida — un nodo a un enlace de un mega-hub de Wikidata ("human", "country") tiene un vecindario de 2 saltos de millones de elementos, por lo que el coste de longitud variable/saltos varía en órdenes de magnitud según la elección del anclaje. La edición anterior de esta tabla muestreaba el propio "primeros N por escaneo" de cada motor, que no es estable ni comparable; esta pasada fija un único conjunto de anclajes compartido y acotado por grado para ambos motores. (shortestPath se omite en esta pasada — entre dos anclajes arbitrarios depende de la existencia de caminos y tiene una varianza demasiado alta como para que una mediana tenga sentido.)

count(*) multi-salto — 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. Mismos anclajes hub en el grafo de 91.6M, maxIntermediate=20M:

count(*) de 3 saltos @ 91.6Mfanout=1fanout=8
latencia / pico del conjunto de trabajo554 ms / 0.66 GiB298 ms / 1.9 GiB

El conteo mantiene O(1) filas. La contabilización no cambia, por lo que un conteo de mega-hub sigue disparando maxIntermediate en cómputo (lecturas de adyacencia), acotado como antes.

Paralelismo por consulta (maxFanout)

Elevar query.maxFanout solapa las lecturas de bloques frías y 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 formas cálidas. 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×); count de 3 saltos 547 → 298 ms. maxFanout=1 es el predeterminado (orientado a rendimiento); 8 es el dial de latencia, con más memoria de trabajo transitoria.

Dónde gana / pierde slater

dimensiónslatermejor del restoveredicto
memoria residente, cualquier escala11–584 MiB (62k → 91.6M)en memoria 1.5–2.7 GiB; no puede cargar 1.5Bslater
count / metadatos / escaneo~0.4 msmotores de servicio 5–80 msslater (10–200×)
búsqueda puntual indexada0.43 ms (MeSH)Memgraph · FalkorDB 0.48 msslater (supera por poco al par en memoria)
multi-salto sin anclaje (filas)1.40 ms (MeSH 2 saltos)Neo4j 5.6 msslater (escaneo por tipo de relación)
agregación (group-by / DISTINCT)0.45 msLadybugDB 5 ms (columnar)slater (histograma en tiempo de compilación)
kNN2.4–2.9 ms (exacto)FalkorDB 1.2 ms (HNSW)supera a Neo4j/Ladybug; ~1.4× detrás de Memgraph; exacto
91.6M metadatos / punto / grado / 3 saltos0.4–32 msNeo4j 6–3,600 msslater (2–8800×)
91.6M 1–2 saltos4.5–23 ms (fan 8)Neo4j 10–35 ms~a la par
91.6M longitud variable *1..2 distinct~1 sNeo4j 47 msNeo4j (una debilidad real de slater)
count(*) multi-salto a escala0.3–0.6 GiBlos motores en memoria materializan el conjunto de filasslater, acotado

Las tablas completas por motor (pole, MeSH, EU-AI-Act + el dial RAM↔latencia de blockCacheBytes, Wikidata 1M y 91.6M) están en perf/cross-engine-hs/README.md; la pasada nueva solo de slater (ambos fanouts, todos los conjuntos de datos) está en perf/PERF_CURRENT_STATUS.md.

Concurrencia y degradación (pruebas de carga)

Los puntos de referencia anteriores son de un solo cliente. El eje complementario — comportamiento bajo muchos clientes concurrentes — tiene su propio arnés, perf/loadtest/: un controlador Locust sobre Bolt más un coordinador que aumenta la carga, lee CALL slater.diagnostics(), encuentra la rodilla de capacidad y nombra al 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):

resultadomedición
Soporta 1000 clientes concurrentes, cero fallosel 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 (una sola ejecución, WSL2)
Caché de bloques acotada y eficaz100% de tasa de aciertos, 0 desalojos, 50 MB residentes para un conjunto de trabajo que cabe en la caché
RSS mantenida bajo carga sostenidael asignador jemalloc mantiene la RSS en ~0.6 GB a lo largo de una rampa 100→500 clientes de wiki_cache_churn — limitada por 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 nivel máximo posterior a la ráfaga en lugar de dejarlo fijado
Memoria agregada acotadaquery.maxIntermediateGlobal a nivel de servidor + expansión contabilizada por adyacencia contienen la inundación de 2 saltos de wiki_budget con 1000 clientes sin OOM (RSS ~0.6 GB; el guardián descarta ~60% de las consultas hub como errores de presupuesto reintentables)

Ambos problemas de memoria que la prueba de carga sacó a la luz están ahora cerrados; todos registrados 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 las atribuciones. Salvo que se indique explícitamente lo contrario, cualquier contribución enviada intencionadamente para su inclusión en esta obra, según se define en la licencia Apache 2.0, se licenciará como arriba, sin términos ni condiciones adicionales.

SPDX-License-Identifier: Apache-2.0

Categorías