@hviana/sema 0.1.4 → 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.
Files changed (58) hide show
  1. package/AGENTS.md +6 -5
  2. package/CITATION.cff +49 -0
  3. package/HOW_IT_WORKS.md +11 -12
  4. package/README.md +7 -5
  5. package/dist/example/train_base.js +13 -10
  6. package/dist/src/alu/src/index.js +1 -1
  7. package/dist/src/config.d.ts +16 -19
  8. package/dist/src/config.js +3 -8
  9. package/dist/src/index.d.ts +2 -2
  10. package/dist/src/index.js +2 -6
  11. package/dist/src/rabitq-ivf/src/database.d.ts +113 -0
  12. package/dist/src/rabitq-ivf/src/database.js +201 -0
  13. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.d.ts +2 -5
  14. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.js +1 -3
  15. package/dist/src/rabitq-ivf/src/ivf.d.ts +200 -0
  16. package/dist/src/rabitq-ivf/src/ivf.js +1165 -0
  17. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.d.ts +1 -1
  18. package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.js +1 -1
  19. package/dist/src/store-sqlite.d.ts +26 -1
  20. package/dist/src/store-sqlite.js +190 -8
  21. package/dist/src/store.d.ts +2 -9
  22. package/dist/src/store.js +6 -23
  23. package/example/train_base.ts +13 -10
  24. package/package.json +2 -2
  25. package/src/alu/README.md +1 -1
  26. package/src/alu/src/index.ts +1 -1
  27. package/src/config.ts +19 -27
  28. package/src/index.ts +6 -11
  29. package/src/rabitq-ivf/README.md +56 -0
  30. package/src/rabitq-ivf/src/database.ts +276 -0
  31. package/src/{rabitq-hnsw → rabitq-ivf}/src/index.ts +2 -5
  32. package/src/rabitq-ivf/src/ivf.ts +1330 -0
  33. package/src/{rabitq-hnsw → rabitq-ivf}/src/prng.ts +1 -1
  34. package/src/store-sqlite.ts +196 -9
  35. package/src/store.ts +8 -32
  36. package/test/08-storage.test.mjs +3 -3
  37. package/test/14-scaling.test.mjs +2 -2
  38. package/test/35-ivf.test.mjs +263 -0
  39. package/test/36-bloom.test.mjs +123 -0
  40. package/dist/src/rabitq-hnsw/src/database.d.ts +0 -200
  41. package/dist/src/rabitq-hnsw/src/database.js +0 -388
  42. package/dist/src/rabitq-hnsw/src/heap.d.ts +0 -22
  43. package/dist/src/rabitq-hnsw/src/heap.js +0 -89
  44. package/dist/src/rabitq-hnsw/src/hnsw.d.ts +0 -125
  45. package/dist/src/rabitq-hnsw/src/hnsw.js +0 -474
  46. package/dist/src/rabitq-hnsw/src/store.d.ts +0 -162
  47. package/dist/src/rabitq-hnsw/src/store.js +0 -825
  48. package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +0 -1
  49. package/dist/src/rabitq-hnsw/test/hnsw.test.js +0 -948
  50. package/src/rabitq-hnsw/README.md +0 -303
  51. package/src/rabitq-hnsw/src/database.ts +0 -492
  52. package/src/rabitq-hnsw/src/heap.ts +0 -90
  53. package/src/rabitq-hnsw/src/hnsw.ts +0 -514
  54. package/src/rabitq-hnsw/src/store.ts +0 -994
  55. package/src/rabitq-hnsw/test/hnsw.test.ts +0 -1213
  56. /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.d.ts +0 -0
  57. /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.js +0 -0
  58. /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
- ```