@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.
- 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/train_base.js +13 -10
- package/dist/src/alu/src/index.js +1 -1
- package/dist/src/config.d.ts +16 -19
- package/dist/src/config.js +3 -8
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -6
- 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-hnsw → rabitq-ivf}/src/index.d.ts +2 -5
- 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-hnsw → rabitq-ivf}/src/prng.d.ts +1 -1
- package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/prng.js +1 -1
- package/dist/src/store-sqlite.d.ts +26 -1
- package/dist/src/store-sqlite.js +190 -8
- package/dist/src/store.d.ts +2 -9
- package/dist/src/store.js +6 -23
- package/example/train_base.ts +13 -10
- package/package.json +2 -2
- 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 -200
- package/dist/src/rabitq-hnsw/src/database.js +0 -388
- package/dist/src/rabitq-hnsw/src/heap.d.ts +0 -22
- package/dist/src/rabitq-hnsw/src/heap.js +0 -89
- package/dist/src/rabitq-hnsw/src/hnsw.d.ts +0 -125
- package/dist/src/rabitq-hnsw/src/hnsw.js +0 -474
- package/dist/src/rabitq-hnsw/src/store.d.ts +0 -162
- package/dist/src/rabitq-hnsw/src/store.js +0 -825
- package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +0 -1
- package/dist/src/rabitq-hnsw/test/hnsw.test.js +0 -948
- 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/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.d.ts +0 -0
- /package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.js +0 -0
- /package/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.ts +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// 35-ivf.test.mjs — the partitioned (IVF) vector index, at its own layer.
|
|
2
|
+
//
|
|
3
|
+
// VectorDatabase is an adaptive IVF index over 1-bit RaBitQ codes in SQLite
|
|
4
|
+
// (src/rabitq-ivf). These tests verify the index's OWN contract, below the
|
|
5
|
+
// Sema store:
|
|
6
|
+
//
|
|
7
|
+
// 1. CRUD — insert/has/get/update/delete, id checking.
|
|
8
|
+
// 2. Recall — clustered data resonates to its own cluster; a stored
|
|
9
|
+
// vector finds itself.
|
|
10
|
+
// 3. Splits — the index partitions past SPLIT_MAX and stays correct.
|
|
11
|
+
// 4. Persistence — close/reopen preserves entries, config, and results.
|
|
12
|
+
// 5. Determinism — same insert sequence ⇒ identical results (no RNG).
|
|
13
|
+
// 6. Compaction — tombstones are reclaimed; internal ids (the keysSince
|
|
14
|
+
// watermark) survive.
|
|
15
|
+
// 7. Scaling — per-query STORAGE READS are bounded once the
|
|
16
|
+
// collection is past its first splits: 2x the corpus
|
|
17
|
+
// must not move the reads (the honest witness the old
|
|
18
|
+
// graph index also swore to).
|
|
19
|
+
|
|
20
|
+
import { test } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { rmSync } from "node:fs";
|
|
23
|
+
import { VectorDatabase } from "../dist/src/rabitq-ivf/src/index.js";
|
|
24
|
+
|
|
25
|
+
// Deterministic PRNG for test vectors.
|
|
26
|
+
function rng(seed) {
|
|
27
|
+
let s = seed >>> 0;
|
|
28
|
+
return () => {
|
|
29
|
+
s ^= s << 13;
|
|
30
|
+
s ^= s >>> 17;
|
|
31
|
+
s ^= s << 5;
|
|
32
|
+
s >>>= 0;
|
|
33
|
+
return s / 0x100000000;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A unit vector near `center` with per-coordinate jitter. */
|
|
38
|
+
function near(center, jitter, r) {
|
|
39
|
+
const v = new Float32Array(center.length);
|
|
40
|
+
for (let i = 0; i < v.length; i++) {
|
|
41
|
+
v[i] = center[i] + (r() * 2 - 1) * jitter;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function randVec(dim, r) {
|
|
47
|
+
const v = new Float32Array(dim);
|
|
48
|
+
for (let i = 0; i < dim; i++) v[i] = r() * 2 - 1;
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("1: CRUD — insert, has, get, update, delete", () => {
|
|
53
|
+
const db = new VectorDatabase({ dbPath: ":memory:", dim: 64 });
|
|
54
|
+
const r = rng(1);
|
|
55
|
+
const v1 = randVec(64, r);
|
|
56
|
+
const v2 = randVec(64, r);
|
|
57
|
+
|
|
58
|
+
db.insert(10, v1);
|
|
59
|
+
assert.equal(db.size, 1);
|
|
60
|
+
assert.ok(db.has(10));
|
|
61
|
+
assert.ok(!db.has(11));
|
|
62
|
+
assert.throws(() => db.insert(10, v2), /already exists/);
|
|
63
|
+
assert.throws(() => db.insert(1.5, v2), /integer/);
|
|
64
|
+
|
|
65
|
+
const code = db.get(10);
|
|
66
|
+
assert.ok(code instanceof Uint32Array);
|
|
67
|
+
|
|
68
|
+
// update to a genuinely different vector: same ext still resolves
|
|
69
|
+
db.update(10, v2);
|
|
70
|
+
assert.equal(db.size, 1);
|
|
71
|
+
assert.ok(db.has(10));
|
|
72
|
+
|
|
73
|
+
// byte-identical update is a no-op (no new tombstone)
|
|
74
|
+
const phys = db.physicalSize;
|
|
75
|
+
db.update(10, v2);
|
|
76
|
+
assert.equal(db.physicalSize, phys);
|
|
77
|
+
|
|
78
|
+
assert.ok(db.delete(10));
|
|
79
|
+
assert.ok(!db.has(10));
|
|
80
|
+
assert.equal(db.size, 0);
|
|
81
|
+
assert.ok(!db.delete(10));
|
|
82
|
+
db.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("2: recall — clustered data finds its own cluster; self-recall", () => {
|
|
86
|
+
const dim = 128;
|
|
87
|
+
const db = new VectorDatabase({ dbPath: ":memory:", dim });
|
|
88
|
+
const r = rng(2);
|
|
89
|
+
// 8 well-separated centers, 100 members each.
|
|
90
|
+
const centers = [];
|
|
91
|
+
for (let c = 0; c < 8; c++) centers.push(randVec(dim, r));
|
|
92
|
+
const vecs = new Map();
|
|
93
|
+
let id = 0;
|
|
94
|
+
const batch = [];
|
|
95
|
+
for (let c = 0; c < 8; c++) {
|
|
96
|
+
for (let m = 0; m < 100; m++) {
|
|
97
|
+
const v = near(centers[c], 0.15, r);
|
|
98
|
+
vecs.set(id, { v, c });
|
|
99
|
+
batch.push({ id, vector: v });
|
|
100
|
+
id++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
db.upsertMany(batch);
|
|
104
|
+
assert.equal(db.size, 800);
|
|
105
|
+
|
|
106
|
+
// Query each center: top-10 should come from that center's members.
|
|
107
|
+
for (let c = 0; c < 8; c++) {
|
|
108
|
+
const hits = db.query(centers[c], 10);
|
|
109
|
+
assert.equal(hits.length, 10);
|
|
110
|
+
let own = 0;
|
|
111
|
+
for (const h of hits) if (vecs.get(h.id).c === c) own++;
|
|
112
|
+
assert.ok(own >= 8, `center ${c}: only ${own}/10 hits from own cluster`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Self-recall: a stored vector should find its own ext in the top 5.
|
|
116
|
+
let self = 0;
|
|
117
|
+
for (let probe = 0; probe < 100; probe += 7) {
|
|
118
|
+
const hits = db.query(vecs.get(probe).v, 5);
|
|
119
|
+
if (hits.some((h) => h.id === probe)) self++;
|
|
120
|
+
}
|
|
121
|
+
assert.ok(self >= 12, `self-recall ${self}/15 too low`);
|
|
122
|
+
db.close();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("3: splits — collections past SPLIT_MAX partition and stay correct", () => {
|
|
126
|
+
const dim = 32;
|
|
127
|
+
const db = new VectorDatabase({ dbPath: ":memory:", dim });
|
|
128
|
+
const r = rng(3);
|
|
129
|
+
const N = 10_000; // > 2 × SPLIT_MAX (4096) — forces several splits
|
|
130
|
+
const batch = [];
|
|
131
|
+
for (let i = 0; i < N; i++) batch.push({ id: i, vector: randVec(dim, r) });
|
|
132
|
+
db.upsertMany(batch);
|
|
133
|
+
assert.equal(db.size, N);
|
|
134
|
+
assert.ok(
|
|
135
|
+
db.clusterCount >= 3,
|
|
136
|
+
`expected several clusters after ${N} inserts, got ${db.clusterCount}`,
|
|
137
|
+
);
|
|
138
|
+
// Every ext still resolves and queries still return k results.
|
|
139
|
+
assert.ok(db.has(0) && db.has(N - 1) && db.has(N >> 1));
|
|
140
|
+
const hits = db.query(randVec(dim, r), 20);
|
|
141
|
+
assert.equal(hits.length, 20);
|
|
142
|
+
db.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("4: persistence — close/reopen preserves entries and results", (t) => {
|
|
146
|
+
const path = `/tmp/ivf-test-${process.pid}.vec`;
|
|
147
|
+
t.after(() => {
|
|
148
|
+
for (const s of ["", "-wal", "-shm"]) {
|
|
149
|
+
try {
|
|
150
|
+
rmSync(path + s);
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const dim = 64;
|
|
155
|
+
const r = rng(4);
|
|
156
|
+
const vecs = [];
|
|
157
|
+
for (let i = 0; i < 500; i++) vecs.push(randVec(dim, r));
|
|
158
|
+
|
|
159
|
+
let db = new VectorDatabase({ dbPath: path, dim });
|
|
160
|
+
db.upsertMany(vecs.map((v, i) => ({ id: i, vector: v })));
|
|
161
|
+
const probe = vecs[123];
|
|
162
|
+
const before = db.query(probe, 10);
|
|
163
|
+
db.close();
|
|
164
|
+
|
|
165
|
+
db = new VectorDatabase({ dbPath: path });
|
|
166
|
+
assert.equal(db.dim, dim);
|
|
167
|
+
assert.equal(db.size, 500);
|
|
168
|
+
const after = db.query(probe, 10);
|
|
169
|
+
assert.deepEqual(after, before);
|
|
170
|
+
db.close();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("5: determinism — same insert sequence, identical results", () => {
|
|
174
|
+
const dim = 64;
|
|
175
|
+
const r1 = rng(5), r2 = rng(5);
|
|
176
|
+
const a = new VectorDatabase({ dbPath: ":memory:", dim });
|
|
177
|
+
const b = new VectorDatabase({ dbPath: ":memory:", dim });
|
|
178
|
+
const N = 6000; // crosses a split
|
|
179
|
+
const batchA = [], batchB = [];
|
|
180
|
+
for (let i = 0; i < N; i++) {
|
|
181
|
+
batchA.push({ id: i, vector: randVec(dim, r1) });
|
|
182
|
+
batchB.push({ id: i, vector: randVec(dim, r2) });
|
|
183
|
+
}
|
|
184
|
+
a.upsertMany(batchA);
|
|
185
|
+
b.upsertMany(batchB);
|
|
186
|
+
assert.equal(a.clusterCount, b.clusterCount);
|
|
187
|
+
const probe = randVec(dim, r1);
|
|
188
|
+
assert.deepEqual(a.query(probe, 20), b.query(probe, 20));
|
|
189
|
+
a.close();
|
|
190
|
+
b.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("6: compaction — tombstones reclaimed, watermark ids preserved", () => {
|
|
194
|
+
const dim = 32;
|
|
195
|
+
const db = new VectorDatabase({ dbPath: ":memory:", dim });
|
|
196
|
+
const r = rng(6);
|
|
197
|
+
const N = 2000;
|
|
198
|
+
db.upsertMany(
|
|
199
|
+
Array.from({ length: N }, (_, i) => ({ id: i, vector: randVec(dim, r) })),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Watermark before deletion.
|
|
203
|
+
let maxInternal = 0;
|
|
204
|
+
for (const { internal } of db.keysSince(0)) {
|
|
205
|
+
maxInternal = Math.max(maxInternal, internal);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Delete every third entry.
|
|
209
|
+
const dead = [];
|
|
210
|
+
for (let i = 0; i < N; i += 3) dead.push(i);
|
|
211
|
+
assert.equal(db.deleteMany(dead), dead.length);
|
|
212
|
+
assert.equal(db.size, N - dead.length);
|
|
213
|
+
assert.ok(db.physicalSize > db.size);
|
|
214
|
+
|
|
215
|
+
db.compact();
|
|
216
|
+
assert.equal(db.physicalSize, db.size);
|
|
217
|
+
for (const i of dead) assert.ok(!db.has(i));
|
|
218
|
+
assert.ok(db.has(1) && db.has(2));
|
|
219
|
+
|
|
220
|
+
// Internal ids survive compaction: entries added AFTER the old watermark
|
|
221
|
+
// are exactly the new inserts.
|
|
222
|
+
db.upsertMany([{ id: 99999, vector: randVec(dim, r) }]);
|
|
223
|
+
const fresh = [...db.keysSince(maxInternal)];
|
|
224
|
+
assert.equal(fresh.length, 1);
|
|
225
|
+
assert.equal(fresh[0].ext, 99999);
|
|
226
|
+
db.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("7: scaling — per-query storage reads are bounded past the first splits", () => {
|
|
230
|
+
const dim = 32;
|
|
231
|
+
// cacheSizeMb 0: no page cache to hide behind; `reads` counts blob rows.
|
|
232
|
+
const db = new VectorDatabase({ dbPath: ":memory:", dim, cacheSizeMb: 0 });
|
|
233
|
+
const r = rng(7);
|
|
234
|
+
const probe = randVec(dim, r);
|
|
235
|
+
const readsNow = () => {
|
|
236
|
+
db.query(probe, 10);
|
|
237
|
+
return db.lastQueryStorageReads;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const grow = (n) => {
|
|
241
|
+
const batch = [];
|
|
242
|
+
for (let i = 0; i < n; i++) {
|
|
243
|
+
batch.push({ id: db.size + i, vector: randVec(dim, r) });
|
|
244
|
+
}
|
|
245
|
+
db.upsertMany(batch);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Past ~nprobe × SPLIT_MAX entries the probed set is a fixed number of
|
|
249
|
+
// bounded clusters — reads must plateau.
|
|
250
|
+
grow(80_000);
|
|
251
|
+
const small = readsNow();
|
|
252
|
+
grow(80_000); // 2x the corpus
|
|
253
|
+
const large = readsNow();
|
|
254
|
+
console.log(
|
|
255
|
+
` ivf reads for one query: N=80k → ${small}, N=160k → ${large}`,
|
|
256
|
+
);
|
|
257
|
+
assert.ok(
|
|
258
|
+
large <= small * 1.5 + 8,
|
|
259
|
+
`reads grew ${small} → ${large} when the corpus doubled — probes are ` +
|
|
260
|
+
`not bounded`,
|
|
261
|
+
);
|
|
262
|
+
db.close();
|
|
263
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// 36-bloom.test.mjs — the negative dedup filter (store-sqlite's Bloom layer).
|
|
2
|
+
//
|
|
3
|
+
// The filter answers "this content hash is definitely not in the node table"
|
|
4
|
+
// in RAM, sparing the training hot path its dominant SQLite probe (profiled:
|
|
5
|
+
// 38.7% of ingest wall at 2.9M nodes). Its ONE hazard is a false negative,
|
|
6
|
+
// which would mint a duplicate node and silently break hash-consing. These
|
|
7
|
+
// tests pin the invariant on the three lifecycle paths:
|
|
8
|
+
//
|
|
9
|
+
// 1. live session — content minted then re-probed dedups (filter sees
|
|
10
|
+
// every insert synchronously);
|
|
11
|
+
// 2. clean close — reopen loads the persisted filter (or rebuilds) and
|
|
12
|
+
// dedup still holds;
|
|
13
|
+
// 3. CRASH — a process that exits without close() leaves no
|
|
14
|
+
// persisted filter (or a stale one); reopen must
|
|
15
|
+
// rebuild/top-up from the table and STILL dedup all
|
|
16
|
+
// previously-minted content, with zero duplicates.
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { rmSync } from "node:fs";
|
|
22
|
+
import { SQliteStore } from "../dist/src/store-sqlite.js";
|
|
23
|
+
|
|
24
|
+
const cleanup = (path) => {
|
|
25
|
+
for (
|
|
26
|
+
const s of [
|
|
27
|
+
".sqlite",
|
|
28
|
+
".sqlite-wal",
|
|
29
|
+
".sqlite-shm",
|
|
30
|
+
".content.vec",
|
|
31
|
+
".content.vec-wal",
|
|
32
|
+
".content.vec-shm",
|
|
33
|
+
".halo.vec",
|
|
34
|
+
".halo.vec-wal",
|
|
35
|
+
".halo.vec-shm",
|
|
36
|
+
]
|
|
37
|
+
) {
|
|
38
|
+
try {
|
|
39
|
+
rmSync(path + s);
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("live session: re-probing minted content dedups (no false negatives)", async () => {
|
|
45
|
+
const s = new SQliteStore({ path: ":memory:", D: 64 });
|
|
46
|
+
const enc = new TextEncoder();
|
|
47
|
+
const g = new Float32Array(64).fill(0.1);
|
|
48
|
+
const ids = [];
|
|
49
|
+
for (let i = 0; i < 300; i++) {
|
|
50
|
+
ids.push(await s.putLeaf(enc.encode("bloom-live-" + i), g));
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < 300; i++) {
|
|
53
|
+
assert.equal(await s.putLeaf(enc.encode("bloom-live-" + i), g), ids[i]);
|
|
54
|
+
}
|
|
55
|
+
// Novel probes are answered by the filter without SQLite (the point).
|
|
56
|
+
const skipsBefore = s.bloomSkips;
|
|
57
|
+
s.findLeaf(enc.encode("definitely-not-present-xyzzy"));
|
|
58
|
+
assert.ok(s.bloomSkips >= skipsBefore, "bloomSkips must be observable");
|
|
59
|
+
await s.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("clean close: reopen dedups against the persisted/rebuilt filter", async (t) => {
|
|
63
|
+
const path = `/tmp/bloom-clean-${process.pid}`;
|
|
64
|
+
t.after(() => cleanup(path));
|
|
65
|
+
const enc = new TextEncoder();
|
|
66
|
+
const g = new Float32Array(64).fill(0.1);
|
|
67
|
+
|
|
68
|
+
let s = new SQliteStore({ path, D: 64 });
|
|
69
|
+
const ids = [];
|
|
70
|
+
for (let i = 0; i < 300; i++) {
|
|
71
|
+
ids.push(await s.putLeaf(enc.encode("bloom-clean-" + i), g));
|
|
72
|
+
}
|
|
73
|
+
await s.close();
|
|
74
|
+
|
|
75
|
+
s = new SQliteStore({ path, D: 64 });
|
|
76
|
+
const before = s.nodeCount();
|
|
77
|
+
for (let i = 0; i < 300; i++) {
|
|
78
|
+
const id = await s.putLeaf(enc.encode("bloom-clean-" + i), g);
|
|
79
|
+
assert.equal(id, ids[i], `content ${i} re-minted after clean reopen`);
|
|
80
|
+
}
|
|
81
|
+
assert.equal(s.nodeCount(), before, "no duplicate mints after clean reopen");
|
|
82
|
+
await s.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("crash: reopen after an unclean exit still dedups everything", (t) => {
|
|
86
|
+
const path = `/tmp/bloom-crash-${process.pid}`;
|
|
87
|
+
t.after(() => cleanup(path));
|
|
88
|
+
|
|
89
|
+
// Phase 1 in a SUBPROCESS that exits WITHOUT close() — a real crash as far
|
|
90
|
+
// as the store is concerned (WAL holds the rows; no filter was persisted).
|
|
91
|
+
const phase1 = `
|
|
92
|
+
import("${process.cwd()}/dist/src/store-sqlite.js").then(async ({ SQliteStore }) => {
|
|
93
|
+
const s = new SQliteStore({ path: "${path}", D: 64 });
|
|
94
|
+
const enc = new TextEncoder();
|
|
95
|
+
const g = new Float32Array(64).fill(0.1);
|
|
96
|
+
for (let i = 0; i < 400; i++) {
|
|
97
|
+
await s.putLeaf(enc.encode("bloom-crash-" + i), g);
|
|
98
|
+
}
|
|
99
|
+
s.commit();
|
|
100
|
+
process.exit(0); // no close()
|
|
101
|
+
});`;
|
|
102
|
+
execFileSync(process.execPath, ["-e", phase1], { stdio: "pipe" });
|
|
103
|
+
|
|
104
|
+
// Phase 2 in a second subprocess: every previously-minted content must
|
|
105
|
+
// resolve to an existing id — zero duplicates.
|
|
106
|
+
const phase2 = `
|
|
107
|
+
import("${process.cwd()}/dist/src/store-sqlite.js").then(async ({ SQliteStore }) => {
|
|
108
|
+
const s = new SQliteStore({ path: "${path}", D: 64 });
|
|
109
|
+
await s.size();
|
|
110
|
+
const enc = new TextEncoder();
|
|
111
|
+
const g = new Float32Array(64).fill(0.1);
|
|
112
|
+
const before = s.nodeCount();
|
|
113
|
+
for (let i = 0; i < 400; i++) {
|
|
114
|
+
const id = await s.putLeaf(enc.encode("bloom-crash-" + i), g);
|
|
115
|
+
if (id >= before) { console.error("DUP " + i); process.exit(1); }
|
|
116
|
+
}
|
|
117
|
+
if (s.nodeCount() !== before) { console.error("GREW"); process.exit(1); }
|
|
118
|
+
await s.close();
|
|
119
|
+
process.exit(0);
|
|
120
|
+
});`;
|
|
121
|
+
execFileSync(process.execPath, ["-e", phase2], { stdio: "pipe" });
|
|
122
|
+
assert.ok(true);
|
|
123
|
+
});
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/** External ids are integers only. */
|
|
2
|
-
export type ExternalId = number;
|
|
3
|
-
export interface DatabaseOptions {
|
|
4
|
-
/** Path to the SQLite database file (use ":memory:" for a transient store). */
|
|
5
|
-
dbPath: string;
|
|
6
|
-
/** Vector dimensionality. Required for a new database; ignored when reopening. */
|
|
7
|
-
dim?: number;
|
|
8
|
-
/** Max neighbours per node on layers >= 1 (layer 0 uses 2*M). Default 16. */
|
|
9
|
-
M?: number;
|
|
10
|
-
/** Candidate list size during construction. Default 200. */
|
|
11
|
-
efConstruction?: number;
|
|
12
|
-
/** Candidate list size during search. Default 100. Tunable at runtime. */
|
|
13
|
-
efSearch?: number;
|
|
14
|
-
/** Bits used to scalar-quantise the query for the RaBitQ estimator.
|
|
15
|
-
* Query-side only (stored codes and the graph are independent of it), so it
|
|
16
|
-
* is tunable at reopen, like efSearch. Default 8: 4 bits cannot resolve a
|
|
17
|
-
* tight cluster's residual and costs ~18% self-recall there (test 2a); 8
|
|
18
|
-
* bits restores it for the price of a Uint16 per-query LUT. */
|
|
19
|
-
queryBits?: number;
|
|
20
|
-
/** Number of sign-flip + Hadamard rounds in the random rotation. Default 3. */
|
|
21
|
-
rotationRounds?: number;
|
|
22
|
-
/** Seed for the rotation and graph levels. Default fixed. */
|
|
23
|
-
seed?: number;
|
|
24
|
-
/** Optional centroid the vectors are centered by before quantisation. */
|
|
25
|
-
centroid?: ArrayLike<number>;
|
|
26
|
-
/**
|
|
27
|
-
* RAM cache size in MiB -- the single memory knob. It sizes SQLite's page
|
|
28
|
-
* cache AND the immutable-code LRU (whose entry-capacity is derived from this
|
|
29
|
-
* budget and the code size — there is no second knob). Both are purely speed
|
|
30
|
-
* enhancements: correctness and the per-operation storage-read count are
|
|
31
|
-
* identical with it off. Pass 0 to run with essentially no cache. Default 64.
|
|
32
|
-
* Not persisted; set per open.
|
|
33
|
-
*/
|
|
34
|
-
cacheSizeMb?: number;
|
|
35
|
-
}
|
|
36
|
-
export interface QueryResult {
|
|
37
|
-
id: ExternalId;
|
|
38
|
-
/** Estimated cosine distance (1 - cosine). */
|
|
39
|
-
distance: number;
|
|
40
|
-
}
|
|
41
|
-
export interface StorageStats {
|
|
42
|
-
/** Bytes a single Float32 copy of the vector would take. */
|
|
43
|
-
float32BytesPerVector: number;
|
|
44
|
-
/** Bytes of 1-bit code kept per vector (on disk). */
|
|
45
|
-
codeBytesPerVector: number;
|
|
46
|
-
/** Bytes kept per vector (just the code; excludes the graph adjacency). */
|
|
47
|
-
bytesPerVector: number;
|
|
48
|
-
/** float32BytesPerVector / bytesPerVector. */
|
|
49
|
-
compressionRatio: number;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* A persistent vector database: an HNSW graph over 1-bit RaBitQ codes (cosine),
|
|
53
|
-
* stored entirely in SQLite at `dbPath`. The graph IS the database -- there is no
|
|
54
|
-
* load/save step and no in-RAM copy of the data, so resident memory stays flat as
|
|
55
|
-
* the collection grows and the store survives process restarts. Reopening the
|
|
56
|
-
* same path restores the exact configuration (the rotation is regenerated from
|
|
57
|
-
* the persisted seed), so the codes already on disk remain valid.
|
|
58
|
-
*
|
|
59
|
-
* External ids are integers. The original float vectors are never retained --
|
|
60
|
-
* only the sign codes -- so a 256-d vector costs 32 bytes instead of 1024. `get`
|
|
61
|
-
* returns the stored code, which can be fed straight back into
|
|
62
|
-
* `insert`/`update`/`query`.
|
|
63
|
-
*/
|
|
64
|
-
export declare class VectorDatabase {
|
|
65
|
-
readonly dim: number;
|
|
66
|
-
private readonly store;
|
|
67
|
-
private readonly quantizer;
|
|
68
|
-
private readonly index;
|
|
69
|
-
private readonly codeWords;
|
|
70
|
-
constructor(options: DatabaseOptions);
|
|
71
|
-
/** Number of live (non-deleted) vectors. */
|
|
72
|
-
get size(): number;
|
|
73
|
-
/**
|
|
74
|
-
* Physical node count including tombstones from deletes/updates. When it grows
|
|
75
|
-
* well beyond `size`, call `compact()` to reclaim the space on disk.
|
|
76
|
-
*/
|
|
77
|
-
get physicalSize(): number;
|
|
78
|
-
get efSearch(): number;
|
|
79
|
-
set efSearch(value: number);
|
|
80
|
-
/** Distance computations performed during the most recent query. */
|
|
81
|
-
get lastQueryDistanceComputations(): number;
|
|
82
|
-
/**
|
|
83
|
-
* Storage row reads issued by the most recent query. This is the honest,
|
|
84
|
-
* cache-independent scalability metric: it counts every node/neighbour fetch
|
|
85
|
-
* that hit the database, so disabling the cache cannot hide a bad access pattern.
|
|
86
|
-
*/
|
|
87
|
-
get lastQueryStorageReads(): number;
|
|
88
|
-
/** Per-vector storage cost of the index versus a Float32 baseline. */
|
|
89
|
-
get storage(): StorageStats;
|
|
90
|
-
has(id: ExternalId): boolean;
|
|
91
|
-
/** Stream every live external id (bounded memory). */
|
|
92
|
-
keys(): IterableIterator<ExternalId>;
|
|
93
|
-
/**
|
|
94
|
-
* Stream live entries whose INTERNAL id is > `after`, as
|
|
95
|
-
* {ext, internal} pairs in internal-id order. Internal ids are assigned
|
|
96
|
-
* monotonically at insert and preserved by {@link compact}, so the largest
|
|
97
|
-
* internal id a caller has seen is a durable incremental watermark: a later
|
|
98
|
-
* call with it yields exactly the entries added since.
|
|
99
|
-
*/
|
|
100
|
-
keysSince(after: number): IterableIterator<{
|
|
101
|
-
ext: ExternalId;
|
|
102
|
-
internal: number;
|
|
103
|
-
}>;
|
|
104
|
-
private checkId;
|
|
105
|
-
/**
|
|
106
|
-
* Convert a value to code bytes, selecting by length:
|
|
107
|
-
* - `codeWords` elements -> an existing 1-bit code (e.g. from `get()`)
|
|
108
|
-
* - otherwise -> a raw `dim`-vector, encoded first
|
|
109
|
-
* `codeWords < dim` for any dim >= 2, so the two never collide.
|
|
110
|
-
*/
|
|
111
|
-
private toCodeBytes;
|
|
112
|
-
/**
|
|
113
|
-
* Create. Accepts a raw `dim`-vector or a 1-bit code (`codeWords` words),
|
|
114
|
-
* detected by length. Throws if the id already exists (use `update`/`upsert`).
|
|
115
|
-
*/
|
|
116
|
-
insert(id: ExternalId, value: ArrayLike<number>): void;
|
|
117
|
-
/** Insert with the caller owning the transaction (used by {@link upsertMany}).
|
|
118
|
-
* `ef` optionally narrows the construction beam for this one vector (a
|
|
119
|
-
* caller-declared cheap entry, e.g. a reach-only interior); omitted means
|
|
120
|
-
* the index's configured efConstruction. */
|
|
121
|
-
private insertCore;
|
|
122
|
-
upsert(id: ExternalId, value: ArrayLike<number>): void;
|
|
123
|
-
/**
|
|
124
|
-
* Upsert many vectors under ONE transaction. The HNSW build touches the store
|
|
125
|
-
* on every wired edge, so a transaction per vector is one WAL commit per vector
|
|
126
|
-
* — which dominates a bulk load on disk. Wrapping the whole batch in a single
|
|
127
|
-
* transaction coalesces those commits into one while leaving the graph and its
|
|
128
|
-
* result identical (reads on the connection still see the uncommitted rows).
|
|
129
|
-
*
|
|
130
|
-
* This is purely about commit batching; it changes nothing about the storage
|
|
131
|
-
* model. Codes still live only in SQLite and are read on demand — the
|
|
132
|
-
* cache-independent per-operation read count is unchanged — so a batched load
|
|
133
|
-
* scales exactly as the per-item path does, just with far fewer fsyncs. A throw
|
|
134
|
-
* rolls the whole batch back, so the caller treats it as the per-item path on
|
|
135
|
-
* failure.
|
|
136
|
-
*/
|
|
137
|
-
upsertMany(entries: Array<{
|
|
138
|
-
id: ExternalId;
|
|
139
|
-
vector: ArrayLike<number>;
|
|
140
|
-
ef?: number;
|
|
141
|
-
}>): void;
|
|
142
|
-
/**
|
|
143
|
-
* Read the stored 1-bit code for an id (a copy as a Uint32Array), or null. The
|
|
144
|
-
* original vector is not retained; the code round-trips into insert/update/query.
|
|
145
|
-
*/
|
|
146
|
-
get(id: ExternalId): Uint32Array | null;
|
|
147
|
-
/**
|
|
148
|
-
* Update the vector bound to an id (raw vector or code, detected by length).
|
|
149
|
-
* The previous node is tombstoned and a fresh one inserted. Throws if absent.
|
|
150
|
-
*/
|
|
151
|
-
update(id: ExternalId, value: ArrayLike<number>): void;
|
|
152
|
-
/** Update with the caller owning the transaction (used by {@link upsertMany}). */
|
|
153
|
-
private updateCore;
|
|
154
|
-
/** Update when the live internal node id is already known.
|
|
155
|
-
*
|
|
156
|
-
* Skips the write entirely when the new code equals the stored one — the
|
|
157
|
-
* update would tombstone the node and replay a full graph insert for a
|
|
158
|
-
* byte-identical result. This is not a corner case: content-addressed
|
|
159
|
-
* callers re-upsert unchanged vectors wholesale after a restart (the same
|
|
160
|
-
* content always encodes to the same code), and each such no-op otherwise
|
|
161
|
-
* costs a tombstone (permanent routing/disk overhead until compaction)
|
|
162
|
-
* plus an O(ef·log N) reinsert. */
|
|
163
|
-
private updateAt;
|
|
164
|
-
/**
|
|
165
|
-
* Delete many ids under ONE transaction — same commit-coalescing rationale
|
|
166
|
-
* as {@link upsertMany}: a tombstone per implicit transaction is one WAL
|
|
167
|
-
* commit per id, which dominates a bulk prune. Absent ids are skipped.
|
|
168
|
-
* Returns the number of vectors actually removed.
|
|
169
|
-
*/
|
|
170
|
-
deleteMany(ids: ExternalId[]): number;
|
|
171
|
-
/** Delete the vector bound to an id. Returns false if absent. */
|
|
172
|
-
delete(id: ExternalId): boolean;
|
|
173
|
-
/**
|
|
174
|
-
* Pre-fill the RAM caches (codes + neighbour lists) with sequential table
|
|
175
|
-
* scans, up to their budget-derived caps. Optional and purely a latency
|
|
176
|
-
* optimisation: a cold session otherwise pays the same warming through
|
|
177
|
-
* random point reads over its first minutes. Call once after open on a
|
|
178
|
-
* session that will do sustained inserts/queries. Returns rows warmed;
|
|
179
|
-
* a 0-budget database returns 0 immediately.
|
|
180
|
-
*/
|
|
181
|
-
warmCache(): number;
|
|
182
|
-
/**
|
|
183
|
-
* Rebuild the graph from the live codes only, dropping tombstones and returning
|
|
184
|
-
* the freed pages to the filesystem. Lossless -- equivalent to the original
|
|
185
|
-
* build -- and needs no original vectors.
|
|
186
|
-
*/
|
|
187
|
-
compact(): void;
|
|
188
|
-
/**
|
|
189
|
-
* k-NN search. The argument's length selects the mode:
|
|
190
|
-
* - `dim` elements -> a raw vector (accurate 4-bit-query estimator)
|
|
191
|
-
* - `codeWords` elements -> a 1-bit code (e.g. `get(id)`), by sign-bit Hamming
|
|
192
|
-
* Detection is by length, so a code is recognised whether it is a Uint32Array
|
|
193
|
-
* or a plain number[]. `codeWords < dim` for any dim >= 2, so they never collide.
|
|
194
|
-
*/
|
|
195
|
-
query(query: ArrayLike<number>, k?: number, opts?: {
|
|
196
|
-
ef?: number;
|
|
197
|
-
}): QueryResult[];
|
|
198
|
-
/** Close the underlying database. The instance must not be used afterwards. */
|
|
199
|
-
close(): void;
|
|
200
|
-
}
|