md-semantic-search 0.3.0 → 0.4.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/src/indexer.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Build / refresh the semantic index for a folder of markdown files.
3
4
  * Incremental at TWO levels:
@@ -10,15 +11,62 @@
10
11
  import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
  import crypto from 'node:crypto';
13
- import { walkMarkdown, parseFile, embed, resolveModel } from './core.mjs';
14
+ import { walkMarkdown, parseFile, embed, resolveModel, decodeVec, encodeVec, SCHEMA_VERSION, SCHEMA_MIGRATIONS, withIndexLock, parseSchemaVersion, resolveIndexDimension } from './core.mjs';
15
+ import { validateIndexEnvelope, validateNumericVector } from './index-format.mjs';
16
+ import { analyzeLexicalDocument, buildLexicalIndex, lexicalIdentity, reverseLexicalIndex, validateLexicalIndex } from './lexical.mjs';
17
+ import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.mjs';
18
+
19
+ /**
20
+ * @typedef {Object} IndexChunk
21
+ * @property {string} file
22
+ * @property {string} title
23
+ * @property {string} heading
24
+ * @property {string[]} headingPath
25
+ * @property {string} text
26
+ * @property {number[]|Float32Array} [vec]
27
+ * @property {string} [chunkHash]
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} PersistedIndex
32
+ * @property {number} [schemaVersion]
33
+ * @property {string} [format]
34
+ * @property {string|null} [model]
35
+ * @property {string} [modelAlias]
36
+ * @property {string} [adapterFingerprint]
37
+ * @property {number} [dim]
38
+ * @property {string} [db]
39
+ * @property {string} [built]
40
+ * @property {boolean} [complete]
41
+ * @property {number} [chunkCount]
42
+ * @property {Record<string,string>} [hashes]
43
+ * @property {import('./lexical.mjs').LexicalIndex} [lexical]
44
+ * @property {IndexChunk[]} chunks
45
+ */
14
46
 
15
47
  const md5 = s => crypto.createHash('md5').update(s).digest('hex');
16
48
  const sha256 = s => crypto.createHash('sha256').update(s).digest('hex');
49
+ const BATCH = 32;
50
+ const CHECKPOINT_BATCHES = 8;
51
+ const INDEX_FORMAT = 'binary-v1';
52
+
53
+ /** @param {unknown} value @returns {value is Record<string,string>} */
54
+ function isStringRecord(value) {
55
+ return value !== null &&
56
+ typeof value === 'object' &&
57
+ !Array.isArray(value) &&
58
+ Object.values(value).every(entry => typeof entry === 'string');
59
+ }
17
60
 
18
61
  /**
19
62
  * Read JSON, falling back to `fb` when the file is missing or corrupt.
20
63
  * A corrupt-but-present file is reported through `warn` (default: silent) so a
21
64
  * torn write or manual edit doesn't silently wipe the previous index state.
65
+ * @template T
66
+ * @param {string} p
67
+ * @param {T} fb
68
+ * @param {(msg:string)=>void} [warn]
69
+ * @returns {T}
22
70
  */
