md-semantic-search 0.4.0 → 0.5.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 (61) hide show
  1. package/README.md +81 -0
  2. package/bin/cli.mjs +368 -14
  3. package/dist/collapse.d.ts +6 -0
  4. package/dist/collapse.js +20 -0
  5. package/dist/core.d.ts +84 -0
  6. package/dist/core.js +479 -0
  7. package/dist/frontmatter.d.ts +20 -0
  8. package/dist/frontmatter.js +150 -0
  9. package/dist/index-format.d.ts +23 -0
  10. package/dist/index-format.js +125 -0
  11. package/dist/index.d.ts +29 -0
  12. package/dist/index.js +29 -0
  13. package/dist/indexer.d.ts +70 -0
  14. package/dist/indexer.js +452 -0
  15. package/dist/ivf.d.ts +33 -0
  16. package/dist/ivf.js +144 -0
  17. package/dist/lexical.d.ts +44 -0
  18. package/dist/lexical.js +226 -0
  19. package/dist/markdown-parser.d.ts +27 -0
  20. package/dist/markdown-parser.js +252 -0
  21. package/dist/mcp.d.ts +111 -0
  22. package/dist/mcp.js +216 -0
  23. package/dist/metrics.d.ts +43 -0
  24. package/dist/metrics.js +97 -0
  25. package/dist/models.d.ts +38 -0
  26. package/dist/models.js +181 -0
  27. package/dist/rerank.d.ts +10 -0
  28. package/dist/rerank.js +41 -0
  29. package/dist/search.d.ts +135 -0
  30. package/dist/search.js +492 -0
  31. package/dist/serve.d.ts +49 -0
  32. package/dist/serve.js +321 -0
  33. package/dist/tui.d.ts +17 -0
  34. package/dist/tui.js +196 -0
  35. package/dist/wikilinks.d.ts +47 -0
  36. package/dist/wikilinks.js +171 -0
  37. package/package.json +19 -5
  38. package/src/{collapse.mjs → collapse.ts} +7 -12
  39. package/src/core.ts +546 -0
  40. package/src/{frontmatter.mjs → frontmatter.ts} +27 -50
  41. package/src/{index-format.mjs → index-format.ts} +57 -40
  42. package/src/{index.mjs → index.ts} +16 -8
  43. package/src/indexer.ts +585 -0
  44. package/src/ivf.ts +190 -0
  45. package/src/{lexical.mjs → lexical.ts} +66 -100
  46. package/src/{markdown-parser.mjs → markdown-parser.ts} +56 -100
  47. package/src/mcp.ts +243 -0
  48. package/src/metrics.ts +135 -0
  49. package/src/models.ts +219 -0
  50. package/src/rerank.ts +54 -0
  51. package/src/search.ts +628 -0
  52. package/src/serve.ts +372 -0
  53. package/src/tui.ts +227 -0
  54. package/src/{wikilinks.mjs → wikilinks.ts} +70 -67
  55. package/src/core.mjs +0 -535
  56. package/src/indexer.mjs +0 -540
  57. package/src/metrics.mjs +0 -186
  58. package/src/models.mjs +0 -299
  59. package/src/rerank.mjs +0 -75
  60. package/src/search.mjs +0 -636
  61. package/src/serve.mjs +0 -387
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Build / refresh the semantic index for a folder of markdown files.
3
+ * Incremental at TWO levels:
4
+ * 1. per-file md5 -> completely unchanged files reuse their chunks as-is;
5
+ * 2. per-chunk hash (chunkHash = SHA-256 of the exact passage input that
6
+ * goes to embed()) -> inside a *changed* file, sections whose embedding
7
+ * input is unchanged reuse their stored vector, so an append/edit in one
8
+ * place of a long file no longer re-embeds all of its sections.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { walkMarkdown, parseFile, embed, resolveModel, decodeVec, encodeVec, SCHEMA_VERSION, SCHEMA_MIGRATIONS, withIndexLock, parseSchemaVersion, resolveIndexDimension, } from './core.js';
14
+ import { validateIndexEnvelope, validateNumericVector } from './index-format.js';
15
+ import { analyzeLexicalDocument, buildLexicalIndex, lexicalIdentity, reverseLexicalIndex, validateLexicalIndex, } from './lexical.js';
16
+ import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
17
+ import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
18
+ const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
19
+ const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
20
+ const BATCH = 32;
21
+ const CHECKPOINT_BATCHES = 8;
22
+ const INDEX_FORMAT = 'binary-v1';
23
+ function isStringRecord(value) {
24
+ return (value !== null &&
25
+ typeof value === 'object' &&
26
+ !Array.isArray(value) &&
27
+ Object.values(value).every((entry) => typeof entry === 'string'));
28
+ }
29
+ const loadJSON = (p, fb, warn = () => { }) => {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
32
+ }
33
+ catch (e) {
34
+ if (e.code !== 'ENOENT') {
35
+ warn(`warning: ${path.basename(p)} is not valid JSON (${e.message}); rebuilding it from scratch.`);
36
+ }
37
+ return fb;
38
+ }
39
+ };
40
+ function atomicWrite(target, data) {
41
+ const tmp = `${target}.${process.pid}.tmp`;
42
+ fs.writeFileSync(tmp, data);
43
+ fs.renameSync(tmp, target);
44
+ if (path.basename(target) === 'vectors.json') {
45
+ const digest = crypto.createHash('sha256').update(data).digest('hex');
46
+ const shaPath = `${target}.sha256`;
47
+ const shaTmp = `${shaPath}.${process.pid}.tmp`;
48
+ fs.writeFileSync(shaTmp, `${digest} vectors.json\n`);
49
+ fs.renameSync(shaTmp, shaPath);
50
+ }
51
+ }
52
+ function normalize(s) {
53
+ return (s ?? '').replace(/\r\n?/g, '\n').trim();
54
+ }
55
+ export function canonicalPassage(chunk) {
56
+ const title = normalize(chunk.title);
57
+ const headingPath = (chunk.headingPath ?? (normalize(chunk.heading) ? [chunk.heading] : [])).map(normalize);
58
+ const serializedPath = (headingPath[0] === title ? headingPath.slice(1) : headingPath).join(' > ');
59
+ return [title, serializedPath, normalize(chunk.text)].join('\n');
60
+ }
61
+ export function chunkHash(model, chunk) {
62
+ const pooling = model.pooling ?? 'mean';
63
+ const input = [
64
+ pooling === 'mean' ? 'heading-path-v1' : `heading-path-v1:${pooling}`,
65
+ model.id,
66
+ model.revision || 'main',
67
+ model.passagePrefix || '',
68
+ canonicalPassage(chunk),
69
+ ].join('\u0000');
70
+ return sha256(input);
71
+ }
72
+ export async function buildIndex(opts) {
73
+ const { db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, } = opts;
74
+ if (_lockHeld)
75
+ return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann });
76
+ return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann }));
77
+ }
78
+ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, }) {
79
+ const model = resolveModel(modelName);
80
+ const modelIdentity = `${model.id}@${model.revision || 'main'}`;
81
+ const adapterFingerprint = embeddingAdapterFingerprint(model);
82
+ const legacyAdapterFingerprint = legacyEmbeddingAdapterFingerprint(model);
83
+ const adapterCompatible = (index) => index.adapterFingerprint === adapterFingerprint ||
84
+ (index.adapterFingerprint === undefined && adapterFingerprint === legacyAdapterFingerprint);
85
+ const vectorsPath = path.join(indexDir, 'vectors.json');
86
+ const hashesPath = path.join(indexDir, '.hashes.json');
87
+ const checkpointPath = path.join(indexDir, '.checkpoint.json');
88
+ fs.mkdirSync(indexDir, { recursive: true });
89
+ const files = walkMarkdown(db, ignore);
90
+ const canonicalHashes = loadJSON(hashesPath, {}, log);
91
+ let canonicalIndex = loadJSON(vectorsPath, { chunks: [] }, log);
92
+ let canonicalSchema = 0;
93
+ if (canonicalIndex !== null && typeof canonicalIndex === 'object' && !Array.isArray(canonicalIndex)) {
94
+ canonicalSchema = parseSchemaVersion(canonicalIndex.schemaVersion);
95
+ if (canonicalSchema > SCHEMA_VERSION) {
96
+ throw new Error(`${vectorsPath} uses schema v${canonicalSchema}, but this mdss writes v${SCHEMA_VERSION} — upgrade md-semantic-search before re-indexing.`);
97
+ }
98
+ }
99
+ if (canonicalIndex === null || typeof canonicalIndex !== 'object' || !Array.isArray(canonicalIndex.chunks)) {
100
+ log('warning: vectors.json root/chunks are invalid; rebuilding it from scratch.');
101
+ canonicalIndex = { chunks: [], model: null };
102
+ }
103
+ if (canonicalSchema === SCHEMA_VERSION) {
104
+ try {
105
+ validateIndexEnvelope(canonicalIndex, vectorsPath, {
106
+ encoding: 'stored',
107
+ validateVectors: false,
108
+ validateLexical: false,
109
+ });
110
+ }
111
+ catch (error) {
112
+ log(`warning: ${error.message}; rebuilding vectors.json from scratch.`);
113
+ canonicalIndex = { chunks: [], model: null };
114
+ }
115
+ }
116
+ const checkpoint = loadJSON(checkpointPath, null, log);
117
+ let checkpointEnvelopeValid = false;
118
+ try {
119
+ if (checkpoint !== null) {
120
+ validateIndexEnvelope(checkpoint, checkpointPath, {
121
+ encoding: 'stored',
122
+ allowMissingVectors: true,
123
+ });
124
+ checkpointEnvelopeValid = true;
125
+ }
126
+ }
127
+ catch {
128
+ checkpointEnvelopeValid = false;
129
+ }
130
+ const checkpointCompatible = checkpoint !== null &&
131
+ checkpointEnvelopeValid &&
132
+ checkpoint.schemaVersion === SCHEMA_VERSION &&
133
+ checkpoint.format === INDEX_FORMAT &&
134
+ checkpoint.model === modelIdentity &&
135
+ adapterCompatible(checkpoint) &&
136
+ checkpoint.db === db &&
137
+ typeof checkpoint.modelAlias === 'string' &&
138
+ typeof checkpoint.built === 'string' &&
139
+ typeof checkpoint.complete === 'boolean' &&
140
+ Number.isInteger(checkpoint.chunkCount) &&
141
+ Array.isArray(checkpoint.chunks) &&
142
+ checkpoint.chunkCount === checkpoint.chunks.length &&
143
+ (!checkpoint.complete || checkpoint.chunks.every((chunk) => chunk.vec !== undefined)) &&
144
+ isStringRecord(checkpoint.hashes) &&
145
+ validateLexicalIndex(checkpoint.lexical, checkpoint.chunks.length) === null;
146
+ const oldHashes = checkpointCompatible ? checkpoint.hashes : canonicalHashes;
147
+ const oldIndex = checkpointCompatible ? checkpoint : canonicalIndex;
148
+ const sourceSchema = parseSchemaVersion(oldIndex.schemaVersion);
149
+ for (let v = sourceSchema + 1; v <= SCHEMA_VERSION; v++) {
150
+ const step = SCHEMA_MIGRATIONS[v];
151
+ if (step)
152
+ step(oldIndex);
153
+ }
154
+ const oldModel = resolveModel(oldIndex.model || oldIndex.modelAlias);
155
+ let oldExpectedDim;
156
+ let oldDimensionValid = true;
157
+ try {
158
+ oldExpectedDim = resolveIndexDimension(oldIndex.dim, oldModel.dim);
159
+ }
160
+ catch (error) {
161
+ oldDimensionValid = false;
162
+ log(`warning: ${error.message}; stored vectors will be rebuilt.`);
163
+ }
164
+ for (const c of oldIndex.chunks) {
165
+ if (typeof c.vec === 'string' && oldIndex.format === 'binary-v1') {
166
+ try {
167
+ c.vec = oldDimensionValid ? decodeVec(c.vec, oldExpectedDim) : undefined;
168
+ }
169
+ catch (e) {
170
+ log(`warning: dropping corrupt vector for ${c.file}` + (c.heading ? ` › ${c.heading}` : '') + ` (${e.message})`);
171
+ c.vec = undefined;
172
+ }
173
+ }
174
+ else if (c.vec !== undefined) {
175
+ try {
176
+ if (!oldDimensionValid)
177
+ throw new Error('invalid stored dimension');
178
+ validateNumericVector(c.vec, oldExpectedDim, `chunk ${c.file}`);
179
+ }
180
+ catch {
181
+ log(`warning: dropping corrupt vector for ${c.file}` + (c.heading ? ` › ${c.heading}` : ''));
182
+ c.vec = undefined;
183
+ }
184
+ }
185
+ }
186
+ const modelChanged = !!oldIndex.model && oldIndex.model !== modelIdentity;
187
+ const contextVectorsReusable = sourceSchema >= 2 && !modelChanged && adapterCompatible(oldIndex);
188
+ let buildDim = model.dim > 0 ? model.dim : contextVectorsReusable && oldDimensionValid ? oldExpectedDim : undefined;
189
+ const acceptBuildVector = (vector) => {
190
+ const length = validateNumericVector(vector, buildDim, 'embedding vector');
191
+ buildDim ??= length;
192
+ };
193
+ const reusableVector = (vector) => {
194
+ try {
195
+ acceptBuildVector(vector);
196
+ return true;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ };
202
+ const lexicalReusable = sourceSchema === 3 &&
203
+ oldIndex.lexical &&
204
+ validateLexicalIndex(oldIndex.lexical, oldIndex.chunks.length) === null &&
205
+ oldIndex.lexical.format === 'bm25-v2';
206
+ const oldLexicalRecords = lexicalReusable && oldIndex.lexical ? reverseLexicalIndex(oldIndex.lexical) : [];
207
+ const oldByFile = new Map();
208
+ const vecByChunkHash = new Map();
209
+ const lexicalByFile = new Map();
210
+ const lexicalByIdentity = new Map();
211
+ for (let oldDocId = 0; oldDocId < oldIndex.chunks.length; oldDocId++) {
212
+ const c = oldIndex.chunks[oldDocId];
213
+ const lexicalRecord = oldLexicalRecords[oldDocId];
214
+ if (contextVectorsReusable) {
215
+ if (!oldByFile.has(c.file))
216
+ oldByFile.set(c.file, []);
217
+ oldByFile.get(c.file).push(c);
218
+ const key = c.chunkHash || chunkHash(model, c);
219
+ if (c.vec)
220
+ vecByChunkHash.set(key, c.vec);
221
+ }
222
+ if (lexicalRecord) {
223
+ if (!lexicalByFile.has(c.file))
224
+ lexicalByFile.set(c.file, []);
225
+ lexicalByFile.get(c.file).push(lexicalRecord);
226
+ lexicalByIdentity.set(lexicalIdentity(c), lexicalRecord);
227
+ }
228
+ }
229
+ const newHashes = {};
230
+ const chunks = [];
231
+ const lexicalRecords = [];
232
+ const toEmbed = [];
233
+ let reused = 0, reusedChunks = 0, changedFiles = 0, skipped = 0;
234
+ const appendChunk = (chunk, record) => {
235
+ chunks.push(chunk);
236
+ lexicalRecords.push(record ?? analyzeLexicalDocument(chunk));
237
+ };
238
+ const checkpointSnapshot = (complete) => ({
239
+ schemaVersion: SCHEMA_VERSION,
240
+ format: INDEX_FORMAT,
241
+ model: modelIdentity,
242
+ modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
243
+ adapterFingerprint,
244
+ ...(buildDim === undefined ? {} : { dim: buildDim }),
245
+ db,
246
+ built: new Date().toISOString(),
247
+ complete,
248
+ chunkCount: chunks.length,
249
+ hashes: newHashes,
250
+ lexical: buildLexicalIndex(lexicalRecords),
251
+ chunks: chunks.map((c) => ({ ...c, vec: c.vec ? (typeof c.vec === 'string' ? c.vec : encodeVec(c.vec)) : undefined })),
252
+ });
253
+ for (const abs of files) {
254
+ const rel = path.relative(db, abs).split(path.sep).join('/');
255
+ let parsed;
256
+ let reusableLexicalRecords;
257
+ try {
258
+ const raw = fs.readFileSync(abs, 'utf8');
259
+ const h = md5(raw);
260
+ newHashes[rel] = h;
261
+ if (oldHashes[rel] === h)
262
+ reusableLexicalRecords = lexicalByFile.get(rel);
263
+ if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && oldHashes[rel] === h && oldByFile.has(rel)) {
264
+ const old = oldByFile.get(rel);
265
+ for (const c of old) {
266
+ if (!c.chunkHash)
267
+ c.chunkHash = chunkHash(model, c);
268
+ }
269
+ for (let oldPosition = 0; oldPosition < old.length; oldPosition++) {
270
+ const c = old[oldPosition];
271
+ if (c.vec && reusableVector(c.vec)) {
272
+ appendChunk(c, reusableLexicalRecords?.[oldPosition]);
273
+ reused++;
274
+ }
275
+ else {
276
+ c.vec = undefined;
277
+ toEmbed.push(c);
278
+ appendChunk(c, reusableLexicalRecords?.[oldPosition]);
279
+ }
280
+ }
281
+ continue;
282
+ }
283
+ changedFiles++;
284
+ parsed = parseFile(abs, db, undefined, raw);
285
+ }
286
+ catch (e) {
287
+ delete newHashes[rel];
288
+ skipped++;
289
+ log(`warning: skipping ${rel} (${e.code || e.message})`);
290
+ continue;
291
+ }
292
+ for (let parsedPosition = 0; parsedPosition < parsed.length; parsedPosition++) {
293
+ const c = parsed[parsedPosition];
294
+ const key = chunkHash(model, c);
295
+ const cached = vecByChunkHash.get(key);
296
+ if (cached && reusableVector(cached)) {
297
+ c.vec = cached;
298
+ c.chunkHash = key;
299
+ reused++;
300
+ reusedChunks++;
301
+ }
302
+ else {
303
+ c.chunkHash = key;
304
+ toEmbed.push(c);
305
+ }
306
+ appendChunk(c, reusableLexicalRecords?.[parsedPosition] ?? lexicalByIdentity.get(lexicalIdentity(c)));
307
+ }
308
+ }
309
+ if (toEmbed.length > 0) {
310
+ log(`Embedding ${toEmbed.length} chunks from ${changedFiles} changed file(s) with ${model.id}...`);
311
+ const embedStartTime = Date.now();
312
+ let completedBatches = 0;
313
+ const concurrency = Math.max(1, Math.min(workers || 1, 16));
314
+ if (concurrency <= 1 || toEmbed.length <= BATCH) {
315
+ for (let i = 0; i < toEmbed.length; i += BATCH) {
316
+ const slice = toEmbed.slice(i, i + BATCH);
317
+ const vecs = await embedFn(slice.map(canonicalPassage), 'passage', model, cacheDir, offline, {
318
+ maxRetries,
319
+ log,
320
+ });
321
+ let batchDim = buildDim;
322
+ try {
323
+ if (!Array.isArray(vecs) || vecs.length !== slice.length)
324
+ throw new Error('batch size');
325
+ if (vecs.length > 0 && batchDim === undefined) {
326
+ batchDim = validateNumericVector(vecs[0], undefined, 'embedding vector 0');
327
+ }
328
+ for (const vec of vecs)
329
+ validateNumericVector(vec, batchDim, 'embedding vector');
330
+ }
331
+ catch {
332
+ throw new Error(`embedding returned invalid vectors` +
333
+ (batchDim === undefined ? '' : ` (expected ${batchDim} dims)`) +
334
+ ' — run `mdss index` to rebuild');
335
+ }
336
+ buildDim = batchDim;
337
+ slice.forEach((c, j) => {
338
+ c.vec = vecs[j];
339
+ });
340
+ completedBatches++;
341
+ if (completedBatches % CHECKPOINT_BATCHES === 0) {
342
+ atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
343
+ }
344
+ const done = Math.min(i + BATCH, toEmbed.length);
345
+ const elapsedSec = (Date.now() - embedStartTime) / 1000;
346
+ const chunksPerSec = elapsedSec > 0 ? done / elapsedSec : 0;
347
+ onProgress?.(done, toEmbed.length, chunksPerSec);
348
+ log(` ${done}/${toEmbed.length}`);
349
+ }
350
+ }
351
+ else {
352
+ const batches = [];
353
+ for (let i = 0; i < toEmbed.length; i += BATCH) {
354
+ batches.push({ slice: toEmbed.slice(i, i + BATCH) });
355
+ }
356
+ let currentBatchIdx = 0;
357
+ let embeddedCount = 0;
358
+ const runWorker = async () => {
359
+ while (currentBatchIdx < batches.length) {
360
+ const idx = currentBatchIdx++;
361
+ const { slice } = batches[idx];
362
+ const vecs = await embedFn(slice.map(canonicalPassage), 'passage', model, cacheDir, offline, {
363
+ maxRetries,
364
+ log,
365
+ });
366
+ let batchDim = buildDim;
367
+ try {
368
+ if (!Array.isArray(vecs) || vecs.length !== slice.length)
369
+ throw new Error('batch size');
370
+ if (vecs.length > 0 && batchDim === undefined) {
371
+ batchDim = validateNumericVector(vecs[0], undefined, 'embedding vector 0');
372
+ }
373
+ for (const vec of vecs)
374
+ validateNumericVector(vec, batchDim, 'embedding vector');
375
+ }
376
+ catch {
377
+ throw new Error(`embedding returned invalid vectors` +
378
+ (batchDim === undefined ? '' : ` (expected ${batchDim} dims)`) +
379
+ ' — run `mdss index` to rebuild');
380
+ }
381
+ buildDim = batchDim;
382
+ slice.forEach((c, j) => {
383
+ c.vec = vecs[j];
384
+ });
385
+ completedBatches++;
386
+ embeddedCount += slice.length;
387
+ if (completedBatches % CHECKPOINT_BATCHES === 0) {
388
+ atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
389
+ }
390
+ const elapsedSec = (Date.now() - embedStartTime) / 1000;
391
+ const chunksPerSec = elapsedSec > 0 ? embeddedCount / elapsedSec : 0;
392
+ onProgress?.(embeddedCount, toEmbed.length, chunksPerSec);
393
+ log(` ${embeddedCount}/${toEmbed.length}`);
394
+ }
395
+ };
396
+ const workerPromises = [];
397
+ for (let w = 0; w < concurrency; w++) {
398
+ workerPromises.push(runWorker());
399
+ }
400
+ await Promise.all(workerPromises);
401
+ }
402
+ }
403
+ if (chunks.some((c) => !c.vec)) {
404
+ throw new Error('Embedding finished without a vector for every chunk.');
405
+ }
406
+ for (const chunk of chunks)
407
+ acceptBuildVector(chunk.vec);
408
+ const dim = buildDim ?? (model.dim > 0 ? model.dim : undefined);
409
+ resolveIndexDimension(dim, model.dim);
410
+ const index = {
411
+ schemaVersion: SCHEMA_VERSION,
412
+ format: INDEX_FORMAT,
413
+ model: modelIdentity,
414
+ modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
415
+ adapterFingerprint,
416
+ ...(dim === undefined ? {} : { dim }),
417
+ db,
418
+ built: new Date().toISOString(),
419
+ chunkCount: chunks.length,
420
+ lexical: buildLexicalIndex(lexicalRecords),
421
+ chunks: chunks.map((c) => ({ ...c, vec: c.vec ? (typeof c.vec === 'string' ? c.vec : encodeVec(c.vec)) : undefined })),
422
+ };
423
+ validateIndexEnvelope(index, 'generated index', { encoding: 'stored' });
424
+ atomicWrite(checkpointPath, JSON.stringify({ ...index, complete: true, hashes: newHashes }));
425
+ atomicWrite(vectorsPath, JSON.stringify(index));
426
+ atomicWrite(hashesPath, JSON.stringify(newHashes, null, 2));
427
+ const ivfPath = path.join(indexDir, 'ivf.json');
428
+ if (ann || chunks.length >= ANN_THRESHOLD) {
429
+ const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec)));
430
+ const ivfIndex = trainIVF(rawVecs);
431
+ atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
432
+ }
433
+ else if (fs.existsSync(ivfPath)) {
434
+ try {
435
+ fs.unlinkSync(ivfPath);
436
+ }
437
+ catch { }
438
+ }
439
+ fs.unlinkSync(checkpointPath);
440
+ return {
441
+ files: files.length,
442
+ skipped,
443
+ chunks: chunks.length,
444
+ reused,
445
+ reusedChunks,
446
+ reusedFiles: reused - reusedChunks,
447
+ embedded: toEmbed.length,
448
+ dim: dim ?? 0,
449
+ model: model.id,
450
+ vectorsPath,
451
+ };
452
+ }
package/dist/ivf.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface IVFIndex {
2
+ dim: number;
3
+ k: number;
4
+ centroids: Float32Array;
5
+ clusters: number[][];
6
+ }
7
+ export interface SerializedIVF {
8
+ dim: number;
9
+ k: number;
10
+ centroids: string;
11
+ clusters: number[][];
12
+ }
13
+ export declare const ANN_THRESHOLD = 500;
14
+ export declare const DEFAULT_NPROBE = 8;
15
+ /**
16
+ * Train a Spherical K-Means IVF index over normalized vectors.
17
+ */
18
+ export declare function trainIVF(vectors: Float32Array[], options?: {
19
+ k?: number;
20
+ maxIterations?: number;
21
+ }): IVFIndex;
22
+ /**
23
+ * Probe top-nprobe closest centroids and return candidate chunk indices.
24
+ */
25
+ export declare function searchIVFCandidates(queryVec: Float32Array, ivf: IVFIndex, nprobe?: number): number[];
26
+ /**
27
+ * Serialize IVFIndex to plain JSON-compatible object.
28
+ */
29
+ export declare function serializeIVF(ivf: IVFIndex): SerializedIVF;
30
+ /**
31
+ * Deserialize JSON object back to IVFIndex.
32
+ */
33
+ export declare function deserializeIVF(raw: any): IVFIndex;
package/dist/ivf.js ADDED
@@ -0,0 +1,144 @@
1
+ import { cosine } from './core.js';
2
+ export const ANN_THRESHOLD = 500;
3
+ export const DEFAULT_NPROBE = 8;
4
+ /**
5
+ * Train a Spherical K-Means IVF index over normalized vectors.
6
+ */
7
+ export function trainIVF(vectors, options = {}) {
8
+ const n = vectors.length;
9
+ if (n === 0) {
10
+ return { dim: 0, k: 0, centroids: new Float32Array(0), clusters: [] };
11
+ }
12
+ const dim = vectors[0].length;
13
+ const targetK = options.k ?? Math.min(256, Math.max(2, Math.floor(Math.sqrt(n))));
14
+ const k = Math.min(targetK, n);
15
+ const maxIterations = options.maxIterations ?? 20;
16
+ // Initialize centroids using deterministic spread or k-means++
17
+ 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);
22
+ }
23
+ let clusters = Array.from({ length: k }, () => []);
24
+ const assignments = new Int32Array(n).fill(-1);
25
+ for (let iter = 0; iter < maxIterations; iter++) {
26
+ let changed = 0;
27
+ const newClusters = Array.from({ length: k }, () => []);
28
+ // 1. Assign each vector to closest centroid (highest cosine / dot product)
29
+ for (let i = 0; i < n; i++) {
30
+ const v = vectors[i];
31
+ let bestScore = -Infinity;
32
+ let bestCluster = 0;
33
+ for (let c = 0; c < k; c++) {
34
+ const score = cosine(v, centroids, c * dim);
35
+ if (score > bestScore) {
36
+ bestScore = score;
37
+ bestCluster = c;
38
+ }
39
+ }
40
+ if (assignments[i] !== bestCluster) {
41
+ assignments[i] = bestCluster;
42
+ changed++;
43
+ }
44
+ newClusters[bestCluster].push(i);
45
+ }
46
+ clusters = newClusters;
47
+ // 2. Recompute centroids
48
+ for (let c = 0; c < k; c++) {
49
+ const members = clusters[c];
50
+ if (members.length === 0) {
51
+ // If empty, reassign to a vector from the largest cluster
52
+ let largest = 0;
53
+ for (let j = 1; j < k; j++) {
54
+ if (clusters[j].length > clusters[largest].length)
55
+ largest = j;
56
+ }
57
+ if (clusters[largest].length > 1) {
58
+ const stolenIdx = clusters[largest].pop();
59
+ clusters[c].push(stolenIdx);
60
+ assignments[stolenIdx] = c;
61
+ centroids.set(vectors[stolenIdx], c * dim);
62
+ }
63
+ continue;
64
+ }
65
+ const cOffset = c * dim;
66
+ for (let d = 0; d < dim; d++)
67
+ centroids[cOffset + d] = 0;
68
+ for (const idx of members) {
69
+ const v = vectors[idx];
70
+ for (let d = 0; d < dim; d++) {
71
+ centroids[cOffset + d] += v[d];
72
+ }
73
+ }
74
+ // L2 Normalize
75
+ let normSq = 0;
76
+ for (let d = 0; d < dim; d++) {
77
+ const val = centroids[cOffset + d];
78
+ normSq += val * val;
79
+ }
80
+ const norm = Math.sqrt(normSq) || 1;
81
+ for (let d = 0; d < dim; d++) {
82
+ centroids[cOffset + d] /= norm;
83
+ }
84
+ }
85
+ if (changed === 0)
86
+ break;
87
+ }
88
+ return { dim, k, centroids, clusters };
89
+ }
90
+ /**
91
+ * Probe top-nprobe closest centroids and return candidate chunk indices.
92
+ */
93
+ export function searchIVFCandidates(queryVec, ivf, nprobe = DEFAULT_NPROBE) {
94
+ const { k, dim, centroids, clusters } = ivf;
95
+ if (k === 0 || clusters.length === 0)
96
+ return [];
97
+ const actualNprobe = Math.min(Math.max(1, nprobe), k);
98
+ // Compute similarities to all centroids
99
+ const scores = [];
100
+ for (let c = 0; c < k; c++) {
101
+ const score = cosine(queryVec, centroids, c * dim);
102
+ scores.push({ cluster: c, score });
103
+ }
104
+ // Sort descending by similarity
105
+ scores.sort((a, b) => b.score - a.score);
106
+ const candidates = [];
107
+ for (let i = 0; i < actualNprobe; i++) {
108
+ const clusterIdx = scores[i].cluster;
109
+ const memberIndices = clusters[clusterIdx];
110
+ for (let j = 0; j < memberIndices.length; j++) {
111
+ candidates.push(memberIndices[j]);
112
+ }
113
+ }
114
+ return candidates;
115
+ }
116
+ /**
117
+ * Serialize IVFIndex to plain JSON-compatible object.
118
+ */
119
+ export function serializeIVF(ivf) {
120
+ const b64 = Buffer.from(ivf.centroids.buffer, ivf.centroids.byteOffset, ivf.centroids.byteLength).toString('base64');
121
+ return {
122
+ dim: ivf.dim,
123
+ k: ivf.k,
124
+ centroids: b64,
125
+ clusters: ivf.clusters,
126
+ };
127
+ }
128
+ /**
129
+ * Deserialize JSON object back to IVFIndex.
130
+ */
131
+ export function deserializeIVF(raw) {
132
+ if (!raw || typeof raw !== 'object') {
133
+ throw new Error('invalid IVF payload: expected object');
134
+ }
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');
139
+ }
140
+ const buf = Buffer.from(raw.centroids, 'base64');
141
+ const centroids = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
142
+ const clusters = Array.isArray(raw.clusters) ? raw.clusters : [];
143
+ return { dim, k, centroids, clusters };
144
+ }
@@ -0,0 +1,44 @@
1
+ import { DocumentMetadata } from './frontmatter.js';
2
+ export type TermFrequencies = Record<string, number>;
3
+ export interface LexicalIndex {
4
+ format: 'bm25-v1' | 'bm25-v2';
5
+ documentLengths: number[];
6
+ postings: Record<string, Array<[number, number]>>;
7
+ }
8
+ export declare const _lexicalStats: {
9
+ documentsAnalyzed: number;
10
+ };
11
+ export interface ChunkForLexical {
12
+ title: string;
13
+ heading: string;
14
+ headingPath?: string[];
15
+ text: string;
16
+ meta?: DocumentMetadata;
17
+ file?: string;
18
+ }
19
+ export declare function lexicalDocument(chunk: ChunkForLexical): string;
20
+ export declare function lexicalIdentity(chunk: ChunkForLexical): string;
21
+ export declare function tokenize(text: string): string[];
22
+ export declare const DEFAULT_FIELD_WEIGHTS: {
23
+ title: number;
24
+ aliases: number;
25
+ headingPath: number;
26
+ body: number;
27
+ };
28
+ export declare function analyzeLexicalDocument(chunk: ChunkForLexical, weights?: {
29
+ title: number;
30
+ aliases: number;
31
+ headingPath: number;
32
+ body: number;
33
+ }): TermFrequencies;
34
+ export declare function buildLexicalIndex(records: TermFrequencies[]): LexicalIndex;
35
+ export declare function editDistance(a: string, b: string): number;
36
+ export declare function fuzzyTitleAliasScores(chunks: Array<{
37
+ file?: string;
38
+ title: string;
39
+ meta?: DocumentMetadata;
40
+ }>, query: string): Map<number, number>;
41
+ export declare function validateLexicalIndex(value: unknown, chunkCount: number): string | null;
42
+ export declare function reverseLexicalIndex(lexical: LexicalIndex): TermFrequencies[];
43
+ export declare function bm25Scores(lexical: LexicalIndex, queryTerms: string[], eligible?: Set<number>): Map<number, number>;
44
+ export declare function matchingTerms(lexical: LexicalIndex, queryTerms: string[], docId: number): string[];