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/dist/indexer.js CHANGED
@@ -15,7 +15,9 @@ 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
19
  import { serializeBinaryIndex } from './binary-format.js';
20
+ import { defaultChunkTokenBudget } from './markdown-parser.js';
19
21
  const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
20
22
  const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
21
23
  export const _stats = {
@@ -23,7 +25,17 @@ export const _stats = {
23
25
  fileHits: 0,
24
26
  embedCount: 0,
25
27
  };
26
- const BATCH = 32;
28
+ // issue #194: batch size is tunable — 64-128 often feeds ONNX noticeably
29
+ // better on short passages; overridable for experiments (clamped 1..512).
30
+ const BATCH = (() => {
31
+ const raw = parseInt(process.env.MDSS_BATCH || '', 10);
32
+ return Number.isSafeInteger(raw) && raw > 0 ? Math.min(raw, 512) : 32;
33
+ })();
34
+ // issue #194: mid-run checkpoints serialize the WHOLE index so far — with
35
+ // faster embedding that cost scales up. Time-budget them (~every 30 s) in
36
+ // addition to the batch cadence, so at most ~30 s of embedding is lost on a
37
+ // crash while serialization stays a small slice of wall time.
38
+ const CHECKPOINT_MIN_INTERVAL_MS = 30_000;
27
39
  const CHECKPOINT_BATCHES = 8;
28
40
  const INDEX_FORMAT = 'binary-v1';
29
41
  function isStringRecord(value) {
@@ -79,13 +91,20 @@ export function chunkHash(model, chunk) {
79
91
  return sha256(input);
80
92
  }
81
93
  export async function buildIndex(opts) {
82
- const { db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', } = opts;
94
+ const { db, indexDir, cacheDir, modelName, dimensions, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', maxChunkTokens, } = opts;
83
95
  if (_lockHeld)
84
- return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize });
85
- return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize }));
96
+ return _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize, maxChunkTokens });
97
+ return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize, maxChunkTokens }));
86
98
  }
87
- async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', }) {
88
- const model = resolveModel(modelName);
99
+ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', maxChunkTokens = undefined, }) {
100
+ const model = resolveModel(modelName, dimensions);
101
+ // issue #202: explicit maxChunkTokens wins; `0` restores the legacy char
102
+ // budget; otherwise min(adapter.maxTokens, 512), undefined → char mode
103
+ const effectiveChunkTokens = maxChunkTokens === 0
104
+ ? undefined
105
+ : maxChunkTokens && maxChunkTokens > 0
106
+ ? maxChunkTokens
107
+ : defaultChunkTokenBudget(model);
89
108
  const modelIdentity = `${model.id}@${model.revision || 'main'}`;
90
109
  const adapterFingerprint = embeddingAdapterFingerprint(model);
91
110
  const legacyAdapterFingerprint = legacyEmbeddingAdapterFingerprint(model);
@@ -94,8 +113,14 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
94
113
  const vectorsPath = path.join(indexDir, 'vectors.json');
95
114
  const hashesPath = path.join(indexDir, '.hashes.json');
96
115
  const checkpointPath = path.join(indexDir, '.checkpoint.json');
116
+ let lastCheckpointAt = 0;
97
117
  fs.mkdirSync(indexDir, { recursive: true });
98
- const files = walkMarkdown(db, ignore);
118
+ // issue #189: unreadable directories shrink the corpus — collect and log
119
+ // them so a "shrinking" index is never published silently
120
+ const walkErrors = [];
121
+ const files = walkMarkdown(db, ignore, walkErrors);
122
+ for (const we of walkErrors)
123
+ log(we);
99
124
  const canonicalHashes = loadJSON(hashesPath, {}, log);
100
125
  let canonicalIndex = loadJSON(vectorsPath, { chunks: [] }, log);
101
126
  let canonicalSchema = 0;
@@ -194,6 +219,14 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
194
219
  }
195
220
  const modelChanged = !!oldIndex.model && oldIndex.model !== modelIdentity;
196
221
  const contextVectorsReusable = sourceSchema >= 2 && !modelChanged && adapterCompatible(oldIndex);
