md-semantic-search 2.0.0 → 2.1.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 CHANGED
@@ -679,7 +679,7 @@ Add to your `claude_desktop_config.json`:
679
679
  | `--multi-query <n>` | Multi-query expansion (issue #203): N query variants (original + N−1 LLM paraphrases) fused by union-RRF; needs `--llm-endpoint`, degrades silently without it. |
680
680
  | `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
681
681
  | `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
682
- | `--ann [ivf\|hnsw]` | Approximate nearest neighbor search (search; auto-enables for large corpora). Engines (issue #201): `ivf` (k-means, the default sidecar) or `hnsw` (graph — no training phase, 100% measured recall at equal-or-lower query latency, `hnsw.json` sidecar, corrupt → brute-force fallback). Bare `--ann` prefers an existing sidecar. |
682
+ | `--ann [ivf\|hnsw]` | Approximate nearest neighbor search (search; auto-enables for large corpora). Engines (issue #201): `ivf` (k-means, the default sidecar) or `hnsw` (graph — no training phase, 100% measured recall at equal-or-lower query latency, `hnsw.json` sidecar, corrupt → brute-force fallback; watch rebuilds reuse the persisted graph via chunk-hash remapping instead of rebuilding it — issue #223). Bare `--ann` prefers an existing sidecar. |
683
683
  | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
684
684
  | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
685
685
  | `--host <ip>` | Bind address for `serve` (default `127.0.0.1` — loopback only; use `0.0.0.0` to expose on the LAN, or `MDSS_HOST`). |
package/RESEARCH.md CHANGED
@@ -421,8 +421,12 @@ corpora keeps **IVF as the default engine**; the criterion's second branch
421
421
  2. Bare `--ann` and the automatic threshold path stay on IVF until either
422
422
  (a) the JS graph build gets an order of magnitude cheaper (worker
423
423
  parallelism, WASM SIMD — see §13), or (b) incremental watch rebuilds
424
- start reusing the persisted graph (the `insertHnsw`/`deleteHnsw` API is
425
- already in place for that).
424
+ start reusing the persisted graph. **(b) is now implemented (issue
425
+ #223):** a watch rebuild remaps the persisted graph onto the new chunk
426
+ numbering via `chunkHash` (`remapHnsw` — vector reuse is hash-keyed, so
427
+ every carried edge distance stays valid), tombstones the gone nodes and
428
+ inserts only the new ones; the build-side gap now applies to FULL builds
429
+ only (first run, engine/budget switch, entry-point-unanchorable remap).
426
430
  3. `--ef <n>` tunes the HNSW search breadth (default 64; recall saturates
427
431
  at 100% already at ef 16 on the bench corpora — the default leaves
428
432
  headroom for noisier real-world distributions).
package/dist/hnsw.d.ts CHANGED
@@ -94,3 +94,12 @@ export declare function serializeHnsw(hnsw: HnswIndex): SerializedHnsw;
94
94
  * falls back to brute force).
95
95
  */
96
96
  export declare function deserializeHnsw(raw: unknown, expectedChunksCount?: number): HnswIndex;
97
+ /**
98
+ * issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
99
+ * is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
100
+ * carry over because vector reuse is keyed by chunkHash — a surviving chunk
101
+ * keeps its exact vector, so every stored edge distance stays valid.
102
+ * Returns null when the remap cannot produce a navigable graph (e.g. the
103
+ * entry point did not survive) — the caller falls back to a full rebuild.
104
+ */
105
+ export declare function remapHnsw(hnsw: HnswIndex, mapping: Array<number | undefined>, newCount: number): HnswIndex | null;
package/dist/hnsw.js CHANGED
@@ -324,3 +324,75 @@ export function deserializeHnsw(raw, expectedChunksCount) {
324
324
  }
325
325
  return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
326
326
  }
327
+ /**
328
+ * issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
329
+ * is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
330
+ * carry over because vector reuse is keyed by chunkHash — a surviving chunk
331
+ * keeps its exact vector, so every stored edge distance stays valid.
332
+ * Returns null when the remap cannot produce a navigable graph (e.g. the
333
+ * entry point did not survive) — the caller falls back to a full rebuild.
334
+ */
335
+ export function remapHnsw(hnsw, mapping, newCount) {
336
+ if (newCount <= 0)
337
+ return null;
338
+ // every new index may receive at most one old node
339
+ const taken = new Set();
340
+ const remapOf = new Map();
341
+ for (let oldIdx = 0; oldIdx < mapping.length; oldIdx++) {
342
+ const newIdx = mapping[oldIdx];
343
+ if (newIdx === undefined || newIdx < 0 || newIdx >= newCount)
344
+ continue;
345
+ if (taken.has(newIdx))
346
+ continue;
347
+ taken.add(newIdx);
348
+ remapOf.set(oldIdx, newIdx);
349
+ }
350
+ // the entry point does NOT need to survive — it re-anchors below
351
+ const levels = new Array(newCount);
352
+ const neighbors = new Array(newCount);
353
+ const deleted = new Set();
354
+ for (const [oldIdx, newIdx] of remapOf) {
355
+ levels[newIdx] = hnsw.levels[oldIdx];
356
+ neighbors[newIdx] = (hnsw.neighbors[oldIdx] ?? []).map((links) => (links ?? [])
357
+ .map((nb) => remapOf.get(nb))
358
+ .filter((nb) => nb !== undefined));
359
+ if (hnsw.deleted.has(oldIdx))
360
+ deleted.add(newIdx);
361
+ }
362
+ // structural holes would break insertHnsw/serialize — unmapped new indices
363
+ // are placeholders the caller fills with insertHnsw immediately after
364
+ for (let i = 0; i < newCount; i++) {
365
+ if (levels[i] === undefined) {
366
+ levels[i] = 0;
367
+ neighbors[i] = [[]];
368
+ deleted.add(i); // invisible until a real node is inserted over it
369
+ }
370
+ }
371
+ // the entry point must sit on the TOP layer: after a partial remap the
372
+ // old entry may carry a lower level, or some survivor may carry a higher
373
+ // one — re-anchor deterministically to the lowest-index survivor at maxLevel
374
+ let maxLevel = -1;
375
+ for (const newIdx of remapOf.values())
376
+ maxLevel = Math.max(maxLevel, levels[newIdx]);
377
+ if (maxLevel < 0)
378
+ return null;
379
+ let entryPoint = -1;
380
+ for (let i = 0; i < newCount; i++) {
381
+ if (!deleted.has(i) && levels[i] === maxLevel) {
382
+ entryPoint = i;
383
+ break;
384
+ }
385
+ }
386
+ if (entryPoint < 0)
387
+ return null;
388
+ return {
389
+ dim: hnsw.dim,
390
+ M: hnsw.M,
391
+ seed: hnsw.seed,
392
+ entryPoint,
393
+ maxLevel,
394
+ levels,
395
+ neighbors,
396
+ deleted,
397
+ };
398
+ }
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,48 @@ 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
+ hnswIndex = buildHnsw(rawVecs, { vectors: rawVecs, distance: hnswDistance(rawVecs) });
528
+ }
498
529
  atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
499
530
  if (fs.existsSync(ivfPath)) {
500
531
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-semantic-search",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
package/src/hnsw.ts CHANGED
@@ -412,3 +412,79 @@ export function deserializeHnsw(raw: unknown, expectedChunksCount?: number): Hns
412
412
  }
413
413
  return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
414
414
  }
