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/src/search.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { embed, cosine, decodeVec, globToRegExp, walkMarkdown, ModelDescriptor } from './core.js';
4
+ import { embed, cosine, decodeVec, globToRegExp, walkMarkdown, fetchTimeoutMs, ModelDescriptor } from './core.js';
5
5
  import { validateIndexEnvelope, validateNumericVector } from './index-format.js';
6
6
  import { rerankScores } from './rerank.js';
7
7
  import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores, LexicalIndex } from './lexical.js';
8
8
  import { collapseResults } from './collapse.js';
9
9
  import { DocumentMetadata } from './frontmatter.js';
10
10
  import { trainIVF, searchIVFCandidates, deserializeIVF, IVFIndex, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
11
+ import { buildHnsw, searchHnswCandidates, deserializeHnsw, HnswIndex, DEFAULT_HNSW_EF } from './hnsw.js';
11
12
  import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood, RelationshipGraph } from './wikilinks.js';
12
13
  import { evaluateFilter, FilterNode } from './filter.js';
13
14
  import { deserializeBinaryIndex } from './binary-format.js';
14
- import { dequantizeFromInt8 } from './quantization.js';
15
+ import { dequantizeFromInt8, int8Norm, asymmetricCosineInt8CachedNorm } from './quantization.js';
15
16
  import { buildDiskKey, diskQueryGet, diskQueryPut } from './query-cache-disk.js';
16
17
 
17
18
  export { tokenize } from './lexical.js';
@@ -62,6 +63,73 @@ export function rrf(
62
63
  return fused;
63
64
  }
64
65
 
