md-semantic-search 2.0.0 → 2.2.0
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/README.md +25 -18
- package/RESEARCH.md +152 -29
- package/bin/cli.mjs +34 -0
- package/dist/hnsw.d.ts +31 -1
- package/dist/hnsw.js +164 -10
- package/dist/indexer.js +47 -12
- package/dist/models.d.ts +1 -1
- package/dist/models.js +6 -1
- package/dist/wasm-dot.d.ts +36 -0
- package/dist/wasm-dot.js +106 -0
- package/package.json +1 -1
- package/src/hnsw.ts +190 -9
- package/src/indexer.ts +47 -8
- package/src/models.ts +6 -1
- package/src/wasm-dot.ts +115 -0
package/src/hnsw.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* is what lets the incremental watch rebuild reuse a persisted graph.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { createWasmDotEngine } from './wasm-dot.js';
|
|
15
|
+
|
|
14
16
|
/** Deterministic PRNG for level assignment (same family as ivf.ts). */
|
|
15
17
|
function mulberry32(seed: number): () => number {
|
|
16
18
|
let s = seed >>> 0;
|
|
@@ -63,6 +65,28 @@ export interface BuildHnswOptions {
|
|
|
63
65
|
*/
|
|
64
66
|
distance: (a: Float32Array | Int8Array | number[], b: number) => number;
|
|
65
67
|
vectors: Array<Float32Array | Int8Array | number[]>;
|
|
68
|
+
/**
|
|
69
|
+
* Issue #228: 'auto' routes the build's distance work through the WASM
|
|
70
|
+
* SIMD dot-batch kernel (src/wasm-dot.ts) when the vectors are a uniform
|
|
71
|
+
* Float32Array matrix and the runtime supports SIMD — ~6× faster dots on a
|
|
72
|
+
* 32-node batch, ~3.4× faster full build (RESEARCH.md §17). ONLY valid when
|
|
73
|
+
* the intended distance is exactly `1 − dot(a, vectors[b])`; any custom
|
|
74
|
+
* distance (int8 norms, asymmetric variants) must keep the default
|
|
75
|
+
* 'closure' engine. Silent fallback to 'closure' when 'auto' cannot apply.
|
|
76
|
+
*/
|
|
77
|
+
engine?: 'auto' | 'closure';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Distance plumbing for the build (issue #228). The closure engine wraps the
|
|
82
|
+
* caller-supplied `distance`; the wasm engine replaces BOTH pointwise and
|
|
83
|
+
* batch distances with kernel calls so every distance on the chain uses the
|
|
84
|
+
* same SIMD accumulation order (self-consistent → deterministic sidecar).
|
|
85
|
+
*/
|
|
86
|
+
export interface HnswBuildEngine {
|
|
87
|
+
dist(aIdx: number, bIdx: number): number;
|
|
88
|
+
/** Distances from vectors[aIdx] to each node, same order — null = pointwise. */
|
|
89
|
+
batch?(aIdx: number, nodes: number[]): Float32Array | null;
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
export const DEFAULT_HNSW_M = 16;
|
|
@@ -82,10 +106,70 @@ export function buildHnsw(
|
|
|
82
106
|
neighbors: [],
|
|
83
107
|
deleted: new Set(),
|
|
84
108
|
};
|
|
85
|
-
|
|
109
|
+
let engine: HnswBuildEngine | undefined;
|
|
110
|
+
if (options.engine === 'auto') {
|
|
111
|
+
engine = createWasmBuildEngine(vectors) ?? undefined;
|
|
112
|
+
// undefined → SIMD unavailable or non-uniform vectors: closure engine, silently.
|
|
113
|
+
}
|
|
114
|
+
for (let i = 0; i < vectors.length; i++) insertHnsw(hnsw, i, options.distance, options.vectors, engine);
|
|
86
115
|
return hnsw;
|
|
87
116
|
}
|
|
88
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Issue #228: wasm-backed build engine for uniform Float32Array matrices.
|
|
120
|
+
* Returns null whenever the fast path cannot apply — the caller then keeps
|
|
121
|
+
* the caller-supplied closure (identical semantics, JS arithmetic).
|
|
122
|
+
*/
|
|
123
|
+
function createWasmBuildEngine(vectors: Array<Float32Array | Int8Array | number[]>): HnswBuildEngine | null {
|
|
124
|
+
for (const v of vectors) {
|
|
125
|
+
if (!(v instanceof Float32Array)) return null;
|
|
126
|
+
}
|
|
127
|
+
const wasmEngine = createWasmDotEngine(vectors as Array<Float32Array>);
|
|
128
|
+
if (!wasmEngine) return null;
|
|
129
|
+
// Hybrid distance plumbing (measured, issue #228): BATCHES (a whole node's
|
|
130
|
+
// neighbor list — the 85% cost center) go through the SIMD kernel; pointwise
|
|
131
|
+
// calls (greedy descent, singleton scans, heuristic pairwise) stay on the
|
|
132
|
+
// plain JS dot — a 1-row wasm call pays call overhead exceeding the work.
|
|
133
|
+
// Both paths are IEEE f32 with fixed orders, so the build stays
|
|
134
|
+
// deterministic; recall is insensitive to the last-ulp difference (measured
|
|
135
|
+
// identical across engines in bench/ann-recall.mjs and the #228 tests).
|
|
136
|
+
const f32Vectors = vectors as Array<Float32Array>;
|
|
137
|
+
return {
|
|
138
|
+
dist: (a, b) => 1 - jsDotF32(f32Vectors[a], f32Vectors[b]),
|
|
139
|
+
batch: (aIdx, nodes) => {
|
|
140
|
+
const dots = wasmEngine.batch(aIdx, nodes);
|
|
141
|
+
const out = new Float32Array(dots.length);
|
|
142
|
+
for (let j = 0; j < dots.length; j++) out[j] = 1 - dots[j];
|
|
143
|
+
return out;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function jsDotF32(a: Float32Array, b: Float32Array): number {
|
|
149
|
+
let s = 0;
|
|
150
|
+
const n = a.length;
|
|
151
|
+
const limit = n - 7;
|
|
152
|
+
let i = 0;
|
|
153
|
+
let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0, s5 = 0, s6 = 0, s7 = 0;
|
|
154
|
+
while (i < limit) {
|
|
155
|
+
s0 += a[i] * b[i];
|
|
156
|
+
s1 += a[i + 1] * b[i + 1];
|
|
157
|
+
s2 += a[i + 2] * b[i + 2];
|
|
158
|
+
s3 += a[i + 3] * b[i + 3];
|
|
159
|
+
s4 += a[i + 4] * b[i + 4];
|
|
160
|
+
s5 += a[i + 5] * b[i + 5];
|
|
161
|
+
s6 += a[i + 6] * b[i + 6];
|
|
162
|
+
s7 += a[i + 7] * b[i + 7];
|
|
163
|
+
i += 8;
|
|
164
|
+
}
|
|
165
|
+
s = s0 + s1 + (s2 + s3) + (s4 + s5) + (s6 + s7);
|
|
166
|
+
while (i < n) {
|
|
167
|
+
s += a[i] * b[i];
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
|
|
89
173
|
/**
|
|
90
174
|
* Insert node `idx` (vector index) into the graph. Incremental — new indices
|
|
91
175
|
* attach to the existing structure without a rebuild; vectors for nodes NOT
|
|
@@ -96,7 +180,8 @@ export function insertHnsw(
|
|
|
96
180
|
hnsw: HnswIndex,
|
|
97
181
|
idx: number,
|
|
98
182
|
distance: (a: Float32Array | Int8Array | number[], b: number) => number,
|
|
99
|
-
vectors: Array<Float32Array | Int8Array | number[]
|
|
183
|
+
vectors: Array<Float32Array | Int8Array | number[]>,
|
|
184
|
+
engine?: HnswBuildEngine
|
|
100
185
|
): void {
|
|
101
186
|
const vec = vectors[idx];
|
|
102
187
|
if (!vec) throw new Error(`hnsw insert: no vector at index ${idx}`);
|
|
@@ -121,7 +206,14 @@ export function insertHnsw(
|
|
|
121
206
|
return;
|
|
122
207
|
}
|
|
123
208
|
|
|
124
|
-
|
|
209
|
+
// Issue #228: with a build engine, all pointwise distances go through
|
|
210
|
+
// dist(aIdx, bIdx) so the whole chain shares one arithmetic order; the
|
|
211
|
+
// per-neighbor scan uses engine.batch when available (one wasm call per
|
|
212
|
+
// node's neighbor list instead of one JS call per distance).
|
|
213
|
+
const dist = engine
|
|
214
|
+
? (aIdx: number, bIdx: number) => engine.dist(aIdx, bIdx)
|
|
215
|
+
: (aIdx: number, bIdx: number) => distance(vectors[aIdx], bIdx);
|
|
216
|
+
const qDist = (node: number) => dist(idx, node);
|
|
125
217
|
|
|
126
218
|
// 1. Greedy descent through layers above the node's level
|
|
127
219
|
let ep = hnsw.entryPoint;
|
|
@@ -142,10 +234,12 @@ export function insertHnsw(
|
|
|
142
234
|
}
|
|
143
235
|
|
|
144
236
|
// 2. Insert on each layer from min(level, maxLevel) down to 0
|
|
145
|
-
const pairwise =
|
|
237
|
+
const pairwise = engine
|
|
238
|
+
? (a: number, b: number) => engine.dist(a, b)
|
|
239
|
+
: (a: number, b: number) => distance(vectors[a], b);
|
|
146
240
|
for (let l = Math.min(level, hnsw.maxLevel); l >= 0; l--) {
|
|
147
241
|
const efC = Math.max(M0, DEFAULT_HNSW_EF);
|
|
148
|
-
const candidates = searchLayer(hnsw, qDist, ep, epDist, l, efC);
|
|
242
|
+
const candidates = searchLayer(hnsw, qDist, ep, epDist, l, efC, engine?.batch, idx);
|
|
149
243
|
const cap = l === 0 ? M0 : M;
|
|
150
244
|
// Algorithm 4: diversity-aware neighbor selection keeps the graph
|
|
151
245
|
// navigable — a candidate is skipped only when it is CLOSER to an
|
|
@@ -191,7 +285,11 @@ function searchLayer(
|
|
|
191
285
|
ep: number,
|
|
192
286
|
epDist: number,
|
|
193
287
|
layer: number,
|
|
194
|
-
ef: number
|
|
288
|
+
ef: number,
|
|
289
|
+
// issue #228: when present, the unvisited neighbors of each expanded node
|
|
290
|
+
// are measured in ONE batched call (same order → same heap evolution).
|
|
291
|
+
batch?: (aIdx: number, nodes: number[]) => Float32Array | null,
|
|
292
|
+
aIdx?: number
|
|
195
293
|
): Array<{ node: number; dist: number }> {
|
|
196
294
|
const visited = new Set<number>([ep]);
|
|
197
295
|
// max-heap by dist via simple sorted insertion (candidate lists are small)
|
|
@@ -204,10 +302,17 @@ function searchLayer(
|
|
|
204
302
|
const worst = results.length >= ef ? results[results.length - 1].dist : Infinity;
|
|
205
303
|
if (current.dist > worst) break;
|
|
206
304
|
|
|
207
|
-
|
|
208
|
-
|
|
305
|
+
const neighborList = hnsw.neighbors[current.node][layer] ?? [];
|
|
306
|
+
const fresh: number[] = [];
|
|
307
|
+
for (const nb of neighborList) {
|
|
308
|
+
if (!visited.has(nb)) fresh.push(nb);
|
|
309
|
+
}
|
|
310
|
+
let dists: Float32Array | null = null;
|
|
311
|
+
if (fresh.length > 1 && batch && aIdx !== undefined) dists = batch(aIdx, fresh);
|
|
312
|
+
for (let j = 0; j < fresh.length; j++) {
|
|
313
|
+
const nb = fresh[j];
|
|
209
314
|
visited.add(nb);
|
|
210
|
-
const d = qDist(nb);
|
|
315
|
+
const d = dists ? dists[j] : qDist(nb);
|
|
211
316
|
const worstNow = results.length >= ef ? results[results.length - 1].dist : Infinity;
|
|
212
317
|
if (d < worstNow || results.length < ef) {
|
|
213
318
|
candidates.push({ node: nb, dist: d });
|
|
@@ -412,3 +517,79 @@ export function deserializeHnsw(raw: unknown, expectedChunksCount?: number): Hns
|
|
|
412
517
|
}
|
|
413
518
|
return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
|
|
414
519
|
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
|
|
523
|
+
* is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
|
|
524
|
+
* carry over because vector reuse is keyed by chunkHash — a surviving chunk
|
|
525
|
+
* keeps its exact vector, so every stored edge distance stays valid.
|
|
526
|
+
* Returns null when the remap cannot produce a navigable graph (e.g. the
|
|
527
|
+
* entry point did not survive) — the caller falls back to a full rebuild.
|
|
528
|
+
*/
|
|
529
|
+
export function remapHnsw(
|
|
530
|
+
hnsw: HnswIndex,
|
|
531
|
+
mapping: Array<number | undefined>,
|
|
532
|
+
newCount: number,
|
|
533
|
+
): HnswIndex | null {
|
|
534
|
+
if (newCount <= 0) return null;
|
|
535
|
+
// every new index may receive at most one old node
|
|
536
|
+
const taken = new Set<number>();
|
|
537
|
+
const remapOf = new Map<number, number>();
|
|
538
|
+
for (let oldIdx = 0; oldIdx < mapping.length; oldIdx++) {
|
|
539
|
+
const newIdx = mapping[oldIdx];
|
|
540
|
+
if (newIdx === undefined || newIdx < 0 || newIdx >= newCount) continue;
|
|
541
|
+
if (taken.has(newIdx)) continue;
|
|
542
|
+
taken.add(newIdx);
|
|
543
|
+
remapOf.set(oldIdx, newIdx);
|
|
544
|
+
}
|
|
545
|
+
// the entry point does NOT need to survive — it re-anchors below
|
|
546
|
+
|
|
547
|
+
const levels: number[] = new Array(newCount);
|
|
548
|
+
const neighbors: number[][][] = new Array(newCount);
|
|
549
|
+
const deleted = new Set<number>();
|
|
550
|
+
|
|
551
|
+
for (const [oldIdx, newIdx] of remapOf) {
|
|
552
|
+
levels[newIdx] = hnsw.levels[oldIdx];
|
|
553
|
+
neighbors[newIdx] = (hnsw.neighbors[oldIdx] ?? []).map((links) =>
|
|
554
|
+
(links ?? [])
|
|
555
|
+
.map((nb) => remapOf.get(nb))
|
|
556
|
+
.filter((nb): nb is number => nb !== undefined),
|
|
557
|
+
);
|
|
558
|
+
if (hnsw.deleted.has(oldIdx)) deleted.add(newIdx);
|
|
559
|
+
}
|
|
560
|
+
// structural holes would break insertHnsw/serialize — unmapped new indices
|
|
561
|
+
// are placeholders the caller fills with insertHnsw immediately after
|
|
562
|
+
for (let i = 0; i < newCount; i++) {
|
|
563
|
+
if (levels[i] === undefined) {
|
|
564
|
+
levels[i] = 0;
|
|
565
|
+
neighbors[i] = [[]];
|
|
566
|
+
deleted.add(i); // invisible until a real node is inserted over it
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// the entry point must sit on the TOP layer: after a partial remap the
|
|
571
|
+
// old entry may carry a lower level, or some survivor may carry a higher
|
|
572
|
+
// one — re-anchor deterministically to the lowest-index survivor at maxLevel
|
|
573
|
+
let maxLevel = -1;
|
|
574
|
+
for (const newIdx of remapOf.values()) maxLevel = Math.max(maxLevel, levels[newIdx]);
|
|
575
|
+
if (maxLevel < 0) return null;
|
|
576
|
+
let entryPoint = -1;
|
|
577
|
+
for (let i = 0; i < newCount; i++) {
|
|
578
|
+
if (!deleted.has(i) && levels[i] === maxLevel) {
|
|
579
|
+
entryPoint = i;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (entryPoint < 0) return null;
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
dim: hnsw.dim,
|
|
587
|
+
M: hnsw.M,
|
|
588
|
+
seed: hnsw.seed,
|
|
589
|
+
entryPoint,
|
|
590
|
+
maxLevel,
|
|
591
|
+
levels,
|
|
592
|
+
neighbors,
|
|
593
|
+
deleted,
|
|
594
|
+
};
|
|
595
|
+
}
|
package/src/indexer.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from './lexical.js';
|
|
37
37
|
import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
|
|
38
38
|
import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
|
|
39
|
-
import { buildHnsw, serializeHnsw, HnswIndex } from './hnsw.js';
|
|
39
|
+
import { buildHnsw, insertHnsw, remapHnsw, serializeHnsw, deserializeHnsw, HnswIndex } from './hnsw.js';
|
|
40
40
|
import { DocumentMetadata } from './frontmatter.js';
|
|
41
41
|
import { serializeBinaryIndex } from './binary-format.js';
|
|
42
42
|
import { defaultChunkTokenBudget } from './markdown-parser.js';
|
|
@@ -658,16 +658,55 @@ async function _buildIndexInner({
|
|
|
658
658
|
if (buildSidecar) {
|
|
659
659
|
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec as number[])));
|
|
660
660
|
if (annEngine === 'hnsw') {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
distance: (a, b) => {
|
|
665
|
-
const bv = rawVecs[b];
|
|
661
|
+
const hnswDistance = (vectors: Array<Float32Array | number[]>) =>
|
|
662
|
+
(a: Float32Array | Int8Array | number[], b: number) => {
|
|
663
|
+
const bv = vectors[b];
|
|
666
664
|
let dot = 0;
|
|
667
665
|
for (let i = 0; i < a.length; i++) dot += (a as ArrayLike<number>)[i] * (bv as ArrayLike<number>)[i];
|
|
668
666
|
return 1 - dot;
|
|
669
|
-
}
|
|
670
|
-
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// issue #223: reuse the persisted graph across watch rebuilds — remap
|
|
670
|
+
// surviving nodes onto the new chunk numbering via chunkHash (vector
|
|
671
|
+
// reuse is hash-keyed, so every carried edge distance stays valid),
|
|
672
|
+
// tombstone the gone ones, insert the new ones. Full rebuild only when
|
|
673
|
+
// no valid previous graph exists (first run, engine/budget switch) or
|
|
674
|
+
// the remap cannot anchor an entry point.
|
|
675
|
+
let hnswIndex: HnswIndex | null = null;
|
|
676
|
+
if (!chunkerChanged && oldIndex.chunks.length > 0) {
|
|
677
|
+
try {
|
|
678
|
+
const prev = deserializeHnsw(JSON.parse(fs.readFileSync(hnswPath, 'utf8')), oldIndex.chunks.length);
|
|
679
|
+
if (prev.levels.length === oldIndex.chunks.length) {
|
|
680
|
+
const newIdxByHash = new Map<string, number>();
|
|
681
|
+
chunks.forEach((c, newIdx) => newIdxByHash.set(c.chunkHash || chunkHash(model, c), newIdx));
|
|
682
|
+
const mapping: Array<number | undefined> = oldIndex.chunks.map((c) =>
|
|
683
|
+
newIdxByHash.get(c.chunkHash || chunkHash(model, c)),
|
|
684
|
+
);
|
|
685
|
+
hnswIndex = remapHnsw(prev, mapping, chunks.length);
|
|
686
|
+
if (hnswIndex) {
|
|
687
|
+
const inserted: number[] = [];
|
|
688
|
+
for (let newIdx = 0; newIdx < chunks.length; newIdx++) {
|
|
689
|
+
if (hnswIndex.levels[newIdx] !== undefined && !hnswIndex.deleted.has(newIdx)) continue;
|
|
690
|
+
insertHnsw(hnswIndex, newIdx, hnswDistance(rawVecs), rawVecs);
|
|
691
|
+
inserted.push(newIdx);
|
|
692
|
+
}
|
|
693
|
+
log(
|
|
694
|
+
`hnsw: reused persisted graph — ` +
|
|
695
|
+
`${chunks.length - inserted.length} surviving node(s), ${inserted.length} inserted`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch {
|
|
700
|
+
hnswIndex = null; // corrupt/foreign sidecar — full rebuild below
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (!hnswIndex) {
|
|
704
|
+
// issue #228: 'auto' routes the full build through the WASM SIMD
|
|
705
|
+
// dot-batch kernel when the vectors are a uniform Float32Array matrix
|
|
706
|
+
// (the hnswDistance closure below is exactly 1 − dot, so semantics
|
|
707
|
+
// match); otherwise buildHnsw silently keeps the closure path.
|
|
708
|
+
hnswIndex = buildHnsw(rawVecs, { vectors: rawVecs, distance: hnswDistance(rawVecs), engine: 'auto' });
|
|
709
|
+
}
|
|
671
710
|
atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
|
|
672
711
|
if (fs.existsSync(ivfPath)) {
|
|
673
712
|
try {
|
package/src/models.ts
CHANGED
|
@@ -131,7 +131,12 @@ export const MODELS: Record<string, ModelAdapter> = {
|
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// Issue #227: granite-multilingual-311m is the default — Apache-2.0 (the
|
|
135
|
+
// measured leader embeddinggemma-300m is license-blocked from being an npm
|
|
136
|
+
// default by the Gemma Terms of Use) and measured +0.078 nDCG@10 over e5-base
|
|
137
|
+
// on the 186-query golden set v2 (RESEARCH.md §15). Same size class (~298 MB
|
|
138
|
+
// q8), 8k ModernBERT context. e5-base stays a registered alias.
|
|
139
|
+
export const DEFAULT_MODEL = 'granite-multilingual-311m';
|
|
135
140
|
export const POOLING: Pooling[] = ['mean', 'last_token', 'none', 'cls'];
|
|
136
141
|
const POOLING_SET = new Set<string>(POOLING);
|
|
137
142
|
|
package/src/wasm-dot.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASM SIMD128 dot-product engine for the HNSW graph build (issue #228).
|
|
3
|
+
*
|
|
4
|
+
* The build's cost profile is ~85% distance computation (measured: 16.5k
|
|
5
|
+
* distance calls per node at n=5000, dim=256; RESEARCH.md §17), and those
|
|
6
|
+
* calls sit on a sequential dependency chain — worker_threads parallelism is
|
|
7
|
+
* structurally blocked (per-insert graph state + the byte-identical-sidecar
|
|
8
|
+
* determinism contract). The achievable lever is making each dot faster:
|
|
9
|
+
* this module batches f32 dot products into ONE WebAssembly call
|
|
10
|
+
* (`dot_batch`: N row indices → N dot products against a shared query row)
|
|
11
|
+
* with a SIMD128 mul/add inner loop — measured ~6× over the unrolled JS dot
|
|
12
|
+
* at dim=256 on a 32-dot batch.
|
|
13
|
+
*
|
|
14
|
+
* Zero native dependencies: the module is 363 bytes of hand-written WAT
|
|
15
|
+
* compiled once via wabt and embedded below (source of record:
|
|
16
|
+
* bench/ann-recall.mjs history + RESEARCH.md §17). Compile failure (no SIMD
|
|
17
|
+
* in the runtime) degrades to `null` and the caller keeps the JS closure path.
|
|
18
|
+
*
|
|
19
|
+
* Determinism: all arithmetic is IEEE f32 with a fixed lane order — repeated
|
|
20
|
+
* builds on the same corpus produce a byte-identical sidecar per engine. The
|
|
21
|
+
* SIMD accumulation order differs from the JS closure path in the last ulp,
|
|
22
|
+
* so the two engines are each self-consistent but not cross-identical; the
|
|
23
|
+
* sidecar records which engine built it (same engine → same bytes).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The compiled `dot_batch` module (see module docstring for provenance). */
|
|
27
|
+
const KERNEL_B64 =
|
|
28
|
+
'AGFzbQEAAAABCQFgBX9/f39/AAMCAQAFAwEAAQcTAgNtZW0CAAlkb3RfYmF0Y2gAAAq3AgG0AgQCfwF9An8EeyADQQRsIQlBACEF' +
|
|
29
|
+
'AkADQCAFIAJODQEgASAFQQRsaigCACAJbCEG/QwAAAAAAAAAAAAAAAAAAAAAIQr9DAAAAAAAAAAAAAAAAAAAAAAhC/0MAAAAAAAAA' +
|
|
30
|
+
'AAAAAAAAAAAACEM/QwAAAAAAAAAAAAAAAAAAAAAIQ1DAAAAACEHQQAhCAJAA0AgCEEQaiAJSg0BIAogACAIav0AAgAgBiAIav0A' +
|
|
31
|
+
'AgD95gH95AEhCiAIQRBqIQgMAAsLAkADQCAIIAlODQEgByAAIAhqKgIAIAYgCGoqAgCUkiEHIAhBBGohCAwACwsgBCAFQQRsaiAK' +
|
|
32
|
+
'/R8AIAr9HwGSIAr9HwIgCv0fA5KSIAcgC/0fACAL/R8BkiAL/R8CIAv9HwOSkpKSOAIAIAVBAWohBQwACwsL';
|
|
33
|
+
|
|
34
|
+
export interface WasmDotEngine {
|
|
35
|
+
/** dot(vectors[a], vectors[b]) — one-row batch, same arithmetic as batch(). */
|
|
36
|
+
point(aIdx: number, bIdx: number): number;
|
|
37
|
+
/** result[j] = dot(vectors[aIdx], vectors[nodes[j]]) — a view into wasm memory, valid until the next call. */
|
|
38
|
+
batch(aIdx: number, nodes: number[]): Float32Array;
|
|
39
|
+
readonly dim: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Max nodes per batch call — HNSW neighbor lists cap at M0 = 2*M ≤ 64. */
|
|
43
|
+
const MAX_BATCH = 64;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the engine over a uniform Float32Array matrix. Returns null when the
|
|
47
|
+
* runtime cannot compile the SIMD module (then callers keep the JS path).
|
|
48
|
+
*/
|
|
49
|
+
export function createWasmDotEngine(vectors: Array<Float32Array>): WasmDotEngine | null {
|
|
50
|
+
if (vectors.length === 0) return null;
|
|
51
|
+
const dim = vectors[0].length;
|
|
52
|
+
if (dim === 0) return null;
|
|
53
|
+
for (const v of vectors) {
|
|
54
|
+
if (!(v instanceof Float32Array) || v.length !== dim) return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let kernelBuffer: ArrayBuffer;
|
|
58
|
+
try {
|
|
59
|
+
const bin = Buffer.from(KERNEL_B64, 'base64');
|
|
60
|
+
kernelBuffer = new ArrayBuffer(bin.byteLength);
|
|
61
|
+
new Uint8Array(kernelBuffer).set(bin);
|
|
62
|
+
if (bin.byteLength === 0) return null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
let instance: WebAssembly.Instance;
|
|
67
|
+
try {
|
|
68
|
+
instance = new WebAssembly.Instance(new WebAssembly.Module(kernelBuffer), {});
|
|
69
|
+
} catch {
|
|
70
|
+
return null; // no SIMD support — caller falls back to the JS closure path
|
|
71
|
+
}
|
|
72
|
+
const exp = instance.exports as unknown as {
|
|
73
|
+
mem: WebAssembly.Memory;
|
|
74
|
+
dot_batch: (q: number, idx: number, n: number, dim: number, out: number) => void;
|
|
75
|
+
};
|
|
76
|
+
const mem = exp.mem;
|
|
77
|
+
|
|
78
|
+
// Linear memory layout: [vectors row-major][idx scratch u32 × MAX_BATCH][out f32 × MAX_BATCH]
|
|
79
|
+
const vecBytes = vectors.length * dim * 4;
|
|
80
|
+
const idxByte = vecBytes;
|
|
81
|
+
const outByte = idxByte + MAX_BATCH * 4;
|
|
82
|
+
const needBytes = outByte + MAX_BATCH * 4 + 4096;
|
|
83
|
+
const pages = Math.ceil(needBytes / 65536);
|
|
84
|
+
try {
|
|
85
|
+
if (mem.buffer.byteLength < needBytes) mem.grow(pages);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// Views are created AFTER the final grow — growing detaches old buffers.
|
|
90
|
+
const f32 = new Float32Array(mem.buffer);
|
|
91
|
+
const u32 = new Uint32Array(mem.buffer);
|
|
92
|
+
for (let i = 0; i < vectors.length; i++) f32.set(vectors[i], i * dim);
|
|
93
|
+
|
|
94
|
+
const qByteOf = (aIdx: number) => {
|
|
95
|
+
if (!Number.isSafeInteger(aIdx) || aIdx < 0 || aIdx >= vectors.length) {
|
|
96
|
+
throw new Error(`wasm-dot: row index out of range: ${aIdx}`);
|
|
97
|
+
}
|
|
98
|
+
return aIdx * dim * 4;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
dim,
|
|
103
|
+
point(aIdx: number, bIdx: number): number {
|
|
104
|
+
u32[idxByte >> 2] = bIdx;
|
|
105
|
+
exp.dot_batch(qByteOf(aIdx), idxByte, 1, dim, outByte);
|
|
106
|
+
return f32[outByte >> 2];
|
|
107
|
+
},
|
|
108
|
+
batch(aIdx: number, nodes: number[]): Float32Array {
|
|
109
|
+
const n = Math.min(nodes.length, MAX_BATCH);
|
|
110
|
+
for (let j = 0; j < n; j++) u32[(idxByte >> 2) + j] = nodes[j];
|
|
111
|
+
exp.dot_batch(qByteOf(aIdx), idxByte, n, dim, outByte);
|
|
112
|
+
return f32.subarray(outByte >> 2, (outByte >> 2) + n);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|