md-semantic-search 1.1.0 → 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 (64) hide show
  1. package/README.md +88 -14
  2. package/RESEARCH.md +182 -0
  3. package/bin/cli.mjs +197 -8
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +5 -0
  6. package/dist/core.d.ts +16 -4
  7. package/dist/core.js +105 -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/indexer.d.ts +17 -1
  15. package/dist/indexer.js +91 -17
  16. package/dist/lexical.d.ts +10 -0
  17. package/dist/lexical.js +98 -15
  18. package/dist/markdown-parser.d.ts +62 -0
  19. package/dist/markdown-parser.js +227 -30
  20. package/dist/mcp.d.ts +86 -3
  21. package/dist/mcp.js +259 -31
  22. package/dist/models.d.ts +7 -1
  23. package/dist/models.js +32 -1
  24. package/dist/providers.d.ts +56 -0
  25. package/dist/providers.js +305 -0
  26. package/dist/quantization.d.ts +12 -0
  27. package/dist/quantization.js +54 -0
  28. package/dist/rag.d.ts +60 -0
  29. package/dist/rag.js +133 -35
  30. package/dist/repair.d.ts +1 -1
  31. package/dist/repair.js +53 -2
  32. package/dist/rerank.d.ts +32 -3
  33. package/dist/rerank.js +58 -8
  34. package/dist/search.d.ts +48 -3
  35. package/dist/search.js +384 -78
  36. package/dist/serve.d.ts +35 -0
  37. package/dist/serve.js +478 -58
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +52 -2
  40. package/dist/watcher.js +23 -12
  41. package/dist/webui.d.ts +3 -3
  42. package/dist/webui.js +201 -2
  43. package/package.json +3 -3
  44. package/src/binary-format.ts +13 -0
  45. package/src/completions.ts +5 -0
  46. package/src/core.ts +115 -12
  47. package/src/federation.ts +51 -17
  48. package/src/filter.ts +14 -0
  49. package/src/hnsw.ts +414 -0
  50. package/src/indexer.ts +107 -18
  51. package/src/lexical.ts +91 -13
  52. package/src/markdown-parser.ts +279 -37
  53. package/src/mcp.ts +323 -34
  54. package/src/models.ts +39 -2
  55. package/src/providers.ts +345 -0
  56. package/src/quantization.ts +61 -0
  57. package/src/rag.ts +189 -34
  58. package/src/repair.ts +51 -2
  59. package/src/rerank.ts +81 -8
  60. package/src/search.ts +417 -82
  61. package/src/serve.ts +499 -66
  62. package/src/tui.ts +56 -2
  63. package/src/watcher.ts +21 -10
  64. package/src/webui.ts +201 -2
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
+ }
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;
@@ -66,8 +74,16 @@ export interface BuildIndexOptions {
66
74
  maxRetries?: number;
67
75
  onProgress?: (done: number, total: number, chunksPerSec: number) => void;
68
76
  workers?: number;
69
- ann?: boolean;
77
+ /** issue #201: ANN sidecar engine — `true`/`'ivf'` = ivf.json, `'hnsw'` = hnsw.json. */
78
+ ann?: boolean | 'ivf' | 'hnsw';
70
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;
71
87
  }
72
88
  export interface BuildIndexResult {
73
89
  files: number;
package/dist/indexer.js CHANGED
@@ -15,7 +15,9 @@ import { validateIndexEnvelope, validateNumericVector } from './index-format.js'
15
15
  import { analyzeLexicalDocument, buildLexicalIndex, lexicalIdentity, reverseLexicalIndex, validateLexicalIndex, } from './lexical.js';
16
16
  import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
17
17
  import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
18
+ import { buildHnsw, serializeHnsw } from './hnsw.js';
18
19
  import { serializeBinaryIndex } from './binary-format.js';
20
+ import { defaultChunkTokenBudget } from './markdown-parser.js';
19
21
  const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
20
22
  const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
21
23
  export const _stats = {
@@ -23,7 +25,17 @@ export const _stats = {
23
25
  fileHits: 0,
24
26
  embedCount: 0,
25
27
  };
26
- const BATCH = 32;
28
+ // issue #194: batch size is tunable — 64-128 often feeds ONNX noticeably
29
+ // better on short passages; overridable for experiments (clamped 1..512).
30
+ const BATCH = (() => {
31
+ const raw = parseInt(process.env.MDSS_BATCH || '', 10);
32
+ return Number.isSafeInteger(raw) && raw > 0 ? Math.min(raw, 512) : 32;
33
+ })();
34
+ // issue #194: mid-run checkpoints serialize the WHOLE index so far — with
35
+ // faster embedding that cost scales up. Time-budget them (~every 30 s) in
36
+ // addition to the batch cadence, so at most ~30 s of embedding is lost on a
37
+ // crash while serialization stays a small slice of wall time.
38
+ const CHECKPOINT_MIN_INTERVAL_MS = 30_000;
27
39
  const CHECKPOINT_BATCHES = 8;
28
40
  const INDEX_FORMAT = 'binary-v1';
29
41
  function isStringRecord(value) {
@@ -79,13 +91,20 @@ export function chunkHash(model, chunk) {
79
91
  return sha256(input);
80
92
  }
81
93
  export async function buildIndex(opts) {
82
- const { db, indexDir, cacheDir, modelName, dimensions, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', } = opts;
94
+ const { db, indexDir, cacheDir, modelName, dimensions, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', maxChunkTokens, } = opts;
83
95
  if (_lockHeld)
84
- return _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize });
85
- return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize }));
96
+ return _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize, maxChunkTokens });
97
+ return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize, maxChunkTokens }));
86
98
  }