66
+ /**
67
+ * issue #191: bounded top-k selection over a dense sweep — a capped min-heap
68
+ * instead of materializing one object per chunk and fully sorting. Ties break
69
+ * toward the smaller idx, which is exactly what a stable sort by
70
+ * (score desc) over idx-ordered input produces, so the selected pool and its
71
+ * order are identical to `[...items].sort((a, b) => b.score - a.score).slice(0, k)`.
72
+ */
73
+ export function topKByScore(
74
+ n: number,
75
+ scoreAt: (i: number) => number,
76
+ k: number,
77
+ ): Array<{ idx: number; score: number }> {
78
+ if (k <= 0 || n <= 0) return [];
79
+ const cap = Math.min(k, n);
80
+ const scores = new Array<number>(cap);
81
+ const idxs = new Array<number>(cap);
82
+ let size = 0;
83
+
84
+ /** true when (aScore, aIdx) is a WORSE keeper than (bScore, bIdx). */
85
+ const worse = (aScore: number, aIdx: number, bScore: number, bIdx: number): boolean =>
86
+ aScore < bScore || (aScore === bScore && aIdx > bIdx);
87
+
88
+ const siftDown = (pos: number): void => {
89
+ for (;;) {
90
+ const left = pos * 2 + 1;
91
+ if (left >= size) return;
92
+ const right = left + 1;
93
+ const child = right < size && worse(scores[right], idxs[right], scores[left], idxs[left]) ? right : left;
94
+ if (!worse(scores[child], idxs[child], scores[pos], idxs[pos])) return;
95
+ const ts = scores[pos]; scores[pos] = scores[child]; scores[child] = ts;
96
+ const ti = idxs[pos]; idxs[pos] = idxs[child]; idxs[child] = ti;
97
+ pos = child;
98
+ }
99
+ };
100
+
101
+ for (let i = 0; i < n; i++) {
102
+ const s = scoreAt(i);
103
+ if (size < cap) {
104
+ let pos = size++;
105
+ scores[pos] = s;
106
+ idxs[pos] = i;
107
+ while (pos > 0) {
108
+ const parent = (pos - 1) >> 1;
109
+ if (!worse(scores[pos], idxs[pos], scores[parent], idxs[parent])) break;
110
+ const ts = scores[pos]; scores[pos] = scores[parent]; scores[parent] = ts;
111
+ const ti = idxs[pos]; idxs[pos] = idxs[parent]; idxs[parent] = ti;
112
+ pos = parent;
113
+ }
114
+ } else if (worse(scores[0], idxs[0], s, i)) {
115
+ scores[0] = s;
116
+ idxs[0] = i;
117
+ siftDown(0);
118
+ }
119
+ }
120
+
121
+ const out: Array<{ idx: number; score: number }> = [];
122
+ while (size > 0) {
123
+ out.push({ idx: idxs[0], score: scores[0] });
124
+ size--;
125
+ scores[0] = scores[size];
126
+ idxs[0] = idxs[size];
127
+ siftDown(0);
128
+ }
129
+ out.sort((a, b) => b.score - a.score || a.idx - b.idx);
130
+ return out;
131
+ }
132
+
65
133
  export interface IndexChunk {
66
134
  file: string;
67
135
  title: string;
@@ -179,12 +247,23 @@ export interface RuntimeIndexState {
179
247
  model: ModelDescriptor;
180
248
  queryCache: QueryEmbeddingCache;
181
249
  ivf?: IVFIndex;
250
+ /** issue #201: optional HNSW graph sidecar (hnsw.json), parallel to ivf. */
251
+ hnsw?: HnswIndex;
182
252
  graph?: RelationshipGraph;
183
253
  pageRank?: Map<string, number>;
254
+ /**
255
+ * issue #190: precomputed sqrt(sum(c^2)) for int8 vectors (zero-copy binary
256
+ * load only) — lets the dense sweep compute the dot product alone.
257
+ */
258
+ chunkNorms?: Float32Array;
184
259
  }
185
260
 
186
- export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: RelationshipGraph; pageRank: Map<string, number> } {
187
- if (runtime.graph && runtime.pageRank) {
261
+ /** issue #190 tool/test seam: the cached int8 sweep norms, when present. */
262
+ export function getRuntimeChunkNorms(index: IndexFile): Float32Array | undefined {
263
+ return runtimeIndexStates.get(index)?.chunkNorms;
264
+ }
265
+
266
+ export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: RelationshipGraph; pageRank: Map<string, number> } { if (runtime.graph && runtime.pageRank) {
188
267
  return { graph: runtime.graph, pageRank: runtime.pageRank };
189
268
  }
190
269
  const docsMap = new Map<string, { file: string; title?: string; meta?: DocumentMetadata; text?: string }>();
@@ -212,12 +291,14 @@ export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: Relationsh
212
291
  /**
213
292
  * Issue #127 pure helpers — exported for deterministic unit tests.
214
293
  */
215
- /** Frontmatter `created` → `updated` → file mtime; null when nothing parses. */
294
+ /** Frontmatter `updated` → `created` → file mtime; null when nothing parses.
295
+ * Issue #164: `updated` wins — long-lived notes that were recently revised
296
+ * must not be penalized by their original creation date. */
216
297
  export function resolvePassageAgeMs(
217
298
  meta: { created?: unknown; updated?: unknown } | undefined,
218
299
  absFile?: string,
219
300
  ): number | null {
220
- for (const v of [meta?.created, meta?.updated]) {
301
+ for (const v of [meta?.updated, meta?.created]) {
221
302
  if (typeof v === 'string') {
222
303
  const t = Date.parse(v);
223
304
  if (Number.isFinite(t)) return t;
@@ -257,6 +338,9 @@ export async function hydePassage(
257
338
  const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
258
339
  method: 'POST',
259
340
  headers: { 'content-type': 'application/json' },
341
+ // issue #184: HyDE must degrade fast on a hung endpoint, not hold a
342
+ // serve slot — failure paths return null below
343
+ signal: AbortSignal.timeout(fetchTimeoutMs()),
260
344
  body: JSON.stringify({
261
345
  ...(model ? { model } : {}),
262
346
  messages: [
@@ -276,6 +360,67 @@ export async function hydePassage(
276
360
 
277
361
  const runtimeIndexStates = new WeakMap<IndexFile, RuntimeIndexState>();
278
362
 
363
+ /**
364
+ * issue #203: LLM paraphrases for multi-query expansion. ONE OpenAI-compatible
365
+ * chat completion asking for N alternative phrasings; returns clean, deduped,
366
+ * non-empty variants (the original query excluded). Returns [] on ANY failure —
367
+ * multi-query must degrade to the single-query baseline silently, exactly like
368
+ * HyDE. Injectable fetch for tests.
369
+ */
370
+ export async function paraphraseQueries(
371
+ endpoint: string,
372
+ query: string,
373
+ count: number,
374
+ model?: string,
375
+ fetchImpl: typeof fetch = globalThis.fetch,
376
+ ): Promise<string[]> {
377
+ if (!(count > 0)) return [];
378
+ const capped = Math.min(Math.floor(count), 5);
379
+ try {
380
+ const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
381
+ method: 'POST',
382
+ headers: { 'content-type': 'application/json' },
383
+ signal: AbortSignal.timeout(fetchTimeoutMs()),
384
+ body: JSON.stringify({
385
+ ...(model ? { model } : {}),
386
+ messages: [
387
+ {
388
+ role: 'system',
389
+ content:
390
+ `Write ${capped} alternative phrasings of the user's search query. ` +
391
+ 'One per line, no numbering, no quotes, keep the same language and meaning. Output only the paraphrases.',
392
+ },
393
+ { role: 'user', content: query },
394
+ ],
395
+ }),
396
+ });
397
+ if (!res.ok) return [];
398
+ const data: any = await res.json();
399
+ const text = data?.choices?.[0]?.message?.content;
400
+ if (typeof text !== 'string' || !text.trim()) return [];
401
+ const seen = new Set([query.trim().toLowerCase()]);
402
+ const out: string[] = [];
403
+ for (const raw of text.split('\n')) {
404
+ // strip list markers, surrounding quotes, and decorative dashes LLMs
405
+ // like to pad output with; a bare "— junk —" line then fails the
406
+ // length floor instead of passing as a paraphrase
407
+ const line = raw
408
+ .replace(/^\s*(?:[-*\d]+[.)]?\s+)/, '')
409
+ .replace(/^["'«“”\s—–-]+|["'»“”\s—–-]+$/g, '')
410
+ .trim();
411
+ if (line.length < 3 || line.length > MAX_QUERY_LENGTH) continue;
412
+ const key = line.toLowerCase();
413
+ if (seen.has(key)) continue;
414
+ seen.add(key);
415
+ out.push(line);
416
+ if (out.length >= capped) break;
417
+ }
418
+ return out;
419
+ } catch {
420
+ return [];
421
+ }
422
+ }
423
+
279
424
  /** Minimal EN+RU stopword set for PRF expansion (issue #125). */
280
425
  const EXPANSION_STOPWORDS = new Set([
281
426
  'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'is', 'are',
@@ -320,6 +465,7 @@ function snapshotRuntimeIndex(
320
465
  dim: number | undefined;
321
466
  model: ModelDescriptor;
322
467
  ivf?: IVFIndex;
468
+ hnsw?: HnswIndex;
323
469
  rawVectorsBuffer?: Float32Array | Int8Array;
324
470
  }
325
471
  ): RuntimeIndexState {
@@ -360,19 +506,32 @@ function snapshotRuntimeIndex(
360
506
 
361
507
  let lexicalState: LexicalState = { kind: 'legacy-overlap' };
362
508
  if (validation.schema >= 3 && index.lexical) {
363
- const postings: Record<string, Array<[number, number]>> = Object.create(null);
364
- for (const [term, posting] of Object.entries(index.lexical.postings)) {
365
- postings[term] = posting.map(([docId, frequency]) => [docId, frequency]);
366
- }
509
+ // issue #192: the per-term [docId, tf] pairs are treated as immutable
510
+ // everywhere (bm25Scores/matchingTerms only read), so sharing them skips
511
+ // the O(terms × postings) deep copy on every load/reindex. The two mutable
512
+ // top levels are still copied so caller-side mutations of the loaded index
513
+ // cannot reach the runtime state.
367
514
  lexicalState = {
368
515
  kind: 'persisted-bm25',
369
516
  lexical: {
370
517
  format: index.lexical.format,
371
518
  documentLengths: [...index.lexical.documentLengths],
372
- postings,
519
+ postings: { ...index.lexical.postings },
373
520
  },
374
521
  };
375
522
  }
523
+
524
+ // issue #190: int8 chunk norms are constant per chunk — compute them once at
525
+ // load instead of inside every query's dense sweep
526
+ let chunkNorms: Float32Array | undefined;
527
+ if (validation.rawVectorsBuffer instanceof Int8Array && dim > 0 && count > 0) {
528
+ chunkNorms = new Float32Array(count);
529
+ const buffer = validation.rawVectorsBuffer;
530
+ for (let i = 0; i < count; i++) {
531
+ chunkNorms[i] = int8Norm(buffer.subarray(i * dim, (i + 1) * dim));
532
+ }
533
+ }
534
+
376
535
  return {
377
536
  schema: validation.schema,
378
537
  db: index.db,
@@ -382,6 +541,8 @@ function snapshotRuntimeIndex(
382
541
  model: { ...validation.model },
383
542
  queryCache: new QueryEmbeddingCache(100),
384
543
  ivf: validation.ivf,
544
+ hnsw: validation.hnsw,
545
+ chunkNorms,
385
546
  };
386
547
  }
387
548
 
@@ -430,7 +591,14 @@ function warnIfStale(index: IndexFile): void {
430
591
  );
431
592
  }
432
593
 
433
- export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDescriptor } {
594
+ /**
595
+ * Sentinel (issue #187): vectors.bin failed its sidecar check — not corrupt,
596
+ * just unverified — so loadIndex falls back to vectors.json instead of throwing.
597
+ */
598
+ const BIN_SIDECAR_STALE = Symbol('bin-sidecar-stale');
599
+
600
+ export function loadIndex(indexDir: string, opts: { /** skip the (expensive) whole-tree staleness walk — deferred to search() */ checkStale?: boolean } = {}): { index: IndexFile; model: ModelDescriptor } {
601
+ const { checkStale = true } = opts;
434
602
  const vectorsBinPath = path.join(indexDir, 'vectors.bin');
435
603
  const vectorsPath = path.join(indexDir, 'vectors.json');
436
604
 
@@ -442,7 +610,9 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
442
610
  try {
443
611
  const binStat = fs.statSync(vectorsBinPath);
444
612
  const jsonStat = fs.statSync(vectorsPath);
445
- if (binStat.mtimeMs >= jsonStat.mtimeMs) {
613
+ // issue #187: strict `>` — written in the same millisecond, vectors.json
614
+ // (the format of record) wins over a possibly half-published vectors.bin
615
+ if (binStat.mtimeMs > jsonStat.mtimeMs) {
446
616
  useBinary = true;
447
617
  }
448
618
  } catch {
@@ -459,7 +629,14 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
459
629
  const expectedHash = fs.readFileSync(shaBinPath, 'utf8').trim().split(/\s+/)[0];
460
630
  const actualHash = crypto.createHash('sha256').update(binBuffer).digest('hex');
461
631
  if (expectedHash && actualHash !== expectedHash) {
462
- throw new Error('vectors.bin integrity check failed (SHA-256 mismatch) — run `mdss index` to rebuild.');
632
+ // issue #187: vectors.bin is a derived cache of vectors.json — a
633
+ // stale sidecar (crash between the two renames) must not brick the
634
+ // daemon when the format of record is available. Fall through.
635
+ process.stderr.write(
636
+ 'warning: vectors.bin.sha256 does not match vectors.bin — ignoring the binary cache and loading vectors.json. ' +
637
+ 'Run `mdss check --fix` to rebuild the binary cache.\n'
638
+ );
639
+ throw BIN_SIDECAR_STALE;
463
640
  }
464
641
  }
465
642
  const deserialized = deserializeBinaryIndex(binBuffer);
@@ -468,7 +645,9 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
468
645
  const { model, dim: validatedDim } = validated;
469
646
  const expectedDim = validatedDim ?? deserialized.dim;
470
647
 
471
- warnIfStale(index);
648
+ // issue #192: the staleness walk is a synchronous whole-tree traversal —
649
+ // when the caller defers it (search()), it runs AFTER results exist
650
+ if (checkStale) warnIfStale(index);
472
651
 
473
652
  const ivfPath = path.join(indexDir, 'ivf.json');
474
653
  let ivf: IVFIndex | undefined;
@@ -478,6 +657,16 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
478
657
  ivf = deserializeIVF(raw);
479
658
  } catch {}
480
659
  }
660
+ // issue #201: optional HNSW sidecar — corrupt file degrades to brute
661
+ // force exactly like a corrupt ivf.json
662
+ const hnswPath = path.join(indexDir, 'hnsw.json');
663
+ let hnsw: HnswIndex | undefined;
664
+ if (fs.existsSync(hnswPath)) {
665
+ try {
666
+ const raw = JSON.parse(fs.readFileSync(hnswPath, 'utf8'));
667
+ hnsw = deserializeHnsw(raw, index.chunks.length);
668
+ } catch {}
669
+ }
481
670
 
482
671
  runtimeIndexStates.set(
483
672
  index,
@@ -486,14 +675,17 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
486
675
  dim: expectedDim,
487
676
  model,
488
677
  ivf,
678
+ hnsw,
489
679
  rawVectorsBuffer: deserialized.rawVectorsBuffer,
490
680
  })
491
681
  );
492
682
 
493
683
  return { index, model };
494
684
  } catch (e: any) {
495
- if (e.message?.includes('integrity check failed')) throw e;
496
- if (!fs.existsSync(vectorsPath)) throw e;
685
+ if (e === BIN_SIDECAR_STALE) {
686
+ // fall through to vectors.json — the format of record (#187)
687
+ } else if (e.message?.includes('integrity check failed')) throw e;
688
+ else if (!fs.existsSync(vectorsPath)) throw e;
497
689
  // If vectors.bin was corrupt or unreadable, fall through to vectors.json fallback
498
690
  }
499
691
  }
@@ -518,10 +710,19 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
518
710
  const expectedHash = shaContent.split(/\s+/)[0];
519
711
  const actualHash = crypto.createHash('sha256').update(rawBytes).digest('hex');
520
712
  if (expectedHash && actualHash !== expectedHash) {
521
- throw new Error(`vectors.json integrity check failed (SHA-256 mismatch) — run \`mdss index\` to rebuild.`);
713
+ // issue #187: atomicWrite renames the payload and its sidecar in two
714
+ // separate steps, so a crash mid-publish leaves a VALID payload with a
715
+ // STALE sidecar. The envelope above already parsed and validated —
716
+ // vectors.json is the format of record, so load it with a loud
717
+ // warning instead of bricking search/serve/mcp until a manual fix.
718
+ // A payload that cannot parse or validate still fails hard (above).
719
+ process.stderr.write(
720
+ 'warning: vectors.json.sha256 does not match vectors.json (stale sidecar after a crash mid-publish?) — ' +
721
+ 'the payload itself is valid and will be loaded. Run `mdss check --fix` to refresh the sidecar.\n'
722
+ );
522
723
  }
523
724
  } catch (e: any) {
524
- if (e.message.includes('integrity check failed')) throw e;
725
+ if (e.message?.includes('integrity check failed')) throw e;
525
726
  }
526
727
  }
527
728
 
@@ -557,7 +758,7 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
557
758
  }
558
759
  }
559
760
 
560
- warnIfStale(index);
761
+ if (checkStale) warnIfStale(index);
561
762
 
562
763
  const ivfPath = path.join(indexDir, 'ivf.json');
563
764
  let ivf: IVFIndex | undefined;
@@ -567,8 +768,16 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
567
768
  ivf = deserializeIVF(raw);
568
769
  } catch {}
569
770
  }
771
+ const hnswPath2 = path.join(indexDir, 'hnsw.json');
772
+ let hnsw: HnswIndex | undefined;
773
+ if (fs.existsSync(hnswPath2)) {
774
+ try {
775
+ const raw = JSON.parse(fs.readFileSync(hnswPath2, 'utf8'));
776
+ hnsw = deserializeHnsw(raw, index.chunks.length);
777
+ } catch {}
778
+ }
570
779
 
571
- runtimeIndexStates.set(index, snapshotRuntimeIndex(index, { schema: validated.schema, dim: expectedDim, model, ivf }));
780
+ runtimeIndexStates.set(index, snapshotRuntimeIndex(index, { schema: validated.schema, dim: expectedDim, model, ivf, hnsw }));
572
781
 
573
782
  return { index, model };
574
783
  }
@@ -615,10 +824,23 @@ export interface SearchOptions {
615
824
  llmEndpoint?: string;
616
825
  /** issue #125: model name for the HyDE chat call. */
617
826
  llmModel?: string;
618
- ann?: boolean;
827
+ /**
828
+ * issue #203: multi-query expansion — N total query variants (original +
829
+ * N-1 LLM paraphrases), each embedded, rankings fused with union-RRF.
830
+ * Requires `llmEndpoint`; without one (or on any LLM failure) it degrades
831
+ * to the single-query baseline. 0/1/undefined = off.
832
+ */
833
+ multiQuery?: number;
834
+ /** issue #203: reranker registry alias (`bge-reranker-base` default, `gte-reranker-modernbert`, ...). */
835
+ rerankModel?: string;
836
+ /** issue #201: ANN engine — `true` = default engine, or explicit 'ivf' | 'hnsw'. */
837
+ ann?: boolean | 'ivf' | 'hnsw';
619
838
  nprobe?: number;
839
+ /** issue #201: HNSW ef-search breadth (default 64). */
840
+ ef?: number;
620
841
  graphBoost?: number;
621
842
  filter?: string | FilterNode;
843
+ dimensions?: number;
622
844
  }
623
845
 
624
846
  export interface SearchResultHit {
@@ -669,11 +891,20 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
669
891
  }
670
892
 
671
893
  const embedFn = opts.embedFn ?? embed;
672
- const rerankFn = opts.rerankFn ?? rerankScores;
894
+ // issue #203: the registry alias rides the DEFAULT rerank path; an injected
895
+ // rerankFn (tests/serve) keeps its 4-arg signature and ignores it.
896
+ const rerankFn = opts.rerankFn ?? ((query: string, texts: string[], dir: string, off: boolean) =>
897
+ rerankScores(query, texts, dir, off, opts.rerankModel));
673
898
  const { index } = loaded;
674
899
  const runtime = validateRuntimeIndex(index);
675
900
  const { chunks, db, lexicalState, expectedDim } = runtime;
676
901
 
902
+ if (opts.dimensions !== undefined && typeof opts.dimensions === 'number' && expectedDim !== undefined && opts.dimensions !== expectedDim) {
903
+ throw new Error(
904
+ `requested search dimensions (${opts.dimensions}) does not match index dimension (${expectedDim}) — run \`mdss index --dimensions ${opts.dimensions}\` to rebuild`
905
+ );
906
+ }
907
+
677
908
  const pathRes = pathFilter ? (Array.isArray(pathFilter) ? pathFilter : [pathFilter]).map(globToRegExp) : [];
678
909
  let sinceMs: number | undefined;
679
910
  if (since !== undefined) {
@@ -758,60 +989,168 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
758
989
  }
759
990
  }
