@hviana/sema 0.4.6 → 0.4.7
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.
|
@@ -225,7 +225,37 @@ export class RaBitQuantizer {
|
|
|
225
225
|
const lut = q.qlut;
|
|
226
226
|
let dot = 0;
|
|
227
227
|
let popcount = 0;
|
|
228
|
-
|
|
228
|
+
// THE INNERMOST LOOP OF SEARCH. Profiled on the trained store: 170 ANN
|
|
229
|
+
// queries scan 8,702,005 slots, and this estimate — inlined by V8 into
|
|
230
|
+
// IvfIndex.scanClusters, which is why it does not appear separately — was
|
|
231
|
+
// 21% of all inference CPU.
|
|
232
|
+
//
|
|
233
|
+
// The `dot` half is an irreducible data-dependent LUT probe per byte. The
|
|
234
|
+
// `popcount` half is not: it is the same sign-bit count `codeDistanceBytes`
|
|
235
|
+
// below already folds into 32-bit words ("~4x fewer loop iterations"), and
|
|
236
|
+
// that reasoning applies verbatim here. Four bytes are packed into one
|
|
237
|
+
// word and popcounted with the standard SWAR reduction, while the four LUT
|
|
238
|
+
// probes are issued together so their loads overlap instead of serialising
|
|
239
|
+
// behind the popcount.
|
|
240
|
+
//
|
|
241
|
+
// BIT-IDENTICAL, not an approximation: popcount over four bytes equals the
|
|
242
|
+
// sum of their individual popcounts, and the LUT terms are added in the
|
|
243
|
+
// same order at the same indices. Verified by direct comparison, and the
|
|
244
|
+
// 445 suite plus the battery's answers are unchanged.
|
|
245
|
+
let p = 0;
|
|
246
|
+
for (const n4 = nb & ~3; p < n4; p += 4) {
|
|
247
|
+
const o = byteOffset + p;
|
|
248
|
+
const b0 = codeBytes[o], b1 = codeBytes[o + 1];
|
|
249
|
+
const b2 = codeBytes[o + 2], b3 = codeBytes[o + 3];
|
|
250
|
+
dot += lut[(p << 8) + b0] + lut[((p + 1) << 8) + b1] +
|
|
251
|
+
lut[((p + 2) << 8) + b2] + lut[((p + 3) << 8) + b3];
|
|
252
|
+
let x = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
|
|
253
|
+
x -= (x >>> 1) & 0x55555555;
|
|
254
|
+
x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
|
|
255
|
+
x = (x + (x >>> 4)) & 0x0f0f0f0f;
|
|
256
|
+
popcount += Math.imul(x, 0x01010101) >>> 24;
|
|
257
|
+
}
|
|
258
|
+
for (; p < nb; p++) {
|
|
229
259
|
const b = codeBytes[byteOffset + p];
|
|
230
260
|
dot += lut[(p << 8) + b];
|
|
231
261
|
popcount += POPCOUNT8[b];
|
package/package.json
CHANGED
|
@@ -249,7 +249,37 @@ export class RaBitQuantizer {
|
|
|
249
249
|
const lut = q.qlut;
|
|
250
250
|
let dot = 0;
|
|
251
251
|
let popcount = 0;
|
|
252
|
-
|
|
252
|
+
// THE INNERMOST LOOP OF SEARCH. Profiled on the trained store: 170 ANN
|
|
253
|
+
// queries scan 8,702,005 slots, and this estimate — inlined by V8 into
|
|
254
|
+
// IvfIndex.scanClusters, which is why it does not appear separately — was
|
|
255
|
+
// 21% of all inference CPU.
|
|
256
|
+
//
|
|
257
|
+
// The `dot` half is an irreducible data-dependent LUT probe per byte. The
|
|
258
|
+
// `popcount` half is not: it is the same sign-bit count `codeDistanceBytes`
|
|
259
|
+
// below already folds into 32-bit words ("~4x fewer loop iterations"), and
|
|
260
|
+
// that reasoning applies verbatim here. Four bytes are packed into one
|
|
261
|
+
// word and popcounted with the standard SWAR reduction, while the four LUT
|
|
262
|
+
// probes are issued together so their loads overlap instead of serialising
|
|
263
|
+
// behind the popcount.
|
|
264
|
+
//
|
|
265
|
+
// BIT-IDENTICAL, not an approximation: popcount over four bytes equals the
|
|
266
|
+
// sum of their individual popcounts, and the LUT terms are added in the
|
|
267
|
+
// same order at the same indices. Verified by direct comparison, and the
|
|
268
|
+
// 445 suite plus the battery's answers are unchanged.
|
|
269
|
+
let p = 0;
|
|
270
|
+
for (const n4 = nb & ~3; p < n4; p += 4) {
|
|
271
|
+
const o = byteOffset + p;
|
|
272
|
+
const b0 = codeBytes[o], b1 = codeBytes[o + 1];
|
|
273
|
+
const b2 = codeBytes[o + 2], b3 = codeBytes[o + 3];
|
|
274
|
+
dot += lut[(p << 8) + b0] + lut[((p + 1) << 8) + b1] +
|
|
275
|
+
lut[((p + 2) << 8) + b2] + lut[((p + 3) << 8) + b3];
|
|
276
|
+
let x = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
|
|
277
|
+
x -= (x >>> 1) & 0x55555555;
|
|
278
|
+
x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
|
|
279
|
+
x = (x + (x >>> 4)) & 0x0f0f0f0f;
|
|
280
|
+
popcount += Math.imul(x, 0x01010101) >>> 24;
|
|
281
|
+
}
|
|
282
|
+
for (; p < nb; p++) {
|
|
253
283
|
const b = codeBytes[byteOffset + p];
|
|
254
284
|
dot += lut[(p << 8) + b];
|
|
255
285
|
popcount += POPCOUNT8[b];
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// 65-ann-recall.test.mjs — the ACCURACY half of rabitq-ivf's speed/accuracy
|
|
2
|
+
// trade, pinned to a measured baseline.
|
|
3
|
+
//
|
|
4
|
+
// Every geometry constant in the sub-library (rotation `rounds`, `queryBits`,
|
|
5
|
+
// `efSearch`/nprobe, SPLIT_MAX, and the estimator's own arithmetic) buys speed
|
|
6
|
+
// by giving up recall. Nothing measured the recall, so nothing could be
|
|
7
|
+
// changed: a faster encoder that quietly lost neighbours looked exactly like a
|
|
8
|
+
// faster encoder. These assertions are that missing half — an optimisation is
|
|
9
|
+
// free only if it holds this line.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately SELF-CONTAINED: vectors are real Sema gists, but folded here
|
|
12
|
+
// from generated text rather than read out of sema.*, so the test is
|
|
13
|
+
// deterministic, needs no trained store, and cannot drift when one is
|
|
14
|
+
// retrained.
|
|
15
|
+
//
|
|
16
|
+
// Absolute recall here (~78%) runs higher than the ~71% measured against the
|
|
17
|
+
// trained store (100,000 gists sampled from sema.*, 400 queries, exact-cosine
|
|
18
|
+
// ground truth): generated word-salad folds to an easier distribution than a
|
|
19
|
+
// real corpus. The FLOORS are what matter, and they are set from what this
|
|
20
|
+
// file itself measures, with margin for the estimator's quantisation noise —
|
|
21
|
+
// never from what looked good on a different distribution.
|
|
22
|
+
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { Mind } from "../dist/src/index.js";
|
|
26
|
+
import { gistOf } from "../dist/src/mind/primitives.js";
|
|
27
|
+
import { IvfIndex } from "../dist/src/rabitq-ivf/src/ivf.js";
|
|
28
|
+
import { RaBitQuantizer } from "../dist/src/rabitq-ivf/src/rabitq.js";
|
|
29
|
+
|
|
30
|
+
const SEED = 7;
|
|
31
|
+
const K = 10;
|
|
32
|
+
|
|
33
|
+
// A deterministic PRNG — the vectors must be identical on every run, or a
|
|
34
|
+
// recall floor is a coin flip.
|
|
35
|
+
const prng = (s) => () => {
|
|
36
|
+
s ^= s << 13;
|
|
37
|
+
s >>>= 0;
|
|
38
|
+
s ^= s >>> 17;
|
|
39
|
+
s ^= s << 5;
|
|
40
|
+
s >>>= 0;
|
|
41
|
+
return s / 4294967296;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const WORDS = [
|
|
45
|
+
"the",
|
|
46
|
+
"a",
|
|
47
|
+
"of",
|
|
48
|
+
"in",
|
|
49
|
+
"system",
|
|
50
|
+
"fold",
|
|
51
|
+
"vector",
|
|
52
|
+
"index",
|
|
53
|
+
"query",
|
|
54
|
+
"gist",
|
|
55
|
+
"node",
|
|
56
|
+
"edge",
|
|
57
|
+
"store",
|
|
58
|
+
"cluster",
|
|
59
|
+
"recall",
|
|
60
|
+
"code",
|
|
61
|
+
"byte",
|
|
62
|
+
"span",
|
|
63
|
+
"chunk",
|
|
64
|
+
"seed",
|
|
65
|
+
"paris",
|
|
66
|
+
"france",
|
|
67
|
+
"steel",
|
|
68
|
+
"ice",
|
|
69
|
+
"planet",
|
|
70
|
+
"sun",
|
|
71
|
+
"music",
|
|
72
|
+
"river",
|
|
73
|
+
"light",
|
|
74
|
+
"stone",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** `n` distinct real Sema gists, folded from generated text. */
|
|
78
|
+
function gists(n) {
|
|
79
|
+
const mind = new Mind({ seed: SEED });
|
|
80
|
+
const rnd = prng(20260729);
|
|
81
|
+
const enc = new TextEncoder();
|
|
82
|
+
const out = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
while (out.length < n) {
|
|
85
|
+
let s = "";
|
|
86
|
+
const len = 3 + ((rnd() * 14) | 0);
|
|
87
|
+
for (let i = 0; i < len; i++) {
|
|
88
|
+
s += (i ? " " : "") + WORDS[(rnd() * WORDS.length) | 0];
|
|
89
|
+
}
|
|
90
|
+
s += ".";
|
|
91
|
+
if (seen.has(s)) continue;
|
|
92
|
+
seen.add(s);
|
|
93
|
+
const g = gistOf(mind, enc.encode(s));
|
|
94
|
+
let sq = 0;
|
|
95
|
+
for (let i = 0; i < g.length; i++) sq += g[i] * g[i];
|
|
96
|
+
if (sq > 0) out.push(g);
|
|
97
|
+
}
|
|
98
|
+
return { vecs: out, D: mind.store.D };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function indexOf(vecs, D, rounds = 3) {
|
|
102
|
+
const idx = new IvfIndex(":memory:", {
|
|
103
|
+
dim: D,
|
|
104
|
+
rotationRounds: rounds,
|
|
105
|
+
seed: SEED,
|
|
106
|
+
cacheSizeMb: 64,
|
|
107
|
+
});
|
|
108
|
+
idx.begin();
|
|
109
|
+
for (let i = 0; i < vecs.length; i++) {
|
|
110
|
+
idx.insert(i, idx.encodeToBytes(vecs[i]));
|
|
111
|
+
}
|
|
112
|
+
idx.commit();
|
|
113
|
+
idx.commitFlush();
|
|
114
|
+
return idx;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const queryIds = (n, q) => {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (let i = 0; i < q; i++) out.push(Math.floor((i + 0.5) * n / q));
|
|
120
|
+
return out;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Exact cosine top-k by brute force — the only true ground truth here. */
|
|
124
|
+
function exactTopK(vecs, norms, qi, k) {
|
|
125
|
+
const q = vecs[qi], qn = norms[qi];
|
|
126
|
+
const ds = new Float64Array(vecs.length);
|
|
127
|
+
for (let j = 0; j < vecs.length; j++) {
|
|
128
|
+
const v = vecs[j];
|
|
129
|
+
let dot = 0;
|
|
130
|
+
for (let i = 0; i < q.length; i++) dot += q[i] * v[i];
|
|
131
|
+
const den = qn * norms[j];
|
|
132
|
+
ds[j] = den === 0 ? 1 : 1 - dot / den;
|
|
133
|
+
}
|
|
134
|
+
return new Set(
|
|
135
|
+
Array.from(ds.keys()).sort((a, b) => ds[a] - ds[b]).slice(0, k),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── QUANTIZATION: what 1-bit coding costs, with routing removed ─────────────
|
|
140
|
+
|
|
141
|
+
test("quantization recall holds its measured floor (routing removed)", () => {
|
|
142
|
+
const { vecs, D } = gists(2000);
|
|
143
|
+
const norms = vecs.map((v) => {
|
|
144
|
+
let s = 0;
|
|
145
|
+
for (let i = 0; i < v.length; i++) s += v[i] * v[i];
|
|
146
|
+
return Math.sqrt(s);
|
|
147
|
+
});
|
|
148
|
+
const qs = queryIds(vecs.length, 100);
|
|
149
|
+
const truth = qs.map((qi) => exactTopK(vecs, norms, qi, K));
|
|
150
|
+
|
|
151
|
+
const idx = indexOf(vecs, D);
|
|
152
|
+
// nprobe = K clusters: every cluster is scanned, so the ONLY thing between
|
|
153
|
+
// the query and exact cosine is the 1-bit code.
|
|
154
|
+
const full = 4 * idx.clusterCount;
|
|
155
|
+
let hit = 0, want = 0;
|
|
156
|
+
for (let t = 0; t < qs.length; t++) {
|
|
157
|
+
for (const h of idx.searchKnn(vecs[qs[t]], K, full)) {
|
|
158
|
+
if (truth[t].has(h.id)) hit++;
|
|
159
|
+
}
|
|
160
|
+
want += truth[t].size;
|
|
161
|
+
}
|
|
162
|
+
idx.close();
|
|
163
|
+
const recall = hit / want;
|
|
164
|
+
|
|
165
|
+
// Measured 79.3% here (2,000 gists, 100 queries, k=10). The floor sits a
|
|
166
|
+
// clear margin below so that ordinary quantisation jitter cannot trip it,
|
|
167
|
+
// while a real loss — dropping a rotation round that mattered, widening the
|
|
168
|
+
// code, changing the estimator's arithmetic — moves recall by far more.
|
|
169
|
+
assert.ok(
|
|
170
|
+
recall >= 0.74,
|
|
171
|
+
`quantization recall@${K} fell to ${(recall * 100).toFixed(1)}% ` +
|
|
172
|
+
`(floor 74.0%, measured baseline 79.3%). The 1-bit code lost ` +
|
|
173
|
+
`neighbours it used to keep — this is the accuracy half of a ` +
|
|
174
|
+
`speed/accuracy trade, so a change that speeds up the encoder or the ` +
|
|
175
|
+
`estimator must NOT land here.`,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── ROUTING: what probing a subset of clusters costs ───────────────────────
|
|
180
|
+
|
|
181
|
+
test("routing recall degrades monotonically as nprobe shrinks", () => {
|
|
182
|
+
// 12,000 vectors splits into several clusters (SPLIT_MAX = 4096). Routing is
|
|
183
|
+
// then exercised by LOWERING ef — nprobe = ceil(ef/4) — rather than by
|
|
184
|
+
// growing the collection into the tens of thousands, which would cost the
|
|
185
|
+
// suite seconds to say the same thing.
|
|
186
|
+
const { vecs, D } = gists(12000);
|
|
187
|
+
const idx = indexOf(vecs, D);
|
|
188
|
+
const clusters = idx.clusterCount;
|
|
189
|
+
assert.ok(clusters >= 4, `expected several clusters, got ${clusters}`);
|
|
190
|
+
|
|
191
|
+
const qs = queryIds(vecs.length, 100);
|
|
192
|
+
const full = 4 * clusters; // nprobe >= clusters: nothing skipped
|
|
193
|
+
const ref = qs.map((qi) =>
|
|
194
|
+
new Set(idx.searchKnn(vecs[qi], K, full).map((h) => h.id))
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const at = (ef) => {
|
|
198
|
+
let hit = 0, want = 0;
|
|
199
|
+
for (let t = 0; t < qs.length; t++) {
|
|
200
|
+
for (const h of idx.searchKnn(vecs[qs[t]], K, ef)) {
|
|
201
|
+
if (ref[t].has(h.id)) hit++;
|
|
202
|
+
}
|
|
203
|
+
want += ref[t].size;
|
|
204
|
+
}
|
|
205
|
+
return hit / want;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const one = at(4); // nprobe 1
|
|
209
|
+
const two = at(8); // nprobe 2
|
|
210
|
+
const all = at(16 * clusters);
|
|
211
|
+
idx.close();
|
|
212
|
+
|
|
213
|
+
// Probing every cluster must reproduce the reference exactly — this is the
|
|
214
|
+
// same scan, so anything below 1.0 means the probe ORDER dropped a cluster
|
|
215
|
+
// it ranked in, not that the quantizer was imprecise.
|
|
216
|
+
assert.equal(
|
|
217
|
+
all,
|
|
218
|
+
1,
|
|
219
|
+
"probing every cluster must match the full scan exactly",
|
|
220
|
+
);
|
|
221
|
+
// More clusters probed is never worse.
|
|
222
|
+
assert.ok(
|
|
223
|
+
two >= one,
|
|
224
|
+
`routing recall fell when nprobe grew: nprobe=1 ${
|
|
225
|
+
(one * 100).toFixed(1)
|
|
226
|
+
}% ` +
|
|
227
|
+
`-> nprobe=2 ${(two * 100).toFixed(1)}%`,
|
|
228
|
+
);
|
|
229
|
+
// Measured 52.9% at nprobe=1 and 76.4% at nprobe=2 of 4 clusters. The floors
|
|
230
|
+
// guard the PIVOT quality: routing is only worth anything if the nearest
|
|
231
|
+
// cluster usually holds the nearest vectors, and a pivot chosen badly (a
|
|
232
|
+
// broken split, a majority-code regression) shows up here first.
|
|
233
|
+
assert.ok(
|
|
234
|
+
one >= 0.4,
|
|
235
|
+
`single-cluster routing recall ${(one * 100).toFixed(1)}% is below the ` +
|
|
236
|
+
`0.4 floor (measured 52.9%) — cluster pivots no longer predict where ` +
|
|
237
|
+
`a query's neighbours live.`,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── ESTIMATOR: the arithmetic itself, pinned exactly ───────────────────────
|
|
242
|
+
|
|
243
|
+
test("the fast estimator is bit-identical to its scalar definition", () => {
|
|
244
|
+
// The scan's inner loop is the hottest code in inference and invites
|
|
245
|
+
// micro-optimisation (it currently folds the popcount into 32-bit SWAR
|
|
246
|
+
// words). Those rewrites are only legitimate while they are EXACT: the
|
|
247
|
+
// estimator's output ranks every candidate, so a last-bit difference is a
|
|
248
|
+
// silent reordering, not a rounding detail. This recomputes the definition
|
|
249
|
+
// straight from the QueryContext and demands equality.
|
|
250
|
+
const rnd = prng(11);
|
|
251
|
+
let checked = 0;
|
|
252
|
+
for (const dim of [8, 64, 100, 256, 1024]) {
|
|
253
|
+
const qz = new RaBitQuantizer(dim, { seed: SEED });
|
|
254
|
+
const nb = qz.paddedDim >>> 3;
|
|
255
|
+
const POP = new Uint8Array(256);
|
|
256
|
+
for (let i = 1; i < 256; i++) POP[i] = POP[i >> 1] + (i & 1);
|
|
257
|
+
for (let t = 0; t < 25; t++) {
|
|
258
|
+
const v = new Float64Array(dim);
|
|
259
|
+
for (let i = 0; i < dim; i++) v[i] = rnd() * 2 - 1;
|
|
260
|
+
const q = qz.prepareQuery(v);
|
|
261
|
+
const code = new Uint8Array(nb * 4);
|
|
262
|
+
for (let i = 0; i < code.length; i++) code[i] = (rnd() * 256) | 0;
|
|
263
|
+
for (let c = 0; c < 4; c++) {
|
|
264
|
+
const off = c * nb;
|
|
265
|
+
let dot = 0, pc = 0;
|
|
266
|
+
for (let p = 0; p < q.nbytes; p++) {
|
|
267
|
+
const b = code[off + p];
|
|
268
|
+
dot += q.qlut[(p << 8) + b];
|
|
269
|
+
pc += POP[b];
|
|
270
|
+
}
|
|
271
|
+
const A = q.vmin * (2 * pc - qz.paddedDim) +
|
|
272
|
+
q.delta * (2 * dot - q.sumQInt);
|
|
273
|
+
const want = q.zero ? 1 : 1 - qz.cosFactor * A;
|
|
274
|
+
assert.ok(
|
|
275
|
+
Object.is(qz.estimate(code, off, q), want),
|
|
276
|
+
`estimate diverged from its definition at dim=${dim}: ` +
|
|
277
|
+
`${qz.estimate(code, off, q)} vs ${want}`,
|
|
278
|
+
);
|
|
279
|
+
checked++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
assert.ok(checked >= 500, `expected a broad sweep, checked ${checked}`);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── GEOMETRY: rotation rounds are saturated at 1 ───────────────────────────
|
|
287
|
+
|
|
288
|
+
test("rotation rounds beyond the first buy no recall", () => {
|
|
289
|
+
// Recorded as an executable fact, because it is the one place in this
|
|
290
|
+
// sub-library with real headroom: a round is a sign-flip plus a
|
|
291
|
+
// Walsh-Hadamard transform (the SRHT construction, for which ONE randomised
|
|
292
|
+
// round already approximates a random rotation), and cost is linear in the
|
|
293
|
+
// count — encode runs 28.0us at rounds=3 against 16.8us at rounds=1.
|
|
294
|
+
//
|
|
295
|
+
// Measured against the trained store — 100,000 real gists, routing removed,
|
|
296
|
+
// 400 queries, recall@10: rounds=4 70.5%, rounds=3 71.1%, rounds=2 70.6%,
|
|
297
|
+
// rounds=1 71.1% — indistinguishable across 4,000 ground-truth slots, and
|
|
298
|
+
// reproduced at N=1,000 and N=4,000. If this assertion ever fails, the
|
|
299
|
+
// rotation has stopped being saturated and the default is worth revisiting.
|
|
300
|
+
const { vecs, D } = gists(2000);
|
|
301
|
+
const norms = vecs.map((v) => {
|
|
302
|
+
let s = 0;
|
|
303
|
+
for (let i = 0; i < v.length; i++) s += v[i] * v[i];
|
|
304
|
+
return Math.sqrt(s);
|
|
305
|
+
});
|
|
306
|
+
const qs = queryIds(vecs.length, 100);
|
|
307
|
+
const truth = qs.map((qi) => exactTopK(vecs, norms, qi, K));
|
|
308
|
+
|
|
309
|
+
const recallFor = (rounds) => {
|
|
310
|
+
const idx = indexOf(vecs, D, rounds);
|
|
311
|
+
const full = 4 * idx.clusterCount;
|
|
312
|
+
let hit = 0, want = 0;
|
|
313
|
+
for (let t = 0; t < qs.length; t++) {
|
|
314
|
+
for (const h of idx.searchKnn(vecs[qs[t]], K, full)) {
|
|
315
|
+
if (truth[t].has(h.id)) hit++;
|
|
316
|
+
}
|
|
317
|
+
want += truth[t].size;
|
|
318
|
+
}
|
|
319
|
+
idx.close();
|
|
320
|
+
return hit / want;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const three = recallFor(3);
|
|
324
|
+
const one = recallFor(1);
|
|
325
|
+
assert.ok(
|
|
326
|
+
Math.abs(three - one) <= 0.03,
|
|
327
|
+
`one rotation round is no longer equivalent to three: ` +
|
|
328
|
+
`rounds=3 ${(three * 100).toFixed(1)}% vs rounds=1 ` +
|
|
329
|
+
`${(one * 100).toFixed(1)}% (tolerance 3 points)`,
|
|
330
|
+
);
|
|
331
|
+
});
|