@hviana/sema 0.1.5 → 0.1.6
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/AGENTS.md +6 -5
- package/CITATION.cff +49 -0
- package/HOW_IT_WORKS.md +11 -12
- package/README.md +7 -5
- package/dist/example/demo.js +25 -25
- package/dist/example/train_base.d.ts +19 -21
- package/dist/example/train_base.js +1687 -1952
- package/dist/src/alphabet.d.ts +3 -3
- package/dist/src/alphabet.js +24 -27
- package/dist/src/alu/src/alu.d.ts +169 -194
- package/dist/src/alu/src/alu.js +374 -414
- package/dist/src/alu/src/expr.d.ts +36 -43
- package/dist/src/alu/src/expr.js +252 -278
- package/dist/src/alu/src/index.d.ts +9 -98
- package/dist/src/alu/src/index.js +10 -64
- package/dist/src/alu/src/kernel-arith.d.ts +1 -5
- package/dist/src/alu/src/kernel-arith.js +195 -285
- package/dist/src/alu/src/kernel-bits.d.ts +1 -5
- package/dist/src/alu/src/kernel-bits.js +81 -115
- package/dist/src/alu/src/kernel-logic.js +33 -63
- package/dist/src/alu/src/kernel-nd.js +155 -195
- package/dist/src/alu/src/kernel-numeric.d.ts +16 -80
- package/dist/src/alu/src/kernel-numeric.js +259 -331
- package/dist/src/alu/src/operation.d.ts +112 -132
- package/dist/src/alu/src/operation.js +149 -163
- package/dist/src/alu/src/parser.d.ts +168 -175
- package/dist/src/alu/src/parser.js +382 -421
- package/dist/src/alu/src/resonance.d.ts +16 -25
- package/dist/src/alu/src/resonance.js +49 -56
- package/dist/src/alu/src/text.d.ts +3 -7
- package/dist/src/alu/src/text.js +31 -37
- package/dist/src/alu/src/value.d.ts +14 -14
- package/dist/src/alu/src/value.js +150 -160
- package/dist/src/alu/test/alu.test.js +543 -656
- package/dist/src/bytes.d.ts +1 -5
- package/dist/src/bytes.js +33 -40
- package/dist/src/config.d.ts +99 -102
- package/dist/src/config.js +78 -83
- package/dist/src/derive/src/deduction.d.ts +60 -62
- package/dist/src/derive/src/deduction.js +103 -109
- package/dist/src/derive/src/index.d.ts +1 -7
- package/dist/src/derive/src/priority-queue.d.ts +8 -8
- package/dist/src/derive/src/priority-queue.js +57 -61
- package/dist/src/derive/src/rewrite.d.ts +15 -18
- package/dist/src/derive/src/rewrite.js +59 -67
- package/dist/src/derive/src/trie.d.ts +56 -56
- package/dist/src/derive/src/trie.js +175 -179
- package/dist/src/derive/test/derive.test.js +96 -100
- package/dist/src/extension.d.ts +13 -16
- package/dist/src/geometry.d.ts +17 -42
- package/dist/src/geometry.js +235 -266
- package/dist/src/index.d.ts +2 -17
- package/dist/src/index.js +2 -12
- package/dist/src/ingest-cache.d.ts +25 -30
- package/dist/src/ingest-cache.js +108 -127
- package/dist/src/mind/articulation.d.ts +1 -5
- package/dist/src/mind/articulation.js +77 -112
- package/dist/src/mind/attention.d.ts +40 -108
- package/dist/src/mind/attention.js +749 -871
- package/dist/src/mind/canonical.d.ts +4 -19
- package/dist/src/mind/canonical.js +27 -31
- package/dist/src/mind/graph-search.d.ts +201 -225
- package/dist/src/mind/graph-search.js +742 -821
- package/dist/src/mind/index.d.ts +2 -10
- package/dist/src/mind/junction.d.ts +31 -58
- package/dist/src/mind/junction.js +172 -237
- package/dist/src/mind/learning.d.ts +16 -52
- package/dist/src/mind/learning.js +143 -165
- package/dist/src/mind/match.d.ts +20 -69
- package/dist/src/mind/match.js +259 -318
- package/dist/src/mind/mechanisms/alu.js +16 -16
- package/dist/src/mind/mechanisms/cast.d.ts +9 -13
- package/dist/src/mind/mechanisms/cast.js +363 -456
- package/dist/src/mind/mechanisms/confluence.d.ts +8 -12
- package/dist/src/mind/mechanisms/confluence.js +152 -183
- package/dist/src/mind/mechanisms/cover.d.ts +2 -8
- package/dist/src/mind/mechanisms/cover.js +148 -210
- package/dist/src/mind/mechanisms/extraction.d.ts +8 -34
- package/dist/src/mind/mechanisms/extraction.js +234 -288
- package/dist/src/mind/mechanisms/recall.d.ts +6 -10
- package/dist/src/mind/mechanisms/recall.js +126 -185
- package/dist/src/mind/mind.d.ts +129 -154
- package/dist/src/mind/mind.js +258 -288
- package/dist/src/mind/pipeline-mechanism.d.ts +112 -124
- package/dist/src/mind/pipeline-mechanism.js +161 -172
- package/dist/src/mind/pipeline.d.ts +4 -14
- package/dist/src/mind/pipeline.js +125 -189
- package/dist/src/mind/primitives.d.ts +8 -32
- package/dist/src/mind/primitives.js +99 -117
- package/dist/src/mind/rationale.d.ts +86 -98
- package/dist/src/mind/rationale.js +113 -121
- package/dist/src/mind/reasoning.d.ts +2 -13
- package/dist/src/mind/reasoning.js +129 -166
- package/dist/src/mind/recognition.d.ts +1 -4
- package/dist/src/mind/recognition.js +183 -208
- package/dist/src/mind/resonance.d.ts +5 -22
- package/dist/src/mind/resonance.js +0 -0
- package/dist/src/mind/trace.d.ts +6 -25
- package/dist/src/mind/trace.js +50 -58
- package/dist/src/mind/traverse.d.ts +16 -58
- package/dist/src/mind/traverse.js +304 -357
- package/dist/src/mind/types.d.ts +120 -127
- package/dist/src/mind/types.js +60 -69
- package/dist/src/rabitq-ivf/src/database.d.ts +113 -0
- package/dist/src/rabitq-ivf/src/database.js +201 -0
- package/dist/src/rabitq-ivf/src/index.d.ts +7 -0
- package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.js +1 -3
- package/dist/src/rabitq-ivf/src/ivf.d.ts +200 -0
- package/dist/src/rabitq-ivf/src/ivf.js +1165 -0
- package/dist/src/rabitq-ivf/src/prng.d.ts +19 -0
- package/dist/src/rabitq-ivf/src/prng.js +36 -0
- package/dist/src/rabitq-ivf/src/rabitq.d.ts +95 -0
- package/dist/src/rabitq-ivf/src/rabitq.js +283 -0
- package/dist/src/sema.d.ts +9 -13
- package/dist/src/sema.js +26 -40
- package/dist/src/store-sqlite.d.ts +169 -166
- package/dist/src/store-sqlite.js +762 -662
- package/dist/src/store.d.ts +586 -630
- package/dist/src/store.js +1423 -1581
- package/dist/src/vec.d.ts +5 -9
- package/dist/src/vec.js +61 -73
- package/example/train_base.ts +13 -10
- package/package.json +1 -1
- package/src/alu/README.md +1 -1
- package/src/alu/src/index.ts +1 -1
- package/src/config.ts +19 -27
- package/src/index.ts +6 -11
- package/src/rabitq-ivf/README.md +56 -0
- package/src/rabitq-ivf/src/database.ts +276 -0
- package/src/{rabitq-hnsw → rabitq-ivf}/src/index.ts +2 -5
- package/src/rabitq-ivf/src/ivf.ts +1330 -0
- package/src/{rabitq-hnsw → rabitq-ivf}/src/prng.ts +1 -1
- package/src/store-sqlite.ts +196 -9
- package/src/store.ts +8 -32
- package/test/08-storage.test.mjs +3 -3
- package/test/14-scaling.test.mjs +2 -2
- package/test/35-ivf.test.mjs +263 -0
- package/test/36-bloom.test.mjs +123 -0
- package/dist/src/rabitq-hnsw/src/database.d.ts +0 -202
- package/dist/src/rabitq-hnsw/src/database.js +0 -405
- package/dist/src/rabitq-hnsw/src/heap.d.ts +0 -22
- package/dist/src/rabitq-hnsw/src/heap.js +0 -94
- package/dist/src/rabitq-hnsw/src/hnsw.d.ts +0 -125
- package/dist/src/rabitq-hnsw/src/hnsw.js +0 -500
- package/dist/src/rabitq-hnsw/src/index.d.ts +0 -15
- package/dist/src/rabitq-hnsw/src/prng.d.ts +0 -19
- package/dist/src/rabitq-hnsw/src/prng.js +0 -38
- package/dist/src/rabitq-hnsw/src/rabitq.d.ts +0 -95
- package/dist/src/rabitq-hnsw/src/rabitq.js +0 -299
- package/dist/src/rabitq-hnsw/src/store.d.ts +0 -162
- package/dist/src/rabitq-hnsw/src/store.js +0 -916
- package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +0 -1
- package/dist/src/rabitq-hnsw/test/hnsw.test.js +0 -1197
- package/src/rabitq-hnsw/README.md +0 -303
- package/src/rabitq-hnsw/src/database.ts +0 -492
- package/src/rabitq-hnsw/src/heap.ts +0 -90
- package/src/rabitq-hnsw/src/hnsw.ts +0 -514
- package/src/rabitq-hnsw/src/store.ts +0 -994
- package/src/rabitq-hnsw/test/hnsw.test.ts +0 -1213
- /package/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.ts +0 -0
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
# rabitq-hnsw
|
|
2
|
-
|
|
3
|
-
A small, well-organised TypeScript library for **approximate nearest-neighbour
|
|
4
|
-
search** using **HNSW** (Hierarchical Navigable Small World graphs) over **1-bit
|
|
5
|
-
RaBitQ codes**, with **cosine** distance. The entire index lives in **SQLite** —
|
|
6
|
-
the durable copy of every code and link is on disk, resident memory is bounded
|
|
7
|
-
by one capped memory budget (never the whole collection), and the index survives
|
|
8
|
-
process restarts.
|
|
9
|
-
|
|
10
|
-
The vectors are stored **only** as 1-bit RaBitQ codes — the original vectors are
|
|
11
|
-
never kept — so the index is both fast _and_ tiny on disk:
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
a 256-d vector:
|
|
15
|
-
Float32 : 256 × 4 bytes = 1024 bytes
|
|
16
|
-
RaBitQ 1-bit (code) : 256 × 1 bit = 32 bytes (32× smaller)
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
The code _is_ the entire per-vector representation — no norms, multipliers, or
|
|
20
|
-
popcounts are stored alongside it. A 1-bit code is therefore a first-class
|
|
21
|
-
value: `get` returns the bare code, and `insert`, `update`, and `query` all
|
|
22
|
-
accept either a raw vector or a code — detected by length, so the same call
|
|
23
|
-
works for a `Uint32Array` or a plain `number[]`.
|
|
24
|
-
|
|
25
|
-
Highlights:
|
|
26
|
-
|
|
27
|
-
- Faithful HNSW: probabilistic level assignment, greedy layer descent, the
|
|
28
|
-
`SEARCH-LAYER` routine and the neighbour-selection **heuristic** (Algorithm
|
|
29
|
-
4), bidirectional edges with pruning, dynamic entry point.
|
|
30
|
-
- **Quantize-first build**: a vector is encoded to its 1-bit code immediately,
|
|
31
|
-
and the graph is built entirely from codes (`XOR` + `popcount`). Queries still
|
|
32
|
-
use the accurate full-precision estimator. There is **no exact re-ranking step
|
|
33
|
-
and no original-vector storage**.
|
|
34
|
-
- `compact()` reclaims deleted/updated space with a **graph-preserving splice**:
|
|
35
|
-
live nodes keep their internal ids and wiring, and each dead neighbour is
|
|
36
|
-
replaced by its own live neighbours (a 1-hop bridge). Codes are copied
|
|
37
|
-
verbatim — nothing is re-quantised — so compaction never loses precision, and
|
|
38
|
-
it runs as one streaming pass instead of a full O(N·ef·log N) rebuild.
|
|
39
|
-
- At query time each distance is a handful of byte lookups in a small per-query
|
|
40
|
-
table (no popcounts).
|
|
41
|
-
- **Truly sub-linear** query work (≈ `O(log N)`), demonstrated by the
|
|
42
|
-
test/benchmark.
|
|
43
|
-
- Vector **CRUD** keyed by integer external ids. `get` returns the stored 1-bit
|
|
44
|
-
code, which can be fed straight back into `insert`, `update`, or `query`.
|
|
45
|
-
- **Bounded RAM footprint**: codes and adjacency lists live in SQLite tables and
|
|
46
|
-
are read/written on demand. The only resident state is one BOUNDED budget,
|
|
47
|
-
`cacheSizeMb`, shared by SQLite's page cache and a derived immutable-code LRU
|
|
48
|
-
— both capped, so the index can grow to millions of vectors without resident
|
|
49
|
-
memory growing with it.
|
|
50
|
-
- **Scalability is cache-independent**: the caches are pure speed layers.
|
|
51
|
-
Correctness and the per-operation _storage-read_ count are identical with the
|
|
52
|
-
budget off (`cacheSizeMb: 0`). `lastQueryStorageReads` exposes that honest,
|
|
53
|
-
cache-independent cost — it is set by the work a query does (the nodes it
|
|
54
|
-
visits, ~log N), never by the collection size — so a poor access pattern can
|
|
55
|
-
never hide behind a warm cache.
|
|
56
|
-
- Reopening the same `dbPath` restores the exact configuration (the rotation is
|
|
57
|
-
regenerated from the persisted seed), so codes already on disk remain valid
|
|
58
|
-
across process restarts.
|
|
59
|
-
- **`node:sqlite`** is the only runtime dependency — no native add-ons.
|
|
60
|
-
|
|
61
|
-
## Install / run
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
npm install # installs dev tooling (typescript, tsx, @types/node)
|
|
65
|
-
npm test # the single test file: CRUD, memory, recall + sub-linearity benchmark
|
|
66
|
-
npm run example # runs examples/quickstart.ts
|
|
67
|
-
npm run typecheck # tsc --noEmit
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
No build step is required to use or test the library — `tsx` runs the TypeScript
|
|
71
|
-
directly. Requires Node.js ≥ 22.5 (for `node:sqlite`).
|
|
72
|
-
|
|
73
|
-
## Usage
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
import { VectorDatabase } from "./src/index";
|
|
77
|
-
|
|
78
|
-
// dbPath is required — use ":memory:" for a transient index
|
|
79
|
-
const db = new VectorDatabase({
|
|
80
|
-
dbPath: ":memory:",
|
|
81
|
-
dim: 256,
|
|
82
|
-
M: 16,
|
|
83
|
-
efConstruction: 200,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// CREATE — associate a vector with an integer id (encoded to 1-bit code immediately)
|
|
87
|
-
db.insert(42, embedding); // embedding: number[] | Float32Array of length dim
|
|
88
|
-
|
|
89
|
-
// BULK CREATE/UPDATE — one transaction around the whole batch (far fewer fsyncs
|
|
90
|
-
// on disk; the graph and its result are identical to the per-item path).
|
|
91
|
-
db.upsertMany([{ id: 1, vector: v1 }, { id: 2, vector: v2 }]);
|
|
92
|
-
|
|
93
|
-
// READ — returns the stored 1-bit code (NOT the original vector)
|
|
94
|
-
const code = db.get(42); // -> Uint32Array (codeWords words) | null
|
|
95
|
-
|
|
96
|
-
// A code is the whole representation: insert / update / query all accept one too,
|
|
97
|
-
// detected by length (codeWords vs dim) — works for Uint32Array or a number[].
|
|
98
|
-
db.insert(999, code!); // store an existing code under a new id
|
|
99
|
-
db.query(code!, 10); // search by code (sign-bit / cosine distance)
|
|
100
|
-
db.update(7, db.get(9)!); // move id-7 onto id-9's code
|
|
101
|
-
|
|
102
|
-
// UPDATE / DELETE with raw vectors
|
|
103
|
-
db.update(42, newEmbedding);
|
|
104
|
-
db.delete(42); // -> boolean
|
|
105
|
-
|
|
106
|
-
// COMPACT — reclaim space from deleted/updated vectors (lossless rebuild)
|
|
107
|
-
db.physicalSize; // physical nodes incl. tombstones
|
|
108
|
-
if (db.physicalSize > db.size * 1.5) db.compact();
|
|
109
|
-
|
|
110
|
-
// SEARCH — k nearest neighbours (ids + estimated cosine distances)
|
|
111
|
-
const hits = db.query(queryVector, 10); // -> [{ id, distance }, ...]
|
|
112
|
-
db.query(queryVector, 10, { ef: 200 }); // override efSearch per query
|
|
113
|
-
|
|
114
|
-
// MEMORY — per-vector storage footprint vs a Float32 baseline
|
|
115
|
-
db.storage; // { float32BytesPerVector, codeBytesPerVector, bytesPerVector, compressionRatio }
|
|
116
|
-
|
|
117
|
-
// PERSIST — just reference the same path again; the SQLite file IS the database
|
|
118
|
-
db.close();
|
|
119
|
-
const db2 = new VectorDatabase({ dbPath: "vectors.db" }); // reopens, exact same state
|
|
120
|
-
|
|
121
|
-
// Transient in-memory index
|
|
122
|
-
const mem = new VectorDatabase({ dbPath: ":memory:", dim: 256 });
|
|
123
|
-
// ... use, then close — data is discarded
|
|
124
|
-
mem.close();
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
Distance is **cosine**. Options: `efSearch`, `queryBits` (query
|
|
128
|
-
scalar-quantisation bits, default 4), `rotationRounds` (default 3), `seed`, an
|
|
129
|
-
optional `centroid` the vectors are centered by before quantisation, and the one
|
|
130
|
-
bounded memory knob `cacheSizeMb` (default 64) — it sizes SQLite's page cache
|
|
131
|
-
and the derived immutable-code LRU together. A pure speed enhancement: pass 0 to
|
|
132
|
-
disable all caching, which changes neither results nor the per-operation
|
|
133
|
-
storage-read count.
|
|
134
|
-
|
|
135
|
-
## How it works
|
|
136
|
-
|
|
137
|
-
**RaBitQ (1-bit).** Each vector is centered by an optional centroid, normalised,
|
|
138
|
-
rotated by a fast random orthogonal transform (random sign flips +
|
|
139
|
-
Walsh–Hadamard, `O(D log D)`), and reduced to one sign bit per padded dimension.
|
|
140
|
-
The packed sign bits are the **only** thing kept per vector. The random rotation
|
|
141
|
-
makes the quantisation error essentially uniform across vectors, so the cosine
|
|
142
|
-
estimate needs only a single fixed scale rather than any per-vector correction.
|
|
143
|
-
|
|
144
|
-
**Two estimators, both on codes.**
|
|
145
|
-
|
|
146
|
-
- _query → code_ (accurate): RaBitQ's inner-product estimator between a
|
|
147
|
-
**full-precision** vector and a stored 1-bit code. The query is
|
|
148
|
-
scalar-quantised to `queryBits` bits, and the only data-dependent quantity in
|
|
149
|
-
the estimate is the dot product of the code's sign bits with the quantised
|
|
150
|
-
query — which is exactly **the sum of the quantised query values at the
|
|
151
|
-
coordinates where the code bit is 1**. So at query time we build one small
|
|
152
|
-
per-query **byte lookup table** (`paddedDim/8 × 256`, ≈8 KB, L1-resident) and
|
|
153
|
-
each distance is just `paddedDim/8` table lookups over the code's bytes — e.g.
|
|
154
|
-
32 lookups for a 256-d vector, no popcounts at all.
|
|
155
|
-
- _code → code_ (coarse): the distance between two stored codes from their
|
|
156
|
-
Hamming distance, `cos ≈ 1 − 2·hamming/D` (`XOR` + `popcount`). Both sides are
|
|
157
|
-
1-bit. **The entire build runs on this estimator** — finding a new node's
|
|
158
|
-
neighbours and pruning lists — so insertion needs nothing but the code (one
|
|
159
|
-
rotation at encode time, then cheap popcounts), and a rebuild from codes is a
|
|
160
|
-
faithful HNSW construction.
|
|
161
|
-
|
|
162
|
-
**HNSW.** A multi-layer navigable graph. Levels are drawn from
|
|
163
|
-
`⌊-ln(U)·(1/ln M)⌋`; the search descends greedily through the upper layers and
|
|
164
|
-
runs an `ef`-bounded best-first search at layer 0. New nodes connect to `M`
|
|
165
|
-
neighbours chosen by the diversity heuristic, with neighbour lists pruned back
|
|
166
|
-
to `2M` at layer 0. Insertion searches the graph with the new node's **code**;
|
|
167
|
-
queries search it with the **full-precision** estimator. There is no exact
|
|
168
|
-
re-ranking — the top-k is taken directly from the codes, which keeps query work
|
|
169
|
-
tiny and sub-linear.
|
|
170
|
-
|
|
171
|
-
**SQLite-native graph.** Codes live in the `nodes` table (one BLOB per row,
|
|
172
|
-
indexed by integer primary key); adjacency lists live in the `links` table
|
|
173
|
-
(`WITHOUT ROWID`, clustered on `(node, layer)`) so a neighbour-list fetch — the
|
|
174
|
-
hot path of search — is a single B-tree descent landing on an inline BLOB.
|
|
175
|
-
Global state (entry point, max level, counts, RNG state) and the quantizer
|
|
176
|
-
configuration (dim, seed, centroid, etc.) are in a single-row `meta` table.
|
|
177
|
-
There is no separate load/save step: the SQLite file **is** the database.
|
|
178
|
-
|
|
179
|
-
**Performance details.** WAL journal mode with NORMAL synchronous for
|
|
180
|
-
crash-safe, fast writes; a page cache sized by `cacheSizeMb` (with mmap tied to
|
|
181
|
-
it) for zero-copy reads of hot pages; a per-_operation_ working set (the records
|
|
182
|
-
the current insert/query is actively comparing, bounded by `efConstruction` /
|
|
183
|
-
`efSearch`, cleared between operations and never the collection size) so within
|
|
184
|
-
one op each touched node is read once; visited tracking and binary heaps on
|
|
185
|
-
parallel arrays with no per-candidate object allocation in the build/search hot
|
|
186
|
-
paths. The `compact()` operation streams the live rows into fresh tables
|
|
187
|
-
(preserving internal ids, splicing dead neighbours out of the adjacency lists)
|
|
188
|
-
in batched transactions — the WAL stays bounded by the batch, never the table —
|
|
189
|
-
then swaps the tables atomically and `VACUUM`s the freed pages back to the
|
|
190
|
-
filesystem.
|
|
191
|
-
|
|
192
|
-
**The immutable-code LRU.** A node's 1-bit code never changes once written, yet
|
|
193
|
-
building and searching the graph re-read the same hub nodes across operations —
|
|
194
|
-
and on clustered data consecutive inserts touch overlapping neighbourhoods, so a
|
|
195
|
-
code is fetched again and again. Each fetch is a SQLite point-query that decodes
|
|
196
|
-
the row and copies the BLOB (≈1.4 µs even when the page is already cached — the
|
|
197
|
-
cost is the statement, not disk I/O), while the distance it feeds is ≈0.04 µs.
|
|
198
|
-
So a **bounded LRU of decoded `NodeRec`s** (keyed by internal id) serves a
|
|
199
|
-
repeatedly-touched code from RAM and skips both the query and the copy. Two
|
|
200
|
-
properties keep it honest:
|
|
201
|
-
|
|
202
|
-
- It is a **latency layer only.** `lastQueryStorageReads` (and the underlying
|
|
203
|
-
`Store.reads`) counts SQLite fall-throughs — cache MISSES — so the
|
|
204
|
-
per-operation storage-read count, the cache-independent scalability witness,
|
|
205
|
-
is identical whether the cache is full or off. The index's _scaling_ never
|
|
206
|
-
depends on it; only the wall-clock constant does.
|
|
207
|
-
- It has **no separate knob.** Its entry capacity is DERIVED from the one memory
|
|
208
|
-
budget, `cacheSizeMb`, and the actual per-`NodeRec` byte cost (code + fixed
|
|
209
|
-
fields + Map overhead), so it shares that budget and resident memory is capped
|
|
210
|
-
regardless of collection size — it never grows into "the whole index in RAM".
|
|
211
|
-
On a clustered build even a modest budget captures essentially all the
|
|
212
|
-
locality, so it buys the speed-up without scaling with N.
|
|
213
|
-
|
|
214
|
-
Two refinements share the same budget and the same honesty rules (hits are never
|
|
215
|
-
counted as reads; correctness never depends on them):
|
|
216
|
-
|
|
217
|
-
- **Upper-layer pinning.** Every insert and query descends from the entry point
|
|
218
|
-
through the upper layers before fanning out on layer 0, so level ≥ 1 nodes (a
|
|
219
|
-
1/M fraction of the collection) are touched by every operation — yet layer-0
|
|
220
|
-
fan-out traffic keeps evicting them from a plain LRU. Their codes pin into a
|
|
221
|
-
section capped at half the budget that layer-0 traffic cannot evict, keeping
|
|
222
|
-
the descent RAM-resident however far the collection outgrows the budget.
|
|
223
|
-
- **A decoded neighbour-list LRU.** Hub adjacency lists are re-read across
|
|
224
|
-
consecutive operations just like hub codes; a bounded LRU (a quarter of the
|
|
225
|
-
budget) serves the decoded list, written through on every `setNeighbors` so a
|
|
226
|
-
hit always equals a fresh row read, and dropped at compaction.
|
|
227
|
-
|
|
228
|
-
The cache stays correct because the only mutable fields — the tombstone flag and
|
|
229
|
-
external id — are updated in place on the cached record by `tombstone()`, and
|
|
230
|
-
the whole cache is dropped at compaction (whose table swap invalidates any
|
|
231
|
-
cached row). A miss only repeats work, never changes a result. `cacheSizeMb: 0`
|
|
232
|
-
disables it (along with the page cache), which the tests use to measure the
|
|
233
|
-
honest read count.
|
|
234
|
-
|
|
235
|
-
Deletes (and the old version after an `update`) are tombstones: still routed
|
|
236
|
-
through for navigation, never returned. `compact()` splices them out of the
|
|
237
|
-
graph (each dead neighbour replaced by its own live neighbours) and drops their
|
|
238
|
-
rows; codes are copied untouched — no original vectors needed, no precision
|
|
239
|
-
lost.
|
|
240
|
-
|
|
241
|
-
## Accuracy trade-off
|
|
242
|
-
|
|
243
|
-
1-bit codes are extremely compact but lossy: the index reliably finds the right
|
|
244
|
-
_neighbourhood_, but it cannot perfectly resolve the order of points that are
|
|
245
|
-
all roughly equidistant from the query. So recall is high when the true
|
|
246
|
-
neighbours are **separable** (the regime where ANN is meaningful —
|
|
247
|
-
near-duplicate retrieval, clustered embeddings, etc.) and lower inside a dense,
|
|
248
|
-
ambiguous cluster. The classic way to recover exact ordering is to re-rank the
|
|
249
|
-
top candidates with the original vectors; this build intentionally omits that to
|
|
250
|
-
maximise speed and minimise memory.
|
|
251
|
-
|
|
252
|
-
## Benchmark (from `npm test`)
|
|
253
|
-
|
|
254
|
-
`dim = 256`, well-separated groups of 10 near-duplicates, 200 queries, `k = 10`,
|
|
255
|
-
`efSearch = 150`. Representative run (disk-backed, SQLite):
|
|
256
|
-
|
|
257
|
-
```
|
|
258
|
-
Per-vector storage (vector payload only, excludes the HNSW graph):
|
|
259
|
-
Float32 baseline : 1024 B
|
|
260
|
-
1-bit RaBitQ code : 32 B (32.0x smaller)
|
|
261
|
-
code (kept) : 32 B (32.0x smaller overall)
|
|
262
|
-
payload at N=32000 : Float32 31.25 MB vs index 0.98 MB
|
|
263
|
-
|
|
264
|
-
N | HNSW q (us) | dist comps | Brute q (us) | recall@10 | speedup
|
|
265
|
-
-------+-------------+------------+--------------+-----------+--------
|
|
266
|
-
1000 | 1930.3 | 863 | 308.2 | 100.0% | 0.2x
|
|
267
|
-
2000 | 3142.8 | 1298 | 549.6 | 100.0% | 0.2x
|
|
268
|
-
4000 | 3763.7 | 1723 | 1073.3 | 100.0% | 0.3x
|
|
269
|
-
8000 | 4603.3 | 2165 | 2186.0 | 100.0% | 0.5x
|
|
270
|
-
16000 | 5315.8 | 2440 | 4398.0 | 100.0% | 0.8x
|
|
271
|
-
32000 | 6058.2 | 2668 | 8886.4 | 100.0% | 1.5x
|
|
272
|
-
|
|
273
|
-
Empirical scaling over a 32x increase in N (work ~ N^exponent):
|
|
274
|
-
HNSW distance computations : N^0.320 <- sub-linear (target < 1)
|
|
275
|
-
Brute-force query : N^0.831 (linear reference ~ 1.0)
|
|
276
|
-
Mean recall@10 : 100.00%
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
The distance-computation count is a deterministic, machine-independent measure
|
|
280
|
-
of work: it grows sub-linearly (≈ `N^0.32`, near-logarithmic) while the
|
|
281
|
-
brute-force baseline grows near-linearly. The per-query latency is higher than a
|
|
282
|
-
purely in-RAM index (the SQLite round-trips add ~constant overhead), but it
|
|
283
|
-
**scales** — at 32,000 vectors the disk-backed HNSW is already 1.5× faster than
|
|
284
|
-
brute force, and the gap widens as N grows (brute is `O(N)` per query; HNSW is
|
|
285
|
-
`O(log N)`). The test asserts the 32× storage compression, the sub-linear
|
|
286
|
-
exponent, perfect recall, and the speed-up over brute force.
|
|
287
|
-
|
|
288
|
-
## Layout
|
|
289
|
-
|
|
290
|
-
```
|
|
291
|
-
src/
|
|
292
|
-
prng.ts deterministic seedable RNG (mulberry32)
|
|
293
|
-
heap.ts binary heap on parallel arrays
|
|
294
|
-
rabitq.ts 1-bit RaBitQ quantizer + fast rotation + query/code estimators
|
|
295
|
-
store.ts SQLite-backed graph storage: codes, adjacency, meta, compaction
|
|
296
|
-
hnsw.ts HNSW graph over codes (build, search, delete, compact)
|
|
297
|
-
database.ts VectorDatabase: CRUD, external ids, storage stats
|
|
298
|
-
index.ts public exports
|
|
299
|
-
test/
|
|
300
|
-
hnsw.test.ts the single test file (correctness + memory + benchmark)
|
|
301
|
-
examples/
|
|
302
|
-
quickstart.ts
|
|
303
|
-
```
|