760
991
 
761
- let qVec: Float32Array | number[];
762
- if (opts.useQueryCache !== false && runtime.queryCache) {
763
- const normQuery = effectiveQuery.trim().toLowerCase();
764
- const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${normQuery}`;
765
- // L2 disk layer (issue #114): persists across processes under cacheDir;
766
- // corrupt/missing files degrade silently to a normal embed.
767
- const diskKey = buildDiskKey(runtime.model.id, runtime.model.revision || 'main', expectedDim, normQuery);
768
- const useDisk = opts.queryDiskCache !== false;
769
- qVec = await runtime.queryCache.getOrCompute(cacheKey, async () => {
770
- if (useDisk) {
771
- const fromDisk = diskQueryGet(cacheDir, diskKey, expectedDim);
772
- if (fromDisk) return fromDisk;
773
- }
774
- const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
775
- if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
776
- throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} - run \`mdss index\` to rebuild`);
777
- }
778
- validateNumericVector(v, undefined, 'query vector');
779
- if (useDisk) diskQueryPut(cacheDir, diskKey, v);
780
- return v;
781
- });
782
- } else {
783
- const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
992
+ // issue #203: one embed helper for the original query AND every multi-query
993
+ // paraphrase — both ride the same in-memory + disk query-embedding cache.
994
+ const embedFor = async (text: string): Promise<Float32Array | number[]> => {
995
+ if (opts.useQueryCache !== false && runtime.queryCache) {
996
+ const normQuery = text.trim().toLowerCase();
997
+ const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${normQuery}`;
998
+ // L2 disk layer (issue #114): persists across processes under cacheDir;
999
+ // corrupt/missing files degrade silently to a normal embed.
1000
+ const diskKey = buildDiskKey(runtime.model.id, runtime.model.revision || 'main', expectedDim, normQuery);
1001
+ const useDisk = opts.queryDiskCache !== false;
1002
+ return runtime.queryCache.getOrCompute(cacheKey, async () => {
1003
+ if (useDisk) {
1004
+ const fromDisk = diskQueryGet(cacheDir, diskKey, expectedDim);
1005
+ if (fromDisk) return fromDisk;
1006
+ }
1007
+ const [v] = await embedFn([text], 'query', runtime.model, cacheDir, offline);
1008
+ if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
1009
+ throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} - run \`mdss index\` to rebuild`);
1010
+ }
1011
+ validateNumericVector(v, undefined, 'query vector');
1012
+ if (useDisk) diskQueryPut(cacheDir, diskKey, v);
1013
+ return v;
1014
+ });
1015
+ }
1016
+ const [v] = await embedFn([text], 'query', runtime.model, cacheDir, offline);
784
1017
  if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
