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/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-semantic-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./src/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.mjs",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
6
11
|
"bin": {
|
|
7
12
|
"md-semantic-search": "bin/cli.mjs",
|
|
8
13
|
"mdss": "bin/cli.mjs"
|
|
@@ -15,7 +20,8 @@
|
|
|
15
20
|
"LICENSE"
|
|
16
21
|
],
|
|
17
22
|
"scripts": {
|
|
18
|
-
"test": "node --test"
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"lint": "tsc --noEmit"
|
|
19
25
|
},
|
|
20
26
|
"keywords": [
|
|
21
27
|
"semantic-search",
|
|
@@ -44,5 +50,8 @@
|
|
|
44
50
|
},
|
|
45
51
|
"dependencies": {
|
|
46
52
|
"@huggingface/transformers": "^4.2.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "^7.0.2"
|
|
47
56
|
}
|
|
48
57
|
}
|
package/src/collapse.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Result collapse and diversity control helpers (issue #45).
|
|
4
|
+
* Deduplicates / caps multiple chunks per document or canonical identity
|
|
5
|
+
* to increase result diversity in search output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Collapse candidate hits so no single file/canonical document exceeds maxPerFile/maxPerDoc.
|
|
10
|
+
* @template T
|
|
11
|
+
* @param {T[]} results
|
|
12
|
+
* @param {(hit: T) => string} getDocId
|
|
13
|
+
* @param {number} [maxPerDoc=1]
|
|
14
|
+
* @returns {T[]}
|
|
15
|
+
*/
|
|
16
|
+
export function collapseResults(results, getDocId, maxPerDoc = 1) {
|
|
17
|
+
if (!maxPerDoc || maxPerDoc <= 0) return results;
|
|
18
|
+
const counts = new Map();
|
|
19
|
+
const collapsed = [];
|
|
20
|
+
|
|
21
|
+
for (const hit of results) {
|
|
22
|
+
const docId = getDocId(hit);
|
|
23
|
+
const count = counts.get(docId) || 0;
|
|
24
|
+
if (count < maxPerDoc) {
|
|
25
|
+
counts.set(docId, count + 1);
|
|
26
|
+
collapsed.push(hit);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return collapsed;
|
|
31
|
+
}
|
package/src/core.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* Core helpers: model loading, embeddings, markdown walking + chunking, cosine.
|
|
3
4
|
* Fully model- and path-agnostic — everything is driven by explicit arguments
|
|
@@ -6,56 +7,429 @@
|
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { resolveModel } from './models.mjs';
|
|
10
|
+
import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.mjs';
|
|
11
|
+
|
|
12
|
+
export { PARSER_VERSION };
|
|
9
13
|
|
|
10
14
|
const _extractors = new Map();
|
|
11
15
|
|
|
16
|
+
// --- index write lock (issue #37) -------------------------------------------
|
|
17
|
+
// vectors.json and .hashes.json are written by two separate atomic renames
|
|
18
|
+
// (buildIndex's atomicWrite); between them a second `mdss index`/`serve
|
|
19
|
+
// --watch` run can interleave and leave the pair from DIFFERENT runs — a torn
|
|
20
|
+
// logical state. A lockfile in the index dir serializes writers: the holder
|
|
21
|
+
// takes it before any read-compute-write and releases it in a finally. There
|
|
22
|
+
// is intentionally no flock/LOCK_EX — advisory cooperation is enough (all
|
|
23
|
+
// writers are ours), and O_EXCL file creation is the portable atomic primitive.
|
|
24
|
+
|
|
25
|
+
/** Name of the lock file inside the index dir. */
|
|
26
|
+
export const LOCK_FILENAME = '.mdss.lock';
|
|
27
|
+
|
|
28
|
+
/** Locks older than this are presumed abandoned and reclaimed (ms). */
|
|
29
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True when `pid` refers to a live process we can signal. `process.kill(pid, 0)`
|
|
33
|
+
* performs no signal — it just probes; it throws ESRCH when the pid is gone,
|
|
34
|
+
* EPERM when it exists but is owned by another user (counted as alive here —
|
|
35
|
+
* we must NOT steal a live owner's lock).
|
|
36
|
+
*/
|
|
37
|
+
export function pidAlive(pid) {
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
39
|
+
try { process.kill(pid, 0); return true; }
|
|
40
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse + validate a .mdss.lock body; returns null when unreadable/garbage. */
|
|
44
|
+
function readLock(lockPath) {
|
|
45
|
+
let raw;
|
|
46
|
+
try { raw = fs.readFileSync(lockPath, 'utf8'); }
|
|
47
|
+
catch { return null; }
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(raw);
|
|
50
|
+
if (!Number.isInteger(j.pid) || typeof j.since !== 'string') return null;
|
|
51
|
+
return j;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Try to take the index lock; never throws for a held lock.
|
|
57
|
+
* @param {string} indexDir
|
|
58
|
+
* @returns {{ acquired: true, lockPath: string } | { acquired: false, reason: string, pid: (number|null), heldSince: (string|null) }}
|
|
59
|
+
* `acquired:false` carries enough context for an actionable error/log line.
|
|
60
|
+
*/
|
|
61
|
+
export function acquireIndexLock(indexDir) {
|
|
62
|
+
const lockPath = path.join(indexDir, LOCK_FILENAME);
|
|
63
|
+
const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
|
|
64
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
65
|
+
let fd;
|
|
66
|
+
try {
|
|
67
|
+
fd = fs.openSync(lockPath, 'wx'); // O_EXCL — atomic create-or-fail
|
|
68
|
+
fs.writeFileSync(fd, payload);
|
|
69
|
+
return { acquired: true, lockPath };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e.code !== 'EEXIST') {
|
|
72
|
+
throw new Error(`cannot create index lock ${lockPath}: ${e.message}`);
|
|
73
|
+
}
|
|
74
|
+
// Lock exists — decide whether its holder is still alive / fresh.
|
|
75
|
+
let stat = null;
|
|
76
|
+
try { stat = fs.statSync(lockPath); } catch { /* raced — holder released between our open and stat */ }
|
|
77
|
+
const info = readLock(lockPath);
|
|
78
|
+
const pid = info?.pid ?? null;
|
|
79
|
+
const heldSince = info?.since ?? null;
|
|
80
|
+
// Garbage body → treat as abandoned (a crashed pre-lock-format writer).
|
|
81
|
+
const garbage = info === null;
|
|
82
|
+
const dead = pid !== null && !pidAlive(pid);
|
|
83
|
+
const staleMs = stat ? (Date.now() - stat.mtimeMs) : 0;
|
|
84
|
+
if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
|
|
85
|
+
// Reclaim: unlink then loop to retry acquisition. The unlink is safe
|
|
86
|
+
// because the holder is provably dead/stale — a live owner never gets
|
|
87
|
+
// its lock stolen.
|
|
88
|
+
try { fs.unlinkSync(lockPath); } catch { /* raced reclaim — someone else got it */ }
|
|
89
|
+
continue; // retry O_EXCL create
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
acquired: false,
|
|
93
|
+
reason: garbage
|
|
94
|
+
? 'lock file is unreadable/abandoned'
|
|
95
|
+
: dead
|
|
96
|
+
? `lock held by dead pid ${pid} (since ${heldSince})`
|
|
97
|
+
: `index is being written by pid ${pid ?? '?'} (since ${heldSince ?? 'unknown'})`,
|
|
98
|
+
pid, heldSince,
|
|
99
|
+
};
|
|
100
|
+
} finally {
|
|
101
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { acquired: false, reason: 'lock reclaimed by another process during retry', pid: null, heldSince: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Release the index lock held by THIS process; safe to call twice. */
|
|
108
|
+
export function releaseIndexLock(indexDir) {
|
|
109
|
+
try { fs.unlinkSync(path.join(indexDir, LOCK_FILENAME)); }
|
|
110
|
+
catch { /* already released / never held */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run `fn` under the index write lock, releasing it in a finally (crash-safe:
|
|
115
|
+
* the pid-liveness/staleness checks above reclaim a lock abandoned by a killed
|
|
116
|
+
* holder, so a hard exit cannot wedge the index forever).
|
|
117
|
+
* @template T
|
|
118
|
+
* @param {string} indexDir
|
|
119
|
+
* @param {() => Promise<T>} fn
|
|
120
|
+
* @returns {Promise<T>}
|
|
121
|
+
* @throws when the lock is already held (see the error message for pid/since).
|
|
122
|
+
*/
|
|
123
|
+
export async function withIndexLock(indexDir, fn) {
|
|
124
|
+
// The lock file lives IN the index dir, so the dir must exist before the
|
|
125
|
+
// O_EXCL create — and callers (serve) legitimately call a build on a fresh
|
|
126
|
+
// dir. Create it here, idempotently, BEFORE acquiring so the lock guards the
|
|
127
|
+
// whole build including the very first write.
|
|
128
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
129
|
+
const r = acquireIndexLock(indexDir);
|
|
130
|
+
if (!r.acquired) throw new Error(/** @type {{reason:string}} */(r).reason);
|
|
131
|
+
try { return await fn(); }
|
|
132
|
+
finally { releaseIndexLock(indexDir); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @typedef {Object} ModelDescriptor
|
|
137
|
+
* @property {string} id - HF repo id (e.g. "Xenova/multilingual-e5-base")
|
|
138
|
+
* @property {string} [revision] - pinned revision (default "main")
|
|
139
|
+
* @property {number} [dim] - embedding dimension (0 for custom ids)
|
|
140
|
+
* @property {string} [queryPrefix] - E5-style "query: " prefix ('' for bge)
|
|
141
|
+
* @property {string} [passagePrefix] - E5-style "passage: " prefix ('' for bge)
|
|
142
|
+
* @property {import('./models.mjs').Pooling} [pooling] - token pooling strategy (default "mean")
|
|
143
|
+
* @property {boolean} [normalize] - whether vectors are L2-normalized (default true)
|
|
144
|
+
* @property {import('./models.mjs').DType} [dtype] - runtime ONNX dtype (default "q8")
|
|
145
|
+
* @property {number} [maxTokens] - declared tokenizer budget (context window)
|
|
146
|
+
* @property {Record<string, unknown>} [sessionOptions] - ONNX Runtime session options (e.g. intraOpNumThreads)
|
|
147
|
+
* @property {boolean} [unknownAdapter] - true for unconfigured raw ids that
|
|
148
|
+
* cannot embed until an explicit adapter is supplied (issue #60)
|
|
149
|
+
* @property {string} [note] - human-readable description
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Select the Transformers.js pipeline source for a model load.
|
|
154
|
+
* @param {ModelDescriptor} model
|
|
155
|
+
* @param {string} cacheDir
|
|
156
|
+
* @param {boolean} offline
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function getPipelineModelSource(model, cacheDir, offline) {
|
|
160
|
+
const revision = model.revision || 'main';
|
|
161
|
+
return offline && revision !== 'main'
|
|
162
|
+
? path.join(cacheDir, model.id, revision)
|
|
163
|
+
: model.id;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve ONNX Runtime session options from model adapter or environment variables (issue #34).
|
|
168
|
+
* @param {ModelDescriptor} [model]
|
|
169
|
+
* @returns {Record<string, unknown>|undefined}
|
|
170
|
+
*/
|
|
171
|
+
export function resolveSessionOptions(model) {
|
|
172
|
+
const envIntra = process.env.MDSS_INTRA_OP_THREADS || process.env.MDSS_NUM_THREADS;
|
|
173
|
+
const envInter = process.env.MDSS_INTER_OP_THREADS;
|
|
174
|
+
const intra = envIntra ? parseInt(envIntra, 10) : undefined;
|
|
175
|
+
const inter = envInter ? parseInt(envInter, 10) : undefined;
|
|
176
|
+
const opts = model?.sessionOptions || {};
|
|
177
|
+
const intraOpNumThreads = Number.isSafeInteger(intra) && intra > 0 ? intra : opts.intraOpNumThreads;
|
|
178
|
+
const interOpNumThreads = Number.isSafeInteger(inter) && inter > 0 ? inter : opts.interOpNumThreads;
|
|
179
|
+
|
|
180
|
+
if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
/** @type {Record<string, unknown>} */
|
|
184
|
+
const result = {};
|
|
185
|
+
if (intraOpNumThreads !== undefined) result.intraOpNumThreads = intraOpNumThreads;
|
|
186
|
+
if (interOpNumThreads !== undefined) result.interOpNumThreads = interOpNumThreads;
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get the in-memory cache key for a pipeline extractor session.
|
|
192
|
+
* @param {ModelDescriptor} model
|
|
193
|
+
* @param {string} cacheDir
|
|
194
|
+
* @param {boolean} [offline]
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
export function getExtractorCacheKey(model, cacheDir, offline) {
|
|
198
|
+
const revision = model.revision || 'main';
|
|
199
|
+
const source = getPipelineModelSource(model, cacheDir, offline);
|
|
200
|
+
const sessionOptions = resolveSessionOptions(model);
|
|
201
|
+
const sessionKey = sessionOptions ? JSON.stringify(sessionOptions) : 'default';
|
|
202
|
+
return `${model.id}@${revision}|${offline ? 'off' : 'on'}|${source}|${sessionKey}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
12
205
|
/**
|
|
13
206
|
* Lazily load (and cache) a feature-extraction pipeline for a model.
|
|
14
|
-
*
|
|
207
|
+
* A neutral unknown-raw-id adapter is rejected here with an actionable message
|
|
208
|
+
* (issue #60) — the model name is never guessed to be E5/BGE compatible.
|
|
209
|
+
* @param {ModelDescriptor} model - resolved adapter from resolveModel()
|
|
15
210
|
* @param {string} cacheDir
|
|
16
211
|
* @param {boolean} [offline=false] - never touch the network; require a cached model
|
|
17
212
|
*/
|
|
18
213
|
export async function getExtractor(model, cacheDir, offline = false) {
|
|
19
|
-
|
|
214
|
+
if (model.unknownAdapter === true) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`model "${model.id}" is not a registered adapter and has no explicit ` +
|
|
217
|
+
'configuration, so it cannot embed safely (no E5 prefixes / pooling are ' +
|
|
218
|
+
'guessed). Register it in MODELS or pass an explicit adapter descriptor ' +
|
|
219
|
+
'(see README "How to add a model") — e.g. for a raw id, supply ' +
|
|
220
|
+
'queryPrefix/passagePrefix/dim/pooling explicitly.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const revision = model.revision || 'main';
|
|
224
|
+
const source = getPipelineModelSource(model, cacheDir, offline);
|
|
225
|
+
const key = getExtractorCacheKey(model, cacheDir, offline);
|
|
20
226
|
if (_extractors.has(key)) return _extractors.get(key);
|
|
21
227
|
const { pipeline, env } = await import('@huggingface/transformers');
|
|
22
228
|
if (cacheDir) env.cacheDir = cacheDir;
|
|
23
229
|
env.allowRemoteModels = !offline;
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
230
|
+
const sessionOptions = resolveSessionOptions(model);
|
|
231
|
+
// Use the adapter-declared runtime dtype (default q8 — the v4 default on Node
|
|
232
|
+
// is fp32, ~4x larger). The declared dtype is part of the runtime load, not
|
|
233
|
+
// of the vector-producing fingerprint, so a dtype change alone does not
|
|
234
|
+
// invalidate stored vectors.
|
|
235
|
+
const pipeOpts = {
|
|
236
|
+
revision,
|
|
237
|
+
dtype: model.dtype || 'q8',
|
|
238
|
+
...(sessionOptions ? { session_options: sessionOptions } : {}),
|
|
239
|
+
};
|
|
240
|
+
const ext = await pipeline('feature-extraction', source, pipeOpts);
|
|
31
241
|
_extractors.set(key, ext);
|
|
32
242
|
return ext;
|
|
33
243
|
}
|
|
34
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Prepare model-specific feature-extraction inputs and invocation options.
|
|
247
|
+
* All vector-producing semantics come from the resolved adapter (issue #60):
|
|
248
|
+
* query/document formatting, pooling, and normalization are read from the
|
|
249
|
+
* descriptor, never inferred from the model name.
|
|
250
|
+
* @param {ModelDescriptor} model - resolved adapter from resolveModel()
|
|
251
|
+
* @param {string[]} texts
|
|
252
|
+
* @param {'query'|'passage'} kind
|
|
253
|
+
* @returns {{input:string[], options:{pooling:import('./models.mjs').Pooling, normalize:boolean}}}
|
|
254
|
+
*/
|
|
255
|
+
export function prepareEmbeddingRequest(model, texts, kind) {
|
|
256
|
+
if (model.unknownAdapter === true) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`model "${model.id}" is not a registered adapter and has no explicit ` +
|
|
259
|
+
'configuration, so it cannot embed safely (issue #60) — supply an ' +
|
|
260
|
+
'explicit adapter descriptor, see README "How to add a model".',
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const prefix = kind === 'query' ? model.queryPrefix : model.passagePrefix;
|
|
264
|
+
const input = prefix ? texts.map(text => prefix + text) : texts;
|
|
265
|
+
return {
|
|
266
|
+
input,
|
|
267
|
+
options: { pooling: model.pooling ?? 'mean', normalize: model.normalize !== false },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
35
271
|
/**
|
|
36
272
|
* Embed texts with the given model descriptor.
|
|
37
273
|
* @param {string[]} texts
|
|
38
274
|
* @param {'query'|'passage'} kind
|
|
39
|
-
* @param {
|
|
275
|
+
* @param {ModelDescriptor} model - descriptor from resolveModel()
|
|
40
276
|
* @param {string} cacheDir
|
|
41
277
|
* @param {boolean} [offline=false] - never touch the network; require a cached model
|
|
42
278
|
* @returns {Promise<number[][]>} L2-normalized vectors
|
|
43
279
|
*/
|
|
44
280
|
export async function embed(texts, kind, model, cacheDir, offline = false) {
|
|
45
281
|
const ext = await getExtractor(model, cacheDir, offline);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const out = await ext(input, { pooling: 'mean', normalize: true });
|
|
282
|
+
const { input, options } = prepareEmbeddingRequest(model, texts, kind);
|
|
283
|
+
const out = await ext(input, options);
|
|
49
284
|
return out.tolist();
|
|
50
285
|
}
|
|
51
286
|
|
|
52
|
-
/** Cosine similarity for L2-normalized vectors == dot product. */
|
|
53
|
-
export function cosine(a, b) {
|
|
287
|
+
/** Cosine similarity for L2-normalized vectors == dot product. Supports offset in contiguous buffer (issue #30). */
|
|
288
|
+
export function cosine(a, b, bOffset = 0) {
|
|
54
289
|
let s = 0;
|
|
55
|
-
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
|
290
|
+
for (let i = 0; i < a.length; i++) s += a[i] * b[bOffset + i];
|
|
56
291
|
return s;
|
|
57
292
|
}
|
|
58
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Encode a numeric vector as a base64 string of its Float32Array bytes
|
|
296
|
+
* (binary vector storage, issue #4). ~4× smaller than decimal JSON: a 768-dim
|
|
297
|
+
* vector is 3072 raw bytes → 4096 base64 chars, vs ~8-10 chars per number.
|
|
298
|
+
* @param {number[]|Float32Array} vec
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
export function encodeVec(vec) {
|
|
302
|
+
const f32 = vec instanceof Float32Array ? vec : Float32Array.from(vec);
|
|
303
|
+
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength).toString('base64');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Decode a base64 vector string back to a Float32Array (binary storage, #4).
|
|
308
|
+
*
|
|
309
|
+
* Throws on corrupt input instead of silently producing garbage (issue #40):
|
|
310
|
+
* - base64 whose byte length is not a multiple of 4 — a truncated string would
|
|
311
|
+
* previously decode into a truncated Float32Array (wrong dim, silent NaN
|
|
312
|
+
* scores downstream);
|
|
313
|
+
* - non-finite values (NaN/Infinity) — e.g. from a partial write or bit rot.
|
|
314
|
+
*
|
|
315
|
+
* When `dim` is given, a length mismatch is also rejected.
|
|
316
|
+
* @param {string} s
|
|
317
|
+
* @param {number} [dim] - expected vector length (issue #40)
|
|
318
|
+
* @returns {Float32Array}
|
|
319
|
+
*/
|
|
320
|
+
export function decodeVec(s, dim) {
|
|
321
|
+
if (s.length === 0) {
|
|
322
|
+
throw new Error('corrupt base64 vector: empty value — run `mdss index` to rebuild');
|
|
323
|
+
}
|
|
324
|
+
if (s.length % 4 !== 0 ||
|
|
325
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
|
|
326
|
+
throw new Error('corrupt base64 vector: encoding is not canonical — ' +
|
|
327
|
+
'run `mdss index` to rebuild');
|
|
328
|
+
}
|
|
329
|
+
const buf = Buffer.from(s, 'base64');
|
|
330
|
+
if (buf.toString('base64') !== s) {
|
|
331
|
+
throw new Error('corrupt base64 vector: encoding is not canonical — ' +
|
|
332
|
+
'run `mdss index` to rebuild');
|
|
333
|
+
}
|
|
334
|
+
if (buf.byteLength % 4 !== 0) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`corrupt base64 vector: ${buf.byteLength} bytes is not a multiple of 4 ` +
|
|
337
|
+
`(a float32 is 4 bytes) — run \`mdss index\` to rebuild`);
|
|
338
|
+
}
|
|
339
|
+
const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
340
|
+
if (dim !== undefined && vec.length !== dim) {
|
|
341
|
+
throw new Error(`corrupt vector: ${vec.length} dims, expected ${dim} — ` +
|
|
342
|
+
`run \`mdss index\` to rebuild`);
|
|
343
|
+
}
|
|
344
|
+
for (let i = 0; i < vec.length; i++) {
|
|
345
|
+
if (!Number.isFinite(vec[i])) {
|
|
346
|
+
throw new Error(`corrupt vector: non-finite value at index ${i} ` +
|
|
347
|
+
`(NaN/Infinity) — run \`mdss index\` to rebuild`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return vec;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* True when the index uses the binary vector format (vec stored as base64).
|
|
355
|
+
* @param {{format?:string}} index - parsed vectors.json
|
|
356
|
+
* @returns {boolean}
|
|
357
|
+
*/
|
|
358
|
+
export function isBinaryIndex(index) {
|
|
359
|
+
return index.format === 'binary-v1';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @param {unknown} value @returns {number} */
|
|
363
|
+
export function parseSchemaVersion(value) {
|
|
364
|
+
if (value === undefined) return 0;
|
|
365
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
366
|
+
throw new Error('schemaVersion must be a non-negative safe integer — ' +
|
|
367
|
+
'run `mdss index` to rebuild');
|
|
368
|
+
}
|
|
369
|
+
return value;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {unknown} stored
|
|
374
|
+
* @param {number} known
|
|
375
|
+
* @returns {number|undefined}
|
|
376
|
+
*/
|
|
377
|
+
export function resolveIndexDimension(stored, known) {
|
|
378
|
+
if (stored !== undefined) {
|
|
379
|
+
if (typeof stored !== 'number' || !Number.isSafeInteger(stored) || stored <= 0) {
|
|
380
|
+
throw new Error('index.dim must be a positive safe integer — ' +
|
|
381
|
+
'run `mdss index` to rebuild');
|
|
382
|
+
}
|
|
383
|
+
if (known > 0 && stored !== known) {
|
|
384
|
+
throw new Error(`index.dim ${stored} does not match known model dimension ${known} — ` +
|
|
385
|
+
'run `mdss index` to rebuild');
|
|
386
|
+
}
|
|
387
|
+
return stored;
|
|
388
|
+
}
|
|
389
|
+
return known > 0 ? known : undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Index schema version (issue #39). Bump on every breaking change to
|
|
394
|
+
* vectors.json and add a migration step to SCHEMA_MIGRATIONS.
|
|
395
|
+
* v0 (implicit): everything written before schemaVersion existed — the
|
|
396
|
+
* loader's format heuristics (decimal vs binary-v1 vecs, missing model
|
|
397
|
+
* field, missing chunkHash, vec-less chunks) already handle all v0 shapes.
|
|
398
|
+
* v1: first explicit version; written by all builds since 0.6.0.
|
|
399
|
+
* v2: chunks persist leaf-inclusive headingPath for contextual embeddings.
|
|
400
|
+
* v3: vectors.json requires an embedded BM25 lexical index.
|
|
401
|
+
*/
|
|
402
|
+
export const SCHEMA_VERSION = 3;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Mark a legacy index as schema v2 without inventing heading paths it never
|
|
406
|
+
* stored. Legacy readers remain usable; the indexer performs the honest
|
|
407
|
+
* contextual rebuild before it persists schema-v2 chunks.
|
|
408
|
+
* @param {{schemaVersion?:number}} index
|
|
409
|
+
*/
|
|
410
|
+
export function migrateToSchemaV2(index) {
|
|
411
|
+
index.schemaVersion = 2;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Schema-v3 is a format declaration; only a rebuild may write it. */
|
|
415
|
+
export function migrateToSchemaV3() {}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Migration table: `SCHEMA_MIGRATIONS[n]` upgrades an index at schema n-1 → n.
|
|
419
|
+
* Read by loadIndex/buildIndex before use; writing a version newer than the
|
|
420
|
+
* binary supports is a hard error instead of a silent misparse (issue #39).
|
|
421
|
+
* @type {Record<number, (index: object) => void>}
|
|
422
|
+
*/
|
|
423
|
+
export const SCHEMA_MIGRATIONS = {
|
|
424
|
+
// v0 → v1: no structural change — legacy shapes (decimal vecs, missing
|
|
425
|
+
// model/chunkHash, vec-less chunks) are normalized by the loaders' existing
|
|
426
|
+
// format heuristics, not by a data rewrite. This entry exists so the table
|
|
427
|
+
// is explicit: every future format change adds a real step here + a test.
|
|
428
|
+
1: () => {},
|
|
429
|
+
2: migrateToSchemaV2,
|
|
430
|
+
3: migrateToSchemaV3,
|
|
431
|
+
};
|
|
432
|
+
|
|
59
433
|
/** Recursively collect .md/.markdown files under dir, honoring ignore globs. */
|
|
60
434
|
export function walkMarkdown(dir, ignore = []) {
|
|
61
435
|
const out = [];
|
|
@@ -112,60 +486,50 @@ export function extractTitle(frontmatter, body, relPath) {
|
|
|
112
486
|
const DEFAULT_MAX_CHUNK = 1400; // chars; ~350-450 tokens, fits e5/bge context
|
|
113
487
|
|
|
114
488
|
/**
|
|
115
|
-
* Chunk markdown by headings
|
|
116
|
-
*
|
|
489
|
+
* Chunk markdown structurally by headings and atomic block boundaries (issue #57).
|
|
490
|
+
* Oversized sections split further on block boundaries and hard wraps.
|
|
491
|
+
* @param {string} body
|
|
492
|
+
* @param {number} [maxChunk=DEFAULT_MAX_CHUNK]
|
|
493
|
+
* @returns {import('./markdown-parser.mjs').StructuralChunk[]}
|
|
117
494
|
*/
|
|
118
495
|
export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK) {
|
|
119
|
-
|
|
120
|
-
const sections = [];
|
|
121
|
-
let curHeading = '';
|
|
122
|
-
let buf = [];
|
|
123
|
-
const flush = () => {
|
|
124
|
-
const text = buf.join('\n').trim();
|
|
125
|
-
if (text) sections.push({ heading: curHeading, text });
|
|
126
|
-
buf = [];
|
|
127
|
-
};
|
|
128
|
-
for (const line of lines) {
|
|
129
|
-
const h = line.match(/^(#{1,6})\s+(.+)$/);
|
|
130
|
-
if (h) { flush(); curHeading = h[2].trim(); }
|
|
131
|
-
else buf.push(line);
|
|
132
|
-
}
|
|
133
|
-
flush();
|
|
134
|
-
|
|
135
|
-
const chunks = [];
|
|
136
|
-
for (const sec of sections) {
|
|
137
|
-
if (sec.text.length <= maxChunk) { chunks.push(sec); continue; }
|
|
138
|
-
const paras = sec.text.split(/\n\s*\n/);
|
|
139
|
-
let acc = '';
|
|
140
|
-
for (const p of paras) {
|
|
141
|
-
if ((acc + '\n\n' + p).length > maxChunk && acc) {
|
|
142
|
-
chunks.push({ heading: sec.heading, text: acc.trim() });
|
|
143
|
-
acc = p;
|
|
144
|
-
} else {
|
|
145
|
-
acc = acc ? acc + '\n\n' + p : p;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (acc.trim()) chunks.push({ heading: sec.heading, text: acc.trim() });
|
|
149
|
-
}
|
|
150
|
-
return chunks.filter(c => c.text.replace(/\s/g, '').length >= 24);
|
|
496
|
+
return chunkMarkdownStructural(body, maxChunk);
|
|
151
497
|
}
|
|
152
498
|
|
|
499
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
500
|
+
|
|
501
|
+
export { parseFrontmatter };
|
|
502
|
+
|
|
153
503
|
/**
|
|
154
504
|
* Parse one file into title + chunk records (no embeddings yet).
|
|
155
505
|
* @param {string} absPath - absolute file path
|
|
156
506
|
* @param {string} dbDir - base dir (for relative file labels)
|
|
507
|
+
* @param {number} [maxChunk] - chunk size cap (defaults to DEFAULT_MAX_CHUNK)
|
|
508
|
+
* @param {string} [raw] - already-read file content. When given, the file is
|
|
509
|
+
* NOT read from disk again (issue #35: buildIndex already read it for the
|
|
510
|
+
* md5 fast-path check, so a changed file was being read twice).
|
|
511
|
+
* @returns {{file:string, title:string, heading:string, headingPath:string[], text:string, startLine?:number, endLine?:number, meta?:import('./frontmatter.mjs').DocumentMetadata}[]}
|
|
157
512
|
*/
|
|
158
|
-
export function parseFile(absPath, dbDir, maxChunk) {
|
|
159
|
-
const
|
|
160
|
-
const { frontmatter, body } = splitFrontmatter(
|
|
513
|
+
export function parseFile(absPath, dbDir, maxChunk, raw) {
|
|
514
|
+
const content = raw ?? fs.readFileSync(absPath, 'utf8');
|
|
515
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
516
|
+
const meta = parseFrontmatter(frontmatter);
|
|
161
517
|
const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
|
|
162
|
-
const title = extractTitle(frontmatter, body, rel);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
title
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
518
|
+
const title = meta.title || extractTitle(frontmatter, body, rel);
|
|
519
|
+
const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
|
|
520
|
+
return chunkMarkdown(body, maxChunk).map(c => {
|
|
521
|
+
const heading = c.heading || title;
|
|
522
|
+
return {
|
|
523
|
+
file: rel,
|
|
524
|
+
title,
|
|
525
|
+
heading,
|
|
526
|
+
headingPath: c.headingPath.length > 0 ? c.headingPath : [heading],
|
|
527
|
+
text: c.text,
|
|
528
|
+
startLine: (c.startLine || 1) + frontmatterLineCount,
|
|
529
|
+
endLine: (c.endLine || 1) + frontmatterLineCount,
|
|
530
|
+
meta,
|
|
531
|
+
};
|
|
532
|
+
});
|
|
169
533
|
}
|
|
170
534
|
|
|
171
535
|
export { resolveModel };
|