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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
fs.
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
28
|
+
const seeding = options.seeding ?? 'kmeans++';
|
|
17
29
|
const centroids = new Float32Array(k * dim);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
194
|
+
const tTerms = targetTokens(c);
|
|
111
195
|
let maxScore = 0;
|
|
112
|
-
for (const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
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[];
|