785
1018
  throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
786
1019
  }
787
1020
  validateNumericVector(v, undefined, 'query vector');
788
- qVec = v;
1021
+ return v;
1022
+ };
1023
+
1024
+ const qVec = await embedFor(effectiveQuery);
1025
+
1026
+ // issue #203: multi-query expansion — N total variants (original first),
1027
+ // each embedded, dense rankings fused by union-RRF below. Paraphrases come
1028
+ // from the LLM bridge; without an endpoint (or on any failure) the list is
1029
+ // empty and the search is byte-identical to the single-query baseline.
1030
+ const multiQueryN = Number.isFinite(opts.multiQuery) ? Math.floor(Number(opts.multiQuery)) : 0;
1031
+ const paraphrases =
1032
+ multiQueryN >= 2 && opts.llmEndpoint
1033
+ ? await paraphraseQueries(opts.llmEndpoint, query, multiQueryN - 1, opts.llmModel)
1034
+ : [];
1035
+ const qVecs: Array<Float32Array | number[]> = [qVec];
1036
+ for (const p of paraphrases.slice(0, multiQueryN - 1)) {
1037
+ try {
1038
+ qVecs.push(await embedFor(p));
1039
+ } catch {
1040
+ // a paraphrase that fails to embed (e.g. dim mismatch) is dropped, not fatal
1041
+ }
789
1042
  }