222
+ // issue #202: a different chunk budget invalidates the file-level fast path
223
+ // (unchanged files must be re-chunked), NOT the per-chunk vector cache —
224
+ // chunks whose text did not move across the budget boundary re-embed free.
225
+ const chunkBudgetMarker = effectiveChunkTokens ?? null;
226
+ const chunkerChanged = (oldIndex.chunkBudget ?? null) !== chunkBudgetMarker;
227
+ if (chunkerChanged && oldIndex.chunks.length > 0) {
228
+ log('chunk budget changed — re-chunking all files (per-chunk vector cache still applies).');
229
+ }
197
230
  let buildDim = model.dim > 0 ? model.dim : contextVectorsReusable && oldDimensionValid ? oldExpectedDim : undefined;
198
231
  const acceptBuildVector = (vector) => {
199
232
  const length = validateNumericVector(vector, buildDim, 'embedding vector');
@@ -250,6 +283,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
250
283
  model: modelIdentity,
251
284
  modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
252
285
  adapterFingerprint,
286
+ chunkBudget: chunkBudgetMarker,
253
287
  ...(buildDim === undefined ? {} : { dim: buildDim }),
254
288
  db,
255
289
  built: new Date().toISOString(),
@@ -269,7 +303,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
269
303
  newHashes[rel] = h;
270
304
  if (oldHashes[rel] === h)
271
305
  reusableLexicalRecords = lexicalByFile.get(rel);
272
- if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && oldHashes[rel] === h && oldByFile.has(rel)) {
306
+ if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && !chunkerChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
273
307
  const old = oldByFile.get(rel);
274
308
  for (const c of old) {
275
309
  if (!c.chunkHash)
@@ -290,7 +324,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
290
324
  continue;
291
325
  }
292
326
  changedFiles++;
293
- parsed = parseFile(abs, db, undefined, raw);
327
+ parsed = parseFile(abs, db, undefined, raw, effectiveChunkTokens);
294
328
  }
295
329
  catch (e) {
296
330
  delete newHashes[rel];
@@ -347,7 +381,8 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
347
381
  c.vec = vecs[j];
348
382
  });
349
383
  completedBatches++;
350
- if (completedBatches % CHECKPOINT_BATCHES === 0) {
384
+ if (completedBatches % CHECKPOINT_BATCHES === 0 && Date.now() - lastCheckpointAt >= CHECKPOINT_MIN_INTERVAL_MS) {
385
+ lastCheckpointAt = Date.now();
351
386
  atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
352
387
  }
353
388
  const done = Math.min(i + BATCH, toEmbed.length);
@@ -393,7 +428,8 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
393
428
  });
394
429
  completedBatches++;
395
430
  embeddedCount += slice.length;
396
- if (completedBatches % CHECKPOINT_BATCHES === 0) {
431
+ if (completedBatches % CHECKPOINT_BATCHES === 0 && Date.now() - lastCheckpointAt >= CHECKPOINT_MIN_INTERVAL_MS) {
432
+ lastCheckpointAt = Date.now();
397
433
  atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
398
434
  }
399
435
  const elapsedSec = (Date.now() - embedStartTime) / 1000;
@@ -422,6 +458,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
422
458
  model: modelIdentity,
423
459
  modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
424
460
  adapterFingerprint,
461
+ chunkBudget: chunkBudgetMarker,
425
462
  ...(dim === undefined ? {} : { dim }),
426
463
  db,
427
464
  built: new Date().toISOString(),
@@ -438,17 +475,54 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
438
475
  const binBuffer = serializeBinaryIndex(index, dim, { quantize });
439
476
  atomicWrite(vectorsBinPath, binBuffer);
440
477
  }
478
+ // issue #201: ANN sidecar engine — explicit 'hnsw' builds the graph
479
+ // instead of IVF; `true`/undefined/'ivf' keep the historical ivf.json path.
480
+ const annEngine = ann === 'hnsw' ? 'hnsw' : 'ivf';
481
+ const buildSidecar = Boolean(ann) || chunks.length >= ANN_THRESHOLD;
441
482
  const ivfPath = path.join(indexDir, 'ivf.json');