415
+
416
+ /**
417
+ * issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
418
+ * is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
419
+ * carry over because vector reuse is keyed by chunkHash — a surviving chunk
420
+ * keeps its exact vector, so every stored edge distance stays valid.
421
+ * Returns null when the remap cannot produce a navigable graph (e.g. the
422
+ * entry point did not survive) — the caller falls back to a full rebuild.
423
+ */
424
+ export function remapHnsw(
425
+ hnsw: HnswIndex,
426
+ mapping: Array<number | undefined>,
427
+ newCount: number,
428
+ ): HnswIndex | null {
429
+ if (newCount <= 0) return null;
430
+ // every new index may receive at most one old node
431
+ const taken = new Set<number>();
432
+ const remapOf = new Map<number, number>();
433
+ for (let oldIdx = 0; oldIdx < mapping.length; oldIdx++) {
434
+ const newIdx = mapping[oldIdx];
435
+ if (newIdx === undefined || newIdx < 0 || newIdx >= newCount) continue;
436
+ if (taken.has(newIdx)) continue;
437
+ taken.add(newIdx);
438
+ remapOf.set(oldIdx, newIdx);
439
+ }
440
+ // the entry point does NOT need to survive — it re-anchors below
441
+
442
+ const levels: number[] = new Array(newCount);
443
+ const neighbors: number[][][] = new Array(newCount);
444
+ const deleted = new Set<number>();
445
+
446
+ for (const [oldIdx, newIdx] of remapOf) {
447
+ levels[newIdx] = hnsw.levels[oldIdx];
448
+ neighbors[newIdx] = (hnsw.neighbors[oldIdx] ?? []).map((links) =>
449
+ (links ?? [])
450
+ .map((nb) => remapOf.get(nb))
451
+ .filter((nb): nb is number => nb !== undefined),
452
+ );
453
+ if (hnsw.deleted.has(oldIdx)) deleted.add(newIdx);
454
+ }
455
+ // structural holes would break insertHnsw/serialize — unmapped new indices
456
+ // are placeholders the caller fills with insertHnsw immediately after
457
+ for (let i = 0; i < newCount; i++) {
458
+ if (levels[i] === undefined) {
459
+ levels[i] = 0;
460
+ neighbors[i] = [[]];
461
+ deleted.add(i); // invisible until a real node is inserted over it
462
+ }
463
+ }
464
+
465
+ // the entry point must sit on the TOP layer: after a partial remap the
466
+ // old entry may carry a lower level, or some survivor may carry a higher
467
+ // one — re-anchor deterministically to the lowest-index survivor at maxLevel
468
+ let maxLevel = -1;
469
+ for (const newIdx of remapOf.values()) maxLevel = Math.max(maxLevel, levels[newIdx]);
470
+ if (maxLevel < 0) return null;
471
+ let entryPoint = -1;
472
+ for (let i = 0; i < newCount; i++) {
473
+ if (!deleted.has(i) && levels[i] === maxLevel) {
474
+ entryPoint = i;
475
+ break;
476
+ }
477
+ }
478
+ if (entryPoint < 0) return null;
479
+
480
+ return {
481
+ dim: hnsw.dim,
482
+ M: hnsw.M,
483
+ seed: hnsw.seed,
484
+ entryPoint,
485
+ maxLevel,
486
+ levels,
487
+ neighbors,
488
+ deleted,
489
+ };
490
+ }
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,51 @@ 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 hnswIndex: HnswIndex = buildHnsw(rawVecs, {
662
- vectors: rawVecs,
663
- // cosine distance (lower = closer) over the flat normalized vectors
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
+ hnswIndex = buildHnsw(rawVecs, { vectors: rawVecs, distance: hnswDistance(rawVecs) });
705
+ }
671
706
  atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
672
707
  if (fs.existsSync(ivfPath)) {
673
708
  try {