790
1043
  const queryTerms = [...new Set(tokenize(query))];
791
1044
 
1045
+ const maxPerDoc = opts.maxPerFile || opts.maxPerDoc;
1046
+ // Issue #127: time-decay recency boost — multiply the fused score by
1047
+ // 0.5^(ageDays / halfLifeDays), post-RRF and pre-collapse. Age source
1048
+ // priority: frontmatter `updated` → `created` → file mtime (issue #164). Missing dates
1049
+ // get factor 1 (evergreen content stays reachable via lexical strength).
1050
+ const recencyHalfLife = Number(opts.recency) > 0 ? Number(opts.recency) : 0;
1051
+ // Issue #163: recency rescores AFTER the pool slice, so the pool must be
1052
+ // expanded like the maxPerDoc path — otherwise candidates ranked k+1..n
1053
+ // by raw RRF are dropped before the fresh-document boost can lift them.
1054
+ // issue #191: the pool must be known BEFORE the sweep for the bounded top-k.
1055
+ const pool = rerank
1056
+ ? (rerankPool || (maxPerDoc && maxPerDoc > 0 ? Math.max(50, k * 5) : Math.max(20, k * 3)))
1057
+ : (maxPerDoc && maxPerDoc > 0 || recencyHalfLife > 0 ? Math.max(50, k * 5) : k);
1058
+
792
1059
  let semantic: Array<{ idx: number; score: number }>;