442
- if (ann || chunks.length >= ANN_THRESHOLD) {
483
+ const hnswPath = path.join(indexDir, 'hnsw.json');
484
+ if (buildSidecar) {
443
485
  const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec)));
444
- const ivfIndex = trainIVF(rawVecs);
445
- atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
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
+ });
498
+ atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
499
+ if (fs.existsSync(ivfPath)) {
500
+ try {
501
+ fs.unlinkSync(ivfPath);
502
+ }
503
+ catch { }
504
+ }
505
+ }
506
+ else {
507
+ const ivfIndex = trainIVF(rawVecs);
508
+ atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
509
+ if (fs.existsSync(hnswPath)) {
510
+ try {
511
+ fs.unlinkSync(hnswPath);
512
+ }
513
+ catch { }
514
+ }
515
+ }
446
516
  }
447
- else if (fs.existsSync(ivfPath)) {
448
- try {
449
- fs.unlinkSync(ivfPath);
517
+ else {
518
+ for (const sidecar of [ivfPath, hnswPath]) {
519
+ if (fs.existsSync(sidecar)) {
520
+ try {
521
+ fs.unlinkSync(sidecar);
522
+ }
523
+ catch { }
524
+ }
450
525
  }
451
- catch { }
452
526
  }
453
527
  fs.unlinkSync(checkpointPath);
454
528
  return {
package/dist/ivf.d.ts CHANGED
@@ -10,15 +10,22 @@ export interface SerializedIVF {
10
10
  centroids: string;
11
11
  clusters: number[][];
12
12
  }
13
+ export interface TrainIVFOptions {
14
+ k?: number;
15
+ maxIterations?: number;
16
+ seeding?: 'kmeans++' | 'spread';
17
+ seed?: number;
18
+ onMetric?: (metric: {
19
+ emptyClusters: number;
20
+ iterations: number;
21
+ }) => void;
22
+ }
13
23
  export declare const ANN_THRESHOLD = 500;
14
24
  export declare const DEFAULT_NPROBE = 8;
15
25
  /**
16
- * Train a Spherical K-Means IVF index over normalized vectors.
26
+ * Train a Spherical K-Means IVF index over normalized vectors using k-means++ seeding.
17
27
  */
18
- export declare function trainIVF(vectors: Float32Array[], options?: {
19
- k?: number;
20
- maxIterations?: number;
21
- }): IVFIndex;
28
+ export declare function trainIVF(vectors: Float32Array[], options?: TrainIVFOptions): IVFIndex;
22
29
  /**
23
30
  * Probe top-nprobe closest centroids and return candidate chunk indices.
24
31
  */
@@ -28,6 +35,6 @@ export declare function searchIVFCandidates(queryVec: Float32Array, ivf: IVFInde
28
35
  */
29
36
  export declare function serializeIVF(ivf: IVFIndex): SerializedIVF;
30
37
  /**
31
- * Deserialize JSON object back to IVFIndex.
38
+ * Deserialize JSON object back to IVFIndex with hardened validation.
32
39
  */
33
- export declare function deserializeIVF(raw: any): IVFIndex;
40
+ export declare function deserializeIVF(raw: unknown, expectedChunksCount?: number): IVFIndex;
package/dist/ivf.js CHANGED
@@ -2,7 +2,19 @@ import { cosine } from './core.js';
2
2
  export const ANN_THRESHOLD = 500;
3
3
  export const DEFAULT_NPROBE = 8;
4
4
  /**
5
- * Train a Spherical K-Means IVF index over normalized vectors.
5
+ * Fast, deterministic pseudo-random number generator (Mulberry32).
6
+ */
7
+ function mulberry32(seed) {
8
+ let s = seed >>> 0;
9
+ return () => {
10
+ s = (s + 0x6d2b79f5) >>> 0;
11
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
12
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
13
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
14
+ };
15
+ }
16
+ /**
17
+ * Train a Spherical K-Means IVF index over normalized vectors using k-means++ seeding.
6
18
  */
7
19
  export function trainIVF(vectors, options = {}) {
8
20
  const n = vectors.length;
@@ -13,16 +25,62 @@ export function trainIVF(vectors, options = {}) {
13
25
  const targetK = options.k ?? Math.min(256, Math.max(2, Math.floor(Math.sqrt(n))));
14
26
  const k = Math.min(targetK, n);
15
27
  const maxIterations = options.maxIterations ?? 20;
16
- // Initialize centroids using deterministic spread or k-means++
28
+ const seeding = options.seeding ?? 'kmeans++';
17
29
  const centroids = new Float32Array(k * dim);
18
- const step = Math.floor(n / k);
19
- for (let c = 0; c < k; c++) {
20
- const src = vectors[c * step];
21
- centroids.set(src, c * dim);
30
+ if (seeding === 'spread' || n <= k) {
31
+ // Deterministic spread
32
+ const step = Math.floor(n / k);
33
+ for (let c = 0; c < k; c++) {
34
+ const src = vectors[c * step];
35
+ centroids.set(src, c * dim);
36
+ }
37
+ }
38
+ else {
39
+ // Deterministic k-means++ seeding with D^2 weighted probability
40
+ const rng = mulberry32(options.seed ?? 42);
41
+ const firstIdx = Math.floor(rng() * n);
42
+ centroids.set(vectors[firstIdx], 0);
43
+ const minDistSq = new Float64Array(n);
44
+ for (let i = 0; i < n; i++) {
45
+ const cos = cosine(vectors[i], centroids, 0);
46
+ minDistSq[i] = Math.max(0, 1 - cos);
47
+ }
48
+ for (let c = 1; c < k; c++) {
49
+ let sumDistSq = 0;
50
+ for (let i = 0; i < n; i++) {
51
+ sumDistSq += minDistSq[i];
52
+ }
53
+ let chosenIdx = -1;
54
+ if (sumDistSq > 1e-12) {
55
+ const target = rng() * sumDistSq;
56
+ let cumulative = 0;
57
+ for (let i = 0; i < n; i++) {
58
+ cumulative += minDistSq[i];
59
+ if (cumulative >= target) {
60
+ chosenIdx = i;
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ if (chosenIdx === -1 || chosenIdx >= n) {
66
+ chosenIdx = Math.floor(rng() * n);
67
+ }
68
+ centroids.set(vectors[chosenIdx], c * dim);
69
+ for (let i = 0; i < n; i++) {
70
+ const cos = cosine(vectors[i], centroids, c * dim);
71
+ const distSq = Math.max(0, 1 - cos);
72
+ if (distSq < minDistSq[i]) {
73
+ minDistSq[i] = distSq;
74
+ }
75
+ }
76
+ }
22
77
  }
23
78
  let clusters = Array.from({ length: k }, () => []);
24
79
  const assignments = new Int32Array(n).fill(-1);
80
+ let totalEmptyClusters = 0;
81
+ let finalIterations = 0;
25
82
  for (let iter = 0; iter < maxIterations; iter++) {
83
+ finalIterations = iter + 1;
26
84
  let changed = 0;
27
85
  const newClusters = Array.from({ length: k }, () => []);
28
86
  // 1. Assign each vector to closest centroid (highest cosine / dot product)
@@ -44,11 +102,13 @@ export function trainIVF(vectors, options = {}) {
44
102
  newClusters[bestCluster].push(i);
45
103
  }
46
104
  clusters = newClusters;
47
- // 2. Recompute centroids
105
+ // 2. Recompute centroids & rescue empty clusters
106
+ let emptyCount = 0;
48
107
  for (let c = 0; c < k; c++) {
49
108
  const members = clusters[c];
50
109
  if (members.length === 0) {
51
- // If empty, reassign to a vector from the largest cluster
110
+ emptyCount++;
111
+ // Reassign to a vector from the largest cluster
52
112
  let largest = 0;
53
113
  for (let j = 1; j < k; j++) {
54
114
  if (clusters[j].length > clusters[largest].length)
@@ -82,9 +142,11 @@ export function trainIVF(vectors, options = {}) {
82
142
  centroids[cOffset + d] /= norm;
83
143
  }
84
144
  }
145
+ totalEmptyClusters = emptyCount;
85
146
  if (changed === 0)
86
147
  break;
87
148
  }
149
+ options.onMetric?.({ emptyClusters: totalEmptyClusters, iterations: finalIterations });
88
150
  return { dim, k, centroids, clusters };
89
151
  }
90
152
  /**
@@ -126,19 +188,65 @@ export function serializeIVF(ivf) {
126
188
  };
127
189
  }
128
190
  /**
129
- * Deserialize JSON object back to IVFIndex.
191
+ * Deserialize JSON object back to IVFIndex with hardened validation.
130
192
  */
131
- export function deserializeIVF(raw) {
132
- if (!raw || typeof raw !== 'object') {
133
- throw new Error('invalid IVF payload: expected object');
193
+ export function deserializeIVF(raw, expectedChunksCount) {
194
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
195
+ throw new Error('invalid IVF payload: expected JSON object — run `mdss index` to rebuild');
196
+ }
197
+ const obj = raw;
198
+ const dim = obj.dim;
199
+ const k = obj.k;
200
+ if (typeof dim !== 'number' ||
201
+ !Number.isSafeInteger(dim) ||
202
+ dim <= 0 ||
203
+ typeof k !== 'number' ||
204
+ !Number.isSafeInteger(k) ||
205
+ k <= 0) {
206
+ throw new Error('invalid IVF payload: invalid dim or k — run `mdss index` to rebuild');
207
+ }
208
+ if (typeof obj.centroids !== 'string' || obj.centroids.length === 0) {
209
+ throw new Error('invalid IVF payload: centroids must be a base64 string — run `mdss index` to rebuild');
134
210
  }
135
- const dim = Number(raw.dim);
136
- const k = Number(raw.k);
137
- if (!Number.isInteger(dim) || dim <= 0 || !Number.isInteger(k) || k <= 0) {
138
- throw new Error('invalid IVF payload: invalid dim or k');
211
+ const buf = Buffer.from(obj.centroids, 'base64');
212
+ if (buf.byteLength % 4 !== 0 || buf.byteLength !== k * dim * 4) {
213
+ throw new Error(`invalid IVF payload: centroids buffer has ${buf.byteLength} bytes, expected ${k * dim * 4} bytes — run \`mdss index\` to rebuild`);
139
214
  }
140
- const buf = Buffer.from(raw.centroids, 'base64');
141
215
  const centroids = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
142
- const clusters = Array.isArray(raw.clusters) ? raw.clusters : [];
216
+ for (let i = 0; i < centroids.length; i++) {
217
+ if (!Number.isFinite(centroids[i])) {
218
+ throw new Error(`invalid IVF payload: non-finite centroid value at index ${i} — run \`mdss index\` to rebuild`);
219
+ }
220
+ }
221
+ if (!Array.isArray(obj.clusters)) {
222
+ throw new Error('invalid IVF payload: clusters must be an array — run `mdss index` to rebuild');
223
+ }
224
+ if (obj.clusters.length !== k) {
225
+ throw new Error(`invalid IVF payload: clusters array has length ${obj.clusters.length}, expected ${k} — run \`mdss index\` to rebuild`);
226
+ }
227
+ const clusters = new Array(k);
228
+ for (let c = 0; c < k; c++) {
229
+ const list = obj.clusters[c];
230
+ if (!Array.isArray(list)) {
231
+ throw new Error(`invalid IVF payload: cluster ${c} must be an array — run \`mdss index\` to rebuild`);
232
+ }
233
+ const validatedCluster = new Array(list.length);
234
+ const seen = new Set();
235
+ for (let j = 0; j < list.length; j++) {
236
+ const idx = list[j];
237
+ if (typeof idx !== 'number' || !Number.isSafeInteger(idx) || idx < 0) {
238
+ throw new Error(`invalid IVF payload: cluster ${c} contains non-integer index "${idx}" — run \`mdss index\` to rebuild`);
239
+ }
240
+ if (expectedChunksCount !== undefined && idx >= expectedChunksCount) {
241
+ throw new Error(`invalid IVF payload: chunk index ${idx} out of range [0, ${expectedChunksCount}) — run \`mdss index\` to rebuild`);
242
+ }
243
+ if (seen.has(idx)) {
244
+ throw new Error(`invalid IVF payload: duplicate chunk index ${idx} in cluster ${c} — run \`mdss index\` to rebuild`);
245
+ }
246
+ seen.add(idx);
247
+ validatedCluster[j] = idx;
248
+ }
249
+ clusters[c] = validatedCluster;
250
+ }
143
251
  return { dim, k, centroids, clusters };
144
252
  }
package/dist/lexical.d.ts CHANGED
@@ -33,6 +33,16 @@ export declare function analyzeLexicalDocument(chunk: ChunkForLexical, weights?:
33
33
  }): TermFrequencies;
34
34
  export declare function buildLexicalIndex(records: TermFrequencies[]): LexicalIndex;
35
35
  export declare function editDistance(a: string, b: string): number;
36
+ /**
37
+ * Damerau-Levenshtein distance with a threshold (issue #193): banded DP that
38
+ * never leaves the |i-j| <= maxDist diagonal and aborts as soon as the whole
39
+ * row exceeds the threshold — O(maxDist × min(alen, blen)) instead of the
40
+ * full O(alen × blen) matrix. Returns the EXACT distance when it is
41
+ * <= maxDist and null otherwise; decisions are identical to
42
+ * `editDistance(a, b) <= maxDist` (optimal paths within the threshold never
43
+ * leave the band).
44
+ */
45
+ export declare function editDistanceAtMost(a: string, b: string, maxDist: number): number | null;
36
46
  export declare function fuzzyTitleAliasScores(chunks: Array<{
37
47
  file?: string;
38
48
  title: string;
package/dist/lexical.js CHANGED
@@ -100,6 +100,90 @@ export function editDistance(a, b) {
100
100
  }
101
101
  return matrix[alen][blen];
102
102
  }
103
+ /**
104
+ * Damerau-Levenshtein distance with a threshold (issue #193): banded DP that
105
+ * never leaves the |i-j| <= maxDist diagonal and aborts as soon as the whole
106
+ * row exceeds the threshold — O(maxDist × min(alen, blen)) instead of the
107
+ * full O(alen × blen) matrix. Returns the EXACT distance when it is
108
+ * <= maxDist and null otherwise; decisions are identical to
109
+ * `editDistance(a, b) <= maxDist` (optimal paths within the threshold never
110
+ * leave the band).
111
+ */
112
+ export function editDistanceAtMost(a, b, maxDist) {
113
+ if (a === b)
114
+ return 0;
115
+ const alen = a.length;
116
+ const blen = b.length;
117
+ if (Math.abs(alen - blen) > maxDist)
118
+ return null;
119
+ if (alen === 0)
120
+ return blen <= maxDist ? blen : null;
121
+ if (blen === 0)
122
+ return alen <= maxDist ? alen : null;
123
+ if (maxDist < 0)
124
+ return null;
125
+ const INF = maxDist + 1;
126
+ // rows are padded with an INF sentinel column on each side so band-edge
127
+ // lookups (j-1 / j+1) never read a real out-of-band value
128
+ const width = blen + 3;
129
+ const rowA = new Int32Array(width);
130
+ const rowB = new Int32Array(width);
131
+ const rowC = new Int32Array(width);
132
+ let prev2 = null; // row i-2 (for transpositions)
133
+ let prev = rowA;
134
+ let cur = rowB;
135
+ const scratch = rowC;
136
+ for (let j = 1; j <= blen + 1; j++)
137
+ prev[j] = j; // row 0: dp[0][j] = j (col 0 unused sentinel)
138
+ prev[0] = 0;
139
+ for (let i = 1; i <= alen; i++) {
140
+ const jLo = Math.max(1, i - maxDist);
141
+ const jHi = Math.min(blen, i + maxDist);
142
+ cur[0] = i; // dp[i][0] = i (column 0 inside the band only when i <= maxDist)
143
+ if (i > maxDist)
144
+ cur[0] = INF;
145
+ let rowMin = cur[0];
146
+ for (let j = jLo; j <= jHi; j++) {
147
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
148
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
149
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1] && prev2 !== null) {
150
+ v = Math.min(v, prev2[j - 2] + cost);
151
+ }
152
+ cur[j] = v;
153
+ if (v < rowMin)
154
+ rowMin = v;
155
+ }
156
+ // band-out cells stay INF for this row
157
+ for (let j = 1; j <= blen; j++) {
158
+ if (j < jLo || j > jHi)
159
+ cur[j] = INF;
160
+ }
161
+ if (rowMin > maxDist)
162
+ return null;
163
+ // three-row rotation: prev2 <- row(i-1), prev <- row(i), cur <- freed row
164
+ const free = prev2 ?? scratch;
165
+ prev2 = prev;
166
+ prev = cur;
167
+ cur = free;
168
+ }
169
+ const d = prev[blen];
170
+ return d <= maxDist ? d : null;
171
+ }
172
+ /**
173
+ * issue #193: target tokenization memoized per chunk object — the fuzzy scan
174
+ * runs on every query, but a chunk's title/aliases tokenize to the same tokens
175
+ * every time. WeakMap so runtime chunks (stable objects in serve) pay once.
176
+ */
177
+ const fuzzyTargetTokens = new WeakMap();
178
+ function targetTokens(chunk) {
179
+ let tokens = fuzzyTargetTokens.get(chunk);
180
+ if (!tokens) {
181
+ const targets = [chunk.title, ...(chunk.meta?.aliases || [])].filter(Boolean);
182
+ tokens = targets.flatMap((t) => tokenize(t));
183
+ fuzzyTargetTokens.set(chunk, tokens);
184
+ }
185
+ return tokens;
186
+ }
103
187
  export function fuzzyTitleAliasScores(chunks, query) {
104
188
  const scores = new Map();
105
189
  const qTerms = tokenize(query);
@@ -107,23 +191,22 @@ export function fuzzyTitleAliasScores(chunks, query) {
107
191
  return scores;
108
192
  for (let docId = 0; docId < chunks.length; docId++) {
109
193
  const c = chunks[docId];
110
- const targets = [c.title, ...(c.meta?.aliases || [])].filter(Boolean);
194
+ const tTerms = targetTokens(c);
111
195
  let maxScore = 0;
112
- for (const target of targets) {
113
- const tTerms = tokenize(target);
114
- for (const qt of qTerms) {
115
- if (qt.length < 3)
196
+ for (const qt of qTerms) {
197
+ if (qt.length < 3)
198
+ continue;
199
+ const maxDist = Math.min(2, Math.floor(qt.length / 3));
200
+ for (const tt of tTerms) {
201
+ if (tt.length < 3)
116
202
  continue;
117
- for (const tt of tTerms) {
118
- if (tt.length < 3)
119
- continue;
120
- const maxDist = Math.min(2, Math.floor(qt.length / 3));
121
- const dist = editDistance(qt, tt);
122
- if (dist <= maxDist) {
123
- const similarity = 1 - dist / Math.max(qt.length, tt.length);
124
- if (similarity > maxScore)
125
- maxScore = similarity;
126
- }
203
+ // exact same decision as editDistance(qt, tt) <= maxDist, without the
204
+ // full O(len²) matrix (issue #193)
205
+ const dist = editDistanceAtMost(qt, tt, maxDist);
206
+ if (dist !== null) {
207
+ const similarity = 1 - dist / Math.max(qt.length, tt.length);
208
+ if (similarity > maxScore)
209
+ maxScore = similarity;
127
210
  }
128
211
  }
129
212
  }
@@ -21,8 +21,70 @@ export interface StructuralChunk {
21
21
  }
22
22
  export declare function parseMarkdownBlocks(body: string): MarkdownBlock[];
23
23
  export declare function resolveChunkBudget(tokens?: number, defaultMax?: number): number;
24
+ /**
25
+ * issue #202: script-calibrated token estimate for the sentencepiece E5/BGE
26
+ * vocab family — the fast-path alternative to running the real tokenizer per
27
+ * block. Ratios are calibrated so the estimate errs slightly HIGH: a budget
28
+ * checked against this estimate never overflows the model's real maxTokens.
29
+ * Latin/digits/punctuation ≈ 4 chars/token, Cyrillic ≈ 1.8 chars/token
30
+ * (RU systematically under-chunked by the old char budget — the point of
31
+ * this issue), CJK ≈ 1 token/char, whitespace mostly merges into the
32
+ * following subword so it counts a little.
33
+ */
34
+ export declare function estimateTokens(text: string): number;
35
+ /** Token measure: injected exact counter when available, else the calibrated estimate. */
36
+ export type TokenCounter = (text: string) => number;
37
+ export interface ChunkBudget {
38
+ /** Max measured size a single chunk may reach before it is split/flushed. */
39
+ budget: number;
40
+ /** Measures a text in the same unit the budget is expressed in. */
41
+ measure: TokenCounter;
42
+ /** Unit name for diagnostics/tests. */
43
+ unit: 'chars' | 'tokens';
44
+ }
45
+ /** Char budget (pre-#202 behavior) vs token budget (issue #202). */
46
+ export declare function resolveChunkingBudget(opts: {
47
+ maxChunk?: number;
48
+ maxChunkTokens?: number;
49
+ countTokens?: TokenCounter;
50
+ }): ChunkBudget;
51
+ /**
52
+ * issue #202: the DEFAULT chunk budget in tokens, derived from the embedding
53
+ * adapter's context window. Capped at 512 — a passage much larger than that
54
+ * dilutes retrieval granularity even for models with 8k+ context (bge-m3).
55
+ * Returns undefined for adapters without a known maxTokens, which keeps the
56
+ * legacy char budget instead of guessing.
57
+ */
58
+ export declare function defaultChunkTokenBudget(adapter: {
59
+ maxTokens?: number;
60
+ }): number | undefined;
24
61
  export declare function chunkMarkdownStructural(input: string | MarkdownBlock[], opts?: number | {
25
62
  maxChunk?: number;
26
63
  targetTokens?: number;
64
+ maxChunkTokens?: number;
65
+ countTokens?: TokenCounter;
27
66
  }): StructuralChunk[];
67
+ export interface OversizedPiece {
68
+ text: string;
69
+ /** 0-based block-relative range of REAL source lines backing this piece. */
70
+ startLine: number;
71
+ endLine: number;
72
+ }
28
73
  export declare function splitOversizedBlock(text: string, maxChunk: number, blockType?: MarkdownBlock['type']): string[];
74
+ /**
75
+ * issue #202: oversized-block splitting under an arbitrary measure (chars or
76
+ * tokens). The heavy lifting (fence repeats, table header repeats, #167 line
77
+ * ranges) stays in the char-based splitOversizedBlockWithRanges; the token
78
+ * budget is converted to a char budget for THIS block via its own chars-per-
79
+ * measured-unit ratio, then every piece is verified — script-skew inside the
80
+ * block (ASCII header + Cyrillic body) is caught by a greedy re-split.
81
+ */
82
+ export declare function splitOversizedByMeasure(text: string, budget: number, measure: TokenCounter, blockType?: MarkdownBlock['type']): OversizedPiece[];
83
+ /**
84
+ * Issue #167: like splitOversizedBlock, but each piece also reports the
85
+ * block-relative range of source lines it was built from. Synthetic lines
86
+ * added to keep pieces self-contained (fence repeats, table header repeats)
87
+ * do NOT count — only the first piece's opening fence/header and a real
88
+ * closing fence belong to the source.
89
+ */
90
+ export declare function splitOversizedBlockWithRanges(text: string, maxChunk: number, blockType?: MarkdownBlock['type']): OversizedPiece[];