md-semantic-search 1.0.3 → 2.0.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.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. package/src/wikilinks.ts +220 -1
package/src/indexer.ts CHANGED
@@ -36,8 +36,10 @@ 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
40
  import { DocumentMetadata } from './frontmatter.js';
40
41
  import { serializeBinaryIndex } from './binary-format.js';
42
+ import { defaultChunkTokenBudget } from './markdown-parser.js';
41
43
 
42
44
  export interface IndexChunk {
43
45
  file: string;
@@ -58,6 +60,14 @@ export interface PersistedIndex {
58
60
  model?: string | null;
59
61
  modelAlias?: string;
60
62
  adapterFingerprint?: string;
63
+ /**
64
+ * issue #202: the effective chunk token budget these chunks were built
65
+ * with (null = legacy char budget). A mismatch forces a full re-chunk —
66
+ * otherwise the file-level fast path would keep stale char-budget chunks
67
+ * for unchanged files and produce a mixed index. Identical chunk texts
68
+ * still reuse their cached vectors through chunkHash.
69
+ */
70
+ chunkBudget?: number | null;
61
71
  dim?: number;
62
72
  db?: string;
63
73
  built?: string;
@@ -78,7 +88,17 @@ export const _stats = {
78
88
  embedCount: 0,
79
89
  };
80
90
 
81
- const BATCH = 32;
91
+ // issue #194: batch size is tunable — 64-128 often feeds ONNX noticeably
92
+ // better on short passages; overridable for experiments (clamped 1..512).
93
+ const BATCH = (() => {
94
+ const raw = parseInt(process.env.MDSS_BATCH || '', 10);
95
+ return Number.isSafeInteger(raw) && raw > 0 ? Math.min(raw, 512) : 32;
96
+ })();
97
+ // issue #194: mid-run checkpoints serialize the WHOLE index so far — with
98
+ // faster embedding that cost scales up. Time-budget them (~every 30 s) in
99
+ // addition to the batch cadence, so at most ~30 s of embedding is lost on a
100
+ // crash while serialization stays a small slice of wall time.
101
+ const CHECKPOINT_MIN_INTERVAL_MS = 30_000;
82
102
  const CHECKPOINT_BATCHES = 8;
83
103
  const INDEX_FORMAT = 'binary-v1';
84
104
 
@@ -156,6 +176,7 @@ export interface BuildIndexOptions {
156
176
  indexDir: string;
157
177
  cacheDir: string;
158
178
  modelName: string;
179
+ dimensions?: number;
159
180
  ignore?: string[];
160
181
  offline?: boolean;
161
182
  log?: (s: string) => void;
@@ -164,8 +185,16 @@ export interface BuildIndexOptions {
164
185
  maxRetries?: number;
165
186
  onProgress?: (done: number, total: number, chunksPerSec: number) => void;
166
187
  workers?: number;
167
- ann?: boolean;
188
+ /** issue #201: ANN sidecar engine — `true`/`'ivf'` = ivf.json, `'hnsw'` = hnsw.json. */
189
+ ann?: boolean | 'ivf' | 'hnsw';
168
190
  quantize?: 'fp32' | 'int8';
191
+ /**
192
+ * issue #202: chunk budget in TOKENS (overrides the legacy char budget).
193
+ * Default: min(adapter.maxTokens, 512) — the point of the issue is that the
194
+ * char budget overflowed real maxTokens for Cyrillic. Explicit `0` restores
195
+ * the legacy char behavior.
196
+ */
197
+ maxChunkTokens?: number;
169
198
  }
170
199
 
171
200
  export interface BuildIndexResult {
@@ -187,6 +216,7 @@ export async function buildIndex(opts: BuildIndexOptions): Promise<BuildIndexRes
187
216
  indexDir,
188
217
  cacheDir,
189
218
  modelName,
219
+ dimensions,
190
220
  ignore = [],
191
221
  log = () => {},
192
222
  offline = false,
@@ -197,11 +227,12 @@ export async function buildIndex(opts: BuildIndexOptions): Promise<BuildIndexRes
197
227
  workers = 1,
198
228
  ann = false,
199
229
  quantize = 'fp32',
230
+ maxChunkTokens,
200
231
  } = opts;
201
232
  if (_lockHeld)
202
- return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize });
233
+ return _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize, maxChunkTokens });
203
234
  return withIndexLock(indexDir, () =>
204
- _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize })
235
+ _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize, maxChunkTokens })
205
236
  );