793
- const useAnn = opts.ann ?? (runtime.ivf !== undefined && candidates.length >= ANN_THRESHOLD);
794
- if (useAnn && candidates.length >= ANN_THRESHOLD) {
795
- if (!runtime.ivf) {
796
- const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
797
- runtime.ivf = trainIVF(rawVecs);
798
- }
799
- const qVecF32 = qVec instanceof Float32Array ? qVec : Float32Array.from(qVec);
800
- const probedSet = new Set(searchIVFCandidates(qVecF32, runtime.ivf, opts.nprobe ?? DEFAULT_NPROBE));
1060
+ let cosByIdx: Map<number, number> | undefined;
1061
+ // issue #201: engine selection — explicit 'ivf'/'hnsw' wins; `true` picks
1062
+ // whichever sidecar exists (hnsw preferred, then ivf); otherwise the
1063
+ // automatic threshold path (sidecar present AND corpus above ANN_THRESHOLD)
1064
+ const annOpt = opts.ann;
1065
+ const autoAnn = annOpt === undefined || annOpt === false
1066
+ ? Boolean(runtime.hnsw || runtime.ivf) && candidates.length >= ANN_THRESHOLD
1067
+ : candidates.length >= ANN_THRESHOLD;
1068
+ const useAnn = Boolean(annOpt) || autoAnn;
1069
+ const annEngine: 'ivf' | 'hnsw' | undefined = useAnn
1070
+ ? (annOpt === 'hnsw' || (annOpt !== 'ivf' && runtime.hnsw) ? 'hnsw' : 'ivf')
1071
+ : undefined;
1072
+ // issue #190: with cached int8 norms the sweep computes the dot product alone
1073
+ const norms = runtime.chunkNorms;
1074
+ const sweepScore = (chunk: RuntimeChunk, idx: number): number =>
1075
+ norms && chunk.vec instanceof Int8Array
1076
+ ? asymmetricCosineInt8CachedNorm(qVec as Float32Array, chunk.vec, norms[idx])
1077
+ : cosine(qVec, chunk.vec);
1078
+ // issue #203: ANN probing unions the candidate sets of every query variant —
1079
+ // a paraphrase must not recall into chunks the original's probe excluded
1080
+ const probed = useAnn && candidates.length >= ANN_THRESHOLD
1081
+ ? (() => {
1082
+ const probedUnion = new Set<number>();
1083
+ for (const vec of qVecs) {
1084
+ const qVecF32 = vec instanceof Float32Array ? vec : Float32Array.from(vec);
1085
+ if (annEngine === 'hnsw') {
1086
+ // issue #201: HNSW graph search — built on the hot path only when
1087
+ // the sidecar is missing (same semantics as the IVF sync train)
1088
+ if (!runtime.hnsw) {
1089
+ const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
1090
+ runtime.hnsw = buildHnsw(rawVecs, {
1091
+ vectors: rawVecs,
1092
+ distance: (a, b) => {
1093
+ const bv = rawVecs[b];
1094
+ return 1 - (a as Float32Array).reduce((sum, x, i) => sum + x * (bv as ArrayLike<number>)[i], 0);
1095
+ },
1096
+ });
1097
+ }
1098
+ for (const idx of searchHnswCandidates(qVecF32, runtime.hnsw, chunks.map((c) => c.vec), { ef: opts.ef ?? DEFAULT_HNSW_EF })) {
1099
+ probedUnion.add(idx);
1100
+ }
1101
+ } else {
1102
+ if (!runtime.ivf) {
1103
+ const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
1104
+ runtime.ivf = trainIVF(rawVecs);
1105
+ }
1106
+ for (const idx of searchIVFCandidates(qVecF32, runtime.ivf, opts.nprobe ?? DEFAULT_NPROBE)) {
1107
+ probedUnion.add(idx);
1108
+ }
1109
+ }
1110
+ }
1111
+ return probedUnion;
1112
+ })()
1113
+ : null;
1114
+ if (qVecs.length > 1) {
1115
+ // issue #203: multi-query — one dense sweep per variant, rankings fused by
1116
+ // union-RRF. `cos` stays anchored to the ORIGINAL query's cosine so the
1117
+ // hit shape (and score display) keeps its meaning.
1118
+ const sweepWith = (vec: Float32Array | number[]) => (chunk: RuntimeChunk, idx: number): number =>
1119
+ norms && chunk.vec instanceof Int8Array
1120
+ ? asymmetricCosineInt8CachedNorm(vec as Float32Array, chunk.vec, norms[idx])
1121
+ : cosine(vec, chunk.vec);
1122
+ const perVectorRankings = qVecs.map((vec) =>
1123
+ candidates.map(({ chunk, idx }) => ({
1124
+ idx,
1125
+ score: probed && !probed.has(idx) ? -1 : sweepWith(vec)(chunk, idx),
1126
+ })),
1127
+ );
1128
+ const fusedSemantic = rrf(perVectorRankings, 60);
1129
+ semantic = [...fusedSemantic.entries()].map(([idx, score]) => ({ idx, score }));
1130
+ cosByIdx = new Map(perVectorRankings[0].map((s) => [s.idx, s.score]));
1131
+ } else if (semanticOnly && !(graphBoost > 0)) {
1132
+ // issue #191: the pure-semantic ranking only needs the top pool — select
1133
+ // it with a bounded min-heap in one pass (no per-chunk objects, no full
1134
+ // sort). Equivalent to [...candidates].map(score).sort(desc).slice(0, pool)
1135
+ // with the stable tie-break toward the smaller idx.
1136
+ semantic = topKByScore(
1137
+ candidates.length,
1138
+ (i) => {
1139
+ const { chunk, idx } = candidates[i];
1140
+ if (probed && !probed.has(idx)) return -1;
1141
+ return sweepScore(chunk, idx);
1142
+ },
1143
+ pool,
1144
+ );
1145
+ } else {
801
1146
  semantic = candidates.map(({ chunk, idx }) => {
802
- if (!probedSet.has(idx)) {
1147
+ if (probed && !probed.has(idx)) {
803
1148
  return { idx, score: -1 };
804
1149
  }
805
- return { idx, score: cosine(qVec, chunk.vec) };
806
- });
807
- } else {
808
- semantic = candidates.map(({ chunk, idx }) => {
809
- return { idx, score: cosine(qVec, chunk.vec) };
1150
+ return { idx, score: sweepScore(chunk, idx) };
810
1151
  });
1152
+ cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
811
1153
  }
812
- const cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
813
-
814
- const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
815
1154
 
816
1155
  let graphRanking: Array<{ idx: number; score: number }> = [];
817
1156
  let graphMap: Map<number, number> | undefined;
@@ -843,14 +1182,14 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
843
1182
  if (graphBoost > 0) {
844
1183
  const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
845
1184
  ranked = [...fused.entries()]
846
- .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
1185
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx!.get(idx)! }))
847
1186
  .sort((a, b) => b.fscore - a.fscore)