23
71
  const loadJSON = (p, fb, warn = () => {}) => {
24
72
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
@@ -47,24 +95,46 @@ function normalize(s) {
47
95
  return (s ?? '').replace(/\r\n?/g, '\n').trim();
48
96
  }
49
97
 
98
+ /**
99
+ * Serialize the one canonical full-context passage used for embedding and
100
+ * chunk identity. The title-derived H1 is omitted from the path only when it
101
+ * duplicates the normalized title.
102
+ * @param {{title:string, heading:string, headingPath?:string[], text:string}} chunk
103
+ */
104
+ export function canonicalPassage(chunk) {
105
+ const title = normalize(chunk.title);
106
+ const headingPath = (chunk.headingPath ?? (normalize(chunk.heading) ? [chunk.heading] : [])).map(normalize);
107
+ const serializedPath = (headingPath[0] === title ? headingPath.slice(1) : headingPath).join(' > ');
108
+ return [title, serializedPath, normalize(chunk.text)].join('\n');
109
+ }
110
+
50
111
  /**
51
112
  * Stable per-chunk hash: SHA-256 over the exact passage input that is passed
52
- * to embed() (`title\nheading\ntext`) plus the model identity and its passage
113
+ * to embed() plus the model identity and its passage
53
114
  * prefix. Because the model participates in the key, switching models
54
115
  * invalidates every chunk (full rebuild) - by design. CRLF/whitespace-only
55
116
  * edits of a section do NOT change its hash, so its vector is reused.
56
- * @param {{id:string, passagePrefix?:string}} model - resolved model descriptor
57
- * @param {string} modelName - alias or id as passed on the CLI
58
- * @param {{title:string, heading:string, text:string}} chunk
117
+ *
118
+ * The pinned revision (id@revision, default "main") is part of the key too, so
119
+ * a @revision bump invalidates the vectors embedded by the old revision
120
+ * (issue #27) - the README's "pinned ids invalidate the index" promise.
121
+ *
122
+ * NOTE: the CLI alias (e.g. "e5-base") is deliberately NOT part of the key —
123
+ * only `model.id` + `revision` + `passagePrefix` determine embedding
124
+ * semantics, so hashing is identical for alias and raw-id spellings (issue #6).
125
+ * Adding `revision` to the key (0.5.0) changes stored hash values vs ≤0.4.x →
126
+ * a one-time re-index of changed sections on upgrade.
127
+ * @param {{id:string, revision?:string, passagePrefix?:string, pooling?:import('./models.mjs').Pooling}} model - resolved model descriptor
128
+ * @param {{title:string, heading:string, headingPath?:string[], text:string}} chunk
59
129
  */
60
- export function chunkHash(model, modelName, chunk) {
130
+ export function chunkHash(model, chunk) {
131
+ const pooling = model.pooling ?? 'mean';
61
132
  const input = [
133
+ pooling === 'mean' ? 'heading-path-v1' : `heading-path-v1:${pooling}`,
62
134
  model.id,
63
- modelName || '',
135
+ model.revision || 'main',
64
136
  model.passagePrefix || '',
65
- normalize(chunk.title),
66
- normalize(chunk.heading),
67
- normalize(chunk.text),
137
+ canonicalPassage(chunk),
68
138
  ].join('\u0000');
69
139
  return sha256(input);
70
140
  }
@@ -80,68 +150,307 @@ export function chunkHash(model, modelName, chunk) {
80
150
  * @param {(s:string)=>void} [opts.log]
81
151
  * @param {Function} [opts.embedFn] - embed override for tests/dependency injection;
82
152
  * signature (texts, kind, model, cacheDir, offline) => Promise<number[][]>
153
+ * @param {boolean} [opts._lockHeld=false] - TESTING ONLY: caller already holds the
154
+ * index lock (we are the inner build). Skips re-acquisition — real callers must
155
+ * NOT set this (the lock IS the guard against concurrent writers, issue #37).
83
156
  */
84
157
  export async function buildIndex(opts) {
85
158
  const {
86
159
  db, indexDir, cacheDir, modelName, ignore = [], log = () => {},
87
- offline = false, embedFn = embed,
160
+ offline = false, embedFn = embed, _lockHeld = false,
88
161
  } = opts;
162
+ // Every write to vectors.json + .hashes.json runs under the index lock
163
+ // (issue #37): the two atomic renames are individually atomic, but TWO
164
+ // concurrent builds interleave and can leave the pair from different runs
165
+ // (a torn logical state). The lock serializes writers; a second process gets
166
+ // a clear "locked by PID …" error instead of corrupting the index.
167
+ if (_lockHeld) return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn });
168
+ return withIndexLock(indexDir, () =>
169
+ _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn }));
170
+ }
171
+
172
+ /**
173
+ * The actual build; always runs under the index lock (see buildIndex).
174
+ * Split out so the lock wrapper stays a one-liner.
175
+ * @param {object} o
176
+ * @param {string} o.db
177
+ * @param {string} o.indexDir
178
+ * @param {string} o.cacheDir
179
+ * @param {string} o.modelName
180
+ * @param {string[]} [o.ignore]
181
+ * @param {(s:string)=>void} [o.log]
182
+ * @param {boolean} [o.offline]
183
+ * @param {Function} [o.embedFn]
184
+ */
185
+ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = [], log = () => {}, offline = false, embedFn = embed }) {
89
186
  const model = resolveModel(modelName);
187
+ // Model identity INCLUDES the pinned revision (issue #27): the README's
188
+ // "pinned ids invalidate the index too (the revision is part of the model
189
+ // key)" must hold. Default (unpinned) models resolve to revision "main", so
190
+ // plain `--model e5-base` keeps the historical `model` value... except the
191
+ // upgrade path: pre-0.5 indexes stored `model` WITHOUT `@main`, so they get
192
+ // one full rebuild on upgrade (same migration the 0.4.0 chunkHash change did).
193
+ const modelIdentity = `${model.id}@${model.revision || 'main'}`;
194
+ const adapterFingerprint = embeddingAdapterFingerprint(model);
195
+ const legacyAdapterFingerprint = legacyEmbeddingAdapterFingerprint(model);
196
+ /** @param {PersistedIndex} index */
197
+ const adapterCompatible = (index) => index.adapterFingerprint === adapterFingerprint ||
198
+ (index.adapterFingerprint === undefined && adapterFingerprint === legacyAdapterFingerprint);
90
199
  const vectorsPath = path.join(indexDir, 'vectors.json');
91
200
  const hashesPath = path.join(indexDir, '.hashes.json');
201
+ const checkpointPath = path.join(indexDir, '.checkpoint.json');
92
202
 
93
203
  fs.mkdirSync(indexDir, { recursive: true });
94
204
 
95
205
  const files = walkMarkdown(db, ignore);
96
- const oldHashes = loadJSON(hashesPath, {}, log);
97
- const oldIndex = loadJSON(vectorsPath, { chunks: [], model: null }, log);
206
+ /** @type {Record<string,string>} */
207
+ const canonicalHashes = loadJSON(hashesPath, {}, log);
208
+ /** @type {PersistedIndex} */
209
+ let canonicalIndex = loadJSON(vectorsPath, { chunks: [], model: null }, log);
210
+ let canonicalSchema = 0;
211
+ if (canonicalIndex !== null && typeof canonicalIndex === 'object' &&
212
+ !Array.isArray(canonicalIndex)) {
213
+ canonicalSchema = parseSchemaVersion(canonicalIndex.schemaVersion);
214
+ if (canonicalSchema > SCHEMA_VERSION) {
215
+ throw new Error(
216
+ `${vectorsPath} uses schema v${canonicalSchema}, but this mdss writes ` +
217
+ `v${SCHEMA_VERSION} (index built by a newer version) — upgrade ` +
218
+ `md-semantic-search before re-indexing.`);
219
+ }
220
+ }
221
+ if (canonicalIndex === null || typeof canonicalIndex !== 'object' ||
222
+ !Array.isArray(canonicalIndex.chunks)) {
223
+ log('warning: vectors.json root/chunks are invalid; rebuilding it from scratch.');
224
+ canonicalIndex = { chunks: [], model: null };
225
+ canonicalSchema = 0;
226
+ }
227
+ if (canonicalSchema === SCHEMA_VERSION) {
228
+ try {
229
+ validateIndexEnvelope(canonicalIndex, vectorsPath, {
230
+ encoding: 'stored', validateVectors: false, validateLexical: false,
231
+ });
232
+ } catch (error) {
233
+ log(`warning: ${error.message}; rebuilding vectors.json from scratch.`);
234
+ canonicalIndex = { chunks: [], model: null };
235
+ canonicalSchema = 0;
236
+ }
237
+ }
238
+
239
+ /** @type {PersistedIndex|null} */
240
+ const checkpoint = loadJSON(checkpointPath, null, log);
241
+ let checkpointEnvelopeValid = false;
242
+ try {
243
+ if (checkpoint !== null) {
244
+ validateIndexEnvelope(checkpoint, checkpointPath, {
245
+ encoding: 'stored', allowMissingVectors: true,
246
+ });
247
+ checkpointEnvelopeValid = true;
248
+ }
249
+ } catch {
250
+ checkpointEnvelopeValid = false;
251
+ }
252
+ const checkpointCompatible = checkpoint !== null &&
253
+ checkpointEnvelopeValid &&
254
+ checkpoint.schemaVersion === SCHEMA_VERSION &&
255
+ checkpoint.format === INDEX_FORMAT &&
256
+ checkpoint.model === modelIdentity &&
257
+ adapterCompatible(checkpoint) &&
258
+ checkpoint.db === db &&
259
+ typeof checkpoint.modelAlias === 'string' &&
260
+ typeof checkpoint.built === 'string' &&
261
+ typeof checkpoint.complete === 'boolean' &&
262
+ Number.isInteger(checkpoint.chunkCount) &&
263
+ Array.isArray(checkpoint.chunks) &&
264
+ checkpoint.chunkCount === checkpoint.chunks.length &&
265
+ (!checkpoint.complete || checkpoint.chunks.every(chunk => chunk.vec !== undefined)) &&
266
+ isStringRecord(checkpoint.hashes) &&
267
+ validateLexicalIndex(checkpoint.lexical, checkpoint.chunks.length) === null;
268
+ const oldHashes = checkpointCompatible ? checkpoint.hashes : canonicalHashes;
269
+ const oldIndex = checkpointCompatible ? checkpoint : canonicalIndex;
98
270
 
99
- // If the model changed, all stored vectors are incompatible → full rebuild.
100
- const modelChanged = oldIndex.model && oldIndex.model !== model.id;
271
+ // Older schemas are migrated via the table (v0 → v1 is a no-op: the legacy
272
+ // shapes are normalized by the heuristics right below).
273
+ const sourceSchema = parseSchemaVersion(oldIndex.schemaVersion);
274
+ for (let v = sourceSchema + 1; v <= SCHEMA_VERSION; v++) {
275
+ const step = SCHEMA_MIGRATIONS[v];
276
+ if (step) step(oldIndex);
277
+ }
278
+
279
+ // v0.4.0+ stores vectors as base64 Float32Array (issue #4); legacy ≤0.3.x
280
+ // indexes hold decimal arrays. Normalize the old format to plain arrays so
281
+ // the chunk-level reuse path (vecByChunkHash) sees numbers either way.
282
+ const oldModel = resolveModel(oldIndex.model || oldIndex.modelAlias);
283
+ let oldExpectedDim;
284
+ let oldDimensionValid = true;
285
+ try {
286
+ oldExpectedDim = resolveIndexDimension(oldIndex.dim, oldModel.dim);
287
+ } catch (error) {
288
+ oldDimensionValid = false;
289
+ log(`warning: ${error.message}; stored vectors will be rebuilt.`);
290
+ }
291
+ for (const c of oldIndex.chunks) {
292
+ if (typeof c.vec === 'string' && oldIndex.format === 'binary-v1') {
293
+ try {
294
+ c.vec = oldDimensionValid ? decodeVec(c.vec, oldExpectedDim) : undefined;
295
+ } catch (e) {
296
+ // A corrupt vector in the OLD index must not abort the re-index: drop
297
+ // it (vec stays undefined → the file-level fast path re-embeds it, see
298
+ // the vec-less chunk handling below) and warn (issue #40).
299
+ log(`warning: dropping corrupt vector for ${c.file}` +
300
+ (c.heading ? ` › ${c.heading}` : '') + ` (${e.message})`);
301
+ c.vec = undefined;
302
+ }
303
+ } else if (c.vec !== undefined) {
304
+ try {
305
+ if (!oldDimensionValid) throw new Error('invalid stored dimension');
306
+ validateNumericVector(c.vec, oldExpectedDim, `chunk ${c.file}`);
307
+ } catch {
308
+ log(`warning: dropping corrupt vector for ${c.file}` +
309
+ (c.heading ? ` › ${c.heading}` : ''));
310
+ c.vec = undefined;
311
+ }
312
+ }
313
+ }
314
+
315
+ // If the model (id OR pinned revision) changed, all stored vectors are
316
+ // incompatible → full rebuild (issue #27: a @revision bump must invalidate).
317
+ const modelChanged = oldIndex.model && oldIndex.model !== modelIdentity;
318
+ const contextVectorsReusable = sourceSchema >= 2 && !modelChanged && adapterCompatible(oldIndex);
319
+ let buildDim = model.dim > 0 ? model.dim :
320
+ (contextVectorsReusable && oldDimensionValid ? oldExpectedDim : undefined);
321
+ /** @param {unknown} vector */
322
+ const acceptBuildVector = (vector) => {
323
+ const length = validateNumericVector(vector, buildDim, 'embedding vector');
324
+ buildDim ??= length;
325
+ };
326
+ /** @param {unknown} vector */
327
+ const reusableVector = (vector) => {
328
+ try {
329
+ acceptBuildVector(vector);
330
+ return true;
331
+ } catch {
332
+ return false;
333
+ }
334
+ };
335
+
336
+ const lexicalReusable = sourceSchema === 3 &&
337
+ validateLexicalIndex(oldIndex.lexical, oldIndex.chunks.length) === null &&
338
+ oldIndex.lexical.format === 'bm25-v2';
339
+ const oldLexicalRecords = lexicalReusable ? reverseLexicalIndex(oldIndex.lexical) : [];
101
340
 
102
341
  const oldByFile = new Map();
103
342
  // chunk-level cache: hash of the passage input -> stored vector. Built from
104
343
  // the OLD index so changed files can reuse vectors of unchanged sections.
105
- // Old chunks (from a pre-chunkHash index) get their hash computed the same
106
- // way, so reuse works even on the first run after an upgrade.
344
+ // Missing hashes are recomputed only for schema-v2 chunks. Earlier schemas
345
+ // predate contextual passages and must never seed either reuse path.
107
346
  const vecByChunkHash = new Map();
108
- if (!modelChanged) {
109
- for (const c of oldIndex.chunks) {
347
+ const lexicalByFile = new Map();
348
+ const lexicalByIdentity = new Map();
349
+ for (let oldDocId = 0; oldDocId < oldIndex.chunks.length; oldDocId++) {
350
+ const c = oldIndex.chunks[oldDocId];
351
+ const lexicalRecord = oldLexicalRecords[oldDocId];
352
+ if (contextVectorsReusable) {
110
353
  if (!oldByFile.has(c.file)) oldByFile.set(c.file, []);
111
354
  oldByFile.get(c.file).push(c);
112
- const key = c.chunkHash || chunkHash(model, modelName, c);
355
+ const key = c.chunkHash || chunkHash(model, c);
113
356
  if (c.vec) vecByChunkHash.set(key, c.vec);
114
357
  }
358
+ if (lexicalRecord) {
359
+ if (!lexicalByFile.has(c.file)) lexicalByFile.set(c.file, []);
360
+ lexicalByFile.get(c.file).push(lexicalRecord);
361
+ lexicalByIdentity.set(lexicalIdentity(c), lexicalRecord);
362
+ }
115
363
  }
116
364
 
117
365
  const newHashes = {};
118
366
  const chunks = [];
367
+ const lexicalRecords = [];
119
368
  const toEmbed = [];
120
- let reused = 0, reusedChunks = 0, changedFiles = 0;
369
+ let reused = 0, reusedChunks = 0, changedFiles = 0, skipped = 0;
370
+
371
+ /** @param {IndexChunk} chunk @param {import('./lexical.mjs').TermFrequencies} [record] */
372
+ const appendChunk = (chunk, record) => {
373
+ chunks.push(chunk);
374
+ lexicalRecords.push(record ?? analyzeLexicalDocument(chunk));
375
+ };
376
+
377
+ /**
378
+ * Serialize all current chunks for crash recovery. Unfinished chunks omit
379
+ * `vec`; the existing vec-less reuse path requeues them on the next run.
380
+ * @param {boolean} complete
381
+ */
382
+ const checkpointSnapshot = (complete) => ({
383
+ schemaVersion: SCHEMA_VERSION,
384
+ format: INDEX_FORMAT,
385
+ model: modelIdentity,
386
+ modelAlias: typeof modelName === 'string' ? (modelName || 'e5-base') : model.id,
387
+ adapterFingerprint,
388
+ ...(buildDim === undefined ? {} : { dim: buildDim }),
389
+ db,
390
+ built: new Date().toISOString(),
391
+ complete,
392
+ chunkCount: chunks.length,
393
+ hashes: newHashes,
394
+ lexical: buildLexicalIndex(lexicalRecords),
395
+ chunks: chunks.map(c => ({ ...c, vec: c.vec ? encodeVec(c.vec) : undefined })),
396
+ });
121
397
 
122
398
  for (const abs of files) {
123
- const raw = fs.readFileSync(abs, 'utf8');
124
399
  const rel = path.relative(db, abs).split(path.sep).join('/');
125
- const h = md5(raw);
126
- newHashes[rel] = h;
127
-
128
- if (!modelChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
129
- const old = oldByFile.get(rel);
130
- // Backfill chunkHash for chunks that predate the chunk-level cache, so
131
- // the persisted index is self-contained and no recompute is needed later.
132
- for (const c of old) {
133
- if (!c.chunkHash) c.chunkHash = chunkHash(model, modelName, c);
400
+ /** @type {IndexChunk[]} */
401
+ let parsed = [];
402
+ let reusableLexicalRecords;
403
+ try {
404
+ const raw = fs.readFileSync(abs, 'utf8');
405
+ const h = md5(raw);
406
+ newHashes[rel] = h;
407
+ if (oldHashes[rel] === h) reusableLexicalRecords = lexicalByFile.get(rel);
408
+
409
+ if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable &&
410
+ oldHashes[rel] === h && oldByFile.has(rel)) {
411
+ const old = oldByFile.get(rel);
412
+ // Backfill chunkHash for chunks that predate the chunk-level cache, so
413
+ // the persisted index is self-contained and no recompute is needed later.
414
+ for (const c of old) {
415
+ if (!c.chunkHash) c.chunkHash = chunkHash(model, c);
416
+ }
417
+ for (let oldPosition = 0; oldPosition < old.length; oldPosition++) {
418
+ const c = old[oldPosition];
419
+ if (c.vec && reusableVector(c.vec)) {
420
+ appendChunk(c, reusableLexicalRecords?.[oldPosition]);
421
+ reused++;
422
+ } else {
423
+ // Vec-less chunk (legacy index or a corrupt write): reusing it as-is
424
+ // would persist a broken index that crashes every search with
425
+ // `cosine(qVec, c.vec)` on undefined. Re-embed it instead (issue #25).
426
+ c.vec = undefined;
427
+ toEmbed.push(c);
428
+ appendChunk(c, reusableLexicalRecords?.[oldPosition]);
429
+ }
430
+ }
431
+ continue;
134
432
  }
135
- chunks.push(...old);
136
- reused += old.length;
433
+ changedFiles++;
434
+ // Pass the already-read content so the file is NOT read a second time
435
+ // (issue #35: changed files were being read twice — once for the md5
436
+ // fast-path check, once inside parseFile).
437
+ parsed = parseFile(abs, db, undefined, raw);
438
+ } catch (e) {
439
+ // One unreadable file (EACCES, EISDIR after a race, a broken symlink)
440
+ // must not abort the whole run: skip it with a warning and keep going
441
+ // (issue #36). The file stays out of newHashes AND out of the index, so
442
+ // its old chunks drop exactly like a deleted file's do — and it is
443
+ // retried (and re-warned) on the next run once it becomes readable again.
444
+ delete newHashes[rel];
445
+ skipped++;
446
+ log(`warning: skipping ${rel} (${e.code || e.message})`);
137
447
  continue;
138
448
  }
139
- changedFiles++;
140
- const parsed = parseFile(abs, db);
141
- for (const c of parsed) {
142
- const key = chunkHash(model, modelName, c);
449
+ for (let parsedPosition = 0; parsedPosition < parsed.length; parsedPosition++) {
450
+ const c = parsed[parsedPosition];
451
+ const key = chunkHash(model, c);
143
452
  const cached = vecByChunkHash.get(key);
144
- if (cached) {
453
+ if (cached && reusableVector(cached)) {
145
454
  c.vec = cached;
146
455
  c.chunkHash = key;
147
456
  reused++;
@@ -150,47 +459,81 @@ export async function buildIndex(opts) {
150
459
  c.chunkHash = key;
151
460
  toEmbed.push(c);
152
461
  }
153
- chunks.push(c);
462
+ appendChunk(c, reusableLexicalRecords?.[parsedPosition] ??
463
+ lexicalByIdentity.get(lexicalIdentity(c)));
154
464
  }
155
465
  }
156
466
 
157
467
  if (toEmbed.length > 0) {
158
468
  log(`Embedding ${toEmbed.length} chunks from ${changedFiles} changed file(s) ` +
159
469
  `with ${model.id}...`);
160
- const BATCH = 32;
470
+ let completedBatches = 0;
161
471
  for (let i = 0; i < toEmbed.length; i += BATCH) {
162
472
  const slice = toEmbed.slice(i, i + BATCH);
163
473
  const vecs = await embedFn(
164
- slice.map(c => `${c.title}\n${c.heading}\n${c.text}`),
474
+ slice.map(canonicalPassage),
165
475
  'passage', model, cacheDir, offline,
166
476
  );
477
+ let batchDim = buildDim;
478
+ try {
479
+ if (!Array.isArray(vecs) || vecs.length !== slice.length) throw new Error('batch size');
480
+ if (vecs.length > 0 && batchDim === undefined) {
481
+ batchDim = validateNumericVector(vecs[0], undefined, 'embedding vector 0');
482
+ }
483
+ for (const vec of vecs) validateNumericVector(vec, batchDim, 'embedding vector');
484
+ } catch {
485
+ throw new Error(`embedding returned invalid vectors` +
486
+ (batchDim === undefined ? '' : ` (expected ${batchDim} dims)`) +
487
+ ' — run `mdss index` to rebuild');
488
+ }
489
+ buildDim = batchDim;
167
490
  slice.forEach((c, j) => { c.vec = vecs[j]; });
491
+ completedBatches++;
492
+ if (completedBatches % CHECKPOINT_BATCHES === 0) {
493
+ atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
494
+ }
168
495
  log(` ${Math.min(i + BATCH, toEmbed.length)}/${toEmbed.length}`);
169
496
  }
170
497
  }
171
498
 
172
- const dim = chunks[0]?.vec?.length ?? model.dim ?? 0;
499
+ if (chunks.some(c => !c.vec)) {
500
+ throw new Error('Embedding finished without a vector for every chunk.');
501
+ }
502
+ for (const chunk of chunks) acceptBuildVector(chunk.vec);
503
+ const dim = buildDim ?? (model.dim > 0 ? model.dim : undefined);
504
+ resolveIndexDimension(dim, model.dim);
173
505
  const index = {
174
- model: model.id,
175
- modelAlias: modelName || 'e5-base',
176
- dim,
506
+ schemaVersion: SCHEMA_VERSION, // format gate (issue #39) — bump + add a migration step on change
507
+ format: INDEX_FORMAT, // vec stored as base64 Float32Array (issue #4)
508
+ model: modelIdentity, // id@revision — revision is part of the key (#27)
509
+ modelAlias: typeof modelName === 'string' ? (modelName || 'e5-base') : model.id,
510
+ adapterFingerprint,
511
+ ...(dim === undefined ? {} : { dim }),
177
512
  db,
178
513
  built: new Date().toISOString(),
179
514
  chunkCount: chunks.length,
180
- chunks,
515
+ lexical: buildLexicalIndex(lexicalRecords),
516
+ // ~4× smaller on disk than decimal JSON: 768-dim float = 3072 B → 4096 B
517
+ // base64, vs ~8-10 chars per number for decimal.
518
+ chunks: chunks.map(c => ({ ...c, vec: c.vec ? encodeVec(c.vec) : undefined })),
181
519
  };
182
520
 
521
+ validateIndexEnvelope(index, 'generated index', { encoding: 'stored' });
522
+
523
+ atomicWrite(checkpointPath, JSON.stringify({ ...index, complete: true, hashes: newHashes }));
183
524
  atomicWrite(vectorsPath, JSON.stringify(index));
184
525
  atomicWrite(hashesPath, JSON.stringify(newHashes, null, 2));
526
+ fs.unlinkSync(checkpointPath);
185
527
 
186
528
  return {
187
529
  files: files.length,
530
+ skipped,
188
531
  chunks: chunks.length,
189
532
  reused,
190
533
  reusedChunks,
191
534
  reusedFiles: reused - reusedChunks, // via the file-level fast path
192
535
  embedded: toEmbed.length,
193
- dim,
536
+ dim: dim ?? 0,
194
537
  model: model.id,
195
538
  vectorsPath,
196
539
  };