md-semantic-search 1.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. package/src/wikilinks.ts +220 -1
package/dist/hnsw.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure JS/TS HNSW approximate nearest neighbor index (issue #201).
3
+ *
4
+ * Hierarchical Navigable Small World graphs (Malkov & Yashunin 2016): no
5
+ * training phase, incremental inserts, ~99% recall with sublinear search —
6
+ * the counterpart of the k-means IVF in `ivf.ts`, sharing its contract:
7
+ * build → serialize → persist → search as a candidate pre-filter in front
8
+ * of the exact sweep. Cosine distance (1 − dot on normalized vectors) is
9
+ * computed through an injected distance function so int8 zero-copy vectors
10
+ * work unchanged. Deleted nodes are tombstones — searches skip them, which
11
+ * is what lets the incremental watch rebuild reuse a persisted graph.
12
+ */
13
+ export interface HnswIndex {
14
+ dim: number;
15
+ /** Max links per node on layers > 0. */
16
+ M: number;
17
+ /** Random level seed — rebuilds from the same insertion order are identical. */
18
+ seed: number;
19
+ /** Node index of the graph entry (top layer). */
20
+ entryPoint: number;
21
+ /** Highest layer in the graph. */
22
+ maxLevel: number;
23
+ /** levels[i] = top layer of node i. */
24
+ levels: number[];
25
+ /** neighbors[i][l] = link list of node i on layer l (l ≤ levels[i]). */
26
+ neighbors: number[][][];
27
+ /** Tombstoned node indices — skipped by search, kept for graph connectivity. */
28
+ deleted: Set<number>;
29
+ }
30
+ export interface SerializedHnsw {
31
+ format: 'hnsw-v1';
32
+ dim: number;
33
+ M: number;
34
+ seed: number;
35
+ entryPoint: number;
36
+ maxLevel: number;
37
+ count: number;
38
+ levels: number[];
39
+ neighbors: number[][][];
40
+ deleted: number[];
41
+ }
42
+ export interface BuildHnswOptions {
43
+ M?: number;
44
+ seed?: number;
45
+ /**
46
+ * Distance between the query/node vector at `a` and the stored node `b` —
47
+ * LOWER is closer (cosine distance: `1 − dot(a, vectors[b])`). Injected so
48
+ * int8 zero-copy and norm-cached sweeps work unchanged.
49
+ */
50
+ distance: (a: Float32Array | Int8Array | number[], b: number) => number;
51
+ vectors: Array<Float32Array | Int8Array | number[]>;
52
+ }
53
+ export declare const DEFAULT_HNSW_M = 16;
54
+ export declare const DEFAULT_HNSW_EF = 64;
55
+ export declare function buildHnsw(vectors: Array<Float32Array | Int8Array | number[]>, options: BuildHnswOptions): HnswIndex;
56
+ /**
57
+ * Insert node `idx` (vector index) into the graph. Incremental — new indices
58
+ * attach to the existing structure without a rebuild; vectors for nodes NOT
59
+ * yet inserted are read from `vectors` when needed, so the caller can insert
60
+ * lazily. Tombstoned indices can be re-inserted (undeletes).
61
+ */
62
+ export declare function insertHnsw(hnsw: HnswIndex, idx: number, distance: (a: Float32Array | Int8Array | number[], b: number) => number, vectors: Array<Float32Array | Int8Array | number[]>): void;
63
+ /** Tombstone a node — searches skip it; the graph keeps it for connectivity. */
64
+ export declare function deleteHnsw(hnsw: HnswIndex, idx: number): void;
65
+ /**
66
+ * Algorithm 4 of the HNSW paper (Malkov & Yashunin 2016): candidates sorted
67
+ * by distance to the query enter the result in order; a candidate is skipped
68
+ * when it is closer to some already-selected neighbor than to the query
69
+ * (redundant direction). `pairwise(a, b)` measures node-to-node distance.
70
+ * If fewer than `m` survive (disconnected candidate set), the closest
71
+ * leftovers backfill so the link count stays useful.
72
+ */
73
+ export declare function selectNeighborsHeuristic(candidates: Array<{
74
+ node: number;
75
+ dist: number;
76
+ }>, m: number, pairwise: (a: number, b: number) => number): Array<{
77
+ node: number;
78
+ dist: number;
79
+ }>;
80
+ /**
81
+ * ANN candidate pre-filter: ef-search from the entry point, returning LIVE
82
+ * candidate chunk indices (tombstones excluded). Mirrors
83
+ * `searchIVFCandidates` — the result feeds the same probed-candidate set.
84
+ */
85
+ export declare function searchHnswCandidates(queryVec: Float32Array | Int8Array | number[], hnsw: HnswIndex, vectors: Array<Float32Array | Int8Array | number[]>, options?: {
86
+ ef?: number;
87
+ /** Lower-is-closer distance; defaults to cosine distance via `cosine`. */
88
+ distance?: (a: Float32Array | Int8Array | number[], b: number) => number;
89
+ }): number[];
90
+ export declare function serializeHnsw(hnsw: HnswIndex): SerializedHnsw;
91
+ /**
92
+ * Hardened deserialization — every structural invariant is checked with the
93
+ * same discipline as `deserializeIVF`; any violation throws (the loader
94
+ * falls back to brute force).
95
+ */
96
+ export declare function deserializeHnsw(raw: unknown, expectedChunksCount?: number): HnswIndex;
package/dist/hnsw.js ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Pure JS/TS HNSW approximate nearest neighbor index (issue #201).
3
+ *
4
+ * Hierarchical Navigable Small World graphs (Malkov & Yashunin 2016): no
5
+ * training phase, incremental inserts, ~99% recall with sublinear search —
6
+ * the counterpart of the k-means IVF in `ivf.ts`, sharing its contract:
7
+ * build → serialize → persist → search as a candidate pre-filter in front
8
+ * of the exact sweep. Cosine distance (1 − dot on normalized vectors) is
9
+ * computed through an injected distance function so int8 zero-copy vectors
10
+ * work unchanged. Deleted nodes are tombstones — searches skip them, which
11
+ * is what lets the incremental watch rebuild reuse a persisted graph.
12
+ */
13
+ /** Deterministic PRNG for level assignment (same family as ivf.ts). */
14
+ function mulberry32(seed) {
15
+ let s = seed >>> 0;
16
+ return () => {
17
+ s = (s + 0x6d2b79f5) >>> 0;
18
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
19
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
20
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
21
+ };
22
+ }
23
+ export const DEFAULT_HNSW_M = 16;
24
+ export const DEFAULT_HNSW_EF = 64;
25
+ export function buildHnsw(vectors, options) {
26
+ const hnsw = {
27
+ dim: vectors[0]?.length ?? 0,
28
+ M: options.M ?? DEFAULT_HNSW_M,
29
+ seed: options.seed ?? 42,
30
+ entryPoint: -1,
31
+ maxLevel: -1,
32
+ levels: [],
33
+ neighbors: [],
34
+ deleted: new Set(),
35
+ };
36
+ for (let i = 0; i < vectors.length; i++)
37
+ insertHnsw(hnsw, i, options.distance, options.vectors);
38
+ return hnsw;
39
+ }
40
+ /**
41
+ * Insert node `idx` (vector index) into the graph. Incremental — new indices
42
+ * attach to the existing structure without a rebuild; vectors for nodes NOT
43
+ * yet inserted are read from `vectors` when needed, so the caller can insert
44
+ * lazily. Tombstoned indices can be re-inserted (undeletes).
45
+ */
46
+ export function insertHnsw(hnsw, idx, distance, vectors) {
47
+ const vec = vectors[idx];
48
+ if (!vec)
49
+ throw new Error(`hnsw insert: no vector at index ${idx}`);
50
+ if (hnsw.levels[idx] !== undefined && !hnsw.deleted.has(idx)) {
51
+ throw new Error(`hnsw insert: node ${idx} already present`);
52
+ }
53
+ hnsw.deleted.delete(idx);
54
+ const M = hnsw.M;
55
+ const M0 = M * 2;
56
+ const mL = 1 / Math.log(M);
57
+ const rng = mulberry32(hnsw.seed + idx * 2654435761);
58
+ const u = Math.max(rng(), Number.MIN_VALUE);
59
+ const level = Math.min(Math.floor(-Math.log(u) * mL), 64);
60
+ hnsw.levels[idx] = level;
61
+ hnsw.neighbors[idx] = Array.from({ length: level + 1 }, () => []);
62
+ if (hnsw.entryPoint < 0) {
63
+ hnsw.entryPoint = idx;
64
+ hnsw.maxLevel = level;
65
+ return;
66
+ }
67
+ const qDist = (node) => distance(vec, node);
68
+ // 1. Greedy descent through layers above the node's level
69
+ let ep = hnsw.entryPoint;
70
+ let epDist = qDist(ep);
71
+ for (let l = hnsw.maxLevel; l > level; l--) {
72
+ let changed = true;
73
+ while (changed) {
74
+ changed = false;
75
+ for (const nb of hnsw.neighbors[ep][l] ?? []) {
76
+ const d = qDist(nb);
77
+ if (d < epDist) {
78
+ epDist = d;
79
+ ep = nb;
80
+ changed = true;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ // 2. Insert on each layer from min(level, maxLevel) down to 0
86
+ const pairwise = (a, b) => distance(vectors[a], b);
87
+ for (let l = Math.min(level, hnsw.maxLevel); l >= 0; l--) {
88
+ const efC = Math.max(M0, DEFAULT_HNSW_EF);
89
+ const candidates = searchLayer(hnsw, qDist, ep, epDist, l, efC);
90
+ const cap = l === 0 ? M0 : M;
91
+ // Algorithm 4: diversity-aware neighbor selection keeps the graph
92
+ // navigable — a candidate is skipped only when it is CLOSER to an
93
+ // already-selected neighbor than to the query
94
+ const selected = selectNeighborsHeuristic(candidates, cap, pairwise);
95
+ hnsw.neighbors[idx][l] = selected.map((c) => c.node);
96
+ for (const { node } of selected) {
97
+ const links = hnsw.neighbors[node][l];
98
+ if (!links.includes(idx))
99
+ links.push(idx);
100
+ if (links.length > cap) {
101
+ // re-select the neighbor's own links with the same heuristic — the
102
+ // diversity rule preserves connectivity instead of blind top-M,
103
+ // which used to sever the just-added edge and strand whole regions
104
+ const pruned = selectNeighborsHeuristic(links.map((n) => ({ node: n, dist: pairwise(node, n) })), cap, pairwise);
105
+ hnsw.neighbors[node][l] = pruned.map((c) => c.node);
106
+ }
107
+ }
108
+ if (candidates.length > 0) {
109
+ ep = candidates[0].node;
110
+ epDist = candidates[0].dist;
111
+ }
112
+ }
113
+ if (level > hnsw.maxLevel) {
114
+ hnsw.maxLevel = level;
115
+ hnsw.entryPoint = idx;
116
+ }
117
+ }
118
+ /** Tombstone a node — searches skip it; the graph keeps it for connectivity. */
119
+ export function deleteHnsw(hnsw, idx) {
120
+ if (hnsw.levels[idx] !== undefined)
121
+ hnsw.deleted.add(idx);
122
+ }
123
+ /** ef-search one layer, returning the closest `ef` live candidates (closest first). */
124
+ function searchLayer(hnsw, qDist, ep, epDist, layer, ef) {
125
+ const visited = new Set([ep]);
126
+ // max-heap by dist via simple sorted insertion (candidate lists are small)
127
+ const candidates = [{ node: ep, dist: epDist }];
128
+ const results = hnsw.deleted.has(ep) ? [] : [{ node: ep, dist: epDist }];
129
+ while (candidates.length > 0) {
130
+ const current = candidates.shift();
131
+ const worst = results.length >= ef ? results[results.length - 1].dist : Infinity;
132
+ if (current.dist > worst)
133
+ break;
134
+ for (const nb of hnsw.neighbors[current.node][layer] ?? []) {
135
+ if (visited.has(nb))
136
+ continue;
137
+ visited.add(nb);
138
+ const d = qDist(nb);
139
+ const worstNow = results.length >= ef ? results[results.length - 1].dist : Infinity;
140
+ if (d < worstNow || results.length < ef) {
141
+ candidates.push({ node: nb, dist: d });
142
+ candidates.sort((a, b) => a.dist - b.dist);
143
+ if (!hnsw.deleted.has(nb)) {
144
+ results.push({ node: nb, dist: d });
145
+ results.sort((a, b) => a.dist - b.dist);
146
+ if (results.length > ef)
147
+ results.pop();
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return results;
153
+ }
154
+ /**
155
+ * Algorithm 4 of the HNSW paper (Malkov & Yashunin 2016): candidates sorted
156
+ * by distance to the query enter the result in order; a candidate is skipped
157
+ * when it is closer to some already-selected neighbor than to the query
158
+ * (redundant direction). `pairwise(a, b)` measures node-to-node distance.
159
+ * If fewer than `m` survive (disconnected candidate set), the closest
160
+ * leftovers backfill so the link count stays useful.
161
+ */
162
+ export function selectNeighborsHeuristic(candidates, m, pairwise) {
163
+ const sorted = [...candidates].sort((a, b) => a.dist - b.dist);
164
+ const result = [];
165
+ const skipped = [];
166
+ for (const cand of sorted) {
167
+ if (result.length >= m)
168
+ break;
169
+ let dominated = false;
170
+ for (const chosen of result) {
171
+ if (pairwise(cand.node, chosen.node) < cand.dist) {
172
+ dominated = true;
173
+ break;
174
+ }
175
+ }
176
+ if (dominated)
177
+ skipped.push(cand);
178
+ else
179
+ result.push(cand);
180
+ }
181
+ for (const cand of skipped) {
182
+ if (result.length >= m)
183
+ break;
184
+ result.push(cand);
185
+ }
186
+ return result;
187
+ }
188
+ /**
189
+ * ANN candidate pre-filter: ef-search from the entry point, returning LIVE
190
+ * candidate chunk indices (tombstones excluded). Mirrors
191
+ * `searchIVFCandidates` — the result feeds the same probed-candidate set.
192
+ */
193
+ export function searchHnswCandidates(queryVec, hnsw, vectors, options = {}) {
194
+ if (hnsw.entryPoint < 0 || hnsw.levels.length === 0)
195
+ return [];
196
+ const distance = options.distance ??
197
+ ((a, b) => 1 - dotSimilarity(a, vectors[b]));
198
+ const qDist = (node) => distance(queryVec, node);
199
+ let ep = hnsw.entryPoint;
200
+ let epDist = qDist(ep);
201
+ for (let l = hnsw.maxLevel; l > 0; l--) {
202
+ let changed = true;
203
+ while (changed) {
204
+ changed = false;
205
+ for (const nb of hnsw.neighbors[ep][l] ?? []) {
206
+ const d = qDist(nb);
207
+ if (d < epDist) {
208
+ epDist = d;
209
+ ep = nb;
210
+ changed = true;
211
+ }
212
+ }
213
+ }
214
+ }
215
+ const ef = Math.max(options.ef ?? DEFAULT_HNSW_EF, 1);
216
+ const found = searchLayer(hnsw, qDist, ep, epDist, 0, ef);
217
+ return found.map((f) => f.node);
218
+ }
219
+ /** Dot product that tolerates int8 storage (norm handling mirrors the exact sweep). */
220
+ function dotSimilarity(a, b) {
221
+ let s = 0;
222
+ const n = Math.min(a.length, b.length);
223
+ for (let i = 0; i < n; i++)
224
+ s += a[i] * b[i];
225
+ return s;
226
+ }
227
+ export function serializeHnsw(hnsw) {
228
+ return {
229
+ format: 'hnsw-v1',
230
+ dim: hnsw.dim,
231
+ M: hnsw.M,
232
+ seed: hnsw.seed,
233
+ entryPoint: hnsw.entryPoint,
234
+ maxLevel: hnsw.maxLevel,
235
+ count: hnsw.levels.length,
236
+ levels: hnsw.levels,
237
+ neighbors: hnsw.neighbors,
238
+ deleted: [...hnsw.deleted].sort((a, b) => a - b),
239
+ };
240
+ }
241
+ /**
242
+ * Hardened deserialization — every structural invariant is checked with the
243
+ * same discipline as `deserializeIVF`; any violation throws (the loader
244
+ * falls back to brute force).
245
+ */
246
+ export function deserializeHnsw(raw, expectedChunksCount) {
247
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
248
+ throw new Error('invalid HNSW payload: expected JSON object — run `mdss index` to rebuild');
249
+ }
250
+ const obj = raw;
251
+ if (obj.format !== 'hnsw-v1') {
252
+ throw new Error(`invalid HNSW payload: format must be "hnsw-v1", got ${JSON.stringify(obj.format)} — run \`mdss index\` to rebuild`);
253
+ }
254
+ const { dim, M, seed, entryPoint, maxLevel, count } = obj;
255
+ if (typeof dim !== 'number' || !Number.isSafeInteger(dim) || dim <= 0 ||
256
+ typeof M !== 'number' || !Number.isSafeInteger(M) || M <= 0 || M > 128 ||
257
+ typeof seed !== 'number' || !Number.isSafeInteger(seed) ||
258
+ typeof entryPoint !== 'number' || !Number.isSafeInteger(entryPoint) || entryPoint < 0 ||
259
+ typeof maxLevel !== 'number' || !Number.isSafeInteger(maxLevel) || maxLevel < 0 ||
260
+ typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) {
261
+ throw new Error('invalid HNSW payload: invalid header — run `mdss index` to rebuild');
262
+ }
263
+ if (!Array.isArray(obj.levels) || obj.levels.length !== count) {
264
+ throw new Error(`invalid HNSW payload: levels must be an array of ${count} — run \`mdss index\` to rebuild`);
265
+ }
266
+ const levels = [];
267
+ for (let i = 0; i < count; i++) {
268
+ const l = obj.levels[i];
269
+ if (typeof l !== 'number' || !Number.isSafeInteger(l) || l < 0 || l > maxLevel) {
270
+ throw new Error(`invalid HNSW payload: level of node ${i} must be an integer in [0, ${maxLevel}] — run \`mdss index\` to rebuild`);
271
+ }
272
+ levels.push(l);
273
+ }
274
+ if (levels[entryPoint] !== maxLevel) {
275
+ throw new Error(`invalid HNSW payload: entry point ${entryPoint} must sit on the top layer ${maxLevel} — run \`mdss index\` to rebuild`);
276
+ }
277
+ if (!Array.isArray(obj.neighbors) || obj.neighbors.length !== count) {
278
+ throw new Error(`invalid HNSW payload: neighbors must be an array of ${count} — run \`mdss index\` to rebuild`);
279
+ }
280
+ const neighbors = new Array(count);
281
+ const m0 = M * 2;
282
+ for (let i = 0; i < count; i++) {
283
+ const perLevel = obj.neighbors[i];
284
+ if (!Array.isArray(perLevel) || perLevel.length !== levels[i] + 1) {
285
+ throw new Error(`invalid HNSW payload: node ${i} must have ${levels[i] + 1} link lists — run \`mdss index\` to rebuild`);
286
+ }
287
+ const node = new Array(levels[i] + 1);
288
+ for (let l = 0; l <= levels[i]; l++) {
289
+ const list = perLevel[l];
290
+ if (!Array.isArray(list)) {
291
+ throw new Error(`invalid HNSW payload: node ${i} layer ${l} must be an array — run \`mdss index\` to rebuild`);
292
+ }
293
+ if (list.length > (l === 0 ? m0 : M)) {
294
+ throw new Error(`invalid HNSW payload: node ${i} layer ${l} exceeds the link cap — run \`mdss index\` to rebuild`);
295
+ }
296
+ const seen = new Set();
297
+ for (const nb of list) {
298
+ if (typeof nb !== 'number' || !Number.isSafeInteger(nb) || nb < 0 || nb >= count || nb === i) {
299
+ throw new Error(`invalid HNSW payload: node ${i} layer ${l} has invalid link ${JSON.stringify(nb)} — run \`mdss index\` to rebuild`);
300
+ }
301
+ if (seen.has(nb)) {
302
+ throw new Error(`invalid HNSW payload: duplicate link ${nb} on node ${i} layer ${l} — run \`mdss index\` to rebuild`);
303
+ }
304
+ seen.add(nb);
305
+ }
306
+ node[l] = list;
307
+ }
308
+ neighbors[i] = node;
309
+ }
310
+ let deleted = new Set();
311
+ if (obj.deleted !== undefined) {
312
+ if (!Array.isArray(obj.deleted)) {
313
+ throw new Error('invalid HNSW payload: deleted must be an array — run `mdss index` to rebuild');
314
+ }
315
+ for (const idx of obj.deleted) {
316
+ if (typeof idx !== 'number' || !Number.isSafeInteger(idx) || idx < 0 || idx >= count) {
317
+ throw new Error(`invalid HNSW payload: deleted index ${JSON.stringify(idx)} out of range — run \`mdss index\` to rebuild`);
318
+ }
319
+ deleted.add(idx);
320
+ }
321
+ }
322
+ if (expectedChunksCount !== undefined && count > expectedChunksCount) {
323
+ throw new Error(`invalid HNSW payload: ${count} nodes exceed ${expectedChunksCount} chunks — run \`mdss index\` to rebuild`);
324
+ }
325
+ return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
326
+ }
@@ -90,7 +90,14 @@ export function validateIndexEnvelope(value, location, options = {}) {
90
90
  else if (modelName !== undefined && modelName !== null && typeof modelName !== 'string') {
91
91
  throw new Error(`${location} model metadata must be a string${REBUILD}`);
92
92
  }
93
- const model = resolveModel(modelName);
93
+ let model;
94
+ try {
95
+ const candidateDim = typeof index.dim === 'number' && index.dim > 0 ? index.dim : undefined;
96
+ model = resolveModel(modelName, candidateDim);
97
+ }
98
+ catch {
99
+ model = resolveModel(modelName);
100
+ }
94
101
  const dim = resolveIndexDimension(index.dim, model.dim);
95
102
  if (schema < 3) {
96
103
  return { index, schema, model, dim };
package/dist/index.d.ts CHANGED
@@ -23,16 +23,16 @@ export { buildIndex, chunkHash } from './indexer.js';
23
23
  export { search, searchIndex, loadIndex, tokenize, keywordScores, rrf, QueryEmbeddingCache, } from './search.js';
24
24
  export { resolveModel, MODELS, DEFAULT_MODEL, normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint, } from './models.js';
25
25
  export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
26
- export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
27
- export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
26
+ export { embed, getExtractor, cosine, truncateAndNormalizeVector, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, getDocLines, GetDocLinesOptions, GetDocLinesResult, } from './core.js';
27
+ export { MCP_TOOLS, MCP_RESOURCE_TEMPLATES, MCP_PROMPTS, handleMcpRequest, startMcpServer, } from './mcp.js';
28
28
  export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
29
- export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, } from './wikilinks.js';
29
+ export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, findRelatedNotes, FindRelatedNotesOptions, RelatedNoteHit, RelatedNoteResult, } from './wikilinks.js';
30
30
  export { parseFilter, tokenizeFilter, evaluateFilter, FilterNode, } from './filter.js';
31
31
  export { serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader, BINARY_MAGIC, BINARY_HEADER_SIZE, } from './binary-format.js';
32
32
  export { quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8, INT8_SCALE, INT8_INV_SCALE, } from './quantization.js';
33
33
  export { searchFederated, FederatedSearchOptions, FederatedSearchResultHit, VaultConfig, } from './federation.js';
34
34
  export { createFileWatcher, FileWatcher, WatcherOptions, } from './watcher.js';
35
- export { askQuestion, synthesizeAnswer, extractAnswerFallback, AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
35
+ export { askQuestion, synthesizeAnswer, extractAnswerFallback, chatTurn, loadChatSession, saveChatSession, listChatSessions, getSessionsDir, AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT, ChatSession, ChatTurn, ChatSourceManifestItem, ChatTurnOptions, } from './rag.js';
36
36
  export { extractKeyphraseTags, summarizePassage, analyzeNote, NoteAnalysis, } from './summarize.js';
37
37
  export { runTui, TuiOptions } from './tui.js';
38
38
  export { generateCompletion, FLAGS, COMMANDS, COMPLETION_SHELLS, } from './completions.js';
package/dist/index.js CHANGED
@@ -23,16 +23,16 @@ export { buildIndex, chunkHash } from './indexer.js';
23
23
  export { search, searchIndex, loadIndex, tokenize, keywordScores, rrf, QueryEmbeddingCache, } from './search.js';
24
24
  export { resolveModel, MODELS, DEFAULT_MODEL, normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint, } from './models.js';
25
25
  export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
26
- export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
27
- export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
26
+ export { embed, getExtractor, cosine, truncateAndNormalizeVector, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, getDocLines, } from './core.js';
27
+ export { MCP_TOOLS, MCP_RESOURCE_TEMPLATES, MCP_PROMPTS, handleMcpRequest, startMcpServer, } from './mcp.js';
28
28
  export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
29
- export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, } from './wikilinks.js';
29
+ export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, findRelatedNotes, } from './wikilinks.js';
30
30
  export { parseFilter, tokenizeFilter, evaluateFilter, } from './filter.js';
31
31
  export { serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader, BINARY_MAGIC, BINARY_HEADER_SIZE, } from './binary-format.js';
32
32
  export { quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8, INT8_SCALE, INT8_INV_SCALE, } from './quantization.js';
33
33
  export { searchFederated, } from './federation.js';
34
34
  export { createFileWatcher, } from './watcher.js';
35
- export { askQuestion, synthesizeAnswer, extractAnswerFallback, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
35
+ export { askQuestion, synthesizeAnswer, extractAnswerFallback, chatTurn, loadChatSession, saveChatSession, listChatSessions, getSessionsDir, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
36
36
  export { extractKeyphraseTags, summarizePassage, analyzeNote, } from './summarize.js';
37
37
  export { runTui } from './tui.js';
38
38
  export { generateCompletion, FLAGS, COMMANDS, COMPLETION_SHELLS, } from './completions.js';
package/dist/indexer.d.ts CHANGED
@@ -18,6 +18,14 @@ export interface PersistedIndex {
18
18
  model?: string | null;
19
19
  modelAlias?: string;
20
20
  adapterFingerprint?: string;
21
+ /**
22
+ * issue #202: the effective chunk token budget these chunks were built
23
+ * with (null = legacy char budget). A mismatch forces a full re-chunk —
24
+ * otherwise the file-level fast path would keep stale char-budget chunks
25
+ * for unchanged files and produce a mixed index. Identical chunk texts
26
+ * still reuse their cached vectors through chunkHash.
27
+ */
28
+ chunkBudget?: number | null;
21
29
  dim?: number;
22
30
  db?: string;
23
31
  built?: string;
@@ -57,6 +65,7 @@ export interface BuildIndexOptions {
57
65
  indexDir: string;
58
66
  cacheDir: string;
59
67
  modelName: string;
68
+ dimensions?: number;
60
69
  ignore?: string[];
61
70
  offline?: boolean;
62
71
  log?: (s: string) => void;
@@ -65,8 +74,16 @@ export interface BuildIndexOptions {
65
74
  maxRetries?: number;
66
75
  onProgress?: (done: number, total: number, chunksPerSec: number) => void;
67
76
  workers?: number;
68
- ann?: boolean;
77
+ /** issue #201: ANN sidecar engine — `true`/`'ivf'` = ivf.json, `'hnsw'` = hnsw.json. */
78
+ ann?: boolean | 'ivf' | 'hnsw';
69
79
  quantize?: 'fp32' | 'int8';
80
+ /**
81
+ * issue #202: chunk budget in TOKENS (overrides the legacy char budget).
82
+ * Default: min(adapter.maxTokens, 512) — the point of the issue is that the
83
+ * char budget overflowed real maxTokens for Cyrillic. Explicit `0` restores
84
+ * the legacy char behavior.
85
+ */
86
+ maxChunkTokens?: number;
70
87
  }
71
88
  export interface BuildIndexResult {
72
89
  files: number;