848
1187
  .slice(0, pool);
849
1188
  } else {
850
- ranked = [...semantic]
851
- .sort((a, b) => b.score - a.score)
852
- .slice(0, pool)
853
- .map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
1189
+ // issue #191: `semantic` is already the top pool, ordered (score desc, idx asc);
1190
+ // issue #203: with multi-query the pool scores are RRF — cos stays the
1191
+ // original query's cosine via cosByIdx
1192
+ ranked = semantic.map((s) => ({ idx: s.idx, fscore: s.score, cos: cosByIdx ? cosByIdx.get(s.idx)! : s.score }));
854
1193
  }
855
1194
  } else {
856
1195
  const kw =
@@ -871,23 +1210,18 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
871
1210
  if (graphBoost > 0) {
872
1211
  const fused = rrf([semantic, kw, fuzzy, graphRanking], 60, [1, 1, 1, graphBoost]);
873
1212
  ranked = [...fused.entries()]
874
- .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
1213
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx!.get(idx)! }))
875
1214
  .sort((a, b) => b.fscore - a.fscore)
876
1215
  .slice(0, pool);
877
1216
  } else {
878
1217
  const fused = rrf([semantic, kw, fuzzy]);
879
1218
  ranked = [...fused.entries()]
880
- .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
1219
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx!.get(idx)! }))
881
1220
  .sort((a, b) => b.fscore - a.fscore)