87
- async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', }) {
99
+ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', maxChunkTokens = undefined, }) {
88
100
  const model = resolveModel(modelName, dimensions);
101
+ // issue #202: explicit maxChunkTokens wins; `0` restores the legacy char
102
+ // budget; otherwise min(adapter.maxTokens, 512), undefined → char mode
103
+ const effectiveChunkTokens = maxChunkTokens === 0
104
+ ? undefined
105
+ : maxChunkTokens && maxChunkTokens > 0
106
+ ? maxChunkTokens
107
+ : defaultChunkTokenBudget(model);
89
108
  const modelIdentity = `${model.id}@${model.revision || 'main'}`;
90
109
  const adapterFingerprint = embeddingAdapterFingerprint(model);
91
110
  const legacyAdapterFingerprint = legacyEmbeddingAdapterFingerprint(model);
@@ -94,8 +113,14 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
94
113
  const vectorsPath = path.join(indexDir, 'vectors.json');
95
114
  const hashesPath = path.join(indexDir, '.hashes.json');
96
115
  const checkpointPath = path.join(indexDir, '.checkpoint.json');
116
+ let lastCheckpointAt = 0;
97
117
  fs.mkdirSync(indexDir, { recursive: true });
98
- const files = walkMarkdown(db, ignore);
118
+ // issue #189: unreadable directories shrink the corpus — collect and log
119
+ // them so a "shrinking" index is never published silently
120
+ const walkErrors = [];
121
+ const files = walkMarkdown(db, ignore, walkErrors);
122
+ for (const we of walkErrors)
123
+ log(we);
99
124
  const canonicalHashes = loadJSON(hashesPath, {}, log);
100
125
  let canonicalIndex = loadJSON(vectorsPath, { chunks: [] }, log);
101
126
  let canonicalSchema = 0;
@@ -194,6 +219,14 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
194
219
  }
195
220
  const modelChanged = !!oldIndex.model && oldIndex.model !== modelIdentity;
196
221
  const contextVectorsReusable = sourceSchema >= 2 && !modelChanged && adapterCompatible(oldIndex);
222
+ // issue #202: a different chunk budget invalidates the file-level fast path
223
+ // (unchanged files must be re-chunked), NOT the per-chunk vector cache —
224
+ // chunks whose text did not move across the budget boundary re-embed free.
225
+ const chunkBudgetMarker = effectiveChunkTokens ?? null;
226
+ const chunkerChanged = (oldIndex.chunkBudget ?? null) !== chunkBudgetMarker;
227
+ if (chunkerChanged && oldIndex.chunks.length > 0) {
228
+ log('chunk budget changed — re-chunking all files (per-chunk vector cache still applies).');
229
+ }
197
230
  let buildDim = model.dim > 0 ? model.dim : contextVectorsReusable && oldDimensionValid ? oldExpectedDim : undefined;
198
231
  const acceptBuildVector = (vector) => {
199
232
  const length = validateNumericVector(vector, buildDim, 'embedding vector');
@@ -250,6 +283,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
250
283
  model: modelIdentity,
251
284
  modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
252
285
  adapterFingerprint,
286
+ chunkBudget: chunkBudgetMarker,
253
287
  ...(buildDim === undefined ? {} : { dim: buildDim }),
254
288
  db,
255
289
  built: new Date().toISOString(),
@@ -269,7 +303,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
269
303
  newHashes[rel] = h;
270
304
  if (oldHashes[rel] === h)
271
305
  reusableLexicalRecords = lexicalByFile.get(rel);
272
- if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && oldHashes[rel] === h && oldByFile.has(rel)) {
306
+ if (sourceSchema === SCHEMA_VERSION && contextVectorsReusable && !chunkerChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
273
307
  const old = oldByFile.get(rel);
274
308
  for (const c of old) {
275
309
  if (!c.chunkHash)
@@ -290,7 +324,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
290
324
  continue;
291
325
  }
292
326
  changedFiles++;
293
- parsed = parseFile(abs, db, undefined, raw);
327
+ parsed = parseFile(abs, db, undefined, raw, effectiveChunkTokens);
294
328
  }
295
329
  catch (e) {
296
330
  delete newHashes[rel];
@@ -347,7 +381,8 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
347
381
  c.vec = vecs[j];
348
382
  });
349
383
  completedBatches++;
