@harperfast/hnsw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +229 -0
- package/Cargo.toml +30 -0
- package/DESIGN.md +319 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/build.mjs +46 -0
- package/build.rs +6 -0
- package/index.d.ts +104 -0
- package/index.js +57 -0
- package/package.json +44 -0
- package/prebuilds/darwin-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-x64/hnsw-plane.node +0 -0
- package/prebuilds/win32-x64/hnsw-plane.node +0 -0
- package/src/bin/bench.rs +246 -0
- package/src/distance.rs +138 -0
- package/src/format.rs +556 -0
- package/src/graph.rs +650 -0
- package/src/insert.rs +292 -0
- package/src/lib.rs +15 -0
- package/src/napi.rs +486 -0
- package/src/search.rs +477 -0
- package/src/seqlock.rs +217 -0
package/Cargo.lock
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "aho-corasick"
|
|
7
|
+
version = "1.1.5"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"memchr",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "bitflags"
|
|
16
|
+
version = "2.13.1"
|
|
17
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
18
|
+
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
|
19
|
+
|
|
20
|
+
[[package]]
|
|
21
|
+
name = "cfg-if"
|
|
22
|
+
version = "1.0.4"
|
|
23
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
24
|
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "convert_case"
|
|
28
|
+
version = "0.6.0"
|
|
29
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
30
|
+
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
|
31
|
+
dependencies = [
|
|
32
|
+
"unicode-segmentation",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "ctor"
|
|
37
|
+
version = "0.2.9"
|
|
38
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
39
|
+
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
|
40
|
+
dependencies = [
|
|
41
|
+
"quote",
|
|
42
|
+
"syn",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[[package]]
|
|
46
|
+
name = "hnsw-plane"
|
|
47
|
+
version = "0.0.1"
|
|
48
|
+
dependencies = [
|
|
49
|
+
"memmap2",
|
|
50
|
+
"napi",
|
|
51
|
+
"napi-build",
|
|
52
|
+
"napi-derive",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[[package]]
|
|
56
|
+
name = "libc"
|
|
57
|
+
version = "0.2.189"
|
|
58
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
59
|
+
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
60
|
+
|
|
61
|
+
[[package]]
|
|
62
|
+
name = "libloading"
|
|
63
|
+
version = "0.8.9"
|
|
64
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
65
|
+
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
|
66
|
+
dependencies = [
|
|
67
|
+
"cfg-if",
|
|
68
|
+
"windows-link",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "memchr"
|
|
73
|
+
version = "2.8.3"
|
|
74
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
75
|
+
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "memmap2"
|
|
79
|
+
version = "0.9.11"
|
|
80
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
81
|
+
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
|
82
|
+
dependencies = [
|
|
83
|
+
"libc",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[[package]]
|
|
87
|
+
name = "napi"
|
|
88
|
+
version = "2.16.17"
|
|
89
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
90
|
+
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
|
91
|
+
dependencies = [
|
|
92
|
+
"bitflags",
|
|
93
|
+
"ctor",
|
|
94
|
+
"napi-derive",
|
|
95
|
+
"napi-sys",
|
|
96
|
+
"once_cell",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[[package]]
|
|
100
|
+
name = "napi-build"
|
|
101
|
+
version = "2.4.1"
|
|
102
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
103
|
+
checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab"
|
|
104
|
+
|
|
105
|
+
[[package]]
|
|
106
|
+
name = "napi-derive"
|
|
107
|
+
version = "2.16.13"
|
|
108
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
109
|
+
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
|
110
|
+
dependencies = [
|
|
111
|
+
"cfg-if",
|
|
112
|
+
"convert_case",
|
|
113
|
+
"napi-derive-backend",
|
|
114
|
+
"proc-macro2",
|
|
115
|
+
"quote",
|
|
116
|
+
"syn",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
[[package]]
|
|
120
|
+
name = "napi-derive-backend"
|
|
121
|
+
version = "1.0.75"
|
|
122
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
123
|
+
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
|
124
|
+
dependencies = [
|
|
125
|
+
"convert_case",
|
|
126
|
+
"once_cell",
|
|
127
|
+
"proc-macro2",
|
|
128
|
+
"quote",
|
|
129
|
+
"regex",
|
|
130
|
+
"semver",
|
|
131
|
+
"syn",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
[[package]]
|
|
135
|
+
name = "napi-sys"
|
|
136
|
+
version = "2.4.0"
|
|
137
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
138
|
+
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
|
139
|
+
dependencies = [
|
|
140
|
+
"libloading",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
[[package]]
|
|
144
|
+
name = "once_cell"
|
|
145
|
+
version = "1.21.4"
|
|
146
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
147
|
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
148
|
+
|
|
149
|
+
[[package]]
|
|
150
|
+
name = "proc-macro2"
|
|
151
|
+
version = "1.0.107"
|
|
152
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
153
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
154
|
+
dependencies = [
|
|
155
|
+
"unicode-ident",
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
[[package]]
|
|
159
|
+
name = "quote"
|
|
160
|
+
version = "1.0.47"
|
|
161
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
162
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
163
|
+
dependencies = [
|
|
164
|
+
"proc-macro2",
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
[[package]]
|
|
168
|
+
name = "regex"
|
|
169
|
+
version = "1.13.1"
|
|
170
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
171
|
+
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
|
172
|
+
dependencies = [
|
|
173
|
+
"aho-corasick",
|
|
174
|
+
"memchr",
|
|
175
|
+
"regex-automata",
|
|
176
|
+
"regex-syntax",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
[[package]]
|
|
180
|
+
name = "regex-automata"
|
|
181
|
+
version = "0.4.18"
|
|
182
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
183
|
+
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
|
184
|
+
dependencies = [
|
|
185
|
+
"aho-corasick",
|
|
186
|
+
"memchr",
|
|
187
|
+
"regex-syntax",
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
[[package]]
|
|
191
|
+
name = "regex-syntax"
|
|
192
|
+
version = "0.8.11"
|
|
193
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
194
|
+
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|
195
|
+
|
|
196
|
+
[[package]]
|
|
197
|
+
name = "semver"
|
|
198
|
+
version = "1.0.28"
|
|
199
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
200
|
+
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
|
201
|
+
|
|
202
|
+
[[package]]
|
|
203
|
+
name = "syn"
|
|
204
|
+
version = "2.0.119"
|
|
205
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
206
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
207
|
+
dependencies = [
|
|
208
|
+
"proc-macro2",
|
|
209
|
+
"quote",
|
|
210
|
+
"unicode-ident",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
[[package]]
|
|
214
|
+
name = "unicode-ident"
|
|
215
|
+
version = "1.0.24"
|
|
216
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
217
|
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
218
|
+
|
|
219
|
+
[[package]]
|
|
220
|
+
name = "unicode-segmentation"
|
|
221
|
+
version = "1.13.3"
|
|
222
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
223
|
+
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
|
224
|
+
|
|
225
|
+
[[package]]
|
|
226
|
+
name = "windows-link"
|
|
227
|
+
version = "0.2.1"
|
|
228
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
229
|
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
package/Cargo.toml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "hnsw-plane"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Native HNSW traversal plane: mmap fixed-slot graph file + off-loop search"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
crate-type = ["cdylib", "rlib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
libc = "0.2"
|
|
13
|
+
memmap2 = "0.9"
|
|
14
|
+
napi = { version = "2", default-features = false, features = ["napi8"], optional = true }
|
|
15
|
+
napi-derive = { version = "2", optional = true }
|
|
16
|
+
|
|
17
|
+
[build-dependencies]
|
|
18
|
+
napi-build = "2"
|
|
19
|
+
|
|
20
|
+
[features]
|
|
21
|
+
default = []
|
|
22
|
+
napi = ["dep:napi", "dep:napi-derive"]
|
|
23
|
+
|
|
24
|
+
[[bin]]
|
|
25
|
+
name = "bench"
|
|
26
|
+
path = "src/bin/bench.rs"
|
|
27
|
+
|
|
28
|
+
[profile.release]
|
|
29
|
+
lto = true
|
|
30
|
+
codegen-units = 1
|
package/DESIGN.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# HNSW native traversal plane — design
|
|
2
|
+
|
|
3
|
+
Origin: this library was designed and extracted from the Harper vector-index engine
|
|
4
|
+
(HarperFast/harper, branch kris/hnsw-native-plane); "the JS implementation" and issue numbers
|
|
5
|
+
below refer to that codebase, and the measured JS baselines come from its
|
|
6
|
+
`benchmarks/hnsw-scale.js`. The design moves HNSW graph storage and traversal into a native
|
|
7
|
+
(Rust/napi-rs) module over a memory-mapped fixed-slot file, replacing a KV-store column
|
|
8
|
+
family as the home of graph nodes.
|
|
9
|
+
|
|
10
|
+
## 1. Motivation — measured, not estimated
|
|
11
|
+
|
|
12
|
+
Per-visit cost decomposition at 5M nodes / ef 512 (768-d int8, `benchmarks/hnsw-scale.js`
|
|
13
|
+
corpus, 22.18 ms p50 / 5,107 visits):
|
|
14
|
+
|
|
15
|
+
| Component | Cost | Share of a warm visit |
|
|
16
|
+
| ------------------------------------------- | ------- | --------------------- |
|
|
17
|
+
| Total per visited node | 4.34 µs | 100% |
|
|
18
|
+
| int8 asymmetric cosine, 768-d, JS | 0.43 µs | 10% |
|
|
19
|
+
| msgpackr decode of one node (VT-cache miss) | 5.57 µs | +128% when cold |
|
|
20
|
+
| Neighbour iteration + visited-set ops | 0.21 µs | 5% |
|
|
21
|
+
|
|
22
|
+
~85% of a warm visit is JS object bookkeeping — candidate heap, visited `Set`, property access,
|
|
23
|
+
allocation, GC — not distance math and not I/O. Three consequences:
|
|
24
|
+
|
|
25
|
+
1. **A native distance kernel is worth ~nothing.** Distance is 10% of the visit; a NAPI crossing
|
|
26
|
+
costs 0.1–0.5 µs. The win requires the whole search loop native, over a native data layout,
|
|
27
|
+
with one boundary crossing per query.
|
|
28
|
+
2. **The fetch path decides the ceiling.** A warm RocksDB `Get` is ~1–2 µs even called natively
|
|
29
|
+
(block-cache lookup, block parse, value memcpy) — 20–40× the SIMD distance it feeds. Direct
|
|
30
|
+
slot addressing (`base + id × SLOT_SIZE`) into a resident mapping is ~100–200 ns. Traversal
|
|
31
|
+
over RocksDB caps at ~3–5× improvement; traversal over a fixed-slot mapping reaches the
|
|
32
|
+
full ceiling.
|
|
33
|
+
3. **Estimated native budget: ~0.25–0.4 µs/visit** (SIMD int8 dot ~50 ns + streaming 768
|
|
34
|
+
contiguous bytes ~150 ns + bitset/heap ops ~50 ns) → **~10–15× on the search path**
|
|
35
|
+
(22 ms → ~1.5–2 ms at 5M/ef 512), with the JS event loop untouched.
|
|
36
|
+
|
|
37
|
+
This is also the enabling dependency for same-node index slicing (parallel slice searches need
|
|
38
|
+
off-loop execution) and changes cluster QPS arithmetic by the same factor.
|
|
39
|
+
|
|
40
|
+
## 2. Goals / non-goals
|
|
41
|
+
|
|
42
|
+
Goals:
|
|
43
|
+
|
|
44
|
+
- Search traversal fully native, off the JS event loop, one NAPI crossing per query.
|
|
45
|
+
- Graph nodes in a memory-mapped fixed-slot file — **the file is the index**: the maintained
|
|
46
|
+
primary of the derived data, updated in place on every commit, not a cache of RocksDB.
|
|
47
|
+
- Incremental maintenance preserved: insert/update/delete keep working exactly as today from
|
|
48
|
+
the application's view.
|
|
49
|
+
- Relaxed transactional adherence (deliberate): HNSW results are approximate by contract, and
|
|
50
|
+
the existing post-load exact rescore + MVCC record lookup already filter stale/wrong
|
|
51
|
+
candidates. No cross-slot atomicity.
|
|
52
|
+
- Node-id reuse via an in-file freelist — structurally fixes the #2182 lifetime high-water
|
|
53
|
+
ef over-provisioning.
|
|
54
|
+
- Slicing-ready: one file per slice; native merge of per-slice top-k (C2 hook).
|
|
55
|
+
|
|
56
|
+
Non-goals (this phase):
|
|
57
|
+
|
|
58
|
+
- Binary quantization / Matryoshka truncation (benchmark-gated per the Reflex study; the format
|
|
59
|
+
reserves a quantization-mode field so a binary plane is a format v2, not a redesign).
|
|
60
|
+
- Native insert loop (phase 3; insert logic stays in JS initially, persisting through the
|
|
61
|
+
native slot-write API).
|
|
62
|
+
- Cross-node ANN protocol. Out of scope entirely.
|
|
63
|
+
- Lexical/BM25 anything.
|
|
64
|
+
|
|
65
|
+
## 3. Architecture
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
JS (worker threads) native (Rust, napi-rs)
|
|
69
|
+
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────┐
|
|
70
|
+
│ HierarchicalNavigableSmallWorld.ts │ │ hnsw-plane │
|
|
71
|
+
│ • pk→nodeId mapping (stays RocksDB) │ │ • mmap'd slot file (per index/slice)│
|
|
72
|
+
│ • insert/update/delete logic (phase 1) ├──►│ • slot read/write API (seqlocked) │
|
|
73
|
+
│ • commit callback → slot writes │ │ • search(query, k, ef, filter) → │
|
|
74
|
+
│ • record load + exact rescore (as-is) │◄──┤ top-k ids, own thread pool │
|
|
75
|
+
│ • runIndexing replay from watermark │ │ • TSFN batch filter callback │
|
|
76
|
+
└─────────────────────────────────────────┘ └─────────────────────────────────────┘
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
What stays in RocksDB: the pk→nodeId mapping (transactional with record writes — it is the
|
|
80
|
+
authority on which node id a record owns), records themselves, and all other indexes. What
|
|
81
|
+
moves to the file: node vectors, per-layer adjacency, entry point, id allocator, freelist.
|
|
82
|
+
|
|
83
|
+
## 4. File format (v1)
|
|
84
|
+
|
|
85
|
+
One file per index (per slice, once C2 lands): `<index-path>.hnsw`.
|
|
86
|
+
|
|
87
|
+
**Header (4 KB page):**
|
|
88
|
+
|
|
89
|
+
| Field | Type | Notes |
|
|
90
|
+
| --------------------------------- | ---------- | ------------------------------------------------------------ |
|
|
91
|
+
| magic + format version | u32 + u32 | rebuild required on version mismatch (accepted contract) |
|
|
92
|
+
| dims, quantization mode | u16 + u8 | v1: int8 asymmetric; f32 supported for `quantization:"none"` |
|
|
93
|
+
| slot_size, layer0_cap, upper_cap | u16 ×3 | derived from M/optimizeRouting at creation |
|
|
94
|
+
| entry_point_id, entry_point_level | u32 + u8 | atomically updated |
|
|
95
|
+
| id_high_water | u64 atomic | replaces the shared Atomics BigInt64Array incrementer |
|
|
96
|
+
| freelist_head | u64 atomic | CAS push/pop; ABA-guarded with a 32-bit tag |
|
|
97
|
+
| txn_watermark | u64 | last durably indexed transaction; advanced by msync cadence |
|
|
98
|
+
| clean_shutdown flag | u8 | torn-state detection on open |
|
|
99
|
+
|
|
100
|
+
**Main region — layer-0 slots**, addressed `4096 + id × slot_size`:
|
|
101
|
+
|
|
102
|
+
| Field | Size (768-d int8, cap 64) |
|
|
103
|
+
| ------------------------------- | ---------------------------------- |
|
|
104
|
+
| seq (seqlock) | 4 B |
|
|
105
|
+
| flags (valid/deleted) + level | 2 B |
|
|
106
|
+
| scale (f32) + invMag (f32) | 8 B |
|
|
107
|
+
| degree | 2 B |
|
|
108
|
+
| vector (int8 × 768) | 768 B |
|
|
109
|
+
| neighbor ids (u32 × layer0_cap) | 256 B |
|
|
110
|
+
| **total, padded** | **1,040 B → 1 KB-aligned 1,088 B** |
|
|
111
|
+
|
|
112
|
+
At 100M nodes: ~109 GB (int8). A binary-code v2 slot (96 B codes + ids) is ~384 B → ~38 GB.
|
|
113
|
+
For comparison, today's encoding averages 1,425 B/node _plus_ RocksDB overhead — so v1 is
|
|
114
|
+
already ~25% smaller while being fixed-offset addressable, because per-edge cached float64
|
|
115
|
+
distances are dropped (recomputing a distance costs ~50 ns native; storing it costs 8 B and
|
|
116
|
+
~40% of today's node bytes).
|
|
117
|
+
|
|
118
|
+
**Upper-layer region** (append-allocated, compacted on rebuild): only ~6% of nodes have
|
|
119
|
+
level > 0, and upper layers hold neighbor id lists only (vectors live in the main slot). Each
|
|
120
|
+
entry: `node_id, level, [degree, ids × upper_cap] × level`. Kept fully resident; a few hundred
|
|
121
|
+
MB at 100M nodes.
|
|
122
|
+
|
|
123
|
+
**Degree cap decision.** Today layer-0 caps at `M<<1` then `<<2` under `optimizeRouting` = 128,
|
|
124
|
+
with transient overshoot to 160 before pruning; measured mean degree is ~37. Sizing slots at
|
|
125
|
+
cap 128 doubles the file for a tail. v1 policy: **hard prune-to-cap-64 on write** — the insert
|
|
126
|
+
path's in-memory candidate selection can overshoot as today, but what is written is pruned to
|
|
127
|
+
64 by the same routing-aware selection that currently prunes at 160→128. Transient overshoot
|
|
128
|
+
never touches the file. Recall impact must be measured in the validation phase (§9); the cap is
|
|
129
|
+
a header field, so revising it is a rebuild, not a format change.
|
|
130
|
+
|
|
131
|
+
## 5. Concurrency
|
|
132
|
+
|
|
133
|
+
- **Per-slot seqlock.** Writer: fetch_add seq to odd → write slot → fetch_add to even. Reader
|
|
134
|
+
(traversal): read seq, copy the ≤1 KB slot (or read fields in place), re-check seq; retry on
|
|
135
|
+
change. Retries are rare (writes touch ~40 slots per insert out of millions) and cheap.
|
|
136
|
+
- **No cross-slot atomicity.** An insert updates the new node's slot plus ~M neighbors'
|
|
137
|
+
back-edge lists, each independently. A traversal may observe the half-linked state: an edge
|
|
138
|
+
to a slot whose valid flag is not yet set → skip (HNSW tolerates missing edges); a
|
|
139
|
+
just-deleted neighbor → skip via flags. Wrong-candidate leakage is filtered by the existing
|
|
140
|
+
exact rescore + MVCC record load, which is why relaxed adherence is safe _here_ and not a
|
|
141
|
+
general storage pattern.
|
|
142
|
+
- **Writers.** Multiple worker threads insert concurrently today (distinct records); the same
|
|
143
|
+
holds: id allocation is one atomic fetch_add on the header, freelist pop is CAS, slot writes
|
|
144
|
+
are seqlocked. Two inserts updating the same neighbor's edge list serialize on that slot's
|
|
145
|
+
seqlock (a Rust-side per-slot spinlock on the odd state).
|
|
146
|
+
- **Id reuse & ABA.** Delete pushes the id onto the freelist; a traversal holding the old id may
|
|
147
|
+
read the reused slot and score the wrong vector — acceptable under the relaxed contract
|
|
148
|
+
(rescore/record-load rejects it). The freelist head itself is tag-guarded against ABA.
|
|
149
|
+
|
|
150
|
+
## 6. Durability & crash recovery
|
|
151
|
+
|
|
152
|
+
The file is `msync`'d on a cadence (default: every N seconds or M mutated slots, configurable),
|
|
153
|
+
**not** per commit. The header watermark records the last transaction whose index mutations are
|
|
154
|
+
known durable; it advances only after a completed msync barrier.
|
|
155
|
+
|
|
156
|
+
On open:
|
|
157
|
+
|
|
158
|
+
- Clean-shutdown flag set → map and serve.
|
|
159
|
+
- Torn state → replay records from `txn_watermark` through the existing `runIndexing` re-feed
|
|
160
|
+
path (which already treats a re-fed already-indexed record as an update — the exact semantics
|
|
161
|
+
needed). This anchors today's heuristic crash re-feed to a precise watermark.
|
|
162
|
+
- Format-version mismatch or corruption (header checksum) → full rebuild from records. Explicit
|
|
163
|
+
contract: **format upgrades require reindex** (accepted).
|
|
164
|
+
|
|
165
|
+
Note the asymmetry with today: RocksDB gave the graph per-commit durability; the file gives it
|
|
166
|
+
bounded-lag durability with deterministic catch-up. For an approximate index whose source of
|
|
167
|
+
truth (records + pk→nodeId) remains fully transactional, bounded lag is the right trade — it
|
|
168
|
+
buys the entire performance model.
|
|
169
|
+
|
|
170
|
+
**Backup/copy-db/reseed:** the file is node-local derived state. Backup either includes it
|
|
171
|
+
(consistent-enough after an msync barrier) or marks the index rebuild-on-restore. Replica
|
|
172
|
+
reseed = rebuild from records (C5 bulk construction makes this fast; until then, the existing
|
|
173
|
+
per-row path).
|
|
174
|
+
|
|
175
|
+
## 7. Search path & NAPI surface
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// one crossing per query; executes on the module's own thread pool
|
|
179
|
+
search(sliceHandles, queryVector: Float32Array, k, ef, filter?): Promise<{ids, distances}>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- Asymmetric distance as today: float query × int8 stored, cached invMag, SIMD (AVX2/VNNI on
|
|
183
|
+
x86, NEON on ARM; `std::arch` intrinsics with a scalar fallback).
|
|
184
|
+
- Visited set: epoch-stamped u32 array (one per pool thread, reused across queries — no
|
|
185
|
+
allocation per query). Candidate heap: fixed-capacity binary heap of (dist, id) pairs.
|
|
186
|
+
- Auto-ef / auto-efC read the node count from the header high-water minus freelist length —
|
|
187
|
+
same semantics as today, minus the #2182 inflation (freed ids return to the pool).
|
|
188
|
+
|
|
189
|
+
**Filtering** (predicate-aware / ACORN, `filteredSearch = true` today):
|
|
190
|
+
|
|
191
|
+
1. **Bitset fast path.** RBAC allow-lists and companion-condition candidate sets are computed
|
|
192
|
+
before the query and passed as a roaring/plain bitset over node ids. Zero callbacks. This
|
|
193
|
+
covers the dominant production filter shapes.
|
|
194
|
+
2. **Pipelined TSFN batch path** for arbitrary JS predicates. Traversal batches candidate ids
|
|
195
|
+
(64–256) through a ThreadsafeFunction to a JS evaluator and **continues expanding in
|
|
196
|
+
distance order while verdicts are in flight**; verdicts merge in to steer selection and
|
|
197
|
+
gate results. The existing `filterExpansion` visit budget bounds speculative overshoot.
|
|
198
|
+
Traversal never blocks on the event loop — that would re-import the p99 problem this
|
|
199
|
+
design exists to remove. Worst case (loop saturated): budget exhausts, return what passed —
|
|
200
|
+
the same contract as today's budget-bound filtered search.
|
|
201
|
+
3. TSFN lifecycle: shutdown-while-query-in-flight is a first-class test (see rocksdb-js #665's
|
|
202
|
+
TSFN teardown SIGSEGV). napi-rs `ThreadsafeFunction` + explicit abort on env teardown.
|
|
203
|
+
|
|
204
|
+
## 8. Write path phasing
|
|
205
|
+
|
|
206
|
+
- **Phase 1 — dual-write, search cutover.** Insert/update/delete logic stays in JS
|
|
207
|
+
(`HierarchicalNavigableSmallWorld.ts` unchanged algorithmically); mutations persist to BOTH
|
|
208
|
+
the index CF (as today) and the file via native slot-write calls. Search runs native from the
|
|
209
|
+
file. Validation = compare native results against the JS path on the same graph; rollback =
|
|
210
|
+
flip search back to JS, drop the file. The double-write cost is bounded (index writes are
|
|
211
|
+
a fraction of insert cost) and temporary.
|
|
212
|
+
|
|
213
|
+
_Integrated_ behind the opt-in `nativePlane: true` index option (search-only: toggling never
|
|
214
|
+
reindexes; int8 + cosine indexes only — the flag no-ops elsewhere). Mutations mirror at the
|
|
215
|
+
exact `indexStore.put/remove` sites via `writeNodeRaw`/`clearNode`/`setEntryPoint` with
|
|
216
|
+
host-allocated ids; the plane file (`<store path>/<table>.<attr>.hnsw`, layer0 cap 128,
|
|
217
|
+
16M-node sparse reservation) is created lazily with a full mirror of the existing CF graph on
|
|
218
|
+
first enable, reopened on restart, deleted on drop/clear/reindex. The compiled module is
|
|
219
|
+
optional (`npm run build:hnsw-plane`); absence falls back to the JS path with one warning.
|
|
220
|
+
Parity, predicate, restart, and lifecycle coverage in `unitTests/resources/vectorIndexPlane.test.js`.
|
|
221
|
+
Watermark/replay wiring, slicing, and msync-cadence flushes are not wired yet (open items).
|
|
222
|
+
|
|
223
|
+
- **Phase 2 — file-primary.** Drop the CF writes; the file is the only graph store. JS insert
|
|
224
|
+
reads nodes through a native `getNode(id)` (one NAPI crossing per read, ~1 µs — comparable to
|
|
225
|
+
today's decode path). Migration for existing indexes: reindex (accepted contract), or a
|
|
226
|
+
one-shot CF→file bulk conversion since it is a pure format transform.
|
|
227
|
+
- **Phase 3 — native insert.** Move the insert search + neighbor selection native (same
|
|
228
|
+
traversal core), leaving JS a thin `index(pk, vector)` call. Unlocks bulk build (C5) at
|
|
229
|
+
native speed and removes the ~tens-of-ms event-loop pin per insert (#895).
|
|
230
|
+
|
|
231
|
+
## 9. Validation plan
|
|
232
|
+
|
|
233
|
+
Baselines exist in `benchmarks/hnsw-scale.js` output (1M/2M/5M anchors, e.g. 1M efC-200:
|
|
234
|
+
p50 7.2 ms / recall@10-set 0.997 @ ef 512). Acceptance for phase 1:
|
|
235
|
+
|
|
236
|
+
1. **Parity:** native search over a dual-written graph returns identical candidate sets to the
|
|
237
|
+
JS path at equal ef (modulo seqlock-retry races under concurrent write load — measured as a
|
|
238
|
+
bounded divergence rate, not exact equality under churn).
|
|
239
|
+
2. **Recall:** cap-64 prune vs cap-128 measured at 1M and 5M; accept if recall@10 delta ≤ 0.5 pt
|
|
240
|
+
at equal ef, else revisit the cap (header field — rebuild, not redesign).
|
|
241
|
+
3. **Latency:** ≥8× p50 improvement at 5M/ef 512 (22.2 ms → ≤2.8 ms), p99 within 2× p50 under
|
|
242
|
+
concurrent insert load (the metric that motivates off-loop execution).
|
|
243
|
+
4. **Crash:** kill -9 during sustained ingest → reopen → watermark replay → graph passes
|
|
244
|
+
connectivity + recall checks (extend the #1712 repair test harness).
|
|
245
|
+
5. **Churn:** delete/reinsert cycles hold node count stable (freelist reuse; #2182 regression
|
|
246
|
+
test).
|
|
247
|
+
|
|
248
|
+
## 10. Decisions & open questions
|
|
249
|
+
|
|
250
|
+
Decided (Kris, 2026-08-31):
|
|
251
|
+
|
|
252
|
+
- **Degree cap: 128 for the int8 plane** (revised 2026-08-31 after measurement). The original
|
|
253
|
+
cap-64 preference assumed 128 doubles the file; it does not for int8 slots — the 768 B vector
|
|
254
|
+
dominates, so 128 costs +23.5% (1,344 vs 1,088 B slots). Measured at 1M: cap-64 loses 2.2 pts
|
|
255
|
+
of recall (0.975 vs 0.996, where JS = 0.997) at equal ef and equal latency. +24% bytes for
|
|
256
|
+
full recall parity is the right trade. The cap stays a header field; the **binary-code v2
|
|
257
|
+
plane reopens the question** (cap-64 ≈ 352 B vs cap-128 ≈ 608 B slots, +73% — there a
|
|
258
|
+
diversity-preserving prune at lower cap is worth engineering).
|
|
259
|
+
- **Platform policy.** Performance is a Linux target only. macOS must work (mmap/msync semantics
|
|
260
|
+
differ slightly — `F_FULLFSYNC` for real durability barriers, no sparse-file guarantees on all
|
|
261
|
+
filesystems — both handled, neither optimized). Windows may fall back to the JS implementation
|
|
262
|
+
entirely; the native plane is allowed to be absent there.
|
|
263
|
+
- **Packaging: independent open-source package.** The core has zero Harper coupling — the crate
|
|
264
|
+
compiles standalone and its NAPI surface is generic (create/open plane, insert(id, vector),
|
|
265
|
+
remove(id), search(query, k, ef, filter), watermark get/set). Harper-specific glue — the
|
|
266
|
+
pk→nodeId mapping, commit-callback integration, txnlog-anchored replay, auto-ef policy
|
|
267
|
+
constants — stays in Harper regardless of packaging. Plan: develop in-repo under
|
|
268
|
+
`native/hnsw-plane/` until the NAPI surface stabilizes (end of phase 1), then split to its own
|
|
269
|
+
repo in the symphony/lmdb-js mold and consume via npm. The pitch as a community package: a
|
|
270
|
+
persistent, incrementally-maintained, concurrently-searchable HNSW for Node — hnswlib-node has
|
|
271
|
+
no durable incremental persistence, no off-loop batched filtering, no seqlock concurrency.
|
|
272
|
+
|
|
273
|
+
Open:
|
|
274
|
+
|
|
275
|
+
- **msync cadence default** — bounded-lag durability window vs write amplification; needs a
|
|
276
|
+
workload measurement, not a guess.
|
|
277
|
+
- **f32 (quantization:"none") slot variant** — 3,072 B vectors → 3.4 KB slots; supported by the
|
|
278
|
+
format (dims × mode in header) but int8 is the default and the optimization target.
|
|
279
|
+
- ~~Upper-layer region persistence~~ — done (format v2): fixed-entry region in the same file,
|
|
280
|
+
per-entry seqlocks, reserved for max_nodes/8. Upper entries leak on delete (bounded by the
|
|
281
|
+
2x-headroom reserve); an upper freelist is the remaining nicety.
|
|
282
|
+
- **Reservation growth** — max_nodes is fixed at create; production needs either a generous
|
|
283
|
+
sparse reservation (Linux-fine; strict-overcommit hosts need care) or mremap-based growth.
|
|
284
|
+
|
|
285
|
+
## 11. Prototype measurements (kzyp Linux box, 768-d int8, ef 512, cap 64)
|
|
286
|
+
|
|
287
|
+
Gaussian-mixture corpus matching `benchmarks/hnsw-scale.js` calibration (intra-cos 0.75,
|
|
288
|
+
clusters = N/500). JS baseline for scale: 4.34 µs/visit; 1M efC-200 anchor: p50 7.2 ms,
|
|
289
|
+
recall@10-set 0.997, ~3,110 visits.
|
|
290
|
+
|
|
291
|
+
| N | cap | p50 | p95 | visits/query | µs/visit | recall@10 (set) | build rate |
|
|
292
|
+
| ------------------- | --- | ------- | ------- | ------------ | -------- | --------------- | --------------- |
|
|
293
|
+
| 100K | 64 | 0.28 ms | 0.46 ms | 1,395 | 0.201 | 1.000 | 5,583 inserts/s |
|
|
294
|
+
| 1M | 64 | 0.81 ms | 1.60 ms | 2,279 | 0.353 | 0.975 | 1,670 inserts/s |
|
|
295
|
+
| 1M | 128 | 0.75 ms | 1.48 ms | 2,309 | 0.324 | **0.996** | 1,242 inserts/s |
|
|
296
|
+
| 1M (fmt v2) | 128 | 0.83 ms | 1.61 ms | 2,309 | 0.359 | 0.996 | 1,346 inserts/s |
|
|
297
|
+
| 1M (coverage prune) | 128 | 0.75 ms | 1.52 ms | 2,279 | 0.327 | **0.999** | 1,359 inserts/s |
|
|
298
|
+
|
|
299
|
+
Concurrency (same 1M graph): **6,345 QPS aggregate** across 8 searcher threads (p50 1.03 ms,
|
|
300
|
+
worst-thread p99 3.84 ms) while a background writer sustained **1,102 inserts/s** — the QPS
|
|
301
|
+
input §9 of the Reflex study lacked. Reverse-edge overflow eviction is coverage-aware
|
|
302
|
+
(evict the far member provably reachable via a kept nearer one; bounded 16×16 checks): the
|
|
303
|
+
concurrent torture test caught closest-keep eviction orphaning nodes in near-duplicate
|
|
304
|
+
clusters (~1-in-4 runs), and the fix also raised 1M recall from 0.996 to 0.999 at equal
|
|
305
|
+
build cost.
|
|
306
|
+
| 1M JS anchor | 128 | 7.2 ms | 12.0 ms | ~3,110 | 4.34 | 0.997 | ~263 inserts/s |
|
|
307
|
+
|
|
308
|
+
At the 1M anchor with cap 128: **9.6× p50, 12.9× per-visit, 4.7× build rate, at JS-equal
|
|
309
|
+
recall.** The µs/visit rise from 100K (0.20) to 1M (0.32–0.35) is the working set leaving L3 —
|
|
310
|
+
the memory-hierarchy term; it is the number that holds at 60–100M. An ef-1024 sweep on a
|
|
311
|
+
reopened cap-64 plane without its hierarchy (pre-sidecar) still reached 0.985 at p50 2.47 ms —
|
|
312
|
+
layer-0 beam is robust to a missing hierarchy, at ~3.4× the visits.
|
|
313
|
+
|
|
314
|
+
Milestones: zero-copy seqlock reads + AVX2 kernels took per-visit cost from 0.440 µs (first
|
|
315
|
+
scalar prototype) to ~0.1–0.35 µs, beating the 0.25–0.4 µs design budget. The
|
|
316
|
+
optimizeRouting-parity insert (including the recomputed neighbor↔neighbor distances) restored
|
|
317
|
+
recall from 0.49 (placeholder insert) to JS parity. Uniform-random 768-d corpora produce
|
|
318
|
+
meaningless recall numbers (the JS benchmark's own calibration note: a corpus "no ANN can
|
|
319
|
+
index") — all comparisons use the mixture corpus.
|