@hviana/sema 0.1.0 → 0.1.1
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/dist/example/demo.d.ts +1 -0
- package/dist/example/demo.js +39 -0
- package/dist/example/train_base.d.ts +87 -0
- package/dist/example/train_base.js +2216 -0
- package/dist/src/alphabet.d.ts +7 -0
- package/dist/src/alphabet.js +33 -0
- package/dist/src/alu/src/alu.d.ts +185 -0
- package/dist/src/alu/src/alu.js +440 -0
- package/dist/src/alu/src/expr.d.ts +61 -0
- package/dist/src/alu/src/expr.js +318 -0
- package/dist/src/alu/src/index.d.ts +11 -0
- package/dist/src/alu/src/index.js +19 -0
- package/dist/src/alu/src/kernel-arith.d.ts +16 -0
- package/dist/src/alu/src/kernel-arith.js +264 -0
- package/dist/src/alu/src/kernel-bits.d.ts +19 -0
- package/dist/src/alu/src/kernel-bits.js +152 -0
- package/dist/src/alu/src/kernel-logic.d.ts +4 -0
- package/dist/src/alu/src/kernel-logic.js +60 -0
- package/dist/src/alu/src/kernel-nd.d.ts +3 -0
- package/dist/src/alu/src/kernel-nd.js +208 -0
- package/dist/src/alu/src/kernel-numeric.d.ts +54 -0
- package/dist/src/alu/src/kernel-numeric.js +366 -0
- package/dist/src/alu/src/operation.d.ts +168 -0
- package/dist/src/alu/src/operation.js +189 -0
- package/dist/src/alu/src/parser.d.ts +212 -0
- package/dist/src/alu/src/parser.js +469 -0
- package/dist/src/alu/src/resonance.d.ts +55 -0
- package/dist/src/alu/src/resonance.js +126 -0
- package/dist/src/alu/src/text.d.ts +31 -0
- package/dist/src/alu/src/text.js +73 -0
- package/dist/src/alu/src/value.d.ts +109 -0
- package/dist/src/alu/src/value.js +300 -0
- package/dist/src/alu/test/alu.test.d.ts +1 -0
- package/dist/src/alu/test/alu.test.js +764 -0
- package/dist/src/bytes.d.ts +14 -0
- package/dist/src/bytes.js +59 -0
- package/dist/src/config.d.ts +114 -0
- package/dist/src/config.js +96 -0
- package/dist/src/derive/src/deduction.d.ts +125 -0
- package/dist/src/derive/src/deduction.js +155 -0
- package/dist/src/derive/src/index.d.ts +7 -0
- package/dist/src/derive/src/index.js +11 -0
- package/dist/src/derive/src/priority-queue.d.ts +20 -0
- package/dist/src/derive/src/priority-queue.js +73 -0
- package/dist/src/derive/src/rewrite.d.ts +56 -0
- package/dist/src/derive/src/rewrite.js +100 -0
- package/dist/src/derive/src/trie.d.ts +90 -0
- package/dist/src/derive/src/trie.js +217 -0
- package/dist/src/derive/test/derive.test.d.ts +1 -0
- package/dist/src/derive/test/derive.test.js +122 -0
- package/dist/src/extension.d.ts +37 -0
- package/dist/src/extension.js +7 -0
- package/dist/src/geometry.d.ts +137 -0
- package/dist/src/geometry.js +430 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +21 -0
- package/dist/src/ingest-cache.d.ts +41 -0
- package/dist/src/ingest-cache.js +161 -0
- package/dist/src/mind/articulation.d.ts +6 -0
- package/dist/src/mind/articulation.js +99 -0
- package/dist/src/mind/attention.d.ts +72 -0
- package/dist/src/mind/attention.js +894 -0
- package/dist/src/mind/canonical.d.ts +29 -0
- package/dist/src/mind/canonical.js +88 -0
- package/dist/src/mind/graph-search.d.ts +270 -0
- package/dist/src/mind/graph-search.js +847 -0
- package/dist/src/mind/index.d.ts +5 -0
- package/dist/src/mind/index.js +5 -0
- package/dist/src/mind/junction.d.ts +95 -0
- package/dist/src/mind/junction.js +262 -0
- package/dist/src/mind/learning.d.ts +47 -0
- package/dist/src/mind/learning.js +201 -0
- package/dist/src/mind/match.d.ts +111 -0
- package/dist/src/mind/match.js +422 -0
- package/dist/src/mind/mechanisms/alu.d.ts +4 -0
- package/dist/src/mind/mechanisms/alu.js +29 -0
- package/dist/src/mind/mechanisms/cast.d.ts +35 -0
- package/dist/src/mind/mechanisms/cast.js +447 -0
- package/dist/src/mind/mechanisms/confluence.d.ts +24 -0
- package/dist/src/mind/mechanisms/confluence.js +213 -0
- package/dist/src/mind/mechanisms/cover.d.ts +6 -0
- package/dist/src/mind/mechanisms/cover.js +179 -0
- package/dist/src/mind/mechanisms/extraction.d.ts +67 -0
- package/dist/src/mind/mechanisms/extraction.js +342 -0
- package/dist/src/mind/mechanisms/recall.d.ts +13 -0
- package/dist/src/mind/mechanisms/recall.js +151 -0
- package/dist/src/mind/mind.d.ts +147 -0
- package/dist/src/mind/mind.js +300 -0
- package/dist/src/mind/pipeline-mechanism.d.ts +142 -0
- package/dist/src/mind/pipeline-mechanism.js +213 -0
- package/dist/src/mind/pipeline.d.ts +20 -0
- package/dist/src/mind/pipeline.js +185 -0
- package/dist/src/mind/primitives.d.ts +43 -0
- package/dist/src/mind/primitives.js +162 -0
- package/dist/src/mind/rationale.d.ts +134 -0
- package/dist/src/mind/rationale.js +162 -0
- package/dist/src/mind/reasoning.d.ts +15 -0
- package/dist/src/mind/reasoning.js +162 -0
- package/dist/src/mind/recognition.d.ts +20 -0
- package/dist/src/mind/recognition.js +223 -0
- package/dist/src/mind/resonance.d.ts +23 -0
- package/dist/src/mind/resonance.js +0 -0
- package/dist/src/mind/trace.d.ts +15 -0
- package/dist/src/mind/trace.js +73 -0
- package/dist/src/mind/traverse.d.ts +100 -0
- package/dist/src/mind/traverse.js +447 -0
- package/dist/src/mind/types.d.ts +174 -0
- package/dist/src/mind/types.js +84 -0
- package/dist/src/rabitq-hnsw/src/database.d.ts +200 -0
- package/dist/src/rabitq-hnsw/src/database.js +388 -0
- package/dist/src/rabitq-hnsw/src/heap.d.ts +22 -0
- package/dist/src/rabitq-hnsw/src/heap.js +89 -0
- package/dist/src/rabitq-hnsw/src/hnsw.d.ts +125 -0
- package/dist/src/rabitq-hnsw/src/hnsw.js +474 -0
- package/dist/src/rabitq-hnsw/src/index.d.ts +10 -0
- package/dist/src/rabitq-hnsw/src/index.js +6 -0
- package/dist/src/rabitq-hnsw/src/prng.d.ts +19 -0
- package/dist/src/rabitq-hnsw/src/prng.js +36 -0
- package/dist/src/rabitq-hnsw/src/rabitq.d.ts +95 -0
- package/dist/src/rabitq-hnsw/src/rabitq.js +283 -0
- package/dist/src/rabitq-hnsw/src/store.d.ts +162 -0
- package/dist/src/rabitq-hnsw/src/store.js +825 -0
- package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +1 -0
- package/dist/src/rabitq-hnsw/test/hnsw.test.js +948 -0
- package/dist/src/store-sqlite.d.ts +149 -0
- package/dist/src/store-sqlite.js +702 -0
- package/dist/src/store.d.ts +638 -0
- package/dist/src/store.js +1618 -0
- package/dist/src/vec.d.ts +31 -0
- package/dist/src/vec.js +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { RaBitQuantizer, VectorDatabase, } from "../src/index.js";
|
|
5
|
+
// ----------------------------- tiny harness ----------------------------------
|
|
6
|
+
let passed = 0;
|
|
7
|
+
function assert(cond, msg) {
|
|
8
|
+
if (!cond)
|
|
9
|
+
throw new Error("ASSERTION FAILED: " + msg);
|
|
10
|
+
passed++;
|
|
11
|
+
}
|
|
12
|
+
function approxEqual(a, b, eps = 1e-4) {
|
|
13
|
+
return Math.abs(a - b) <= eps * (1 + Math.abs(a) + Math.abs(b));
|
|
14
|
+
}
|
|
15
|
+
function section(title) {
|
|
16
|
+
console.log("\n" + "=".repeat(72) + "\n" + title + "\n" + "=".repeat(72));
|
|
17
|
+
}
|
|
18
|
+
function now() {
|
|
19
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
20
|
+
}
|
|
21
|
+
// ------------------------------- seeded RNG ----------------------------------
|
|
22
|
+
class RNG {
|
|
23
|
+
s;
|
|
24
|
+
constructor(seed) {
|
|
25
|
+
this.s = seed >>> 0 || 1;
|
|
26
|
+
}
|
|
27
|
+
next() {
|
|
28
|
+
let t = (this.s = (this.s + 0x6d2b79f5) | 0);
|
|
29
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
30
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
31
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
32
|
+
}
|
|
33
|
+
int(n) {
|
|
34
|
+
return (this.next() * n) | 0;
|
|
35
|
+
}
|
|
36
|
+
_gaussSpare = 0;
|
|
37
|
+
_gaussHasSpare = false;
|
|
38
|
+
/** Box-Muller — caches the second value so every call produces one Gaussian
|
|
39
|
+
* without wasting the log/sqrt pair. */
|
|
40
|
+
gauss() {
|
|
41
|
+
if (this._gaussHasSpare) {
|
|
42
|
+
this._gaussHasSpare = false;
|
|
43
|
+
return this._gaussSpare;
|
|
44
|
+
}
|
|
45
|
+
let u = 0;
|
|
46
|
+
let v = 0;
|
|
47
|
+
while (u === 0)
|
|
48
|
+
u = this.next();
|
|
49
|
+
while (v === 0)
|
|
50
|
+
v = this.next();
|
|
51
|
+
const r = Math.sqrt(-2 * Math.log(u));
|
|
52
|
+
this._gaussSpare = r * Math.sin(2 * Math.PI * v);
|
|
53
|
+
this._gaussHasSpare = true;
|
|
54
|
+
return r * Math.cos(2 * Math.PI * v);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function unit(v) {
|
|
58
|
+
let s = 0;
|
|
59
|
+
for (const x of v)
|
|
60
|
+
s += x * x;
|
|
61
|
+
const n = Math.sqrt(s) || 1;
|
|
62
|
+
return v.map((x) => x / n);
|
|
63
|
+
}
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// 1) Vector CRUD + persistence (SQLite file round-trip)
|
|
66
|
+
// =============================================================================
|
|
67
|
+
function testCrudAndPersistence() {
|
|
68
|
+
section("1) Vector CRUD + persistence (get returns the 1-bit code)");
|
|
69
|
+
const DIM = 48; // paddedDim 64 -> codeWords 2, distinctive codes
|
|
70
|
+
const dbPath = path.join(os.tmpdir(), `rabitq-test-crud-${Date.now()}.sqlite`);
|
|
71
|
+
let db = new VectorDatabase({
|
|
72
|
+
dbPath,
|
|
73
|
+
dim: DIM,
|
|
74
|
+
M: 8,
|
|
75
|
+
efConstruction: 64,
|
|
76
|
+
seed: 7,
|
|
77
|
+
});
|
|
78
|
+
// distinct directions so the 1-bit codes differ
|
|
79
|
+
const mk = (i, scale = 1) => {
|
|
80
|
+
const v = new Array(DIM).fill(0);
|
|
81
|
+
v[i] = scale;
|
|
82
|
+
return v;
|
|
83
|
+
};
|
|
84
|
+
// Integer IDs only
|
|
85
|
+
const ID = { alpha: 1, bravo: 2, charlie: 3, delta: 4, echo: 5 };
|
|
86
|
+
const vectors = {
|
|
87
|
+
alpha: mk(0),
|
|
88
|
+
bravo: mk(10),
|
|
89
|
+
charlie: mk(20),
|
|
90
|
+
delta: mk(30),
|
|
91
|
+
echo: mk(45),
|
|
92
|
+
};
|
|
93
|
+
// CREATE
|
|
94
|
+
for (const [name, v] of Object.entries(vectors)) {
|
|
95
|
+
db.insert(ID[name], v);
|
|
96
|
+
}
|
|
97
|
+
assert(db.size === 5, "size should be 5 after inserting 5 vectors");
|
|
98
|
+
let threw = false;
|
|
99
|
+
try {
|
|
100
|
+
db.insert(ID.alpha, vectors.alpha);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
threw = true;
|
|
104
|
+
}
|
|
105
|
+
assert(threw, "inserting a duplicate external id must throw");
|
|
106
|
+
// READ — returns the bare 1-bit code (Uint32Array)
|
|
107
|
+
const code = db.get(ID.alpha);
|
|
108
|
+
assert(code !== null, "get(alpha) returns a stored code");
|
|
109
|
+
assert(code instanceof Uint32Array && code.length === 2, "code is 2 packed 32-bit words");
|
|
110
|
+
assert(code.length * 32 === 64, "code covers paddedDim = 64 sign bits");
|
|
111
|
+
assert(db.get(9999) === null, "get of unknown id returns null");
|
|
112
|
+
assert(db.has(ID.bravo) && !db.has(9999), "has() reflects membership");
|
|
113
|
+
// SEARCH — querying with an exact copy returns that vector first
|
|
114
|
+
assert(db.query(vectors.alpha, 3)[0].id === ID.alpha, "exact-copy query returns alpha first");
|
|
115
|
+
assert(db.query(vectors.bravo, 1)[0].id === ID.bravo, "exact-copy query returns bravo first");
|
|
116
|
+
// UPDATE
|
|
117
|
+
db.update(ID.charlie, mk(40));
|
|
118
|
+
assert(db.query(mk(40), 1)[0].id === ID.charlie, "updated charlie is found at its new location");
|
|
119
|
+
assert(db.size === 5, "size unchanged after update");
|
|
120
|
+
// DELETE
|
|
121
|
+
assert(db.delete(ID.delta) === true, "delete returns true for existing id");
|
|
122
|
+
assert(db.delete(ID.delta) === false, "delete returns false for already removed id");
|
|
123
|
+
assert(!db.has(ID.delta) && db.size === 4, "delta removed, size now 4");
|
|
124
|
+
assert(!db.query(vectors.delta, 4).some((r) => r.id === ID.delta), "deleted vector never appears in results");
|
|
125
|
+
// PERSISTENCE round-trip — capture state, then close and reopen the same file
|
|
126
|
+
const liveKeys = Array.from(db.keys());
|
|
127
|
+
const probe = mk(0);
|
|
128
|
+
const before = db.query(probe, 4);
|
|
129
|
+
const codesBefore = new Map();
|
|
130
|
+
for (const id of liveKeys)
|
|
131
|
+
codesBefore.set(id, db.get(id));
|
|
132
|
+
db.close();
|
|
133
|
+
const reloaded = new VectorDatabase({ dbPath });
|
|
134
|
+
assert(reloaded.size === db.size, "reloaded size matches");
|
|
135
|
+
for (const id of liveKeys) {
|
|
136
|
+
const a = codesBefore.get(id);
|
|
137
|
+
const b = reloaded.get(id);
|
|
138
|
+
assert(b !== null, `reloaded code for ${id} is not null`);
|
|
139
|
+
assert(a.length === b.length, `reloaded code length for ${id} matches`);
|
|
140
|
+
for (let i = 0; i < a.length; i++) {
|
|
141
|
+
assert(a[i] === b[i], `reloaded code word for ${id} matches`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const after = reloaded.query(probe, 4);
|
|
145
|
+
assert(before.length === after.length, "reloaded query result length matches");
|
|
146
|
+
for (let i = 0; i < before.length; i++) {
|
|
147
|
+
assert(before[i].id === after[i].id, "reloaded query ordering matches");
|
|
148
|
+
assert(approxEqual(before[i].distance, after[i].distance, 1e-6), "reloaded query distance matches");
|
|
149
|
+
}
|
|
150
|
+
reloaded.close();
|
|
151
|
+
// Cleanup
|
|
152
|
+
fs.unlinkSync(dbPath);
|
|
153
|
+
console.log(`CRUD + persistence OK (get returns ${code.length}-word codes; reload identical)`);
|
|
154
|
+
}
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// 1b) insert / update / query by an already-quantized 1-bit code
|
|
157
|
+
// =============================================================================
|
|
158
|
+
function testCodeIO() {
|
|
159
|
+
section("1b) insert / update / query by a 1-bit code (length-detected)");
|
|
160
|
+
const DIM = 128; // paddedDim 128 -> codeWords 4
|
|
161
|
+
const rng = new RNG(909);
|
|
162
|
+
const vec = () => Array.from({ length: DIM }, () => rng.gauss());
|
|
163
|
+
const db = new VectorDatabase({
|
|
164
|
+
dbPath: ":memory:",
|
|
165
|
+
dim: DIM,
|
|
166
|
+
M: 16,
|
|
167
|
+
efConstruction: 100,
|
|
168
|
+
seed: 5,
|
|
169
|
+
});
|
|
170
|
+
const N = 800;
|
|
171
|
+
for (let i = 0; i < N; i++)
|
|
172
|
+
db.insert(i, vec());
|
|
173
|
+
const src = db.get(123);
|
|
174
|
+
assert(src instanceof Uint32Array && src.length === 4, "get() yields a 4-word code");
|
|
175
|
+
// QUERY by code: a stored code finds itself at distance 0
|
|
176
|
+
const byCode = db.query(src, 5);
|
|
177
|
+
assert(byCode[0].id === 123, "query(code) returns the owning vector first");
|
|
178
|
+
assert(approxEqual(byCode[0].distance, 0, 1e-9), "query(code) distance to the same code is 0");
|
|
179
|
+
// INSERT by code (length-detected): the copy is a real, findable node
|
|
180
|
+
const copyId = N + 1;
|
|
181
|
+
db.insert(copyId, src);
|
|
182
|
+
assert(db.size === N + 1, "insert(code) adds a vector");
|
|
183
|
+
const copyCode = db.get(copyId);
|
|
184
|
+
for (let i = 0; i < src.length; i++) {
|
|
185
|
+
assert(copyCode[i] === src[i], "insert(code) stored the code verbatim");
|
|
186
|
+
}
|
|
187
|
+
const hits = db.query(src, 5).map((h) => h.id);
|
|
188
|
+
assert(hits.includes(copyId) && hits.includes(123), "both the original and its code-copy are retrievable");
|
|
189
|
+
// number[] code (e.g. after conversion) is detected the same way
|
|
190
|
+
const asArray = Array.from(src);
|
|
191
|
+
const arrId = N + 2;
|
|
192
|
+
db.insert(arrId, asArray);
|
|
193
|
+
const arrCode = db.get(arrId);
|
|
194
|
+
for (let i = 0; i < src.length; i++) {
|
|
195
|
+
assert(arrCode[i] === src[i], "insert(number[] code) works identically");
|
|
196
|
+
}
|
|
197
|
+
// UPDATE by code: move id 7 onto id 200's code
|
|
198
|
+
const target = db.get(200);
|
|
199
|
+
db.update(7, target);
|
|
200
|
+
const moved = db.get(7);
|
|
201
|
+
for (let i = 0; i < target.length; i++) {
|
|
202
|
+
assert(moved[i] === target[i], "update(code) replaced the code");
|
|
203
|
+
}
|
|
204
|
+
assert(db.size === N + 2, "update(code) keeps size (replace, not add)");
|
|
205
|
+
const near200 = db.query(target, 5).map((h) => h.id);
|
|
206
|
+
assert(near200.includes(7) && near200.includes(200), "updated-by-code id now sits with its target");
|
|
207
|
+
db.close();
|
|
208
|
+
console.log("insert / update / query by 1-bit code OK (Uint32Array and number[])");
|
|
209
|
+
}
|
|
210
|
+
// =============================================================================
|
|
211
|
+
// 2) Cosine metric sanity
|
|
212
|
+
// =============================================================================
|
|
213
|
+
function testCosine() {
|
|
214
|
+
section("2) Cosine sanity");
|
|
215
|
+
const DIM = 64;
|
|
216
|
+
const db = new VectorDatabase({ dbPath: ":memory:", dim: DIM, seed: 3 });
|
|
217
|
+
const axis = (i) => {
|
|
218
|
+
const v = new Array(DIM).fill(0);
|
|
219
|
+
v[i] = 1;
|
|
220
|
+
return v;
|
|
221
|
+
};
|
|
222
|
+
db.insert(1, axis(0)); // "x"
|
|
223
|
+
db.insert(2, axis(20)); // "y"
|
|
224
|
+
db.insert(3, axis(40)); // "z"
|
|
225
|
+
const r = db.query(axis(0), 3); // query along x's direction
|
|
226
|
+
assert(r[0].id === 1, "cosine: nearest direction to x's axis is x");
|
|
227
|
+
assert(r[0].distance <= 0.1, "cosine distance to an identical direction is ~ 0");
|
|
228
|
+
assert(r[r.length - 1].distance > r[0].distance, "orthogonal directions are farther than the aligned one");
|
|
229
|
+
db.close();
|
|
230
|
+
console.log("Cosine metric OK");
|
|
231
|
+
}
|
|
232
|
+
// =============================================================================
|
|
233
|
+
// 2a) Near-duplicate self-recall (tight cluster)
|
|
234
|
+
// =============================================================================
|
|
235
|
+
// Every other recall test here uses WELL-SEPARATED groups, so the 1-bit codes
|
|
236
|
+
// differ in many sign bits and recall looks perfect. Real workloads are not so
|
|
237
|
+
// kind: vectors often form a TIGHT cluster — a shared base plus a small
|
|
238
|
+
// distinctive residual — so each one sits a hair away from its neighbours on
|
|
239
|
+
// the sphere. The contract that must hold there is the most basic one: a stored
|
|
240
|
+
// vector, queried verbatim, retrieves ITSELF as the nearest neighbour.
|
|
241
|
+
//
|
|
242
|
+
// This is the case that exposed a precision bug in the query estimator. RaBitQ
|
|
243
|
+
// scalar-quantises the full-precision query to `1 << queryBits` levels before
|
|
244
|
+
// scoring it against the 1-bit codes. At 4 bits (16 levels) that step is too
|
|
245
|
+
// coarse to resolve the small residual separating cluster siblings, so the
|
|
246
|
+
// estimate misranks them and a vector can fail to find itself (~82% self-recall
|
|
247
|
+
// on this data). The fix lives in rabitq.ts: the default queryBits is 8, which
|
|
248
|
+
// resolves the residual and restores near-perfect self-recall — touching only
|
|
249
|
+
// the query side (the stored 1-bit codes, disk size and graph build are
|
|
250
|
+
// unchanged; only the per-query LUT widens from Uint8 to Uint16). The
|
|
251
|
+
// assertions pin both ends: an explicit 4 bits is measurably degraded, the
|
|
252
|
+
// default recovers.
|
|
253
|
+
function testNearDuplicateRecall() {
|
|
254
|
+
section("2a) Near-duplicate self-recall (tight cluster)");
|
|
255
|
+
const DIM = 256;
|
|
256
|
+
const N = 150;
|
|
257
|
+
const SPREAD = 0.15;
|
|
258
|
+
// A fixed base direction; each vector is the base plus a small per-coordinate
|
|
259
|
+
// perturbation, then normalised — a dense cluster of near-duplicates. A plain
|
|
260
|
+
// LCG drives the perturbation: it produces a uniformly spread cloud around the
|
|
261
|
+
// base (the regime where the queryBits effect is sharpest); the Box-Muller
|
|
262
|
+
// RNG above clusters too tightly to separate the two settings.
|
|
263
|
+
const build = (queryBits) => {
|
|
264
|
+
let s = 7 >>> 0;
|
|
265
|
+
const next = () => {
|
|
266
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
267
|
+
return s / 4294967296;
|
|
268
|
+
};
|
|
269
|
+
const base = new Array(DIM);
|
|
270
|
+
for (let d = 0; d < DIM; d++)
|
|
271
|
+
base[d] = next() * 2 - 1;
|
|
272
|
+
const vecs = [];
|
|
273
|
+
const db = new VectorDatabase({
|
|
274
|
+
dbPath: ":memory:",
|
|
275
|
+
dim: DIM,
|
|
276
|
+
M: 16,
|
|
277
|
+
efConstruction: 64,
|
|
278
|
+
efSearch: 64,
|
|
279
|
+
seed: 123,
|
|
280
|
+
queryBits,
|
|
281
|
+
});
|
|
282
|
+
for (let i = 0; i < N; i++) {
|
|
283
|
+
const v = new Array(DIM);
|
|
284
|
+
let n = 0;
|
|
285
|
+
for (let d = 0; d < DIM; d++) {
|
|
286
|
+
v[d] = base[d] + SPREAD * (next() * 2 - 1);
|
|
287
|
+
n += v[d] * v[d];
|
|
288
|
+
}
|
|
289
|
+
n = Math.sqrt(n) || 1;
|
|
290
|
+
for (let d = 0; d < DIM; d++)
|
|
291
|
+
v[d] /= n;
|
|
292
|
+
vecs.push(v);
|
|
293
|
+
db.insert(i, v);
|
|
294
|
+
}
|
|
295
|
+
let self = 0;
|
|
296
|
+
for (let i = 0; i < N; i++) {
|
|
297
|
+
const h = db.query(vecs[i], 1);
|
|
298
|
+
if (h[0] && h[0].id === i)
|
|
299
|
+
self++;
|
|
300
|
+
}
|
|
301
|
+
db.close();
|
|
302
|
+
return self / N;
|
|
303
|
+
};
|
|
304
|
+
const r4 = build(4); // explicitly coarse — the old default
|
|
305
|
+
const rDefault = build(8); // the library default after the fix
|
|
306
|
+
console.log(`near-duplicate self-recall: queryBits=4 ${(r4 * 100).toFixed(1)}% ` +
|
|
307
|
+
`default(8) ${(rDefault * 100).toFixed(1)}%`);
|
|
308
|
+
// The bug: 4-bit query quantisation cannot resolve a tight cluster, so a
|
|
309
|
+
// sizeable fraction of vectors fail to retrieve themselves.
|
|
310
|
+
assert(r4 < 0.9, `queryBits=4 is degraded on a tight cluster (got ${(r4 * 100).toFixed(1)}%)`);
|
|
311
|
+
// The fix: the default B_q restores near-perfect self-recall — same stored
|
|
312
|
+
// codes, sharper query estimate.
|
|
313
|
+
assert(rDefault >= 0.95, `default queryBits recovers self-recall on a tight cluster (got ${(rDefault * 100).toFixed(1)}%)`);
|
|
314
|
+
assert(rDefault > r4, "the default queryBits must beat the coarse 4-bit setting");
|
|
315
|
+
console.log("Near-duplicate self-recall OK (queryBits resolves the cluster)");
|
|
316
|
+
}
|
|
317
|
+
// =============================================================================
|
|
318
|
+
// 2a-bis) The cosFactor bias — a known, deliberate approximation in estimate()
|
|
319
|
+
// =============================================================================
|
|
320
|
+
// FINDING (the deeper cause behind the near-duplicate misranking above).
|
|
321
|
+
//
|
|
322
|
+
// RaBitQuantizer.estimate() returns `1 - cosFactor * A`, where
|
|
323
|
+
// cosFactor = sqrt(pi / (2 * paddedDim))
|
|
324
|
+
// is a FIXED constant: the EXPECTED L1 norm of a randomly-rotated unit vector.
|
|
325
|
+
// Canonical RaBitQ does not use that expectation — it divides A by each
|
|
326
|
+
// vector's OWN rotated-L1 norm (the per-vector factor <o_bar, o>). Substituting
|
|
327
|
+
// the population mean for the per-vector quantity makes the estimator BIASED,
|
|
328
|
+
// and the bias is what this test pins down precisely.
|
|
329
|
+
//
|
|
330
|
+
// The cleanest probe is self-distance: a stored code scored against the query
|
|
331
|
+
// it was made from must, for an unbiased cosine estimator, return distance 0
|
|
332
|
+
// (cosine 1). With the fixed cosFactor it does NOT. Because each vector's true
|
|
333
|
+
// rotated-L1 norm scatters around the mean, the estimate scatters around 0:
|
|
334
|
+
// - it is non-zero (mean and max are both > 0 by a clear margin), and
|
|
335
|
+
// - it goes NEGATIVE for a large fraction of vectors — a negative distance
|
|
336
|
+
// means estimated cosine > 1, which is geometrically impossible and is the
|
|
337
|
+
// unmistakable fingerprint of the mean-vs-per-vector substitution.
|
|
338
|
+
//
|
|
339
|
+
// This bias is small in magnitude but systematic, so near-threshold pairs read
|
|
340
|
+
// as closer (or farther) than they truly are — exactly the misranking that
|
|
341
|
+
// collapses a tight cluster. The test documents the property as-is (it does NOT
|
|
342
|
+
// assert the bias away): the bias is REAL and lives in estimate(); raising
|
|
343
|
+
// queryBits, tested above, is what keeps recall correct in its presence. Should
|
|
344
|
+
// estimate() ever be upgraded to the true per-vector factor, the self-distance
|
|
345
|
+
// here would tighten to ~0 and the negatives would vanish — at which point this
|
|
346
|
+
// test is the tripwire that flags the behavioural change.
|
|
347
|
+
function testCosFactorBias() {
|
|
348
|
+
section("2a-bis) cosFactor self-distance bias (fixed mean vs per-vector norm)");
|
|
349
|
+
const DIM = 256;
|
|
350
|
+
const N = 300;
|
|
351
|
+
const SPREAD = 0.15;
|
|
352
|
+
let s = 7 >>> 0;
|
|
353
|
+
const next = () => {
|
|
354
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
355
|
+
return s / 4294967296;
|
|
356
|
+
};
|
|
357
|
+
const base = new Array(DIM);
|
|
358
|
+
for (let d = 0; d < DIM; d++)
|
|
359
|
+
base[d] = next() * 2 - 1;
|
|
360
|
+
const q = new RaBitQuantizer(DIM, { seed: 123 });
|
|
361
|
+
const vecs = [];
|
|
362
|
+
const codes = [];
|
|
363
|
+
for (let i = 0; i < N; i++) {
|
|
364
|
+
const v = new Array(DIM);
|
|
365
|
+
let n = 0;
|
|
366
|
+
for (let d = 0; d < DIM; d++) {
|
|
367
|
+
v[d] = base[d] + SPREAD * (next() * 2 - 1);
|
|
368
|
+
n += v[d] * v[d];
|
|
369
|
+
}
|
|
370
|
+
n = Math.sqrt(n) || 1;
|
|
371
|
+
for (let d = 0; d < DIM; d++)
|
|
372
|
+
v[d] /= n;
|
|
373
|
+
vecs.push(v);
|
|
374
|
+
codes.push(q.codeToBytes(q.encode(v)));
|
|
375
|
+
}
|
|
376
|
+
// Self-distance: each vector scored against its OWN code. An unbiased cosine
|
|
377
|
+
// estimator returns 0 here; the fixed cosFactor does not.
|
|
378
|
+
let sum = 0;
|
|
379
|
+
let max = 0;
|
|
380
|
+
let negatives = 0;
|
|
381
|
+
for (let i = 0; i < N; i++) {
|
|
382
|
+
const ctx = q.prepareQuery(vecs[i]);
|
|
383
|
+
const d = q.estimate(codes[i], 0, ctx);
|
|
384
|
+
sum += d;
|
|
385
|
+
if (d > max)
|
|
386
|
+
max = d;
|
|
387
|
+
if (d < 0)
|
|
388
|
+
negatives++; // distance < 0 <=> estimated cosine > 1 (impossible)
|
|
389
|
+
}
|
|
390
|
+
const mean = sum / N;
|
|
391
|
+
console.log(`self-distance under fixed cosFactor: mean ${mean.toFixed(5)}, ` +
|
|
392
|
+
`max ${max.toFixed(5)}, negatives ${negatives}/${N} ` +
|
|
393
|
+
`(an unbiased estimator would give 0 with 0 negatives)`);
|
|
394
|
+
// The estimate is genuinely non-zero on a self-match: the fixed mean factor
|
|
395
|
+
// does not reproduce the per-vector normalisation.
|
|
396
|
+
assert(max > 0.005, `self-distance is biased away from 0 (max ${max.toFixed(5)})`);
|
|
397
|
+
// The fingerprint: a large fraction of self-matches yield an IMPOSSIBLE
|
|
398
|
+
// cosine > 1 (negative distance) — only a mean-vs-per-vector substitution
|
|
399
|
+
// produces this. If this ever drops to 0, estimate() changed.
|
|
400
|
+
assert(negatives > N / 10, `cosFactor produces impossible cosine>1 on self-matches ` +
|
|
401
|
+
`(${negatives}/${N} negative distances)`);
|
|
402
|
+
console.log("cosFactor bias documented (fixed mean L1 norm, not the per-vector factor)");
|
|
403
|
+
}
|
|
404
|
+
// =============================================================================
|
|
405
|
+
// 2b) SQLite persistence round-trip
|
|
406
|
+
// =============================================================================
|
|
407
|
+
function testPersistence() {
|
|
408
|
+
section("2b) SQLite persistence round-trip");
|
|
409
|
+
const DIM = 96;
|
|
410
|
+
const rng = new RNG(2024);
|
|
411
|
+
const vec = () => Array.from({ length: DIM }, () => rng.gauss());
|
|
412
|
+
{
|
|
413
|
+
const dbPath = path.join(os.tmpdir(), `rabitq-test-persist-${Date.now()}.sqlite`);
|
|
414
|
+
const db = new VectorDatabase({
|
|
415
|
+
dbPath,
|
|
416
|
+
dim: DIM,
|
|
417
|
+
M: 12,
|
|
418
|
+
efConstruction: 80,
|
|
419
|
+
seed: 17,
|
|
420
|
+
});
|
|
421
|
+
for (let i = 0; i < 1500; i++)
|
|
422
|
+
db.insert(i, vec());
|
|
423
|
+
for (let i = 0; i < 150; i++)
|
|
424
|
+
db.delete(i * 10); // tombstones
|
|
425
|
+
for (let i = 0; i < 120; i++)
|
|
426
|
+
db.update(3 + i * 10, vec()); // re-inserts
|
|
427
|
+
// Capture state before closing
|
|
428
|
+
const liveKeys = Array.from(db.keys());
|
|
429
|
+
const codesBefore = new Map();
|
|
430
|
+
for (const id of liveKeys)
|
|
431
|
+
codesBefore.set(id, db.get(id));
|
|
432
|
+
const queriesBefore = [];
|
|
433
|
+
for (let q = 0; q < 40; q++) {
|
|
434
|
+
const query = vec();
|
|
435
|
+
queriesBefore.push({ query, results: db.query(query, 10) });
|
|
436
|
+
}
|
|
437
|
+
const sizeBefore = db.size;
|
|
438
|
+
db.close();
|
|
439
|
+
// Reopen from file
|
|
440
|
+
const db2 = new VectorDatabase({ dbPath });
|
|
441
|
+
assert(db2.size === sizeBefore, "size preserved across persistence round-trip");
|
|
442
|
+
// get() parity: identical codes for every live id
|
|
443
|
+
for (const id of liveKeys) {
|
|
444
|
+
const a = codesBefore.get(id);
|
|
445
|
+
const b = db2.get(id);
|
|
446
|
+
assert(b !== null, `id ${id} present after reload`);
|
|
447
|
+
assert(a.length === b.length, "code length identical after reload");
|
|
448
|
+
for (let i = 0; i < a.length; i++) {
|
|
449
|
+
assert(a[i] === b[i], "code identical after reload");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// query parity: identical ids and (bit-exact) distances
|
|
453
|
+
let maxDiff = 0;
|
|
454
|
+
for (const { query, results: ra } of queriesBefore) {
|
|
455
|
+
const rb = db2.query(query, 10);
|
|
456
|
+
assert(ra.length === rb.length, "result length identical after reload");
|
|
457
|
+
for (let i = 0; i < ra.length; i++) {
|
|
458
|
+
assert(ra[i].id === rb[i].id, "result id/order identical after reload");
|
|
459
|
+
maxDiff = Math.max(maxDiff, Math.abs(ra[i].distance - rb[i].distance));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
assert(maxDiff === 0, `distances are bit-exact after reload (max diff ${maxDiff})`);
|
|
463
|
+
// Mutation still works after a reload
|
|
464
|
+
db2.insert(99999, vec());
|
|
465
|
+
assert(db2.has(99999), "insert works after persistence load");
|
|
466
|
+
assert(db2.get(99999) !== null, "get works after persistence load");
|
|
467
|
+
db2.close();
|
|
468
|
+
fs.unlinkSync(dbPath);
|
|
469
|
+
console.log(`${sizeBefore} vecs, round-trip via SQLite file — exact match`);
|
|
470
|
+
}
|
|
471
|
+
// Non-existent file: node:sqlite may create the file or may error — this is
|
|
472
|
+
// implementation-specific. We just verify no crash.
|
|
473
|
+
try {
|
|
474
|
+
new VectorDatabase({ dbPath: "/nonexistent/path/db.sqlite" });
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// tolerated — either outcome is acceptable
|
|
478
|
+
}
|
|
479
|
+
console.log("Non-existent path handled gracefully");
|
|
480
|
+
}
|
|
481
|
+
// =============================================================================
|
|
482
|
+
// 2c) compact() — lossless rebuild that reclaims tombstones
|
|
483
|
+
// =============================================================================
|
|
484
|
+
function testCompact() {
|
|
485
|
+
section("2c) compact() — lossless rebuild reclaiming tombstones");
|
|
486
|
+
const DIM = 80;
|
|
487
|
+
const rng = new RNG(555);
|
|
488
|
+
const vec = () => Array.from({ length: DIM }, () => rng.gauss());
|
|
489
|
+
// ---- losslessness: build-then-delete-then-compact == fresh build of survivors ----
|
|
490
|
+
const N = 2500;
|
|
491
|
+
const data = [];
|
|
492
|
+
for (let i = 0; i < N; i++)
|
|
493
|
+
data.push(vec());
|
|
494
|
+
const deleted = new Set();
|
|
495
|
+
for (let i = 0; i < N; i += 3)
|
|
496
|
+
deleted.add(i); // delete ~1/3
|
|
497
|
+
const dbFull = new VectorDatabase({
|
|
498
|
+
dbPath: ":memory:",
|
|
499
|
+
dim: DIM,
|
|
500
|
+
M: 16,
|
|
501
|
+
efConstruction: 100,
|
|
502
|
+
seed: 88,
|
|
503
|
+
});
|
|
504
|
+
for (let i = 0; i < N; i++)
|
|
505
|
+
dbFull.insert(i, data[i]);
|
|
506
|
+
for (const i of deleted)
|
|
507
|
+
dbFull.delete(i);
|
|
508
|
+
assert(dbFull.physicalSize === N, "before compact: tombstones still occupy physical slots");
|
|
509
|
+
assert(dbFull.size === N - deleted.size, "before compact: live size already excludes deleted");
|
|
510
|
+
dbFull.compact();
|
|
511
|
+
const survivors = [];
|
|
512
|
+
for (let i = 0; i < N; i++)
|
|
513
|
+
if (!deleted.has(i))
|
|
514
|
+
survivors.push(i);
|
|
515
|
+
assert(dbFull.size === survivors.length, "after compact: size is the survivor count");
|
|
516
|
+
assert(dbFull.physicalSize === survivors.length, "after compact: no tombstones remain (physical == live)");
|
|
517
|
+
for (const i of survivors) {
|
|
518
|
+
assert(dbFull.has(i), `survivor ${i} still present after compact`);
|
|
519
|
+
}
|
|
520
|
+
for (const i of deleted) {
|
|
521
|
+
assert(!dbFull.has(i), `deleted ${i} absent after compact`);
|
|
522
|
+
}
|
|
523
|
+
// Fresh index built from the survivors, same order + seed.
|
|
524
|
+
const dbFresh = new VectorDatabase({
|
|
525
|
+
dbPath: ":memory:",
|
|
526
|
+
dim: DIM,
|
|
527
|
+
M: 16,
|
|
528
|
+
efConstruction: 100,
|
|
529
|
+
seed: 88,
|
|
530
|
+
});
|
|
531
|
+
for (const i of survivors)
|
|
532
|
+
dbFresh.insert(i, data[i]);
|
|
533
|
+
// HNSW is an approximate index — two builds with the same data+seed may
|
|
534
|
+
// produce slightly different graphs (the heuristic is not order-invariant
|
|
535
|
+
// under all edge cases). What matters is that recall between the two is
|
|
536
|
+
// very high, and that distances are consistent.
|
|
537
|
+
let compactHits = 0;
|
|
538
|
+
let maxDistDiff = 0;
|
|
539
|
+
let totalCmp = 0;
|
|
540
|
+
for (let q = 0; q < 60; q++) {
|
|
541
|
+
const query = vec();
|
|
542
|
+
const a = dbFull.query(query, 10);
|
|
543
|
+
const b = dbFresh.query(query, 10);
|
|
544
|
+
const bIds = new Set(b.map((r) => r.id));
|
|
545
|
+
for (const r of a)
|
|
546
|
+
if (bIds.has(r.id))
|
|
547
|
+
compactHits++;
|
|
548
|
+
totalCmp += a.length;
|
|
549
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
550
|
+
maxDistDiff = Math.max(maxDistDiff, Math.abs(a[i].distance - b[i].distance));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const recallCompact = compactHits / totalCmp;
|
|
554
|
+
assert(recallCompact >= 0.97, `compacted recall vs fresh build must be high (got ${(recallCompact * 100).toFixed(1)}%)`);
|
|
555
|
+
assert(maxDistDiff < 0.05, `distance agreement across the two builds (max diff ${maxDistDiff})`);
|
|
556
|
+
console.log(`losslessness: ${N} built, ${deleted.size} deleted, compacted to ${survivors.length}; recall vs fresh=${(recallCompact * 100).toFixed(1)}%`);
|
|
557
|
+
// ---- compact also handles updates, and recall stays high on separable data ----
|
|
558
|
+
const G = 10;
|
|
559
|
+
const groups = 400;
|
|
560
|
+
const centers = [];
|
|
561
|
+
for (let g = 0; g < groups; g++)
|
|
562
|
+
centers.push(vec());
|
|
563
|
+
const db2 = new VectorDatabase({
|
|
564
|
+
dbPath: ":memory:",
|
|
565
|
+
dim: DIM,
|
|
566
|
+
M: 16,
|
|
567
|
+
efConstruction: 100,
|
|
568
|
+
seed: 7,
|
|
569
|
+
});
|
|
570
|
+
const live = new Map();
|
|
571
|
+
let id = 0;
|
|
572
|
+
for (let g = 0; g < groups; g++) {
|
|
573
|
+
for (let j = 0; j < G; j++) {
|
|
574
|
+
const v = centers[g].map((c) => c + 0.1 * rng.gauss());
|
|
575
|
+
db2.insert(id, v);
|
|
576
|
+
live.set(id, v);
|
|
577
|
+
id++;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
for (let k = 0; k < 400; k++)
|
|
581
|
+
db2.delete(k); // drop 40 whole groups
|
|
582
|
+
for (let k = 0; k < 400; k++)
|
|
583
|
+
live.delete(k);
|
|
584
|
+
for (let k = 600; k < 800; k++) {
|
|
585
|
+
const v = centers[Math.floor(k / G)].map((c) => c + 0.1 * rng.gauss());
|
|
586
|
+
db2.update(k, v); // re-place within the same group
|
|
587
|
+
live.set(k, v);
|
|
588
|
+
}
|
|
589
|
+
assert(db2.physicalSize > db2.size, "updates+deletes created tombstones");
|
|
590
|
+
db2.compact();
|
|
591
|
+
assert(db2.physicalSize === db2.size && db2.size === live.size, "compact reclaimed all tombstones");
|
|
592
|
+
// recall@10 over the surviving set after compaction
|
|
593
|
+
const liveIds = Array.from(live.keys());
|
|
594
|
+
const liveVecs = Array.from(live.values());
|
|
595
|
+
// Precompute data norms once (they don't change per query).
|
|
596
|
+
const liveNorms = new Float64Array(liveVecs.length);
|
|
597
|
+
for (let n = 0; n < liveVecs.length; n++) {
|
|
598
|
+
let s = 0;
|
|
599
|
+
const v = liveVecs[n];
|
|
600
|
+
for (let d = 0; d < DIM; d++)
|
|
601
|
+
s += v[d] * v[d];
|
|
602
|
+
liveNorms[n] = Math.sqrt(s) || 1;
|
|
603
|
+
}
|
|
604
|
+
let hits = 0;
|
|
605
|
+
const Q = 100;
|
|
606
|
+
for (let q = 0; q < Q; q++) {
|
|
607
|
+
const center = centers[Math.floor((600 + (q % 200)) / G)];
|
|
608
|
+
const query = center.map((c) => c + 0.1 * rng.gauss());
|
|
609
|
+
// Query norm computed once, not N times.
|
|
610
|
+
let qn = 0;
|
|
611
|
+
for (let d = 0; d < DIM; d++)
|
|
612
|
+
qn += query[d] * query[d];
|
|
613
|
+
qn = Math.sqrt(qn) || 1;
|
|
614
|
+
const bd = new Float64Array(10).fill(Infinity);
|
|
615
|
+
const bi = new Int32Array(10).fill(-1);
|
|
616
|
+
for (let n = 0; n < liveVecs.length; n++) {
|
|
617
|
+
const v = liveVecs[n];
|
|
618
|
+
let dot = 0;
|
|
619
|
+
for (let d = 0; d < DIM; d++)
|
|
620
|
+
dot += v[d] * query[d];
|
|
621
|
+
const s = 1 - dot / (liveNorms[n] * qn);
|
|
622
|
+
if (s < bd[9]) {
|
|
623
|
+
let p = 9;
|
|
624
|
+
while (p > 0 && bd[p - 1] > s) {
|
|
625
|
+
bd[p] = bd[p - 1];
|
|
626
|
+
bi[p] = bi[p - 1];
|
|
627
|
+
p--;
|
|
628
|
+
}
|
|
629
|
+
bd[p] = s;
|
|
630
|
+
bi[p] = liveIds[n];
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const truth = new Set(Array.from(bi).filter((x) => x >= 0));
|
|
634
|
+
for (const r of db2.query(query, 10))
|
|
635
|
+
if (truth.has(r.id))
|
|
636
|
+
hits++;
|
|
637
|
+
}
|
|
638
|
+
const recall = hits / (Q * 10);
|
|
639
|
+
assert(recall >= 0.9, `recall after compact stays high (got ${(recall * 100).toFixed(1)}%)`);
|
|
640
|
+
console.log(`updates+deletes: compacted to ${db2.size} live; recall@10 after compact ${(recall * 100).toFixed(1)}%`);
|
|
641
|
+
dbFull.close();
|
|
642
|
+
dbFresh.close();
|
|
643
|
+
db2.close();
|
|
644
|
+
}
|
|
645
|
+
// =============================================================================
|
|
646
|
+
// 3) Memory + recall + sub-linearity benchmark
|
|
647
|
+
// =============================================================================
|
|
648
|
+
function benchmark() {
|
|
649
|
+
section("3) Memory + recall + sub-linearity benchmark (HNSW over 1-bit codes)");
|
|
650
|
+
// Tweak these for a faster run or a wider sweep.
|
|
651
|
+
const DIM = 256; // 256-d -> 32-byte codes (the headline example)
|
|
652
|
+
const checkpoints = [1000, 2000, 4000, 8000, 16000, 32000];
|
|
653
|
+
const GROUP = 10; // well-separated groups of near-duplicates
|
|
654
|
+
const K = 10; // recall@K against the exact K nearest
|
|
655
|
+
const SPREAD = 0.1;
|
|
656
|
+
const Q = 200;
|
|
657
|
+
const EF = 150;
|
|
658
|
+
const rng = new RNG(12345);
|
|
659
|
+
const MAX_N = checkpoints[checkpoints.length - 1];
|
|
660
|
+
const numGroups = Math.ceil(MAX_N / GROUP);
|
|
661
|
+
console.log(`dim=${DIM}, ${GROUP}-point groups, queries=${Q}, k=${K}, efSearch=${EF}`);
|
|
662
|
+
console.log("generating data ...");
|
|
663
|
+
const centers = new Float32Array(numGroups * DIM);
|
|
664
|
+
for (let g = 0; g < numGroups; g++) {
|
|
665
|
+
for (let d = 0; d < DIM; d++)
|
|
666
|
+
centers[g * DIM + d] = rng.gauss();
|
|
667
|
+
}
|
|
668
|
+
const data = new Float32Array(MAX_N * DIM);
|
|
669
|
+
for (let i = 0; i < MAX_N; i++) {
|
|
670
|
+
const base = Math.floor(i / GROUP) * DIM;
|
|
671
|
+
const off = i * DIM;
|
|
672
|
+
for (let d = 0; d < DIM; d++) {
|
|
673
|
+
data[off + d] = centers[base + d] + SPREAD * rng.gauss();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// queries land in groups that exist at every checkpoint
|
|
677
|
+
const firstGroups = checkpoints[0] / GROUP;
|
|
678
|
+
const queries = new Float32Array(Q * DIM);
|
|
679
|
+
for (let q = 0; q < Q; q++) {
|
|
680
|
+
const base = rng.int(firstGroups) * DIM;
|
|
681
|
+
const off = q * DIM;
|
|
682
|
+
for (let d = 0; d < DIM; d++) {
|
|
683
|
+
queries[off + d] = centers[base + d] + SPREAD * rng.gauss();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const db = new VectorDatabase({
|
|
687
|
+
dbPath: ":memory:",
|
|
688
|
+
dim: DIM,
|
|
689
|
+
M: 16,
|
|
690
|
+
efConstruction: 100,
|
|
691
|
+
efSearch: EF,
|
|
692
|
+
seed: 999,
|
|
693
|
+
});
|
|
694
|
+
// Precompute data vector norms once — O(N·D) instead of O(Q·N·D).
|
|
695
|
+
const dataNorms = new Float64Array(MAX_N);
|
|
696
|
+
for (let i = 0; i < MAX_N; i++) {
|
|
697
|
+
let s = 0;
|
|
698
|
+
const off = i * DIM;
|
|
699
|
+
for (let d = 0; d < DIM; d++)
|
|
700
|
+
s += data[off + d] * data[off + d];
|
|
701
|
+
dataNorms[i] = Math.sqrt(s) || 1;
|
|
702
|
+
}
|
|
703
|
+
function bruteForceTopK(n, qOff) {
|
|
704
|
+
const bd = new Float64Array(K).fill(Infinity);
|
|
705
|
+
const bi = new Int32Array(K).fill(-1);
|
|
706
|
+
let qn = 0;
|
|
707
|
+
for (let d = 0; d < DIM; d++)
|
|
708
|
+
qn += queries[qOff + d] * queries[qOff + d];
|
|
709
|
+
qn = Math.sqrt(qn) || 1;
|
|
710
|
+
for (let i = 0; i < n; i++) {
|
|
711
|
+
const off = i * DIM;
|
|
712
|
+
let dot = 0;
|
|
713
|
+
for (let d = 0; d < DIM; d++)
|
|
714
|
+
dot += data[off + d] * queries[qOff + d];
|
|
715
|
+
const s = 1 - dot / (dataNorms[i] * qn);
|
|
716
|
+
if (s < bd[K - 1]) {
|
|
717
|
+
let p = K - 1;
|
|
718
|
+
while (p > 0 && bd[p - 1] > s) {
|
|
719
|
+
bd[p] = bd[p - 1];
|
|
720
|
+
bi[p] = bi[p - 1];
|
|
721
|
+
p--;
|
|
722
|
+
}
|
|
723
|
+
bd[p] = s;
|
|
724
|
+
bi[p] = i;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Direct set from typed array filtered values.
|
|
728
|
+
const truth = new Set();
|
|
729
|
+
for (let i = 0; i < K; i++) {
|
|
730
|
+
if (bi[i] >= 0)
|
|
731
|
+
truth.add(bi[i]);
|
|
732
|
+
}
|
|
733
|
+
return truth;
|
|
734
|
+
}
|
|
735
|
+
const rows = [];
|
|
736
|
+
let inserted = 0;
|
|
737
|
+
for (const cp of checkpoints) {
|
|
738
|
+
const tb = now();
|
|
739
|
+
for (; inserted < cp; inserted++) {
|
|
740
|
+
db.insert(inserted, data.subarray(inserted * DIM, inserted * DIM + DIM));
|
|
741
|
+
}
|
|
742
|
+
console.log(` built N=${cp} (+${(now() - tb).toFixed(0)}ms)`);
|
|
743
|
+
for (let w = 0; w < 25; w++) {
|
|
744
|
+
const o = (w % Q) * DIM;
|
|
745
|
+
db.query(queries.subarray(o, o + DIM), K);
|
|
746
|
+
}
|
|
747
|
+
const hnswResults = new Array(Q);
|
|
748
|
+
let comps = 0;
|
|
749
|
+
const th = now();
|
|
750
|
+
for (let qi = 0; qi < Q; qi++) {
|
|
751
|
+
const o = qi * DIM;
|
|
752
|
+
hnswResults[qi] = db.query(queries.subarray(o, o + DIM), K).map((x) => x.id);
|
|
753
|
+
comps += db.lastQueryDistanceComputations;
|
|
754
|
+
}
|
|
755
|
+
const hnswMs = now() - th;
|
|
756
|
+
const truths = new Array(Q);
|
|
757
|
+
const tbf = now();
|
|
758
|
+
for (let qi = 0; qi < Q; qi++)
|
|
759
|
+
truths[qi] = bruteForceTopK(cp, qi * DIM);
|
|
760
|
+
const bruteMs = now() - tbf;
|
|
761
|
+
let hits = 0;
|
|
762
|
+
for (let qi = 0; qi < Q; qi++) {
|
|
763
|
+
for (const id of hnswResults[qi])
|
|
764
|
+
if (truths[qi].has(id))
|
|
765
|
+
hits++;
|
|
766
|
+
}
|
|
767
|
+
rows.push({
|
|
768
|
+
n: cp,
|
|
769
|
+
hnswUs: (hnswMs / Q) * 1000,
|
|
770
|
+
comps: comps / Q,
|
|
771
|
+
bruteUs: (bruteMs / Q) * 1000,
|
|
772
|
+
recall: hits / (Q * K),
|
|
773
|
+
speedup: bruteMs / hnswMs,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
// ---- memory report ----
|
|
777
|
+
const st = db.storage;
|
|
778
|
+
const mb = (bytes) => (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
|
779
|
+
console.log("");
|
|
780
|
+
console.log("Per-vector storage (vector payload only, excludes the HNSW graph):");
|
|
781
|
+
console.log(` Float32 baseline : ${st.float32BytesPerVector} B`);
|
|
782
|
+
console.log(` 1-bit RaBitQ code : ${st.codeBytesPerVector} B (${(st.float32BytesPerVector / st.codeBytesPerVector).toFixed(1)}x smaller)`);
|
|
783
|
+
console.log(` code (kept) : ${st.bytesPerVector} B (${st.compressionRatio.toFixed(1)}x smaller overall)`);
|
|
784
|
+
console.log(` payload at N=${MAX_N} : Float32 ${mb(st.float32BytesPerVector * MAX_N)} vs index ${mb(st.bytesPerVector * MAX_N)}`);
|
|
785
|
+
// ---- results table ----
|
|
786
|
+
console.log("");
|
|
787
|
+
console.log(" N | HNSW q (us) | dist comps | Brute q (us) | recall@10 | speedup");
|
|
788
|
+
console.log("-------+-------------+------------+--------------+-----------+--------");
|
|
789
|
+
for (const r of rows) {
|
|
790
|
+
console.log(`${String(r.n).padStart(6)} | ${r.hnswUs.toFixed(1).padStart(11)} | ${r.comps.toFixed(0).padStart(10)} | ${r.bruteUs
|
|
791
|
+
.toFixed(1)
|
|
792
|
+
.padStart(12)} | ${(r.recall * 100).toFixed(1).padStart(8)}% | ${r.speedup.toFixed(1).padStart(6)}x`);
|
|
793
|
+
}
|
|
794
|
+
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
795
|
+
const exponent = (xs, ys) => {
|
|
796
|
+
const lx = xs.map(Math.log);
|
|
797
|
+
const ly = ys.map((y) => Math.log(Math.max(y, 1e-12)));
|
|
798
|
+
const mx = mean(lx);
|
|
799
|
+
const my = mean(ly);
|
|
800
|
+
let num = 0;
|
|
801
|
+
let den = 0;
|
|
802
|
+
for (let i = 0; i < lx.length; i++) {
|
|
803
|
+
num += (lx[i] - mx) * (ly[i] - my);
|
|
804
|
+
den += (lx[i] - mx) * (lx[i] - mx);
|
|
805
|
+
}
|
|
806
|
+
return num / den;
|
|
807
|
+
};
|
|
808
|
+
const ns = rows.map((r) => r.n);
|
|
809
|
+
const expComps = exponent(ns, rows.map((r) => r.comps));
|
|
810
|
+
const expBrute = exponent(ns, rows.map((r) => r.bruteUs));
|
|
811
|
+
const avgRecall = mean(rows.map((r) => r.recall));
|
|
812
|
+
const range = ns[ns.length - 1] / ns[0];
|
|
813
|
+
console.log("");
|
|
814
|
+
console.log(`Empirical scaling over a ${range}x increase in N (work ~ N^exponent):`);
|
|
815
|
+
console.log(` HNSW distance computations : N^${expComps.toFixed(3)} <- sub-linear (target < 1)`);
|
|
816
|
+
console.log(` Brute-force query : N^${expBrute.toFixed(3)} (linear reference ~ 1.0)`);
|
|
817
|
+
console.log(` Mean recall@${K} : ${(avgRecall * 100).toFixed(2)}%`);
|
|
818
|
+
const last = rows[rows.length - 1];
|
|
819
|
+
assert(st.codeBytesPerVector === Math.ceil(DIM / 32) * 4, "code size is ceil(dim/32) 32-bit words");
|
|
820
|
+
assert(st.compressionRatio > 10, `index must be far smaller than Float32 (got ${st.compressionRatio.toFixed(1)}x)`);
|
|
821
|
+
assert(expComps < 0.8, `HNSW distance-computation growth must be sub-linear (got N^${expComps.toFixed(3)})`);
|
|
822
|
+
assert(avgRecall >= 0.9, `mean recall must be >= 90% (got ${(avgRecall * 100).toFixed(1)}%)`);
|
|
823
|
+
assert(last.hnswUs < last.bruteUs, "HNSW must be faster than brute force at the largest N");
|
|
824
|
+
assert(last.speedup > 1, `HNSW must be faster than brute force at the largest N (got ${last.speedup.toFixed(1)}x)`);
|
|
825
|
+
console.log("\nMemory, sub-linearity, recall and speed-up checks OK");
|
|
826
|
+
db.close();
|
|
827
|
+
}
|
|
828
|
+
// =============================================================================
|
|
829
|
+
// 4) Scalability is INTRINSIC, not cache-borne
|
|
830
|
+
//
|
|
831
|
+
// Codes and adjacency lists live in SQLite and are read on demand; the only
|
|
832
|
+
// cache is SQLite's page cache (Store's cacheSizeMb), a pure speed layer. So
|
|
833
|
+
// the engine's scalability must hold with that cache OFF: the STORAGE READS an
|
|
834
|
+
// operation issues (Store.reads, surfaced per query as lastQueryStorageReads)
|
|
835
|
+
// must be governed by the WORK the operation does — the distinct nodes it
|
|
836
|
+
// visits, an HNSW property that grows ~log N — NOT by how many vectors already
|
|
837
|
+
// exist. An implementation that leans on a cache to be fast betrays itself here:
|
|
838
|
+
// with the cache disabled its per-operation reads track the whole working set
|
|
839
|
+
// and climb with N. This is a STORAGE contract, so the guard lives in the
|
|
840
|
+
// rabitq-hnsw suite.
|
|
841
|
+
//
|
|
842
|
+
// The witness is `db.lastQueryStorageReads` — node + neighbour-list fetches that
|
|
843
|
+
// hit the database on the last query, counted below the cache so a warm cache
|
|
844
|
+
// can never hide a bad access pattern. We build ONE store in blocks (cacheSizeMb
|
|
845
|
+
// 0) and, at each checkpoint, measure the mean storage reads of a fixed query
|
|
846
|
+
// set. Across an 8x increase in N the per-query read count must stay within a
|
|
847
|
+
// flat band; the log–log growth exponent makes "does not grow with N" precise.
|
|
848
|
+
// =============================================================================
|
|
849
|
+
function testScalabilityWithoutCache() {
|
|
850
|
+
section("4) Scalability is intrinsic, not cache-borne (cacheSizeMb = 0)");
|
|
851
|
+
// A small dimension and modest graph degree keep the build cheap — this test
|
|
852
|
+
// is about the SHAPE of storage-reads vs N, not throughput, so the constants
|
|
853
|
+
// only need to be large enough for HNSW's structure to hold.
|
|
854
|
+
const DIM = 64;
|
|
855
|
+
const db = new VectorDatabase({
|
|
856
|
+
dbPath: ":memory:",
|
|
857
|
+
dim: DIM,
|
|
858
|
+
M: 8,
|
|
859
|
+
efConstruction: 40,
|
|
860
|
+
efSearch: 40,
|
|
861
|
+
seed: 4242,
|
|
862
|
+
cacheSizeMb: 0, // ALL caches off (page cache + code LRU) — honest reads
|
|
863
|
+
});
|
|
864
|
+
const rng = new RNG(20260628);
|
|
865
|
+
const vec = () => {
|
|
866
|
+
const v = new Array(DIM);
|
|
867
|
+
for (let d = 0; d < DIM; d++)
|
|
868
|
+
v[d] = rng.gauss();
|
|
869
|
+
return unit(v);
|
|
870
|
+
};
|
|
871
|
+
// A fixed set of probes, reused at every checkpoint so the only variable is N.
|
|
872
|
+
const Q = 40;
|
|
873
|
+
const probes = [];
|
|
874
|
+
for (let q = 0; q < Q; q++)
|
|
875
|
+
probes.push(vec());
|
|
876
|
+
const checkpoints = [1000, 2000, 4000, 8000]; // 8x range
|
|
877
|
+
const readsPerQuery = [];
|
|
878
|
+
let id = 0;
|
|
879
|
+
for (const cp of checkpoints) {
|
|
880
|
+
const tb = now();
|
|
881
|
+
for (; id < cp; id++)
|
|
882
|
+
db.insert(id, vec());
|
|
883
|
+
// Mean storage reads over the fixed probe set — the cache-independent cost
|
|
884
|
+
// of ONE query at this N.
|
|
885
|
+
let reads = 0;
|
|
886
|
+
for (const p of probes) {
|
|
887
|
+
db.query(p, 10);
|
|
888
|
+
reads += db.lastQueryStorageReads;
|
|
889
|
+
}
|
|
890
|
+
readsPerQuery.push(reads / Q);
|
|
891
|
+
console.log(` N=${String(cp).padStart(6)} +${(now() - tb).toFixed(0)}ms ` +
|
|
892
|
+
`storage-reads/query ${(reads / Q).toFixed(1)}`);
|
|
893
|
+
}
|
|
894
|
+
// Compare the per-query read count at the LARGEST store against the smallest:
|
|
895
|
+
// if scaling leaned on the cache, the cache-disabled reads would rise with N
|
|
896
|
+
// (a deeper graph re-read from disk). The contract is that it stays within a
|
|
897
|
+
// small constant band — work set by the query, not by the corpus. Log–log
|
|
898
|
+
// growth exponent makes it precise.
|
|
899
|
+
const lx = checkpoints.map(Math.log);
|
|
900
|
+
const ly = readsPerQuery.map((y) => Math.log(Math.max(y, 1e-9)));
|
|
901
|
+
const mx = lx.reduce((a, c) => a + c, 0) / lx.length;
|
|
902
|
+
const my = ly.reduce((a, c) => a + c, 0) / ly.length;
|
|
903
|
+
let num = 0, den = 0;
|
|
904
|
+
for (let i = 0; i < lx.length; i++) {
|
|
905
|
+
num += (lx[i] - mx) * (ly[i] - my);
|
|
906
|
+
den += (lx[i] - mx) * (lx[i] - mx);
|
|
907
|
+
}
|
|
908
|
+
const exponent = num / den;
|
|
909
|
+
const first = readsPerQuery[0];
|
|
910
|
+
const last = readsPerQuery[readsPerQuery.length - 1];
|
|
911
|
+
console.log(` reads/query ${first.toFixed(1)} → ${last.toFixed(1)} over ${checkpoints[checkpoints.length - 1] / checkpoints[0]}x N · growth N^${exponent.toFixed(3)}`);
|
|
912
|
+
// PRIMARY guard — sub-linear in N: an O(1)-per-touch, log-N-touched query has
|
|
913
|
+
// storage-reads growing like log N (exponent ≈ 0); an implementation forced to
|
|
914
|
+
// re-read its working set from disk as N grows pushes this toward and past 1.
|
|
915
|
+
// Well under 0.5 proves the index does not lean on the cache to scale.
|
|
916
|
+
assert(exponent < 0.5, `cache-disabled storage-reads/query grew as N^${exponent.toFixed(3)} — the index must scale WITHOUT the page cache (reads set by the query's work, not the corpus size)`);
|
|
917
|
+
// SECONDARY — a flat band: the largest store costs no more than ~2.5x the
|
|
918
|
+
// smallest per query. Generous against HNSW's genuine log-N growth, strict
|
|
919
|
+
// enough to fail a regression whose reads track the corpus.
|
|
920
|
+
assert(last < first * 2.5 + 8, `cache-disabled reads/query climbed ${first.toFixed(1)} → ${last.toFixed(1)} as the store grew — expected a flat band (work set by the query, not the corpus)`);
|
|
921
|
+
db.close();
|
|
922
|
+
console.log("\nCache-independent scalability OK");
|
|
923
|
+
}
|
|
924
|
+
// ------------------------------------ main -----------------------------------
|
|
925
|
+
function main() {
|
|
926
|
+
const start = now();
|
|
927
|
+
testCrudAndPersistence();
|
|
928
|
+
testCodeIO();
|
|
929
|
+
testCosine();
|
|
930
|
+
testNearDuplicateRecall();
|
|
931
|
+
testCosFactorBias();
|
|
932
|
+
testPersistence();
|
|
933
|
+
testCompact();
|
|
934
|
+
testScalabilityWithoutCache();
|
|
935
|
+
benchmark();
|
|
936
|
+
const dt = ((now() - start) / 1000).toFixed(1);
|
|
937
|
+
console.log("\n" + "=".repeat(72));
|
|
938
|
+
console.log(`ALL TESTS PASSED (${passed} assertions, ${dt}s)`);
|
|
939
|
+
console.log("=".repeat(72));
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
main();
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
console.error("\n" + String(err && err.stack ? err.stack : err));
|
|
946
|
+
if (typeof process !== "undefined")
|
|
947
|
+
process.exitCode = 1;
|
|
948
|
+
}
|