206
237
  }
207
238
 
@@ -210,6 +241,7 @@ async function _buildIndexInner({
210
241
  indexDir,
211
242
  cacheDir,
212
243
  modelName,
244
+ dimensions,
213
245
  ignore = [],
214
246
  log = () => {},
215
247
  offline = false,
@@ -219,13 +251,24 @@ async function _buildIndexInner({
219
251
  workers = 1,
220
252
  ann = false,
221
253
  quantize = 'fp32',
222
- }: Required<Omit<BuildIndexOptions, '_lockHeld' | 'onProgress' | 'workers' | 'ann' | 'quantize'>> & {
254
+ maxChunkTokens = undefined,
255
+ }: Required<Omit<BuildIndexOptions, '_lockHeld' | 'onProgress' | 'workers' | 'ann' | 'quantize' | 'dimensions' | 'maxChunkTokens'>> & {
256
+ dimensions?: number;
223
257
  onProgress?: (done: number, total: number, chunksPerSec: number) => void;
224
258
  workers?: number;
225
- ann?: boolean;
259
+ ann?: boolean | 'ivf' | 'hnsw';
226
260
  quantize?: 'fp32' | 'int8';
261
+ maxChunkTokens?: number;
227
262
  }): Promise<BuildIndexResult> {
228
- const model = resolveModel(modelName);
263
+ const model = resolveModel(modelName, dimensions);
264
+ // issue #202: explicit maxChunkTokens wins; `0` restores the legacy char
265
+ // budget; otherwise min(adapter.maxTokens, 512), undefined → char mode
266
+ const effectiveChunkTokens =
267
+ maxChunkTokens === 0
268
+ ? undefined
269
+ : maxChunkTokens && maxChunkTokens > 0
270
+ ? maxChunkTokens
271
+ : defaultChunkTokenBudget(model);
229
272
  const modelIdentity = `${model.id}@${model.revision || 'main'}`;
230
273
  const adapterFingerprint = embeddingAdapterFingerprint(model);
231
274
  const legacyAdapterFingerprint = legacyEmbeddingAdapterFingerprint(model);
@@ -235,10 +278,15 @@ async function _buildIndexInner({
235
278
  const vectorsPath = path.join(indexDir, 'vectors.json');
236
279
  const hashesPath = path.join(indexDir, '.hashes.json');
237
280
  const checkpointPath = path.join(indexDir, '.checkpoint.json');
281
+ let lastCheckpointAt = 0;
238
282
 
239
283
  fs.mkdirSync(indexDir, { recursive: true });
240
284
 
241
- const files = walkMarkdown(db, ignore);
285
+ // issue #189: unreadable directories shrink the corpus — collect and log
286
+ // them so a "shrinking" index is never published silently
287
+ const walkErrors: string[] = [];
288
+ const files = walkMarkdown(db, ignore, walkErrors);
289
+ for (const we of walkErrors) log(we);
242
290
  const canonicalHashes = loadJSON<Record<string, string>>(hashesPath, {}, log);
243
291
  let canonicalIndex = loadJSON<PersistedIndex>(vectorsPath, { chunks: [] }, log);
244
292
  let canonicalSchema = 0;
@@ -337,6 +385,14 @@ async function _buildIndexInner({
337
385
 
338
386
  const modelChanged = !!oldIndex.model && oldIndex.model !== modelIdentity;
339
387
  const contextVectorsReusable = sourceSchema >= 2 && !modelChanged && adapterCompatible(oldIndex);
388
+ // issue #202: a different chunk budget invalidates the file-level fast path
389
+ // (unchanged files must be re-chunked), NOT the per-chunk vector cache —
390
+ // chunks whose text did not move across the budget boundary re-embed free.
391
+ const chunkBudgetMarker = effectiveChunkTokens ?? null;
392
+ const chunkerChanged = (oldIndex.chunkBudget ?? null) !== chunkBudgetMarker;
393
+ if (chunkerChanged && oldIndex.chunks.length > 0) {
394
+ log('chunk budget changed — re-chunking all files (per-chunk vector cache still applies).');
395
+ }
340
396
  let buildDim = model.dim > 0 ? model.dim : contextVectorsReusable && oldDimensionValid ? oldExpectedDim : undefined;
341
397
  const acceptBuildVector = (vector: unknown) => {
342
398
  const length = validateNumericVector(vector, buildDim, 'embedding vector');
@@ -398,6 +454,7 @@ async function _buildIndexInner({
398
454
  model: modelIdentity,
399
455
  modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
400
456
  adapterFingerprint,
457
+ chunkBudget: chunkBudgetMarker,
401
458
  ...(buildDim === undefined ? {} : { dim: buildDim }),
402
459
  db,
403
460
  built: new Date().toISOString(),
@@ -418,7 +475,7 @@ async function _buildIndexInner({
418
475
  newHashes[rel] = h;
419
476
  if (oldHashes[rel] === h) reusableLexicalRecords = lexicalByFile.get(rel);
420
477
 
421
- if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && oldHashes[rel] === h && oldByFile.has(rel)) {
478
+ if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && !chunkerChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
422
479
  const old = oldByFile.get(rel)!;
423
480
  for (const c of old) {
424
481
  if (!c.chunkHash) c.chunkHash = chunkHash(model, c);
@@ -437,7 +494,7 @@ async function _buildIndexInner({
437
494
  continue;
438
495
  }
439
496
  changedFiles++;
440
- parsed = parseFile(abs, db, undefined, raw);
497
+ parsed = parseFile(abs, db, undefined, raw, effectiveChunkTokens);
441
498
  } catch (e: any) {
442
499
  delete newHashes[rel];
443
500
  skipped++;
@@ -493,7 +550,8 @@ async function _buildIndexInner({
493
550
  c.vec = vecs[j];
494
551
  });
495
552
  completedBatches++;
496
- if (completedBatches % CHECKPOINT_BATCHES === 0) {
553
+ if (completedBatches % CHECKPOINT_BATCHES === 0 && Date.now() - lastCheckpointAt >= CHECKPOINT_MIN_INTERVAL_MS) {
554
+ lastCheckpointAt = Date.now();
497
555
  atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
498
556
  }
499
557
  const done = Math.min(i + BATCH, toEmbed.length);
@@ -539,7 +597,8 @@ async function _buildIndexInner({
539
597
  });
540
598
  completedBatches++;
541
599
  embeddedCount += slice.length;
542
- if (completedBatches % CHECKPOINT_BATCHES === 0) {
600
+ if (completedBatches % CHECKPOINT_BATCHES === 0 && Date.now() - lastCheckpointAt >= CHECKPOINT_MIN_INTERVAL_MS) {
601
+ lastCheckpointAt = Date.now();
543
602
  atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
544
603
  }
545
604
  const elapsedSec = (Date.now() - embedStartTime) / 1000;
@@ -569,6 +628,7 @@ async function _buildIndexInner({
569
628
  model: modelIdentity,
570
629
  modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
571
630
  adapterFingerprint,
631
+ chunkBudget: chunkBudgetMarker,
572
632
  ...(dim === undefined ? {} : { dim }),
573
633
  db,
574
634
  built: new Date().toISOString(),
@@ -589,15 +649,48 @@ async function _buildIndexInner({
589
649
  atomicWrite(vectorsBinPath, binBuffer);
590
650
  }
591
651
 
652
+ // issue #201: ANN sidecar engine — explicit 'hnsw' builds the graph
653
+ // instead of IVF; `true`/undefined/'ivf' keep the historical ivf.json path.
654
+ const annEngine = ann === 'hnsw' ? 'hnsw' : 'ivf';
655
+ const buildSidecar = Boolean(ann) || chunks.length >= ANN_THRESHOLD;
592
656
  const ivfPath = path.join(indexDir, 'ivf.json');
593
- if (ann || chunks.length >= ANN_THRESHOLD) {
657
+ const hnswPath = path.join(indexDir, 'hnsw.json');
658
+ if (buildSidecar) {
594
659
  const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec as number[])));
595
- const ivfIndex = trainIVF(rawVecs);
596
- atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
597
- } else if (fs.existsSync(ivfPath)) {
598
- try {
599
- fs.unlinkSync(ivfPath);
600
- } catch {}
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];
666
+ let dot = 0;
667
+ for (let i = 0; i < a.length; i++) dot += (a as ArrayLike<number>)[i] * (bv as ArrayLike<number>)[i];
668
+ return 1 - dot;
669
+ },
670
+ });
671
+ atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
672
+ if (fs.existsSync(ivfPath)) {
673
+ try {
674
+ fs.unlinkSync(ivfPath);
675
+ } catch {}
676
+ }
677
+ } else {
678
+ const ivfIndex = trainIVF(rawVecs);
679
+ atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
680
+ if (fs.existsSync(hnswPath)) {
681
+ try {
682
+ fs.unlinkSync(hnswPath);
683
+ } catch {}
684
+ }
685
+ }
686
+ } else {
687
+ for (const sidecar of [ivfPath, hnswPath]) {
688
+ if (fs.existsSync(sidecar)) {
689
+ try {
690
+ fs.unlinkSync(sidecar);
691
+ } catch {}
692
+ }
693
+ }
601
694
  }
602
695
 
603
696
  fs.unlinkSync(checkpointPath);
package/src/ivf.ts CHANGED
@@ -4,7 +4,7 @@ export interface IVFIndex {
4
4
  dim: number;
5
5
  k: number;
6
6
  centroids: Float32Array; // Flattened k * dim
7
- clusters: number[][]; // clusters[clusterIndex] = array of chunk indices
7
+ clusters: number[][]; // clusters[clusterIndex] = array of chunk indices
8
8
  }
9
9
 
10
10
  export interface SerializedIVF {
@@ -14,15 +14,36 @@ export interface SerializedIVF {
14
14
  clusters: number[][];
15
15
  }
16
16
 
17
+ export interface TrainIVFOptions {
18
+ k?: number;
19
+ maxIterations?: number;
20
+ seeding?: 'kmeans++' | 'spread';
21
+ seed?: number;
22
+ onMetric?: (metric: { emptyClusters: number; iterations: number }) => void;
23
+ }
24
+
17
25
  export const ANN_THRESHOLD = 500;
18
26
  export const DEFAULT_NPROBE = 8;
19
27
 
20
28
  /**
21
- * Train a Spherical K-Means IVF index over normalized vectors.
29
+ * Fast, deterministic pseudo-random number generator (Mulberry32).
30
+ */
31
+ function mulberry32(seed: number): () => number {
32
+ let s = seed >>> 0;
33
+ return () => {
34
+ s = (s + 0x6d2b79f5) >>> 0;
35
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
36
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Train a Spherical K-Means IVF index over normalized vectors using k-means++ seeding.
22
43
  */
23
44
  export function trainIVF(
24
45
  vectors: Float32Array[],
25
- options: { k?: number; maxIterations?: number } = {}
46
+ options: TrainIVFOptions = {}
26
47
  ): IVFIndex {
27
48
  const n = vectors.length;
28
49
  if (n === 0) {
@@ -32,19 +53,70 @@ export function trainIVF(
32
53
  const targetK = options.k ?? Math.min(256, Math.max(2, Math.floor(Math.sqrt(n))));
33
54
  const k = Math.min(targetK, n);
34
55
  const maxIterations = options.maxIterations ?? 20;
56
+ const seeding = options.seeding ?? 'kmeans++';
35
57
 
36
- // Initialize centroids using deterministic spread or k-means++
37
58
  const centroids = new Float32Array(k * dim);
38
- const step = Math.floor(n / k);
39
- for (let c = 0; c < k; c++) {
40
- const src = vectors[c * step];
41
- centroids.set(src, c * dim);
59
+
60
+ if (seeding === 'spread' || n <= k) {
61
+ // Deterministic spread
62
+ const step = Math.floor(n / k);
63
+ for (let c = 0; c < k; c++) {
64
+ const src = vectors[c * step];
65
+ centroids.set(src, c * dim);
66
+ }
67
+ } else {
68
+ // Deterministic k-means++ seeding with D^2 weighted probability
69
+ const rng = mulberry32(options.seed ?? 42);
70
+ const firstIdx = Math.floor(rng() * n);
71
+ centroids.set(vectors[firstIdx], 0);
72
+
73
+ const minDistSq = new Float64Array(n);
74
+ for (let i = 0; i < n; i++) {
75
+ const cos = cosine(vectors[i], centroids, 0);
76
+ minDistSq[i] = Math.max(0, 1 - cos);
77
+ }
78
+
79
+ for (let c = 1; c < k; c++) {
80
+ let sumDistSq = 0;
81
+ for (let i = 0; i < n; i++) {
82
+ sumDistSq += minDistSq[i];
83
+ }
84
+
85
+ let chosenIdx = -1;
86
+ if (sumDistSq > 1e-12) {
87
+ const target = rng() * sumDistSq;
88
+ let cumulative = 0;
89
+ for (let i = 0; i < n; i++) {
90
+ cumulative += minDistSq[i];
91
+ if (cumulative >= target) {
92
+ chosenIdx = i;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ if (chosenIdx === -1 || chosenIdx >= n) {
98
+ chosenIdx = Math.floor(rng() * n);
99
+ }
100
+
101
+ centroids.set(vectors[chosenIdx], c * dim);
102
+
103
+ for (let i = 0; i < n; i++) {
104
+ const cos = cosine(vectors[i], centroids, c * dim);
105
+ const distSq = Math.max(0, 1 - cos);
106
+ if (distSq < minDistSq[i]) {
107
+ minDistSq[i] = distSq;
108
+ }
109
+ }
110
+ }
42
111
  }
43
112
 
44
113
  let clusters: number[][] = Array.from({ length: k }, () => []);
45
114
  const assignments = new Int32Array(n).fill(-1);
115
+ let totalEmptyClusters = 0;
116
+ let finalIterations = 0;
46
117
 
47
118
  for (let iter = 0; iter < maxIterations; iter++) {
119
+ finalIterations = iter + 1;
48
120
  let changed = 0;
49
121
  const newClusters: number[][] = Array.from({ length: k }, () => []);
50
122
 
@@ -71,11 +143,13 @@ export function trainIVF(
71
143
 
72
144
  clusters = newClusters;
73
145
 
74
- // 2. Recompute centroids
146
+ // 2. Recompute centroids & rescue empty clusters
147
+ let emptyCount = 0;
75
148
  for (let c = 0; c < k; c++) {
76
149
  const members = clusters[c];
77
150
  if (members.length === 0) {
78
- // If empty, reassign to a vector from the largest cluster
151
+ emptyCount++;
152
+ // Reassign to a vector from the largest cluster
79
153
  let largest = 0;
80
154
  for (let j = 1; j < k; j++) {
81
155
  if (clusters[j].length > clusters[largest].length) largest = j;
@@ -111,9 +185,12 @@ export function trainIVF(
111
185
  }
112
186
  }
113
187
 
188
+ totalEmptyClusters = emptyCount;
114
189
  if (changed === 0) break;
115
190
  }
116
191
 
192
+ options.onMetric?.({ emptyClusters: totalEmptyClusters, iterations: finalIterations });
193
+
117
194
  return { dim, k, centroids, clusters };
118
195
  }
119
196
 
@@ -171,20 +248,80 @@ export function serializeIVF(ivf: IVFIndex): SerializedIVF {
171
248
  }
172
249
 
173
250
  /**
174
- * Deserialize JSON object back to IVFIndex.
251
+ * Deserialize JSON object back to IVFIndex with hardened validation.
175
252
  */
176
- export function deserializeIVF(raw: any): IVFIndex {
177
- if (!raw || typeof raw !== 'object') {
178
- throw new Error('invalid IVF payload: expected object');
253
+ export function deserializeIVF(raw: unknown, expectedChunksCount?: number): IVFIndex {
254
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
255
+ throw new Error('invalid IVF payload: expected JSON object — run `mdss index` to rebuild');
179
256
  }
180
- const dim = Number(raw.dim);
181
- const k = Number(raw.k);
182
- if (!Number.isInteger(dim) || dim <= 0 || !Number.isInteger(k) || k <= 0) {
183
- throw new Error('invalid IVF payload: invalid dim or k');
257
+ const obj = raw as Record<string, unknown>;
258
+ const dim = obj.dim;
259
+ const k = obj.k;
260
+ if (
261
+ typeof dim !== 'number' ||
262
+ !Number.isSafeInteger(dim) ||
263
+ dim <= 0 ||
264
+ typeof k !== 'number' ||
265
+ !Number.isSafeInteger(k) ||
266
+ k <= 0
267
+ ) {
268
+ throw new Error('invalid IVF payload: invalid dim or k — run `mdss index` to rebuild');
269
+ }
270
+ if (typeof obj.centroids !== 'string' || obj.centroids.length === 0) {
271
+ throw new Error('invalid IVF payload: centroids must be a base64 string — run `mdss index` to rebuild');
272
+ }
273
+ const buf = Buffer.from(obj.centroids, 'base64');
274
+ if (buf.byteLength % 4 !== 0 || buf.byteLength !== k * dim * 4) {
275
+ throw new Error(
276
+ `invalid IVF payload: centroids buffer has ${buf.byteLength} bytes, expected ${k * dim * 4} bytes — run \`mdss index\` to rebuild`
277
+ );
184
278
  }
185
- const buf = Buffer.from(raw.centroids, 'base64');
186
279
  const centroids = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
187
- const clusters = Array.isArray(raw.clusters) ? raw.clusters : [];
280
+ for (let i = 0; i < centroids.length; i++) {
281
+ if (!Number.isFinite(centroids[i])) {
282
+ throw new Error(`invalid IVF payload: non-finite centroid value at index ${i} — run \`mdss index\` to rebuild`);
283
+ }
284
+ }
285
+
286
+ if (!Array.isArray(obj.clusters)) {
287
+ throw new Error('invalid IVF payload: clusters must be an array — run `mdss index` to rebuild');
288
+ }
289
+ if (obj.clusters.length !== k) {
290
+ throw new Error(
291
+ `invalid IVF payload: clusters array has length ${obj.clusters.length}, expected ${k} — run \`mdss index\` to rebuild`
292
+ );
293
+ }
294
+
295
+ const clusters: number[][] = new Array(k);
296
+ for (let c = 0; c < k; c++) {
297
+ const list = obj.clusters[c];
298
+ if (!Array.isArray(list)) {
299
+ throw new Error(`invalid IVF payload: cluster ${c} must be an array — run \`mdss index\` to rebuild`);
300
+ }
301
+ const validatedCluster: number[] = new Array(list.length);
302
+ const seen = new Set<number>();
303
+ for (let j = 0; j < list.length; j++) {
304
+ const idx = list[j];
305
+ if (typeof idx !== 'number' || !Number.isSafeInteger(idx) || idx < 0) {
306
+ throw new Error(
307
+ `invalid IVF payload: cluster ${c} contains non-integer index "${idx}" — run \`mdss index\` to rebuild`
308
+ );
309
+ }
310
+ if (expectedChunksCount !== undefined && idx >= expectedChunksCount) {
311
+ throw new Error(
312
+ `invalid IVF payload: chunk index ${idx} out of range [0, ${expectedChunksCount}) — run \`mdss index\` to rebuild`
313
+ );
314
+ }
315
+ if (seen.has(idx)) {
316
+ throw new Error(
317
+ `invalid IVF payload: duplicate chunk index ${idx} in cluster ${c} — run \`mdss index\` to rebuild`
318
+ );
319
+ }
320
+ seen.add(idx);
321
+ validatedCluster[j] = idx;
322
+ }
323
+ clusters[c] = validatedCluster;
324
+ }
188
325
 
189
326
  return { dim, k, centroids, clusters };
190
327
  }
package/src/lexical.ts CHANGED
@@ -129,6 +129,85 @@ export function editDistance(a: string, b: string): number {
129
129
  return matrix[alen][blen];
130
130
  }
131
131
 
132
+ /**
133
+ * Damerau-Levenshtein distance with a threshold (issue #193): banded DP that
134
+ * never leaves the |i-j| <= maxDist diagonal and aborts as soon as the whole
135
+ * row exceeds the threshold — O(maxDist × min(alen, blen)) instead of the
136
+ * full O(alen × blen) matrix. Returns the EXACT distance when it is
137
+ * <= maxDist and null otherwise; decisions are identical to
138
+ * `editDistance(a, b) <= maxDist` (optimal paths within the threshold never
139
+ * leave the band).
140
+ */
141
+ export function editDistanceAtMost(a: string, b: string, maxDist: number): number | null {
142
+ if (a === b) return 0;
143
+ const alen = a.length;
144
+ const blen = b.length;
145
+ if (Math.abs(alen - blen) > maxDist) return null;
146
+ if (alen === 0) return blen <= maxDist ? blen : null;
147
+ if (blen === 0) return alen <= maxDist ? alen : null;
148
+ if (maxDist < 0) return null;
149
+
150
+ const INF = maxDist + 1;
151
+ // rows are padded with an INF sentinel column on each side so band-edge
152
+ // lookups (j-1 / j+1) never read a real out-of-band value
153
+ const width = blen + 3;
154
+ const rowA = new Int32Array(width);
155
+ const rowB = new Int32Array(width);
156
+ const rowC = new Int32Array(width);
157
+ let prev2: Int32Array<ArrayBuffer> | null = null; // row i-2 (for transpositions)
158
+ let prev: Int32Array<ArrayBuffer> = rowA;
159
+ let cur: Int32Array<ArrayBuffer> = rowB;
160
+ const scratch: Int32Array<ArrayBuffer> = rowC;
161
+ for (let j = 1; j <= blen + 1; j++) prev[j] = j; // row 0: dp[0][j] = j (col 0 unused sentinel)
162
+ prev[0] = 0;
163
+
164
+ for (let i = 1; i <= alen; i++) {
165
+ const jLo = Math.max(1, i - maxDist);
166
+ const jHi = Math.min(blen, i + maxDist);
167
+ cur[0] = i; // dp[i][0] = i (column 0 inside the band only when i <= maxDist)
168
+ if (i > maxDist) cur[0] = INF;
169
+ let rowMin = cur[0];
170
+ for (let j = jLo; j <= jHi; j++) {
171
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
172
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
173
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1] && prev2 !== null) {
174
+ v = Math.min(v, prev2[j - 2] + cost);
175
+ }
176
+ cur[j] = v;
177
+ if (v < rowMin) rowMin = v;
178
+ }
179
+ // band-out cells stay INF for this row
180
+ for (let j = 1; j <= blen; j++) {
181
+ if (j < jLo || j > jHi) cur[j] = INF;
182
+ }
183
+ if (rowMin > maxDist) return null;
184
+ // three-row rotation: prev2 <- row(i-1), prev <- row(i), cur <- freed row
185
+ const free = prev2 ?? scratch;
186
+ prev2 = prev;
187
+ prev = cur;
188
+ cur = free;
189
+ }
190
+ const d = prev[blen];
191
+ return d <= maxDist ? d : null;
192
+ }
193
+
194
+ /**
195
+ * issue #193: target tokenization memoized per chunk object — the fuzzy scan
196
+ * runs on every query, but a chunk's title/aliases tokenize to the same tokens
197
+ * every time. WeakMap so runtime chunks (stable objects in serve) pay once.
198
+ */
199
+ const fuzzyTargetTokens = new WeakMap<object, string[]>();
200
+
201
+ function targetTokens(chunk: { title: string; meta?: DocumentMetadata }): string[] {
202
+ let tokens = fuzzyTargetTokens.get(chunk);
203
+ if (!tokens) {
204
+ const targets = [chunk.title, ...(chunk.meta?.aliases || [])].filter(Boolean);
205
+ tokens = targets.flatMap((t) => tokenize(t));
206
+ fuzzyTargetTokens.set(chunk, tokens);
207
+ }
208
+ return tokens;
209
+ }
210
+
132
211
  export function fuzzyTitleAliasScores(
133
212
  chunks: Array<{ file?: string; title: string; meta?: DocumentMetadata }>,
134
213
  query: string
@@ -139,21 +218,20 @@ export function fuzzyTitleAliasScores(
139
218
 
140
219
  for (let docId = 0; docId < chunks.length; docId++) {
141
220
  const c = chunks[docId];
142
- const targets = [c.title, ...(c.meta?.aliases || [])].filter(Boolean);
221
+ const tTerms = targetTokens(c);
143
222
  let maxScore = 0;
144
223
 
145
- for (const target of targets) {
146
- const tTerms = tokenize(target);
147
- for (const qt of qTerms) {
148
- if (qt.length < 3) continue;
149
- for (const tt of tTerms) {
150
- if (tt.length < 3) continue;
151
- const maxDist = Math.min(2, Math.floor(qt.length / 3));
152
- const dist = editDistance(qt, tt);
153
- if (dist <= maxDist) {
154
- const similarity = 1 - dist / Math.max(qt.length, tt.length);
155
- if (similarity > maxScore) maxScore = similarity;
156
- }
224
+ for (const qt of qTerms) {
225
+ if (qt.length < 3) continue;
226
+ const maxDist = Math.min(2, Math.floor(qt.length / 3));
227
+ for (const tt of tTerms) {
228
+ if (tt.length < 3) continue;
229
+ // exact same decision as editDistance(qt, tt) <= maxDist, without the
230
+ // full O(len²) matrix (issue #193)
231
+ const dist = editDistanceAtMost(qt, tt, maxDist);
232
+ if (dist !== null) {
233
+ const similarity = 1 - dist / Math.max(qt.length, tt.length);
234
+ if (similarity > maxScore) maxScore = similarity;
157
235
  }
158
236
  }
159
237
  }