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/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
- const qDist = (node) => distance(vec, node);
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 = (a, b) => distance(vectors[a], b);
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
- for (const nb of hnsw.neighbors[current.node][layer] ?? []) {
135
- if (visited.has(nb))
136
- continue;
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 hnswIndex = buildHnsw(rawVecs, {
488
- vectors: rawVecs,
489
- // cosine distance (lower = closer) over the flat normalized vectors
490
- distance: (a, b) => {
491
- const bv = rawVecs[b];
492
- let dot = 0;
493
- for (let i = 0; i < a.length; i++)
494
- dot += a[i] * bv[i];
495
- return 1 - dot;
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 = "e5-base";
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
- export const DEFAULT_MODEL = 'e5-base';
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;
@@ -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.0.0",
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",