当前版本:v0.25.2 — 所有发布。
一句话概括: Slater 服务于无法放入内存的图——数亿个节点和数十亿条边仅占用几百 MB 内存——通过标准 Bolt 协议提供服务,因此任何 neo4j 驱动都能直接使用;磁盘原生的向量搜索紧邻图而生,并且它在不放弃这一切的同时接受实时、持久的写入。常驻内存由你选择的缓存预算决定,而不是由图的大小决定。
快捷入口
| 为什么存在 Slater | 读取与写入 | 你会得到什么 | 功能特性 |
| 使用 Docker 运行 | 工作原理 | 可写层 | 存储后端(文件系统 / S3 / GCS) |
| 挂载 | 环境与配置 | ACL | 健康检查 |
| 实操示例 | 开发 | 性能 | 许可证 |
| Graphiti 记忆存储 | 📖 完整手册 |
图数据库将数据存储为事物(节点)以及它们之间的关系(边),关系是一等公民。当你关注的是连接而非行时,这正是你想要的——“谁在这个账户三跳之内?”、“这次构建背后的完整依赖链是什么?”、“哪些账户共享设备、地址和银行卡?”——这些查询在 SQL 中会成为递归连接的泥潭,但在图中却自然呈现。
关于图数据库最常见的抱怨是:它们无法扩展到超出内存能容纳的规模。 其中许多(如 neo4j、Memgraph、FalkorDB 等)将整个图常驻内存:一个 40 GB 的图就需要 40 GB 内存——每个实例。想按区域、按租户或按 Pod 各放一个副本?账单成倍增加。而且一旦超过一定规模,它们根本无法加载:例如 9000 万节点 / 15 亿边的 Wikidata 图需要约 64–128 GiB 常驻内存,因此内存引擎根本无法打开它。
Slater 是对此的反驳。它不把图加载到内存,而是离线一次性编译:slater-build 将你的数据转换为内容寻址、不可变的磁盘映像,任意数量的 Slater 服务器随后通过 Bolt 提供该映像(因此你现有的 neo4j 驱动可以直接使用),按需分页加载块,并仅保留固定的缓存预算常驻内存。正是这样,同一个 9000 万节点的图只需几百 MB 内存即可服务——图的大小与内存账单解耦。一个 4 GB 的图和一个 400 GB 的图服务所需内存相同,因此你可以廉价地横向扩展无状态只读副本,让存储而不是堆来持有图。
这使它天然适合 RAG 背后的知识图谱、推荐与身份图、依赖图——任何庞大且相互关联、你想廉价而频繁查询的数据。磁盘原生的向量搜索紧邻图而生,因此同一引擎也可作为嵌入向量的检索层。
不过,一次性编译并不意味着冻结。该映像是一个基础,而不是最终状态:一个可选的写入层位于其上,因此在线图可以被修正和扩展,而无需重建任何东西。
核心是不可变的,但图不是。启用可写层(delta.enabled)后,你可以通过 Bolt 进行写入——修正一个属性、添加一个节点、撤回一条边——变更持久生效,无需重建映像。让读取端保持廉价的关键是写入的存放位置。
写入会累积在不可变核心之上的日志结构合并(LSM)层中:一个预写日志和一张内存表,溢出为不可变的增量段,并通过定期合并(consolidation) 折回成全新的核心。这带来以下好处:
count(*)、标签与关系类型的边际统计——即使存在未完成的写入,也仍然是元数据读取:增量维护自己的计数器,因此对 9160 万节点核心且带有 50 万待处理写入执行 count(*),仍可在几十毫秒内返回结果,而无需触碰任何数据块。fsync 完成之后才返回 SUCCESS。将写入分组可以降低成本——一次写入 UNWIND 每个批次只提交一次 fsync,而不是每行一次。MERGE / MATCH … SET / DELETE(以及 CREATE / REMOVE、分离删除、关系写入)——或等价的 ISO GQL 数据修改语句(INSERT / SET / REMOVE / ),它们降级到同一条路径。对节点和边进行修正、插入、upsert(插入或更新)和撤回,以你数据已有的方式来寻址。在关闭该层(默认情况)时,Slater 只提供纯粹的不可变核心,并拒绝写入。完整模型请参阅 可写层。
关于名字。 Slater 得名于 Archer(一部很棒的美剧)中的 CIA 特工, 他坚持只用一个名字——“就叫我……Slater”——也是我在这部剧中最喜欢的 角色之一。参见 角色维基页面。
MERGE / SET / DELETE,组提交并由 fsync 确保持久化,通过合并折回成全新核心。读取无需为此付出代价。current 指针,服务器即会拾取。每个块都有校验和,因此半拷贝的映像会被拒绝而不是被服务。工作区由两个二进制文件组成:
| 二进制 | 角色 |
|---|---|
slater | 在线 Bolt 服务器(容器 ENTRYPOINT):提供读取,并在启用 delta.enabled 时提供单写入者持久写入路径。 |
slater-build | 离线编译器:将基础 Cypher 转储转换为不可变、内容哈希的代次目录。 |
Slater 将批量构建与服务分离:slater-build 在离线状态下完成繁重工作——摄取你的数据并将其编译为不可变代次——因此冷图永远不会在服务热路径上组装。在服务器内部,读取面回答广泛的 Cypher 子集——模式匹配、WITH/UNION/CALL {…} 子查询、70+ 标量与聚合函数、时间与地理空间值、图算法(algo.*)以及磁盘原生向量 KNN(db.idx.vector.queryNodes)——而可写层的增量覆盖层位于该读取面之下,为空时零成本,因此读取永远不会携带写入侧的机制。你可以通过两种方式更新图:通过 Bolt 实时写入(参见 可写层),或离线构建新代次并原子地切换 current 指针,运行中的服务器通过其代次守卫(generation guard)拾取该指针(参见 代次守卫)。
完整的用户手册位于 docs/manual/——这是一份逐功能指南,针对每项能力解释它是什么、为什么存在以及如何使用,并带有可在随附示例图上运行的实操示例。凡超出本概述的内容,请从那里开始。
graphiti-slater 是一个适配器,让 Graphiti 将其时间知识图谱存储在 Slater 中,并附带一个可运行的 docker-example/——包括将其作为 MCP 服务器暴露给 Claude Code。其工作原理及运行方式请参阅该仓库。
Slater 设计为以 Docker 部署方式运行——这是预期的使用方式。预构建的多架构镜像(linux/amd64 + linux/arm64)发布到 Docker Hub:hikarisystems/slater,每次发布都会打上 :latest 和 :vX.Y.Z 标签:```sh
docker pull hikarisystems/slater:latest
一份仅使用 Docker 命令的用法、配置和操作指南位于 [`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/main/DOCKERHUB.md)(并镜像到 Docker Hub 概览页)——**如果你要部署,请先阅读该指南。** 简而言之:```sh
# Build a graph generation with the offline writer:
docker run --rm -v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
--entrypoint /app/slater-build hikarisystems/slater:latest \
--input /dumps/people.cypher --graph people --data-dir /data
# Serve it over Bolt on 7687 (read-only unless `delta.enabled`):
docker run -d --name slater -p 7687:7687 \
-v slater-data:/data:ro -v "$PWD/acl.json:/config/acl.json:ro" \
hikarisystems/slater:latest
要在本地构建镜像(例如用于开发):```sh
docker compose build
docker compose up slater
build):docker compose run --rm builder
--input /dumps/people.cypher --graph people --data-dir /data
构建阶段为 rustls 的 `aws-lc-rs` 后端安装了 `cmake`、`clang` 和 `libclang-dev`;`git`(基础镜像中已包含)是 `hs-utils` 的 git+tag 依赖所必需的,`.cargo/config.toml` 通过 git CLI 获取该依赖。
以下各节介绍磁盘上的格式、配置、ACL 以及一个本地(非 Docker)的实际示例。
## 工作原理```
slater-build slater (Bolt server)
dump.cypher ──────────▶ /data/<graph>/<uuid>/ ──────────▶ neo4j driver
(offline, atomic) MANIFEST.json, *.blk, (bolt / bolt+s)
range/*.isam, vector/*.{vamana,pq},
current → <uuid>
MANIFEST.json(符号表、
索引描述符、可选的加密头),列式块文件
(node_props.blk、node_labels.blk、edge_props.blk、topology.csr.blk、
vectors.f32.blk)、范围索引(range/<name>.isam)、超阈值 ANN
索引(vector/<label>.<prop>.{vamana,pq}),以及一个 current 文本指针。--encrypt 时,每个
块还会使用 XChaCha20-Poly1305(静态 AEAD)进行额外密封。当 delta.enabled 时,不可变世代成为一个小型日志结构化合并树的
完全压缩底层("core"),实时写入在其之上
进行:```
write (Bolt) read (Bolt)
│ │
▼ ▼
┌──────────────┐ flush ┌──────────────┐ ┌──────────────────────┐
│ WAL + active │ ───────▶ │ L0 delta │ │ a query pins one │
│ memtable │ │ segments │ │ (core, delta) view │
└──────────────┘ └──────┬───────┘ │ and reads the merge │
(fsync = ack) │ └──────────────────────┘
consolidation │ (folds core + delta → fresh core)
▼
┌─────────────┐
│ new core │ (atomic current swap)
└─────────────┘
* **持久性下限 — WAL。** 每个变更都被序列化到每个图对应的单个写入器之后,追加到每个图的预写日志中,并在返回 Bolt `SUCCESS` 之前执行 `fsync` —— 因此 *已确认 ⇒ 已持久化*,撕裂的尾部会在重放时被丢弃。批量写入 `UNWIND` 会追加其行,并为整批提交**一次** `fsync`。WAL **仅位于本地磁盘**(不经过存储后端路由),这使 *写入* 节点成为有状态的:它需要在 `delta.walDir` 处有一个持久化本地卷。只读副本保持无状态。
* **Memtable → L0 → 压缩(consolidation)。** 写入在内存中的 memtable 内累积(由 `delta.memtableBytes` 限制);当它填满时会刷写到一个不可变的 L0 delta 段。**压缩** 通过将合并后的视图重新经 `slater-build` 序列化并原子地替换 `current`,将 `{core + delta}` 折叠成一个新的 core —— 与任何已发布的 generation 相同的内容哈希保护。可以手动通过 `CALL slater.consolidate()` 触发,或在达到 core 大小的 `delta.deltaCorePercent` 时自动触发(可选择限制到非高峰 `delta.consolidateWindow`),或让 `delta.deltaHardBytes` 节流作为失控增长的兜底。
* **叠加层位于读表面之下。** 执行器通过 `ReadView` 读取,该视图要么是裸 core(delta 始终为空),要么是合并的 `(core, delta)` 视图;引擎在其上进行单态化,因此空 delta 可编译为单一可预测分支,且只读路径字节级一致。全图计数器(`count(*)`、标签/关系类型的边际计数)由 delta 自身的实时计数器提供,因此即使有待处理写入,它们仍是元数据读取。
* **查询看到稳定的快照。** 它在整个生命周期内固定一个 `(core, delta)` 元组。没有多语句事务,也没有回滚 —— 写入是持久的、按业务键寻址的更正,而不是 OLTP 事务。
确切的写入语法和调节项见下面的 [配置](#environment--configuration) 表(`delta.*`)和 [实际示例](#worked-example)。
### 范围索引(ISAM)
范围索引(`range/<name>.isam`,每个被索引的 `(label, property)` 对应一个)允许 `MATCH (n:Label {prop: v})` 或 `WHERE n.prop <op> v` 解析到匹配的节点 ID,**而无需扫描该标签**。它是一种 **[ISAM](https://en.wikipedia.org/wiki/ISAM)**(索引顺序存取方法,Indexed Sequential Access Method)结构 —— 经典的 *静态、排序、块结构* 索引,这正是不变 generation 的理想形态:没有需要重新平衡的插入,因此 ISAM 的简洁性所获得的效果,B-tree 的变更机制只会使其复杂化。
* 条目 `(value, entity_id)` 按值排序,并像其他所有内容一样打包进 zstd 压缩的 256 KiB 块中。
* 一个较小的**常驻顶层**保存每个块的第一个键(稀疏索引)。查找时在内存中的顶层进行二分搜索,找到键可能所在的*唯一*块,读取并解压该块并扫描 —— 因此等值查找只需**一次块读取**,范围扫描则遍历其覆盖的连续块序列。(这就是为什么 `meshUi` 索引查找只需个位数毫秒,而对未索引属性做同样匹配则要扫描整个标签。)
* 规划器通过 `NodeScan::RangeEq` / `RangeRange` 选择该索引;未索引的谓词会回退到标签扫描或全表扫描,无论哪种方式,执行器都会重新检查每个谓词。
### 向量搜索(Vamana + PQ)—— 余弦、L2 和点积,读 *和* 写
向量 KNN(`db.idx.vector.queryNodes`)运行在 **余弦、L2 或点积(MIPS)** 索引之上。基础索引离线构建,包含两条执行路径,由 `--ann-threshold`(默认 50 000 个向量)按索引选择:
* **低于阈值 —— 暴力搜索。** 完整的 `f32` 向量位于 `vectors.f32.blk`;查询会扫描该索引的组,并以索引的度量计算精确距离。简单且精确;在向量集较小时很合适。
* **达到或超过阈值 —— Vamana + PQ**,即磁盘原生的 ANN 路径,无论向量数量多少,都能将常驻内存保持有界:
* **[Vamana](https://arxiv.org/pdf/2401.11324)** 是 DiskANN 系列工作中的图索引:一个单一邻近图,其边经过剪枝(`--vamana-r` 出度和 `--vamana-alpha` 长边因子),因此*贪婪束搜索* —— 从 medoid 开始,反复朝查询跳跃,并维护宽度为 `vectorQuery.beamWidth` 的候选列表 —— 能在少量跳跃内到达节点的真正邻居,即**每次查询只需少量随机块读取**。图块(`vector/<label>.<prop>.vamana`)通过向量缓存按页调入,而非整体驻留。
* **[乘积量化(PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)** 将每个向量压缩为短代码(`--pq-subspaces` × `--pq-bits`):维度被拆分为子空间,每个子空间独立进行 k-means 聚类,向量则存储为最近质心 ID 的元组。这些代码(`vector/<label>.<prop>.pq`)足够小,可以**常驻内存**,因此束搜索从 RAM 中为候选打分,只有选中的少数完整向量才从磁盘读取。这个常驻 PQ 集就是 `cache.vectorCacheBytes` 缓冲池所固定的内容。
**可写嵌入 —— 向量写入阶梯([FreshDiskANN](https://arxiv.org/abs/2105.09613) 风格)。** 被索引的嵌入是一等可写值。`SET n.embedding = vecf32([…])`(以及 `REMOVE`)落入写入 delta,并**立即以精确排序对 KNN 可见**,随后在段刷写、合并和压缩中存活。查询最多合并三个层级 —— 已封存的基础索引、已封存的每段索引,以及内存中的 **RW-index**(写 delta 之上的实时可变 Vamana)—— 因此延迟在写入累积时保持平稳,而不是随着待处理写入数量增长。删除会留下一个*空洞*:该节点不再被返回,但作为导航路点保留,直到后台**删除压缩**将其从图中拼接出去,因此删除不再耗费查询 IO。而且由于磁盘上的图按布局位置而非节点 ID 寻址邻居,`CALL slater.consolidate()` 会**按引用**携带 Vamana —— 硬链接、字节一致 —— 并且只重写一个小型 ID 列,将向量写入合并进基础索引,而**无需** O(N·R·L) 图重建。带有说明的实测数据见 [性能报告](https://github.com/hikari-systems/slater/blob/main/docs/PERF-REPORT.md)。
## 存储后端(文件系统 / S3 / GCS)
每个 generation 文件都通过 **`ObjectStore`** 抽象打开,而非直接使用 `std::fs`,因此 *相同* 的磁盘字节格式 —— 块、索引、清单、`current` 指针 —— 可从任何后端原样提供;只有 *字节来源* 不同,读取器、查询引擎或完整性检查从不因后端而异。热路径是位置读取(`read_exact_at`),映射到本地文件上的 `pread` 和对象存储上的 HTTP 字节范围请求 —— Slater 从不使用 mmap,因此显式、有界读取模型在所有地方都相同。
**三个一流的后端**,通过 `dataBackend.kind` 选择。文件系统是简单的默认项;**Amazon S3 和 Google Cloud Storage 是同等、完全受支持的对象存储后端** —— 发布镜像内置了两者,因此每个都只需配置即可,而且一次构建的 generation 可以从其中任何一个提供服务(甚至可以从 `fs` → S3 → GCS 迁移),无需重建。
| `dataBackend.kind` | 位置读取 | 打开时的完整性 | 凭据 |
| --- | --- | --- | --- |
| `fs` *(默认)* | `pread` | 对每个文件进行完整 BLAKE3 重新哈希 | — |
| `s3` | HTTP `Range` GET | 通过 `HEAD` 获取服务器 **SHA-256**(若缺失,则对正文进行 BLAKE3 重新哈希) | 配置密钥、AWS 链或 IAM 角色 |
| `gcs` | HTTP 范围读取 | 通过 `get_object` 获取服务器 **CRC32C**(若缺失,则对正文进行 BLAKE3 重新哈希) | ADC / Workload Identity,或服务账号 JSON |
两个对象存储都通过**存储端已经计算并保留的校验和**来验证完整性,该校验和作为对象元数据获取:`slater-build` 在上传时发送该校验和(存储端据此验证字节并存储),服务器在打开时读回并与清单比对 —— 每个文件一次元数据请求,不下载正文。它是内容级的,且与 S3(SHA-256)和 GCS(CRC32C)在本质上一致。当对象**没有**存储端校验和时(通过带外方式复制,或使用不同默认值上传),服务器会**对照清单 BLAKE3 重新哈希对象正文**,而不是信任其字节长度 —— 请求的完整性检查绝不会被静默降级为大小比较。Slater 发布的 generation 始终携带校验和,因此它们保持在廉价的元数据路径上。
这个列在每种后端上检查的是文件**与清单匹配**。清单本身是否可信是另一个问题,而主密钥就是答案:配置了密钥后,清单会携带带密钥的 MAC,服务器在信任任何字段(包括这些哈希)之前会先验证该 MAC,因此被重写以描述被篡改文件的清单会被拒绝;没有密钥时,整个比较过程都是无密钥的,能够写入数据目录的人可以同时重写文件和清单。请参阅
[每种配置下完整性的含义](https://github.com/hikari-systems/slater/blob/main/THREAT_MODEL.md#what-integrity-means-in-each-configuration)。
该检查本身可以通过 `dataBackend.verifyIntegrity: false` 关闭,以换取更快的打开速度。
### 文件系统(`fs`)
默认项,根目录为 `dataBackend.fs.dir`。适合大多数部署:位于本地 SSD(或 NFS/EBS 挂载)上的 generation 以只读方式提供服务。完整性是在打开时对每个文件进行完整的 BLAKE3 重新哈希。
### Amazon S3(`s3`)
S3 或兼容 S3 的存储桶(AWS、MinIO、localstack)。凭据**首先**来自配置(`dataBackend.s3.awsAccessKey` / `awsSecretKey`,以及用于临时 STS 凭据的 `awsSessionToken`),留空时回退到标准 AWS 链(`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` 环境变量、共享配置文件,或实例/IRSA 角色)。```sh
# serve from S3 (env-var form; see the config table for every key)
dataBackend__kind=s3
dataBackend__s3__bucket=slater
dataBackend__s3__region=eu-west-2
dataBackend__s3__awsAccessKey=… # omit to use the AWS chain / instance role
dataBackend__s3__awsSecretKey=…
# S3-compatible (e.g. MinIO): also set
dataBackend__s3__endpoint=http://minio:9000
dataBackend__s3__pathStyle=true # required by most S3-compatible servers
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
--publish-s3-bucket slater --publish-s3-region eu-west-2 --publish-s3-prefix prod
# MinIO: add --publish-s3-endpoint http://localhost:9000 --publish-s3-path-style
gcs)一个 GCS bucket,通过 JSON API 访问。授权采用 GCP 原生方式:默认情况下会解析 Application Default Credentials——包括 GKE Workload Identity、GCE metadata server,或 gcloud / GOOGLE_APPLICATION_CREDENTIALS 密钥。可设置 dataBackend.gcs.credentialsPath(服务账号 JSON 密钥文件)或内联的 credentialsJson 来提供显式密钥。dataBackend.gcs.endpoint 指向 fake-gcs-server 模拟器,而 dataBackend.gcs.anonymous=true 仅对该模拟器启用未认证访问——切勿用于真实的 GCS。```sh
dataBackend__kind=gcs dataBackend__gcs__bucket=slater dataBackend__gcs__prefix=prod dataBackend__gcs__credentialsPath=/secrets/sa.json # omit for ADC / Workload Identity
```sh
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
--publish-gcs-bucket slater --publish-gcs-prefix prod
# explicit key: add --publish-gcs-credentials /secrets/sa.json
在所有情况下,slater-build 都会先将完整的生成结果写入 --data-dir(其本地暂存区域),然后额外上传到存储桶;远程的 current 指针最后写入,因此服务节点永远不会看到半发布的生成结果。
当你希望生成结果存放在持久、集中的对象存储中,而不是节点磁盘上时,选择 s3 或 gcs —— 典型场景包括:发布一次,然后扇出到许多无状态、无磁盘的服务器副本,它们都读取同一个存储桶;将构建主机与服务主机解耦;或者依靠存储的持久性/版本管理/生命周期,而不是自行管理卷。代价是延迟:冷块是一次网络往返(约 10–50 ms),而不是本地读取(约 0.1 ms)。Slater 通过内存块缓存、并发预读以及可选的磁盘缓存(见下文)隐藏了其中大部分延迟。如果你的生成结果已经位于快速本地存储上,并且不需要集中式存储桶模型,那么 fs 更简单、更快。
内存中的 BlockCache 刻意保持较小(有界 RSS 是首要保证),因此在大于 RAM 的工作集上,同样的块每次溢出时都会从对象存储重新获取。一个可选的本地 SSD 第二缓存层解决了这个问题:从 RAM 逐出的块从本地磁盘(约 0.1 ms)提供服务,而不是重新执行对象 GET,从而在内存逐出后仍然存活,并降低对象存储的请求次数/成本——一旦预热,可使基于对象存储的节点接近本地文件系统的性能。它对 s3 和 gcs 都是可选启用的,通过设置 dataBackend.<s3|gcs>.diskCacheBytes > 0 以及一个可写的 diskCacheDir 来开启。
--encrypt 生成结果)仍然是 AEAD 密封的——位于解密/解压缩之下。缓存层从不持有加密密钥,也从不重新加密,因此静态状态免费保留:加密的生成结果以仍密封的状态落到磁盘上。diskCacheDir 必须指向真实可写的卷——绝不能是 tmpfs(tmpfs 是内存,会破坏有界 RSS 保证)。跟踪它的内存索引会占用少量 RAM(每个缓存块约几十字节),这部分计入你的 RSS 上限——目录大小应远大于内存块缓存。blockCacheBytes / 8(下限由 diskCacheBytes 决定)——默认为 8 MiB——并且会丢弃而不是增长,因此冷扫描不会使其膨胀;被丢弃的块只会在下次未命中时重新获取。它无需配置:它随 blockCacheBytes 伸缩,因此磁盘层除了其索引外,不会在 RSS 预算中增加新数字。只读副本以只读根文件系统和非 root 用户(appuser:1000)运行——它所需的一切都以只读方式挂载。写入者(delta.enabled)还需要一个持久、可写的卷来存放其 WAL。
配置由标准的仓库分层加载器加载:内置的 config.json,然后 /sandbox/config.json 深合并到其上,然后是 KEY__sub 环境变量覆盖(双下划线表示嵌套;键与 camelCase 配置匹配)。
每个配置旋钮——它的 camelCase 键、KEY__sub 环境变量覆盖、默认值及其作用——都列在 配置参考 的表格中。最常调整的旋钮是缓存预算(cache.*)、查询保护(query.*)、连接上限(server.*)、存储后端(dataBackend.*)以及可写层(delta.*)。
常驻内存跟踪的是
blockCacheBytes + vectorCacheBytes + resultCacheBytes 在有限的每条目和分配器开销以内——每个池都称量自己的内容(字符串和容器按分配的容量计算)并逐出以保持在预算内,但你设置的数字之上还有每条目记账和分配器的尺寸类别舍入——再加上少量固定开销(以及用于 lazy 度数列的 degreeColumnBytes,一旦度数总和 count(endpoint) 快速路径被占用)。它与图大小无关——这是首要保证,由 rss_stays_bounded_under_sustained_knn_load 集成测试验证,该测试将峰值与稳定 RSS 的增长严格控制在预算总和之内。每连接缓冲区位于缓存预算之外,因此在对抗性负载下保证仍然成立,只是因为 server.maxConnections 限制了同时存在的连接数量。
Slater 是只读副本句柄;主要的连接安全控制是网络,而不是二进制。将其绑定到私有接口,在网络层限制源范围(安全组 / NetworkPolicy),并且——如果它面向任何非可信客户端——在其前面放置一个限制连接的 L4 代理(HAProxy maxconn + 按源的 stick-table,或 nftables connlimit + hashlimit)。这位于文件描述符交给进程之前,因此是最稳健的限制。
上述二进制内限制(maxConnections、maxPreAuthConnections、maxConnectionsPerIp、差分字节上限和 loginTimeoutMs)是纵深防御:它们默认开启且宽松,因此对合法客户端群体不可见,但即使在忘记代理的情况下也能使有界 RSS 保证成立。完整的防御态势请参阅 docs/HARDENING.md,权威细节请参阅 THREAT_MODEL.md / SECURITY_WORKLIST.md。
Slater 每 generationPollMs 轮询一次每个图的 current 指针(轮询,而非 inotify——数据目录可能是远程/网络存储,如 NFS,其中的文件系统变更事件不可靠)。当它发生变化时:
reloadStrategy=exit(默认):服务器记录致命错误并以非零状态退出,以便编排器针对新生成结果干净地重启它。reloadStrategy=swap:服务器打开并验证新的生成结果(与启动时相同的内容哈希保护),原子地将其换入,并让进行中的查询在旧生成结果上完成。损坏/不完整的新镜像会被拒绝,旧生成结果继续服务。acl.json 将用户映射到 argon2id 密码哈希和按图的 read / write 授权。使用以下命令生成哈希(绝不要存储明文):```sh
slater hash-password 's3cret' # prints a $argon2id$… string for acl.json
仓库根目录附带一个入门用的 `acl.json`;其结构如下:```json
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
"grants": {
"people": ["read"],
"products": ["read", "write"]
}
}
}
}
users — 每位登录用户一条记录,以用户名作为键。
passwordArgon2id — 来自 slater hash-password 的 $argon2id$… 字符串
(绝不允许明文;该文件本身是纯 JSON,且存放在共享存储上)。
grants — 按图划分的能力列表。只有两种权限具有实际意义:
read — 查询图。用户 grants 中不存在的图,对该用户不可见。write — 通过可写层(delta.enabled)变更图:
MERGE / SET / DELETE 语句以及 CALL slater.consolidate()。两者是相互独立的:read 授权并不赋予任何写权限。 因此启用可写层
并不会把你现有的读者提升为写者。写者需要同时拥有两者 — —
因为解析业务键以便写入,本身也是一次读取。
无法识别的权限字符串会被忽略(它们不授予任何权限)。
以只读方式挂载到 aclPath 指定的路径(默认为 /config/acl.json)。
服务器会在每次生成热切换时重新加载该文件,并且每次重新加载时都会
重新检查静态 ACL 印记(参见 requireAclStamp)。
slater 二进制本身兼作存活探针:slater healthcheck [host] [port] 会对服务器执行一次 Bolt 握手(不是 HTTP 请求),
若协商出协议版本则以 0 退出,否则以 1 退出 — 默认使用
localhost 和已配置的 Bolt 端口。这正是容器
HEALTHCHECK 所运行的内容,因此编排器看到的是一个真正 Bolt 就绪的服务器,
而不仅仅是开放的套接字:```sh
slater healthcheck localhost 7687 # exit 0 = healthy
docker exec slater /app/slater healthcheck # inside the container
## 一次性查询
对于脚本编写、CI 检查和快速查找,`slater query` 挂载图的
当前世代,在进程内运行单个只读 Cypher 查询,打印结果
为 JSON 对象,然后退出——无需服务器,无需 Bolt 连接。它遵循
与服务器相同的配置(存储后端、加密密钥、查询预算):```sh
# GRAPH defaults to `defaultGraph`. Without -q, normal datestamped logging
# (config, "opened generation", …) is written to stdout alongside the result.
slater query mygraph 'MATCH (n) RETURN count(n) AS c'
# -q/--quiet ⇒ logging suppressed, so stdout is *only* the compact result JSON
slater query mygraph -q 'MATCH (c:Company) RETURN c.ticker AS t LIMIT 3' | jq
# {"columns":["t"],"rows":[["AUPH"],["KYMR"],["MREO"]]}
节点和关系会展开为其标签/类型和属性。使用 -q
当你需要机器可解析的输出时(结果 JSON 是唯一出现在
stdout 上的内容);省略它以进行面向操作员的带日志运行。不带 -q 时,一个
仅含指标的摘要会在每次运行后被记录 — 例如```text
INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10
返回查询的 `cost`(计费元素数)、`resultCount`、`execMs` 以及
`limitRowCount`(仅在查询指定了 `LIMIT` 时)—— 绝不返回查询文本
或任何结果值。退出状态为 `0` 表示成功,`1` 表示解析/打开/执行
错误(消息输出到 stderr)。
## 导出图(`slater dump`)
`slater dump` 从**运行中**的服务器将图导出为业务键 `MERGE` Cypher —— 即 `slater-build` 所摄入的同一种方言 —— 从而使图能够往返(dump → `slater-build` → 新生成),用于迁移或文本备份。与 `slater query` 不同,它通过 **Bolt** 连接、进行身份验证,并遵循每图 ACL,因此无需对服务器的磁盘访问。密码从 `SLATER_DUMP_PASSWORD` 或 stdin 读取(绝不用标志,使其不出现在 `ps`/历史中)。```sh
# List the graphs the authenticated user may read.
SLATER_DUMP_PASSWORD=pw slater dump --list -u reporting
# Dump a graph to a file (identity keys inferred from range indexes).
SLATER_DUMP_PASSWORD=pw slater dump people -u reporting -o people.cypher
# Rebuild it into a fresh generation.
slater-build --input people.cypher --graph people --data-dir ./data
每个标签的标识键是其范围索引所携带的属性;
可通过 --key Label=prop(可重复)或全局 --pk <field> 覆盖。
CREATE INDEX DDL 首先发出,以便重建时重新创建索引。
多标签节点保留 每个 标签 — 它以 MERGE (n:Ident:Other {key: v}) 形式发出,
标识标签(提供业务键的那个)在前,其余
按序排列;合并仅以标识标签为键,因此尾随标签
被写入该节点,而不会创建另一个节点。包含特殊字符的标签、关系
类型和属性键在发出时
用反引号引用,因此不寻常的名称可以忠实地往返,
且无法将 Cypher 注入重建。向量(以及其他
没有 Cypher 字面量拼写的值)无法随 MERGE 转储输出,会被丢弃
并在 stderr 上发出警告。成功时退出状态为 0,出错时为 1。
一个完整、可运行的演练 — 构建图、为其提供服务、使用 neo4j JavaScript 和 Python 驱动程序连接并向其写入数据 — 位于手册的 快速入门 和 写入数据 页面,使用 docs/manual/examples/ 中捆绑的示例图。
export PATH="$HOME/.cargo/bin:$PATH" cargo build cargo test # unit + the bounded-RSS headline integration test cargo clippy --all-targets -- -D warnings cargo fmt --all -- --check
### 对象存储后端是可选的 cargo 特性
普通的 `cargo build` 会生成一个 **仅文件系统** 的二进制文件 —— `s3` 和 `gcs`
后端由 cargo 特性控制,因此默认构建保持精简(无需 AWS
或 Google SDK,无需异步运行时)。请在 `slater`(serve)
和 `slater-build`(publish)**两者**上启用你需要的后端:```sh
# S3 only / GCS only / both
cargo build -p slater -p slater-build --features s3
cargo build -p slater -p slater-build --features gcs
cargo build -p slater -p slater-build --features s3,gcs
Each crate 暴露匹配的 s3 / gcs features,这些 feature 转发到 graph-format/{s3,gcs}。如果在运行时请求某个后端(dataBackend.kind=s3|gcs,或 slater-build --publish-{s3,gcs}-*),而该 feature 未编译进去,则会快速失败并给出清晰的“built without the … feature”错误。发布的 Docker 镜像同时启用了两者(Dockerfile CARGO_FEATURES),因此预构建镜像无需额外标志——这仅在从源码构建时才重要。集成测试也做了同样的门控:--features s3 --test s3_minio、--features gcs --test gcs_emulator(一个 fake-gcs-server),以及 --features gcs --test gcs_real(通过 ADC 访问真实 GCS);每一项测试都会在未设置对应 SLATER_* 环境变量时跳过。
设计说明、里程碑记录和决策日志分别见 docs/PLAN.md、docs/PROGRESS.md 和 docs/DECISIONS.md。
最多六个引擎、一套单客户端测试套件,图规模从 62k 节点的玩具图到 Wikidata 91.6M 节点 / 1.5B 边。每个引擎都隔离测量(其他所有容器停止——RSS 和延迟都是其自身占用)。下面的延迟表是在 Slater 0.21.0(可写构建)上重新实测的:小/中规模图(MeSH、EU-AI-Act)为全新测量,91.6M 图则是全新的同机、共享锚点 slater-vs-Neo4j 对照(见该表)。常驻内存数据沿用较早一轮的测量(通过容器 cgroup 测量;读路径与可写层空闲时字节一致)。其他引擎的数字来自既有的跨引擎测试(它们的版本/性能未变)。所有数字均为中位数(ms)或峰值常驻内存(MiB)。每处数值越低越好;加粗 = 行内最佳。 slater 运行在其本地文件系统(fs)后端上;S3 和 GCS 后端用对象存储往返换取本地读延迟(由内存缓存和可选本地磁盘缓存层缓解),因此这些数字刻画的是引擎本身,而非网络存储部署。
三个从磁盘分页的引擎——slater、Neo4j 5 和 LadybugDB——能够加载全部五个图。内存三人组(Memgraph · FalkorDB · ArcadeDB)完全无法容纳 1.5B 边图(需要约 64–128 GiB 常驻),ArcadeDB 的导入器也无法完成该图。
每个数字都是已提交工作内存——即操作系统无法回收的部分。除 slater 外的每个引擎都把图放在已提交的匿名内存中(各自堆、Neo4j 的堆外页缓存或缓冲池),因此其峰值 RSS 就是其已提交占用。只有 slater 从磁盘存储的可回收 OS 页缓存提供服务,因此其数字是匿名工作集;存储的页缓存(在压力下可被驱逐——slater 仍会继续服务)被排除在外,并在 91.6M 图中以括号里的 total 显示。加粗 = 最低。
slater 在每个规模下都是最低,且在图增长约 1,500× 时仅增长约 50×——其占用跟踪的是 查询工作集,而非图本身(全程空闲约 16–71 MiB)。内存三人组几乎线性增长,且无法加载 1.5B 图;Neo4j 无论查询如何都会提交约 2 GiB 堆。(† LadybugDB 仅在边界形状上——其在 1.5B 边上的 hub / 变长 / shortestPath 遍历需要把读池提高到 ≥2 GiB,而 slater 则有自动的 maxIntermediate 上限。)构建期的值→计数直方图增加的可忽略不计——低基数索引列仅几 KB,而像 Wikidata 这样的唯一键图则为 零(wikidata_id 超过直方图基数上限,因此不存储)——所以这些数字不受该 feature 影响。
slater 主导了元数据 / 索引 / 扫描类形状(count、label、idx-eq、scan——约 0.4 ms,是服务引擎的 10–200×)以及索引点查(0.43 ms,现在已追上内存二人组的 0.48 ms)、无锚点多跳(2 跳 1.40 ms,通过关系类型扫描实现,业内最快)——以及通过构建期对索引分组键建立的值→计数直方图——整标签 group-by / count(DISTINCT)(0.45 ms,领先 LadybugDB 的列式 5.3 ms)。内存服务器只在原始 1 跳上保持领先(Memgraph 1.21 ms 对 slater 的 1.28 ms)。(pole 62k/106k 情况相同:slater 在 count/scan 上唯一最快约 0.4 ms,跳数约 1.3–2.6 ms。)
slater 用精确暴力扫描回答 kNN(这些集合低于其 50k 向量的 ANN 阈值),而其他引擎使用近似常驻 HNSW——因此 slater 的结果是精确的(召回率 1.0)。SIMD 距离内核 + 常驻、预归一化的向量矩阵将 Concept 从约 23 → 约 2.9 ms,Chunk 从约 10 → 约 2.4 ms,因此 slater 现在超越 Neo4j 和 LadybugDB,并达到 Memgraph 的约 1.4× 以内,仅落后于 FalkorDB——同时保持精确。
上面的表是跨引擎的 读 对比。向量写路径(在静态 Vamana 基线上采用 FreshDiskANN 风格的写入阶梯)没有跨引擎对应物——此处的其他引擎都没有做磁盘原生、可写 ANN——所以下面这些数字是单一引擎的组件基准,运行在合成的、类 embedding 的测试数据(低秩流形,维度 768,范数不等)上,提交在 crates/slater/benches/ 下,并在 docs/PERF-REPORT.md 中完整记录——包括方法论和所有注意事项。召回率始终与对实时集合的精确暴力搜索比较,而非一个索引对另一个索引。这里的规模具有代表性,仅在指标按大小线性时才做外推。
唯一需要专门性能箱的指标是 慢路径 合并重写的吞吐量——当合并携带删除或新向量而非纯重排时,它是以单线程 zstd 和本地磁盘为界的顺序重压缩,因此绝对 MiB/s 与环境相关(报告展示了形态并解释了环境范围)。
内存引擎(Memgraph / FalkorDB / ArcadeDB)根本无法加载此图(约 64–128 GiB 常驻)。只有 slater 和 Neo4j 5 可以。这是一轮全新同机、当日、针对共享固定锚点集的对照——每个查询在两个引擎上都命中相同节点,因此这场正面交锋是公平的(一个共同的 wikidata_id 中等度数锚点池;下面有关于为何重要的说明)。slater 以两种扇出展示(query.maxFanout 1 = 吞吐量默认,8 = 用于重叠冷块读取的延迟旋钮)。加粗 = 行内最佳。
实话实说:slater 在元数据 / 索引形状上占绝对优势——count(*) 由元数据服务(0.41 ms 对 Neo4j 的 3.6 s 磁盘扫描,约 8800×),点查 / 度数 / 3 跳快约 2–10×——在 1–2 跳上与 Neo4j 相当(扇出 8 在冷读上领先),但在 var-length *1..2 distinct 上明显落败(约 1 s 对 Neo4j 的 47 ms):slater 的变长 distinct 展开在此明显更慢,这是值得单独调查的真实弱点。而所有这一切都只用了数百 MB RSS,对比 Neo4j 已提交的约 2 GiB 堆。
关于锚点。 这些遍历数字很大程度上取决于你从哪些节点出发——一个离 Wikidata 巨型 hub(“human”“country”)只有一跳的节点,其 2 跳邻域可达百万级,因此变长 / 跳数成本会随锚点选择而波动几个数量级。该表的旧版本采样的是各引擎自己的“按扫描的前 N 个”,既不稳定也不可比;本轮为两个引擎固定了一个共享的、度数受限的锚点集。(本轮省略了 shortestPath——在任意两个锚点之间它取决于路径是否存在,方差太高,无法有意义地取中位数。)
count(*)——内存与结果大小解耦无上限的多跳 RETURN count(*) 在展开过程中计数,而不是物化匹配行。在 91.6M 图上使用相同 hub 锚点,maxIntermediate=20M:
| 91.6M 上的 3 跳 count(*) | fanout=1 | fanout=8 |
|---|---|---|
| 延迟 / 峰值工作集 | 554 ms / 0.66 GiB | 298 ms / 1.9 GiB |
计数只持有 O(1) 行。计费不变,因此巨型 hub 计数仍会在计算(邻接读取)上触发 maxIntermediate,界限与之前相同。
maxFanout)提高 query.maxFanout 会在多核上重叠查询的冷、I/O 受限块读取——它有助于大冷工作集、磁盘受限的形状,在热形状上则持平。在 1.5B 图上:shortestPath ≤6 918 → 608 ms(1.5×,最大搜索 6,269 → 2,350 ms,2.7×);3 跳计数 547 → 298 ms。maxFanout=1 是默认值(面向吞吐量);8 是延迟旋钮,代价是更高的瞬时工作线程内存。
各引擎完整表(pole、MeSH、EU-AI-Act + blockCacheBytes RAM↔延迟旋钮、Wikidata 1M 和 91.6M)见 perf/cross-engine-hs/README.md;最新的 slater 单独轮(两种扇出、所有数据集)见 perf/PERF_CURRENT_STATUS.md。
上面的基准是单客户端。互补的维度——多并发客户端下的行为——有自己专用的测试框架,perf/loadtest/:一个基于 Bolt 的 Locust 驱动,外加一个协调器,用于逐步加压、读取 CALL slater.diagnostics()、找到容量拐点并指出限制因素(完整方法见 docs/LOAD-TESTING.md)。以下是在 Wikidata-1M 图上、256 MiB 缓存的一次运行(一台 16 核机器)的要点:
负载测试暴露的两个内存问题现在都已关闭;全部跟踪记录见负载测试文档。
根据 Apache License 2.0 许可。完整文本见 LICENSE,署名信息见 NOTICE。除非你另有明确说明,否则任何有意提交以纳入本作品的贡献(按 Apache 2.0 许可证定义)均应按照上述条款许可,不附加任何额外条款或条件。
SPDX-License-Identifier: Apache-2.0
DELETE| 功能特性 | 对你意味着什么 |
|---|
| 有界、可预知的内存 | 常驻内存跟踪你设定的三个缓存预算,误差在有界的每条目与分配器开销之内——它不会随图的大小增长;你调整性能/内存权衡,而不是为整个图做资源预配。带有后台清理的 jemalloc 分配器会在大量查询突发后把释放的内存归还给操作系统,因此常驻内存回落到空闲基线,而不是停留在突发后的高水位。 |
| 开箱即用的多租户 | 一台服务器托管多个图,并支持按用户的读取授权——这是大多数图数据库只在付费/企业版中提供的多数据库隔离能力。 |
| 静态与传输中加密 | 逐块 XChaCha20-Poly1305 密封(密钥从不写入磁盘),外加可选的 TLS(bolt+s://)。按构造即符合 GDPR。加密同时也是获得可认证完整性的手段:构建器用带密钥的 MAC 密封清单,持有密钥的服务器会验证它,并拒绝服务清单被伪造、篡改或被剥离 MAC 的代次。未加密钥(明文)的映像仅由无密钥的内容哈希保护——能检测完整性与损坏,但无法检测篡改。参见 每种配置中的完整性含义。 |
| 极小的安装包 | 基于 distroless glibc 的小型 stripped 二进制(无 shell/apt)——多架构(amd64/arm64)镜像拉取约 22 MB,仅服务器的 slater:latest-lite 标签约 12 MB;纯 Rust TLS,无 OpenSSL。拉取即可运行。 |
| 为定期发布而设计 | 离线构建图,以不可变方式提供服务,然后零停机原子地切换新版本——非常适合数据仓库 / 定时刷新工作负载。 |
| 负载下依然坚固 | 服务器和离线构建器都以 #![forbid(unsafe_code)] 编译——引擎中唯一的 unsafe 位于经过审计的 jemalloc 分配器 crate 中。核心不可变,因此读取无需加锁,也永远不必等待写入者;单个写入者只在写入路径上串行化变更。没有 GC 暂停,没有数据竞争。一个坏查询无法让服务器宕机。 |
| 与你现有的 neo4j 工具兼容 | 讲 Bolt 5.4 / 4.4 / 4.1——可直接使用标准 neo4j 驱动(JS、Python、Go、Java……)、cypher-shell 或图浏览器,无需修改。 |
| 丰富的 Cypher 查询面 | 广泛的读取面:MATCH/WHERE/WITH/UNION、CALL {…} 子查询、70+ 函数与聚合、时间与地理空间值,以及正则表达式。 |
| 实时、持久的写入 | 不可变核心之上可选启用的单写入者 LSM 层(delta.enabled):对节点和关系进行业务键 MERGE / SET / DELETE / CREATE / REMOVE、批处理写入 UNWIND(每批一次 fsync),以及 CALL slater.consolidate()——组提交、fsync 持久化,并通过合并折回成全新核心。当增量为空时,读取路径逐字节一致。 |
| ISO GQL,读写皆可 | 在同一个 Bolt 连接上讲 ISO GQL(ISO/IEC 39075)子集——量化路径、路径限制器、最短路径选择器、标签/类型布尔表达式、FOR、CAST、可选的 GQL/CYPHER 方言前缀——并且当可写层开启时,GQL 的数据修改语句(INSERT / SET / REMOVE / [DETACH] DELETE)降级到同一条持久写入路径。Cypher 与 GQL,读取与写入,同一引擎内实现。 |
| 向量 + 图,同一引擎 | 磁盘原生的 ANN 向量搜索(Vamana + PQ;cosine / L2 / dot),用于嵌入/RAG,另有图算法(PageRank、BFS、介数、WCC……)——即使有数百万向量,内存也保持有界。嵌入向量可写(FreshDiskANN 风格的写入阶梯):插入 / 更新 / 删除向量,KNN 立即可见,无需重建即可折回基础。 |
| 网络存储上安全 | 每个文件都经过 BLAKE3 内容哈希并在打开时验证;撕裂或半拷贝的映像会被拒绝而不是被服务。专为 NFS/远程卷设计(没有 mmap 的意外)。 |
| 可插拔的存储后端 | 从本地文件系统、S3(S3 兼容)存储桶或 Google Cloud Storage 存储桶提供相同的代次格式——发布一次,横向扩展到无状态副本——并可在对象存储前放置可选的本地 SSD 缓存层。参见 存储后端(文件系统 / S3 / GCS)。 |
| 路径 | 用途 | 说明 |
|---|
/data | 图生成结果(<graph>/<uuid>/… + current)。 | 对副本只读;由 slater-build 生成。可能位于远程/网络存储(例如 NFS)上,因此不假定读取具有快速本地 SSD 的延迟。 |
/sandbox | 按环境的配置覆盖层 + 密钥。 | /sandbox/config.json 会深合并到内置的 config.json 之上;还存放 acl.json、TLS PEM 材料、静态加密密钥文件。 |
/tmp、/run | 临时空间(tmpfs)。 | 只读副本默认从不写入磁盘。 |
(写入者) delta.walDir | 预写日志 + L0 delta 段,当 delta.enabled 时使用。 | 可写,并且是持久、真实的卷——绝不能是 tmpfs(它是持久性的底线)。相对路径在数据目录下解析;在此处为写入者提供其自己的持久卷。 |
| (可选) 磁盘缓存 | 本地磁盘块缓存,当 dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0 时使用。 | 可写,并且是真实卷——不是 tmpfs。由 s3 和 gcs 后端使用;参见 存储后端。 |
["read", "write"]| 引擎 | 类别 | 内存界限 |
|---|
| slater | 磁盘支撑、分页 | query.maxIntermediate 自动限定工作集 |
| Neo4j 5 | 磁盘支撑、JVM | 约 2 GiB 堆 + 堆外,无论查询如何都会提交 |
| Memgraph · FalkorDB | 内存 | 全图常驻 RAM |
| ArcadeDB | 内存、JVM | 全图常驻;最重 |
| LadybugDB | 嵌入式、列式 | 手动缓冲池,必须超过查询需求 |
| 图(节点 / 边) | 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 向量) | 99 | 729 | 229 | 312 | 1,948 | 286 |
| Wikidata — 91.6M / 1.5B | 584 (total 4,595) | ~2,900 | 无法加载 | 无法加载 | 无法加载 | ~652 † |
| 形状 | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |
|---|
| count(*) 所有节点 | 0.41 | 15.0 | 23.8 | 16.4 | 82.0 | 2.2 |
| 标签计数 | 0.42 | 4.2 | 20.7 | 1.1 | 4.4 | 4.3 |
| 索引点查 | 0.43 | 3.9 | 0.48 | 0.48 | 0.65 | 8.8 |
| idx-eq 计数 | 0.42 | 4.9 | 5.0 | 2.0 | 381 | 2.5 |
| 1 跳(索引锚点) | 1.28 | 5.8 | 1.21 | 4.1 | 390 | 4.9 |
| 2 跳(无锚点) | 1.40 | 5.6 | 8.5 | 16.7 | 444 | 6.4 |
| group-by / count(DISTINCT) | 0.45 | 47–51 | 63–64 | 31–39 | 411 | 5.3 |
全扫描 CONTAINS | 0.43 | 5.4 | 24.1 | 1.7 | 16.3 | 4.1 |
| 形状 | slater | 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 |
| 性质 | 实测 | 为何重要 |
|---|
| KNN 延迟 vs 待处理写入 | RW 索引 约 1.5–2 ms,持平至 5 万待处理;预索引暴力叠加层 1.9 → 115 ms(随增量线性)——在 5 万时 61× | 查询延迟不会随着写入在合并之间堆积而退化 |
| 嵌入插入 | 每向量 约 1.5–2 ms 写入实时索引 | 写入立即可被 KNN 看到;增量重建预算 ≈ 2 ms × 增量上限 |
| 等召回率下的删除 IO | 删除 67 % 时每次查询减少 2.9× 节点读取,删除 80 % 时减少 5.2×(召回率 ≥ 0.90) | 合并后的图不会为已删除向量付出读取税 |
| 合并、纯重排 | O(1) — .vamana 硬链接字节相同,只重写 id 列 | 将向量写入并入基线可跳过 O(N·R·L) 重建 |
| 整条阶梯上的召回率 | 合并后 ≥ 基线,适用于 cosine、L2 和 dot | 写入阶梯在每一级都保持召回率 |
| 形状 | slater(fan 1) | slater(fan 8) | Neo4j 5 |
|---|
| count(*) 所有节点 | 0.41 | 0.41 | 3606 |
| 点查(索引) | 0.72 | 0.49 | 6.3 |
| 度数(1 跳计数) | 0.43 | 0.44 | 6.0 |
| 1 跳邻居 | 9.8 | 4.5 | 10.1 |
| 2 跳 | 37 | 23 | 34.5 |
| 3 跳 | 32 | 25 | 74 |
变长 *1..2 distinct | 985 | 1056 | 47 |
| 维度 | slater | 场上最佳 | 结论 |
|---|
| 任何规模下的常驻内存 | 11–584 MiB(62k → 91.6M) | 内存型 1.5–2.7 GiB;无法加载 1.5B | slater |
| count / 元数据 / 扫描 | 约 0.4 ms | 服务引擎 5–80 ms | slater(10–200×) |
| 索引点查 | 0.43 ms(MeSH) | Memgraph · FalkorDB 0.48 ms | slater(略胜内存二人组) |
| 无锚点多跳(行) | 1.40 ms(MeSH 2 跳) | Neo4j 5.6 ms | slater(关系类型扫描) |
| 聚合(group-by / DISTINCT) | 0.45 ms | LadybugDB 5 ms(列式) | slater(构建期直方图) |
| kNN | 2.4–2.9 ms(精确) | FalkorDB 1.2 ms(HNSW) | 胜 Neo4j/Ladybug;约 1.4× 于 Memgraph;精确 |
| 91.6M 元数据 / 点 / 度数 / 3 跳 | 0.4–32 ms | Neo4j 6–3,600 ms | slater(2–8800×) |
| 91.6M 1–2 跳 | 4.5–23 ms(fan 8) | Neo4j 10–35 ms | 大致持平 |
91.6M 变长 *1..2 distinct | 约 1 s | Neo4j 47 ms | Neo4j(slater 的真实弱点) |
大规模多跳 count(*) | 0.3–0.6 GiB | 内存引擎物化行集 | slater,有界限 |
| 结果 | 测量 |
|---|
| 可承受 1000 个并发客户端、零失败 | 吞吐量峰值约 2.5k rps;延迟拐点约在 750 个客户端时出现(p99 51 → 750 ms)——核心争用下的排队,而非硬上限(单次运行,WSL2) |
| 块缓存有界且有效 | 命中率 100 %,0 次驱逐,适合缓存的 50 MB 常驻工作集 |
| 持续负载下 RSS 保持稳定 | jemalloc 分配器在 100→500 客户端的 wiki_cache_churn 斜坡中将 RSS 保持在约 0.6 GB——缓存受限且稳定,无需 MALLOC_* 调优(此前的 MALLOC_ARENA_MAX=2 + trim 阈值已退役);其后台清理也会收回突发后的高位水位,而不是让它钉住 |
| 聚合内存有界 | 服务器级 query.maxIntermediateGlobal + 按邻接计费的展开在 1000 个客户端下遏制了 wiki_budget 2 跳洪泛而不 OOM(RSS 约 0.6 GB;防护将约 60% 的 hub 查询以可重试的预算错误甩掉) |