350
- if (completedBatches % CHECKPOINT_BATCHES === 0) {
384
+ if (completedBatches % CHECKPOINT_BATCHES === 0 && Date.now() - lastCheckpointAt >= CHECKPOINT_MIN_INTERVAL_MS) {
385
+ lastCheckpointAt = Date.now();
351
386
  atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
352
387
  }
353
388
  const done = Math.min(i + BATCH, toEmbed.length);
@@ -393,7 +428,8 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
393
428
  });
394
429
  completedBatches++;
395
430
  embeddedCount += slice.length;
396
- if (completedBatches % CHECKPOINT_BATCHES === 0) {
431
+ if (completedBatches % CHECKPOINT_BATCHES === 0 && Date.now() - lastCheckpointAt >= CHECKPOINT_MIN_INTERVAL_MS) {
432
+ lastCheckpointAt = Date.now();
397
433
  atomicWrite(checkpointPath, JSON.stringify(checkpointSnapshot(false)));
398
434
  }
399
435
  const elapsedSec = (Date.now() - embedStartTime) / 1000;
@@ -422,6 +458,7 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
422
458
  model: modelIdentity,
423
459
  modelAlias: typeof modelName === 'string' ? modelName || 'e5-base' : model.id,
424
460
  adapterFingerprint,
461
+ chunkBudget: chunkBudgetMarker,
425
462
  ...(dim === undefined ? {} : { dim }),
426
463
  db,
427
464
  built: new Date().toISOString(),
@@ -438,17 +475,54 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
438
475
  const binBuffer = serializeBinaryIndex(index, dim, { quantize });
439
476
  atomicWrite(vectorsBinPath, binBuffer);
440
477
  }
478
+ // issue #201: ANN sidecar engine — explicit 'hnsw' builds the graph
479
+ // instead of IVF; `true`/undefined/'ivf' keep the historical ivf.json path.
480
+ const annEngine = ann === 'hnsw' ? 'hnsw' : 'ivf';
481
+ const buildSidecar = Boolean(ann) || chunks.length >= ANN_THRESHOLD;
441
482
  const ivfPath = path.join(indexDir, 'ivf.json');
442
- if (ann || chunks.length >= ANN_THRESHOLD) {
483
+ const hnswPath = path.join(indexDir, 'hnsw.json');
484
+ if (buildSidecar) {
443
485
  const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec)));
444
- const ivfIndex = trainIVF(rawVecs);
445
- atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
486
+ if (annEngine === 'hnsw') {
487
+ const hnswIndex = buildHnsw(rawVecs, {
488
+ vectors: rawVecs,
489
+ // cosine distance (lower = closer) over the flat normalized vectors
490
+ distance: (a, b) => {
491
+ const bv = rawVecs[b];
492
+ let dot = 0;
493
+ for (let i = 0; i < a.length; i++)
494
+ dot += a[i] * bv[i];
495
+ return 1 - dot;
496
+ },
497
+ });
498
+ atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
499
+ if (fs.existsSync(ivfPath)) {
500
+ try {
501
+ fs.unlinkSync(ivfPath);
502
+ }
503
+ catch { }
504
+ }
505
+ }
506
+ else {
507
+ const ivfIndex = trainIVF(rawVecs);
508
+ atomicWrite(ivfPath, JSON.stringify(serializeIVF(ivfIndex)));
509
+ if (fs.existsSync(hnswPath)) {
510
+ try {
511
+ fs.unlinkSync(hnswPath);
512
+ }
513
+ catch { }
514
+ }
515
+ }
446
516
  }
447
- else if (fs.existsSync(ivfPath)) {
448
- try {
449
- fs.unlinkSync(ivfPath);
517
+ else {
518
+ for (const sidecar of [ivfPath, hnswPath]) {
519
+ if (fs.existsSync(sidecar)) {
520
+ try {
521
+ fs.unlinkSync(sidecar);
522
+ }
523
+ catch { }
524
+ }
450
525
  }
451
- catch { }
452
526
  }
453
527
  fs.unlinkSync(checkpointPath);
454
528
  return {
package/dist/lexical.d.ts CHANGED
@@ -33,6 +33,16 @@ export declare function analyzeLexicalDocument(chunk: ChunkForLexical, weights?:
33
33
  }): TermFrequencies;
34
34
  export declare function buildLexicalIndex(records: TermFrequencies[]): LexicalIndex;
35
35
  export declare function editDistance(a: string, b: string): number;
36
+ /**
37
+ * Damerau-Levenshtein distance with a threshold (issue #193): banded DP that
38
+ * never leaves the |i-j| <= maxDist diagonal and aborts as soon as the whole
39
+ * row exceeds the threshold — O(maxDist × min(alen, blen)) instead of the
40
+ * full O(alen × blen) matrix. Returns the EXACT distance when it is
41
+ * <= maxDist and null otherwise; decisions are identical to
42
+ * `editDistance(a, b) <= maxDist` (optimal paths within the threshold never
43
+ * leave the band).
44
+ */
45
+ export declare function editDistanceAtMost(a: string, b: string, maxDist: number): number | null;
36
46
  export declare function fuzzyTitleAliasScores(chunks: Array<{
37
47
  file?: string;
38
48
  title: string;