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.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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[][];
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
221
|
+
const tTerms = targetTokens(c);
|
|
143
222
|
let maxScore = 0;
|
|
144
223
|
|
|
145
|
-
for (const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
}
|