882
1221
  .slice(0, pool);
883
1222
  }
884
1223
  }
885
1224
 
886
- // Issue #127: time-decay recency boost — multiply the fused score by
887
- // 0.5^(ageDays / halfLifeDays), post-RRF and pre-collapse. Age source
888
- // priority: frontmatter `created` → `updated` → file mtime. Missing dates
889
- // get factor 1 (evergreen content stays reachable via lexical strength).
890
- const recencyHalfLife = Number(opts.recency) > 0 ? Number(opts.recency) : 0;
891
1225
  if (recencyHalfLife > 0) {
892
1226
  const now = Date.now();
893
1227
  const ageCache = new Map<string, number | null>();
@@ -910,8 +1244,10 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
910
1244
  const scores = await rerankFn(query, texts, cacheDir, offline);
911
1245
  ranked = ranked
912
1246
  .map((r, i) => ({ ...r, rerank: scores[i] ?? -Infinity }))
913
- .sort((a, b) => b.rerank - a.rerank)
914
- .slice(0, k);
1247
+ .sort((a, b) => b.rerank - a.rerank);
1248
+ if (!maxPerDoc || maxPerDoc <= 0) {
1249
+ ranked = ranked.slice(0, k);
1250
+ }
915
1251
  }
916
1252
 
917
1253
  const hasMetaFields = (m?: DocumentMetadata) =>
@@ -974,18 +1310,24 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
974
1310
  };
975
1311
  });
976
1312
 
977
- const maxPerDoc = opts.maxPerFile || opts.maxPerDoc;
978
1313
  if (maxPerDoc && maxPerDoc > 0) {
979
1314
  return collapseResults(hits, (h) => h.meta?.canonicalRef || h.file, maxPerDoc).slice(0, k);
980
1315
  }
981
- return hits;
1316
+ // Issue #163: with the pool expanded for recency the final k-truncation
1317
+ // moves here (it used to be the pool slice itself). Paths that already
1318
+ // truncate (rerank, maxPerDoc collapse) keep their behavior.
1319
+ return hits.slice(0, k);
982
1320
  }
983
1321
 
984
1322
  export type OneShotSearchOptions = Omit<SearchOptions, 'loaded'> & { indexDir: string };
985
1323
 
986
1324
  export async function search(opts: OneShotSearchOptions): Promise<SearchResultHit[]> {
987
1325
  const { indexDir, ...rest } = opts;
988
- const loaded = loadIndex(indexDir);
989
- return searchIndex({ ...rest, loaded });
1326
+ // issue #192: the staleness check walks the entire tree synchronously —
1327
+ // defer it until AFTER the results are computed so it never delays them
1328
+ const loaded = loadIndex(indexDir, { checkStale: false });
1329
+ const hits = await searchIndex({ ...rest, loaded });
1330
+ warnIfStale(loaded.index);
1331
+ return hits;
990
1332
  }
991
1333