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/dist/hnsw.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* work unchanged. Deleted nodes are tombstones — searches skip them, which
|
|
11
11
|
* is what lets the incremental watch rebuild reuse a persisted graph.
|
|
12
12
|
*/
|
|
13
|
+
import { createWasmDotEngine } from './wasm-dot.js';
|
|
13
14
|
/** Deterministic PRNG for level assignment (same family as ivf.ts). */
|
|
14
15
|
function mulberry32(seed) {
|
|
15
16
|
let s = seed >>> 0;
|
|
@@ -33,17 +34,78 @@ export function buildHnsw(vectors, options) {
|
|
|
33
34
|
neighbors: [],
|
|
34
35
|
deleted: new Set(),
|
|
35
36
|
};
|
|
37
|
+
let engine;
|
|
38
|
+
if (options.engine === 'auto') {
|
|
39
|
+
engine = createWasmBuildEngine(vectors) ?? undefined;
|
|
40
|
+
// undefined → SIMD unavailable or non-uniform vectors: closure engine, silently.
|
|
41
|
+
}
|
|
36
42
|
for (let i = 0; i < vectors.length; i++)
|
|
37
|
-
insertHnsw(hnsw, i, options.distance, options.vectors);
|
|
43
|
+
insertHnsw(hnsw, i, options.distance, options.vectors, engine);
|
|
38
44
|
return hnsw;
|
|
39
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Issue #228: wasm-backed build engine for uniform Float32Array matrices.
|
|
48
|
+
* Returns null whenever the fast path cannot apply — the caller then keeps
|
|
49
|
+
* the caller-supplied closure (identical semantics, JS arithmetic).
|
|
50
|
+
*/
|
|
51
|
+
function createWasmBuildEngine(vectors) {
|
|
52
|
+
for (const v of vectors) {
|
|
53
|
+
if (!(v instanceof Float32Array))
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const wasmEngine = createWasmDotEngine(vectors);
|
|
57
|
+
if (!wasmEngine)
|
|
58
|
+
return null;
|
|
59
|
+
// Hybrid distance plumbing (measured, issue #228): BATCHES (a whole node's
|
|
60
|
+
// neighbor list — the 85% cost center) go through the SIMD kernel; pointwise
|
|
61
|
+
// calls (greedy descent, singleton scans, heuristic pairwise) stay on the
|
|
62
|
+
// plain JS dot — a 1-row wasm call pays call overhead exceeding the work.
|
|
63
|
+
// Both paths are IEEE f32 with fixed orders, so the build stays
|
|
64
|
+
// deterministic; recall is insensitive to the last-ulp difference (measured
|
|
65
|
+
// identical across engines in bench/ann-recall.mjs and the #228 tests).
|
|
66
|
+
const f32Vectors = vectors;
|
|
67
|
+
return {
|
|
68
|
+
dist: (a, b) => 1 - jsDotF32(f32Vectors[a], f32Vectors[b]),
|
|
69
|
+
batch: (aIdx, nodes) => {
|
|
70
|
+
const dots = wasmEngine.batch(aIdx, nodes);
|
|
71
|
+
const out = new Float32Array(dots.length);
|
|
72
|
+
for (let j = 0; j < dots.length; j++)
|
|
73
|
+
out[j] = 1 - dots[j];
|
|
74
|
+
return out;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function jsDotF32(a, b) {
|
|
79
|
+
let s = 0;
|
|
80
|
+
const n = a.length;
|
|
81
|
+
const limit = n - 7;
|
|
82
|
+
let i = 0;
|
|
83
|
+
let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0, s5 = 0, s6 = 0, s7 = 0;
|
|
84
|
+
while (i < limit) {
|
|
85
|
+
s0 += a[i] * b[i];
|
|
86
|
+
s1 += a[i + 1] * b[i + 1];
|
|
87
|
+
s2 += a[i + 2] * b[i + 2];
|
|
88
|
+
s3 += a[i + 3] * b[i + 3];
|
|
89
|
+
s4 += a[i + 4] * b[i + 4];
|
|
90
|
+
s5 += a[i + 5] * b[i + 5];
|
|
91
|
+
s6 += a[i + 6] * b[i + 6];
|
|
92
|
+
s7 += a[i + 7] * b[i + 7];
|
|
93
|
+
i += 8;
|
|
94
|
+
}
|
|
95
|
+
s = s0 + s1 + (s2 + s3) + (s4 + s5) + (s6 + s7);
|
|
96
|
+
while (i < n) {
|
|
97
|
+
s += a[i] * b[i];
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
40
102
|
/**
|
|
41
103
|
* Insert node `idx` (vector index) into the graph. Incremental — new indices
|
|
42
104
|
* attach to the existing structure without a rebuild; vectors for nodes NOT
|
|
43
105
|
* yet inserted are read from `vectors` when needed, so the caller can insert
|
|
44
106
|
* lazily. Tombstoned indices can be re-inserted (undeletes).
|
|
45
107
|
*/
|
|
46
|
-
export function insertHnsw(hnsw, idx, distance, vectors) {
|
|
108
|
+
export function insertHnsw(hnsw, idx, distance, vectors, engine) {
|
|
47
109
|
const vec = vectors[idx];
|
|
48
110
|
if (!vec)
|
|
49
111
|
throw new Error(`hnsw insert: no vector at index ${idx}`);
|
|
@@ -64,7 +126,14 @@ export function insertHnsw(hnsw, idx, distance, vectors) {
|
|
|
64
126
|
hnsw.maxLevel = level;
|
|
65
127
|
return;
|
|
66
128
|
}
|
|
67
|
-
|
|
129
|
+
// Issue #228: with a build engine, all pointwise distances go through
|
|
130
|
+
// dist(aIdx, bIdx) so the whole chain shares one arithmetic order; the
|
|
131
|
+
// per-neighbor scan uses engine.batch when available (one wasm call per
|
|
132
|
+
// node's neighbor list instead of one JS call per distance).
|
|
133
|
+
const dist = engine
|
|
134
|
+
? (aIdx, bIdx) => engine.dist(aIdx, bIdx)
|
|
135
|
+
: (aIdx, bIdx) => distance(vectors[aIdx], bIdx);
|
|
136
|
+
const qDist = (node) => dist(idx, node);
|
|
68
137
|
// 1. Greedy descent through layers above the node's level
|
|
69
138
|
let ep = hnsw.entryPoint;
|
|
70
139
|
let epDist = qDist(ep);
|
|
@@ -83,10 +152,12 @@ export function insertHnsw(hnsw, idx, distance, vectors) {
|
|
|
83
152
|
}
|
|
84
153
|
}
|
|
85
154
|
// 2. Insert on each layer from min(level, maxLevel) down to 0
|
|
86
|
-
const pairwise =
|
|
155
|
+
const pairwise = engine
|
|
156
|
+
? (a, b) => engine.dist(a, b)
|
|
157
|
+
: (a, b) => distance(vectors[a], b);
|
|
87
158
|
for (let l = Math.min(level, hnsw.maxLevel); l >= 0; l--) {
|
|
88
159
|
const efC = Math.max(M0, DEFAULT_HNSW_EF);
|
|
89
|
-
const candidates = searchLayer(hnsw, qDist, ep, epDist, l, efC);
|
|
160
|
+
const candidates = searchLayer(hnsw, qDist, ep, epDist, l, efC, engine?.batch, idx);
|
|
90
161
|
const cap = l === 0 ? M0 : M;
|
|
91
162
|
// Algorithm 4: diversity-aware neighbor selection keeps the graph
|
|
92
163
|
// navigable — a candidate is skipped only when it is CLOSER to an
|
|
@@ -121,7 +192,10 @@ export function deleteHnsw(hnsw, idx) {
|
|
|
121
192
|
hnsw.deleted.add(idx);
|
|
122
193
|
}
|
|
123
194
|
/** ef-search one layer, returning the closest `ef` live candidates (closest first). */
|
|
124
|
-
function searchLayer(hnsw, qDist, ep, epDist, layer, ef
|
|
195
|
+
function searchLayer(hnsw, qDist, ep, epDist, layer, ef,
|
|
196
|
+
// issue #228: when present, the unvisited neighbors of each expanded node
|
|
197
|
+
// are measured in ONE batched call (same order → same heap evolution).
|
|
198
|
+
batch, aIdx) {
|
|
125
199
|
const visited = new Set([ep]);
|
|
126
200
|
// max-heap by dist via simple sorted insertion (candidate lists are small)
|
|
127
201
|
const candidates = [{ node: ep, dist: epDist }];
|
|
@@ -131,11 +205,19 @@ function searchLayer(hnsw, qDist, ep, epDist, layer, ef) {
|
|
|
131
205
|
const worst = results.length >= ef ? results[results.length - 1].dist : Infinity;
|
|
132
206
|
if (current.dist > worst)
|
|
133
207
|
break;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
208
|
+
const neighborList = hnsw.neighbors[current.node][layer] ?? [];
|
|
209
|
+
const fresh = [];
|
|
210
|
+
for (const nb of neighborList) {
|
|
211
|
+
if (!visited.has(nb))
|
|
212
|
+
fresh.push(nb);
|
|
213
|
+
}
|
|
214
|
+
let dists = null;
|
|
215
|
+
if (fresh.length > 1 && batch && aIdx !== undefined)
|
|
216
|
+
dists = batch(aIdx, fresh);
|
|
217
|
+
for (let j = 0; j < fresh.length; j++) {
|
|
218
|
+
const nb = fresh[j];
|
|
137
219
|
visited.add(nb);
|
|
138
|
-
const d = qDist(nb);
|
|
220
|
+
const d = dists ? dists[j] : qDist(nb);
|
|
139
221
|
const worstNow = results.length >= ef ? results[results.length - 1].dist : Infinity;
|
|
140
222
|
if (d < worstNow || results.length < ef) {
|
|
141
223
|
candidates.push({ node: nb, dist: d });
|
|
@@ -324,3 +406,75 @@ export function deserializeHnsw(raw, expectedChunksCount) {
|
|
|
324
406
|
}
|
|
325
407
|
return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
|
|
326
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
|
|
411
|
+
* is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
|
|
412
|
+
* carry over because vector reuse is keyed by chunkHash — a surviving chunk
|
|
413
|
+
* keeps its exact vector, so every stored edge distance stays valid.
|
|
414
|
+
* Returns null when the remap cannot produce a navigable graph (e.g. the
|
|
415
|
+
* entry point did not survive) — the caller falls back to a full rebuild.
|
|
416
|
+
*/
|
|
417
|
+
export function remapHnsw(hnsw, mapping, newCount) {
|
|
418
|
+
if (newCount <= 0)
|
|
419
|
+
return null;
|
|
420
|
+
// every new index may receive at most one old node
|
|
421
|
+
const taken = new Set();
|
|
422
|
+
const remapOf = new Map();
|
|
423
|
+
for (let oldIdx = 0; oldIdx < mapping.length; oldIdx++) {
|
|
424
|
+
const newIdx = mapping[oldIdx];
|
|
425
|
+
if (newIdx === undefined || newIdx < 0 || newIdx >= newCount)
|
|
426
|
+
continue;
|
|
427
|
+
if (taken.has(newIdx))
|
|
428
|
+
continue;
|
|
429
|
+
taken.add(newIdx);
|
|
430
|
+
remapOf.set(oldIdx, newIdx);
|
|
431
|
+
}
|
|
432
|
+
// the entry point does NOT need to survive — it re-anchors below
|
|
433
|
+
const levels = new Array(newCount);
|
|
434
|
+
const neighbors = new Array(newCount);
|
|
435
|
+
const deleted = new Set();
|
|
436
|
+
for (const [oldIdx, newIdx] of remapOf) {
|
|
437
|
+
levels[newIdx] = hnsw.levels[oldIdx];
|
|
438
|
+
neighbors[newIdx] = (hnsw.neighbors[oldIdx] ?? []).map((links) => (links ?? [])
|
|
439
|
+
.map((nb) => remapOf.get(nb))
|
|
440
|
+
.filter((nb) => nb !== undefined));
|
|
441
|
+
if (hnsw.deleted.has(oldIdx))
|
|
442
|
+
deleted.add(newIdx);
|
|
443
|
+
}
|
|
444
|
+
// structural holes would break insertHnsw/serialize — unmapped new indices
|
|
445
|
+
// are placeholders the caller fills with insertHnsw immediately after
|
|
446
|
+
for (let i = 0; i < newCount; i++) {
|
|
447
|
+
if (levels[i] === undefined) {
|
|
448
|
+
levels[i] = 0;
|
|
449
|
+
neighbors[i] = [[]];
|
|
450
|
+
deleted.add(i); // invisible until a real node is inserted over it
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// the entry point must sit on the TOP layer: after a partial remap the
|
|
454
|
+
// old entry may carry a lower level, or some survivor may carry a higher
|
|
455
|
+
// one — re-anchor deterministically to the lowest-index survivor at maxLevel
|
|
456
|
+
let maxLevel = -1;
|
|
457
|
+
for (const newIdx of remapOf.values())
|
|
458
|
+
maxLevel = Math.max(maxLevel, levels[newIdx]);
|
|
459
|
+
if (maxLevel < 0)
|
|
460
|
+
return null;
|
|
461
|
+
let entryPoint = -1;
|
|
462
|
+
for (let i = 0; i < newCount; i++) {
|
|
463
|
+
if (!deleted.has(i) && levels[i] === maxLevel) {
|
|
464
|
+
entryPoint = i;
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (entryPoint < 0)
|
|
469
|
+
return null;
|
|
470
|
+
return {
|
|
471
|
+
dim: hnsw.dim,
|
|
472
|
+
M: hnsw.M,
|
|
473
|
+
seed: hnsw.seed,
|
|
474
|
+
entryPoint,
|
|
475
|
+
maxLevel,
|
|
476
|
+
levels,
|
|
477
|
+
neighbors,
|
|
478
|
+
deleted,
|
|
479
|
+
};
|
|
480
|
+
}
|
package/dist/indexer.js
CHANGED
|
@@ -15,7 +15,7 @@ import { validateIndexEnvelope, validateNumericVector } from './index-format.js'
|
|
|
15
15
|
import { analyzeLexicalDocument, buildLexicalIndex, lexicalIdentity, reverseLexicalIndex, validateLexicalIndex, } from './lexical.js';
|
|
16
16
|
import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
|
|
17
17
|
import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
|
|
18
|
-
import { buildHnsw, serializeHnsw } from './hnsw.js';
|
|
18
|
+
import { buildHnsw, insertHnsw, remapHnsw, serializeHnsw, deserializeHnsw } from './hnsw.js';
|
|
19
19
|
import { serializeBinaryIndex } from './binary-format.js';
|
|
20
20
|
import { defaultChunkTokenBudget } from './markdown-parser.js';
|
|
21
21
|
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
|
|
@@ -484,17 +484,52 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
|
|
|
484
484
|
if (buildSidecar) {
|
|
485
485
|
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec)));
|
|
486
486
|
if (annEngine === 'hnsw') {
|
|
487
|
-
const
|
|
488
|
-
vectors
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
487
|
+
const hnswDistance = (vectors) => (a, b) => {
|
|
488
|
+
const bv = vectors[b];
|
|
489
|
+
let dot = 0;
|
|
490
|
+
for (let i = 0; i < a.length; i++)
|
|
491
|
+
dot += a[i] * bv[i];
|
|
492
|
+
return 1 - dot;
|
|
493
|
+
};
|
|
494
|
+
// issue #223: reuse the persisted graph across watch rebuilds — remap
|
|
495
|
+
// surviving nodes onto the new chunk numbering via chunkHash (vector
|
|
496
|
+
// reuse is hash-keyed, so every carried edge distance stays valid),
|
|
497
|
+
// tombstone the gone ones, insert the new ones. Full rebuild only when
|
|
498
|
+
// no valid previous graph exists (first run, engine/budget switch) or
|
|
499
|
+
// the remap cannot anchor an entry point.
|
|
500
|
+
let hnswIndex = null;
|
|
501
|
+
if (!chunkerChanged && oldIndex.chunks.length > 0) {
|
|
502
|
+
try {
|
|
503
|
+
const prev = deserializeHnsw(JSON.parse(fs.readFileSync(hnswPath, 'utf8')), oldIndex.chunks.length);
|
|
504
|
+
if (prev.levels.length === oldIndex.chunks.length) {
|
|
505
|
+
const newIdxByHash = new Map();
|
|
506
|
+
chunks.forEach((c, newIdx) => newIdxByHash.set(c.chunkHash || chunkHash(model, c), newIdx));
|
|
507
|
+
const mapping = oldIndex.chunks.map((c) => newIdxByHash.get(c.chunkHash || chunkHash(model, c)));
|
|
508
|
+
hnswIndex = remapHnsw(prev, mapping, chunks.length);
|
|
509
|
+
if (hnswIndex) {
|
|
510
|
+
const inserted = [];
|
|
511
|
+
for (let newIdx = 0; newIdx < chunks.length; newIdx++) {
|
|
512
|
+
if (hnswIndex.levels[newIdx] !== undefined && !hnswIndex.deleted.has(newIdx))
|
|
513
|
+
continue;
|
|
514
|
+
insertHnsw(hnswIndex, newIdx, hnswDistance(rawVecs), rawVecs);
|
|
515
|
+
inserted.push(newIdx);
|
|
516
|
+
}
|
|
517
|
+
log(`hnsw: reused persisted graph — ` +
|
|
518
|
+
`${chunks.length - inserted.length} surviving node(s), ${inserted.length} inserted`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
hnswIndex = null; // corrupt/foreign sidecar — full rebuild below
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!hnswIndex) {
|
|
527
|
+
// issue #228: 'auto' routes the full build through the WASM SIMD
|
|
528
|
+
// dot-batch kernel when the vectors are a uniform Float32Array matrix
|
|
529
|
+
// (the hnswDistance closure below is exactly 1 − dot, so semantics
|
|
530
|
+
// match); otherwise buildHnsw silently keeps the closure path.
|
|
531
|
+
hnswIndex = buildHnsw(rawVecs, { vectors: rawVecs, distance: hnswDistance(rawVecs), engine: 'auto' });
|
|
532
|
+
}
|
|
498
533
|
atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
|
|
499
534
|
if (fs.existsSync(ivfPath)) {
|
|
500
535
|
try {
|
package/dist/models.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface ModelAdapter {
|
|
|
24
24
|
unknownAdapter?: boolean;
|
|
25
25
|
}
|
|
26
26
|
export declare const MODELS: Record<string, ModelAdapter>;
|
|
27
|
-
export declare const DEFAULT_MODEL = "
|
|
27
|
+
export declare const DEFAULT_MODEL = "granite-multilingual-311m";
|
|
28
28
|
export declare const POOLING: Pooling[];
|
|
29
29
|
export declare function normalizeAdapter(raw: Partial<ModelAdapter> | ModelAdapter): ModelAdapter;
|
|
30
30
|
export declare function resolveModel(name?: string | Partial<ModelAdapter> | ModelAdapter | null, chosenDim?: number): ModelAdapter;
|
package/dist/models.js
CHANGED
|
@@ -102,7 +102,12 @@ export const MODELS = {
|
|
|
102
102
|
note: '~300 MB q8. Google EmbeddingGemma, pre-pooled sentence_embedding output, MRL. Gemma license (not MIT/Apache). Issue #200.',
|
|
103
103
|
},
|
|
104
104
|
};
|
|
105
|
-
|
|
105
|
+
// Issue #227: granite-multilingual-311m is the default — Apache-2.0 (the
|
|
106
|
+
// measured leader embeddinggemma-300m is license-blocked from being an npm
|
|
107
|
+
// default by the Gemma Terms of Use) and measured +0.078 nDCG@10 over e5-base
|
|
108
|
+
// on the 186-query golden set v2 (RESEARCH.md §15). Same size class (~298 MB
|
|
109
|
+
// q8), 8k ModernBERT context. e5-base stays a registered alias.
|
|
110
|
+
export const DEFAULT_MODEL = 'granite-multilingual-311m';
|
|
106
111
|
export const POOLING = ['mean', 'last_token', 'none', 'cls'];
|
|
107
112
|
const POOLING_SET = new Set(POOLING);
|
|
108
113
|
const aliasByRepoId = new Map();
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
export interface WasmDotEngine {
|
|
26
|
+
/** dot(vectors[a], vectors[b]) — one-row batch, same arithmetic as batch(). */
|
|
27
|
+
point(aIdx: number, bIdx: number): number;
|
|
28
|
+
/** result[j] = dot(vectors[aIdx], vectors[nodes[j]]) — a view into wasm memory, valid until the next call. */
|
|
29
|
+
batch(aIdx: number, nodes: number[]): Float32Array;
|
|
30
|
+
readonly dim: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the engine over a uniform Float32Array matrix. Returns null when the
|
|
34
|
+
* runtime cannot compile the SIMD module (then callers keep the JS path).
|
|
35
|
+
*/
|
|
36
|
+
export declare function createWasmDotEngine(vectors: Array<Float32Array>): WasmDotEngine | null;
|
package/dist/wasm-dot.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
/** The compiled `dot_batch` module (see module docstring for provenance). */
|
|
26
|
+
const KERNEL_B64 = 'AGFzbQEAAAABCQFgBX9/f39/AAMCAQAFAwEAAQcTAgNtZW0CAAlkb3RfYmF0Y2gAAAq3AgG0AgQCfwF9An8EeyADQQRsIQlBACEF' +
|
|
27
|
+
'AkADQCAFIAJODQEgASAFQQRsaigCACAJbCEG/QwAAAAAAAAAAAAAAAAAAAAAIQr9DAAAAAAAAAAAAAAAAAAAAAAhC/0MAAAAAAAAA' +
|
|
28
|
+
'AAAAAAAAAAAACEM/QwAAAAAAAAAAAAAAAAAAAAAIQ1DAAAAACEHQQAhCAJAA0AgCEEQaiAJSg0BIAogACAIav0AAgAgBiAIav0A' +
|
|
29
|
+
'AgD95gH95AEhCiAIQRBqIQgMAAsLAkADQCAIIAlODQEgByAAIAhqKgIAIAYgCGoqAgCUkiEHIAhBBGohCAwACwsgBCAFQQRsaiAK' +
|
|
30
|
+
'/R8AIAr9HwGSIAr9HwIgCv0fA5KSIAcgC/0fACAL/R8BkiAL/R8CIAv9HwOSkpKSOAIAIAVBAWohBQwACwsL';
|
|
31
|
+
/** Max nodes per batch call — HNSW neighbor lists cap at M0 = 2*M ≤ 64. */
|
|
32
|
+
const MAX_BATCH = 64;
|
|
33
|
+
/**
|
|
34
|
+
* Build the engine over a uniform Float32Array matrix. Returns null when the
|
|
35
|
+
* runtime cannot compile the SIMD module (then callers keep the JS path).
|
|
36
|
+
*/
|
|
37
|
+
export function createWasmDotEngine(vectors) {
|
|
38
|
+
if (vectors.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const dim = vectors[0].length;
|
|
41
|
+
if (dim === 0)
|
|
42
|
+
return null;
|
|
43
|
+
for (const v of vectors) {
|
|
44
|
+
if (!(v instanceof Float32Array) || v.length !== dim)
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
let kernelBuffer;
|
|
48
|
+
try {
|
|
49
|
+
const bin = Buffer.from(KERNEL_B64, 'base64');
|
|
50
|
+
kernelBuffer = new ArrayBuffer(bin.byteLength);
|
|
51
|
+
new Uint8Array(kernelBuffer).set(bin);
|
|
52
|
+
if (bin.byteLength === 0)
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
let instance;
|
|
59
|
+
try {
|
|
60
|
+
instance = new WebAssembly.Instance(new WebAssembly.Module(kernelBuffer), {});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null; // no SIMD support — caller falls back to the JS closure path
|
|
64
|
+
}
|
|
65
|
+
const exp = instance.exports;
|
|
66
|
+
const mem = exp.mem;
|
|
67
|
+
// Linear memory layout: [vectors row-major][idx scratch u32 × MAX_BATCH][out f32 × MAX_BATCH]
|
|
68
|
+
const vecBytes = vectors.length * dim * 4;
|
|
69
|
+
const idxByte = vecBytes;
|
|
70
|
+
const outByte = idxByte + MAX_BATCH * 4;
|
|
71
|
+
const needBytes = outByte + MAX_BATCH * 4 + 4096;
|
|
72
|
+
const pages = Math.ceil(needBytes / 65536);
|
|
73
|
+
try {
|
|
74
|
+
if (mem.buffer.byteLength < needBytes)
|
|
75
|
+
mem.grow(pages);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
// Views are created AFTER the final grow — growing detaches old buffers.
|
|
81
|
+
const f32 = new Float32Array(mem.buffer);
|
|
82
|
+
const u32 = new Uint32Array(mem.buffer);
|
|
83
|
+
for (let i = 0; i < vectors.length; i++)
|
|
84
|
+
f32.set(vectors[i], i * dim);
|
|
85
|
+
const qByteOf = (aIdx) => {
|
|
86
|
+
if (!Number.isSafeInteger(aIdx) || aIdx < 0 || aIdx >= vectors.length) {
|
|
87
|
+
throw new Error(`wasm-dot: row index out of range: ${aIdx}`);
|
|
88
|
+
}
|
|
89
|
+
return aIdx * dim * 4;
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
dim,
|
|
93
|
+
point(aIdx, bIdx) {
|
|
94
|
+
u32[idxByte >> 2] = bIdx;
|
|
95
|
+
exp.dot_batch(qByteOf(aIdx), idxByte, 1, dim, outByte);
|
|
96
|
+
return f32[outByte >> 2];
|
|
97
|
+
},
|
|
98
|
+
batch(aIdx, nodes) {
|
|
99
|
+
const n = Math.min(nodes.length, MAX_BATCH);
|
|
100
|
+
for (let j = 0; j < n; j++)
|
|
101
|
+
u32[(idxByte >> 2) + j] = nodes[j];
|
|
102
|
+
exp.dot_batch(qByteOf(aIdx), idxByte, n, dim, outByte);
|
|
103
|
+
return f32.subarray(outByte >> 2, (outByte >> 2) + n);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-semantic-search",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|