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/README.md +357 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +573 -32
- package/package.json +11 -2
- package/src/collapse.mjs +31 -0
- package/src/core.mjs +424 -60
- package/src/frontmatter.mjs +213 -0
- package/src/index-format.mjs +134 -0
- package/src/index.mjs +47 -0
- package/src/indexer.mjs +390 -47
- package/src/lexical.mjs +291 -0
- package/src/markdown-parser.mjs +347 -0
- package/src/metrics.mjs +186 -0
- package/src/models.mjs +250 -18
- package/src/rerank.mjs +75 -0
- package/src/search.mjs +571 -42
- package/src/serve.mjs +387 -0
- package/src/wikilinks.mjs +241 -0
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()
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* @
|
|
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,
|
|
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
|
-
|
|
135
|
+
model.revision || 'main',
|
|
64
136
|
model.passagePrefix || '',
|
|
65
|
-
|
|
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
|
-
|
|
97
|
-
const
|
|
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
|
-
//
|
|
100
|
-
|
|
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
|
-
//
|
|
106
|
-
//
|
|
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
|
-
|
|
109
|
-
|
|
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,
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
};
|