md-semantic-search 2.0.0 โ†’ 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ measurements that shaped its defaults.
33
33
 
34
34
  - ๐Ÿ”Œ **Any folder, anywhere.** Point `--db` at any directory of `.md`/`.markdown`
35
35
  files. It does **not** have to live inside this project. Recursive by default.
36
- - ๐ŸŒ **Cross-lingual.** Multilingual embeddings (default `multilingual-e5-base`).
36
+ - ๐ŸŒ **Cross-lingual.** Multilingual embeddings (default `granite-multilingual-311m`, Apache-2.0).
37
37
  - ๐Ÿงญ **Markdown-aware context.** Each embedding includes the full active heading
38
38
  path (`title > h1 > h2 > ...`), so a deeply nested passage remains searchable
39
39
  by its parent topics without an LLM-generated context step.
@@ -109,8 +109,15 @@ measurements that shaped its defaults.
109
109
 
110
110
  ## Install
111
111
 
112
- Requires **Node.js โ‰ฅ 18**. Native dependencies ship as prebuilt binaries โ€” no
113
- compiler toolchain needed on any platform.
112
+ Requires **Node.js โ‰ฅ 20.9**. Supported runtimes:
113
+
114
+ | Runtime | Status | Support |
115
+ |---|---|---|
116
+ | **Node 22, 24** | Maintained LTS lines | Tested in CI |
117
+ | **Node 20.9โ€“20.x** | EOL since 2026-04-30 | Runs, prints a deprecation warning on stderr; dropped in **v3.0.0** |
118
+
119
+ Native dependencies ship as prebuilt binaries โ€” no compiler toolchain needed
120
+ on any platform.
114
121
 
115
122
  Run on demand with `npx` (no install):
116
123
 
@@ -508,7 +515,7 @@ against a loopback allowlist (`403` otherwise) so DNS-rebinding pages cannot rea
508
515
  and CORS stays fully off unless you pass `--cors-origin`. `/health` exposes the live counters
509
516
  (`in_flight`, `queued`, `rejected_total`).
510
517
 
511
- ### Web UI (built-in, `--ui`)
518
+ ### Web UI (built-in; disable with `--no-ui`)
512
519
 
513
520
  By default `mdss serve` also serves a **zero-dependency web UI at `/`** โ€” a keyboard-first search page
514
521
  with live debounced results, term highlighting, score bars, tag/type/status filters, dark/light theme
@@ -660,7 +667,7 @@ Add to your `claude_desktop_config.json`:
660
667
  | `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
661
668
  | `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
662
669
  | `--cache-dir <dir>` | Model cache dir. Default: `$XDG_CACHE_HOME/mdss` if set, else `~/.cache/mdss` on **all** platforms (so on Windows that's `C:\Users\<you>\.cache\mdss` โ€” *not* `%LOCALAPPDATA%`; override with `MDSS_CACHE_DIR`). |
663
- | `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
670
+ | `--model <name\|id>` | Embedding model (default `granite-multilingual-311m`). See `mdss models`. |
664
671
  | `--workers <n>` | Number of parallel batch workers for embedding (default: 1). |
665
672
  | `--format <fmt>` | Export format: `jsonl` (default), `csv`, `parquet` (`export`). |
666
673
  | `--no-vectors` | Omit embeddings from export (`export`). |
@@ -679,7 +686,7 @@ Add to your `claude_desktop_config.json`:
679
686
  | `--multi-query <n>` | Multi-query expansion (issue #203): N query variants (original + Nโˆ’1 LLM paraphrases) fused by union-RRF; needs `--llm-endpoint`, degrades silently without it. |
680
687
  | `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
681
688
  | `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
682
- | `--ann [ivf\|hnsw]` | Approximate nearest neighbor search (search; auto-enables for large corpora). Engines (issue #201): `ivf` (k-means, the default sidecar) or `hnsw` (graph โ€” no training phase, 100% measured recall at equal-or-lower query latency, `hnsw.json` sidecar, corrupt โ†’ brute-force fallback). Bare `--ann` prefers an existing sidecar. |
689
+ | `--ann [ivf\|hnsw]` | Approximate nearest neighbor search (search; auto-enables for large corpora). Engines (issue #201): `ivf` (k-means, the default sidecar) or `hnsw` (graph โ€” no training phase, 100% measured recall at equal-or-lower query latency, `hnsw.json` sidecar, corrupt โ†’ brute-force fallback; watch rebuilds reuse the persisted graph via chunk-hash remapping instead of rebuilding it โ€” issue #223). Bare `--ann` prefers an existing sidecar. |
683
690
  | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
684
691
  | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
685
692
  | `--host <ip>` | Bind address for `serve` (default `127.0.0.1` โ€” loopback only; use `0.0.0.0` to expose on the LAN, or `MDSS_HOST`). |
@@ -786,26 +793,26 @@ mdss models
786
793
  | Alias | Model | Dim | Notes |
787
794
  |-------|-------|-----|-------|
788
795
  | `e5-small` | `Xenova/multilingual-e5-small` | 384 | Fastest (~120 MB). **Weak cross-lingual** โ€” see RESEARCH. |
789
- | `e5-base` โญ | `Xenova/multilingual-e5-base` | 768 | Default. Best balance. |
796
+ | `e5-base` | `Xenova/multilingual-e5-base` | 768 | Default before v2.2.0. Apache-2.0, MIT-style terms. |
790
797
  | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
791
798
  | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
792
799
  | `qwen3-embedding-0.6b` | `onnx-community/Qwen3-Embedding-0.6B-ONNX` | 1024 | ~613 MB q8. Opt-in; instruction-prefixed queries and last-token pooling. |
793
- | `granite-multilingual-311m` | `onnx-community/granite-embedding-311m-multilingual-r2-ONNX` | 768 | ~298 MB q8. IBM Granite r2 โ€” ModernBERT 8k context, CLS pooling, Apache-2.0 (issue #200). |
794
- | `embeddinggemma-300m` | `onnx-community/embeddinggemma-300m-ONNX` | 768 | ~300 MB q8. Google EmbeddingGemma โ€” pre-pooled output, MRL dims 64โ€“768, Gemma license (issue #200). |
800
+ | `granite-multilingual-311m` โญ | `onnx-community/granite-embedding-311m-multilingual-r2-ONNX` | 768 | **Default.** ~298 MB q8. IBM Granite r2 โ€” ModernBERT 8k context, CLS pooling, **Apache-2.0**. Measured nDCG@10 0.8653 on golden v2 (issue #227). |
801
+ | `embeddinggemma-300m` | `onnx-community/embeddinggemma-300m-ONNX` | 768 | ~300 MB q8. Measured leader (nDCG@10 0.8691) but **license-blocked from being the npm default**: Gemma Terms of Use, not MIT/Apache (issue #227). |
795
802
 
796
803
  Switching models invalidates the stored vectors automatically โ€” the next
797
804
  `index` run does a full rebuild.
798
805
 
799
806
  Weights are downloaded in quantized (q8) form when the model repo ships them
800
- (e5-base is ~280 MB instead of ~1.1 GB fp32).
801
-
802
- The Qwen3 alias is pinned to a tested model revision and automatically applies
803
- the retrieval instruction required for queries; indexed passages remain
804
- unprefixed. Its upstream benchmark is promising, but it has not replaced
805
- `e5-base` as the default because the project corpus has not been benchmarked
806
- against it yet. This is experimental enablement rather than completion of issue
807
- #50: release provenance and the versioned RU/EN benchmark remain tracked by
808
- #55 and #56 (the explicit model adapter contract landed in #60).
807
+ (the default granite is ~298 MB).
808
+
809
+ Why granite is the default (issue #227): measured on the 186-query golden set
810
+ v2 against the incumbent e5-base โ€” **granite 0.8653 vs e5-base 0.7874 nDCG@10**
811
+ with the same size class and a fully permissive Apache-2.0 license. The strictly
812
+ better `embeddinggemma-300m` (0.8691) is a two-thousandths edge over granite but
813
+ carries Google's Gemma Terms of Use; making it the npm default would impose
814
+ those terms on every user, so it stays the recommended opt-in. Full decision
815
+ record and per-category tables: RESEARCH.md ยง15.
809
816
 
810
817
  ### How to add a model
811
818
 
package/RESEARCH.md CHANGED
@@ -175,6 +175,29 @@ mdss index --db ./your-wiki --model qwen3-embedding-0.6b
175
175
  mdss search --db ./your-wiki --semantic "the same paraphrase"
176
176
  ```
177
177
 
178
+ ### Expansion to golden set v2 (issue #226)
179
+
180
+ The original 48-query dev slice was too thin to overturn a default (ยง15) and
181
+ carried no long-document slice at all (ยง12). The v2 expansion re-measured
182
+ everything on a frozen 26-document corpus and **204 queries** (186 dev /
183
+ 12 test / 6 holdout; 100 RU / 104 EN), adding two measured slices:
184
+
185
+ - **`cross-lingual`** (n=27 dev): query language โ‰  answering-document language
186
+ โ€” the headline use case, now a first-class bench line.
187
+ - **`long-doc-section`** (n=21 dev): answers live only in 3rd/4th-level sections
188
+ of long documents with frontmatter โ€” the ยง12 heading-path ablation gate.
189
+ - **Hard negatives** grew to 20, built from same-topic and cross-topic confuser
190
+ pairs (nginx-TLS vs ัะตั€ั‚ะธั„ะธะบะฐั‚-ั€ะพั‚ะฐั†ะธั, postgres vs redis, api-tokens vs
191
+ ั€ะพั‚ะฐั†ะธั-ัะตั€ั‚ะธั„ะธะบะฐั‚ะพะฒ) to keep reranker discrimination under test.
192
+
193
+ e5-base (default, multilingual-e5-base, dev slice, k=10, real model):
194
+ **nDCG@10 0.7874 ยท MRR 0.7577 ยท Hit@10 0.9301 ยท Recall@10 1.0000** โ€” the
195
+ committed `golden-baseline.json`. The v2 set is intentionally harder than v1
196
+ (0.8856 on n=48): paraphrase/hard-negative/cross-lingual weight grew and the
197
+ new categories reward fine-grained discrimination. The #198 nightly gate now
198
+ runs against this baseline; the baseline is meaningful only for the same
199
+ fixture+corpus hashes (recorded in the file).
200
+
178
201
  ## 9. Recommendations
179
202
 
180
203
  | If you wantโ€ฆ | Use |
@@ -284,34 +307,54 @@ golden set (#56) before any default change.
284
307
 
285
308
  ### Golden-Set Results (frozen corpus, hash `dev-golden.json`)
286
309
 
287
- | Model (alias) | q8 size | nDCG@10 (dev, n=48) | MRR | Hit@10 | Recall@10 | nDCG (test) | nDCG (holdout) |
288
- |---|---|---|---|---|---|---|---|
289
- | `e5-base` (baseline) | ~280 MB | 0.8797 | 0.8628 | 0.979 | 1.000 | 1.0000 | 0.9077 |
290
- | `granite-multilingual-311m` | ~298 MB | 0.8761 | 0.8559 | 0.979 | 1.000 | 1.0000 | 0.9077 |
291
- | `embeddinggemma-300m` | ~300 MB | **0.9002** | **0.8837** | **1.000** | 1.000 | 1.0000 | 0.9077 |
292
-
293
- - **Non-inferiority (#198 tolerance: drop โ‰ค 0.01):** granite passes (โˆ’0.0036);
294
- embeddinggemma **exceeds** the baseline (+0.0205) and is the only candidate
295
- that recovers the two dev queries e5-base misses (Hit 1.000).
296
- - **Discriminating slice is `dev`** โ€” `test` and `holdout` are saturated for
297
- both models, so they cannot rank the candidates further.
298
-
299
- ### Decision
310
+ Original n=48 fixture (historical):
300
311
 
301
- 1. **Both candidates ship as opt-in registry entries** with pinned revisions
302
- (issue #200 task 1) and are documented in the README Models table.
303
- 2. **The default stays `e5-base`.** EmbeddingGemma's superiority is proven on
304
- our golden set, but the default switch is gated on two factors outside this
305
- measurement: (a) the **Gemma license** โ€” not MIT/Apache, so making it the
306
- npm-distributed default imposes Google's additional terms on every user,
307
- which is a project-licensing decision, not an engineering one; (b) one
308
- fixture (48 dev queries) is a thin base for overturning a default that the
309
- #198 gate has been calibrated on. Re-evaluate if/when the golden set grows.
310
- **Until then `embeddinggemma-300m` is the recommended opt-in** (`--model
311
- embeddinggemma-300m`) โ€” same size class as e5-base, better measured quality.
312
- 3. **Infrastructure added:** `pooling: 'cls'` (granite) and the
313
- `pooledOutput` adapter flag (pre-pooled ONNX graphs bypass the
314
- feature-extraction pipeline) โ€” both fingerprint-relevant and unit-tested.
312
+ | Model (alias) | q8 size | nDCG@10 (dev, n=48) | MRR | Hit@10 | Recall@10 |
313
+ |---|---|---|---|---|---|
314
+ | `e5-base` (baseline) | ~280 MB | 0.8797 | 0.8628 | 0.979 | 1.000 |
315
+ | `granite-multilingual-311m` | ~298 MB | 0.8761 | 0.8559 | 0.979 | 1.000 |
316
+ | `embeddinggemma-300m` | ~300 MB | **0.9002** | **0.8837** | **1.000** | 1.000 |
317
+
318
+ Re-measured on **golden set v2** (issue #226 โ€” 186 dev queries, k=10, real
319
+ models, this closed the "thin fixture" blocker):
320
+
321
+ | Model (alias) | nDCG@10 | MRR | Hit@10 | Recall@10 | License | Size |
322
+ |---|---|---|---|---|---|---|
323
+ | `e5-base` (incumbent) | 0.7874 | 0.7577 | 0.9301 | 1.000 | MIT | ~280 MB |
324
+ | `qwen3-embedding-0.6b` | 0.8519 | 0.8401 | 0.9462 | 1.000 | Apache-2.0 | ~613 MB |
325
+ | `granite-multilingual-311m` | 0.8653 | 0.8495 | 0.9624 | 1.000 | **Apache-2.0** | ~298 MB |
326
+ | `embeddinggemma-300m` | **0.8691** | **0.8594** | **0.9677** | 1.000 | Gemma ToU | ~300 MB |
327
+
328
+ Per-category (v2 dev): granite **beats** embeddinggemma on `cross-lingual`
329
+ (0.7766 vs 0.7552) and `paraphrase` (0.8829 vs 0.8751); embeddinggemma leads on
330
+ `long-doc-section` (0.8443 vs 0.8087); hard-negative and identifier are tied.
331
+ The v2 set amplified the gap to the incumbent (harder paraphrase/hard-negative
332
+ weight), so the "thin fixture" concern resolved in favor of a switch.
333
+
334
+ ### Decision (final, issue #227 โ€” revisits the 2026-10 decision above)
335
+
336
+ 1. **The default moves `e5-base` โ†’ `granite-multilingual-311m`** (v2.2.0):
337
+ measured +0.078 nDCG@10 over the incumbent on the expanded set, same size
338
+ class, **Apache-2.0**, 8k ModernBERT context. The #198 nightly gate baseline
339
+ was re-taken with granite (0.8653) and verified deterministic.
340
+ 2. **`embeddinggemma-300m` remains the recommended opt-in, not the default.**
341
+ The Gemma Terms of Use are not MIT/Apache-compatible; making it the
342
+ npm-distributed default would impose Google's additional terms (attribution,
343
+ Prohibited Use Policy, derivative-model obligations) on every user of an
344
+ MIT-licensed tool. Precedent: sentence-transformers / fastembed ship
345
+ permissive defaults and list Gemma models as opt-in. Re-evaluation triggers:
346
+ (a) an EmbeddingGemma successor under Apache-2.0 with a transformers.js
347
+ artifact (EmbeddingGemma 2 was announced on that path), or (b) a measured
348
+ quality gap on our set that grows beyond the license trade-off.
349
+ 3. **`qwen3-embedding-0.6b` stays opt-in** โ€” Apache-2.0 but 2ร— the download of
350
+ granite at lower measured quality (0.8519).
351
+ 4. **Migration:** the adapter fingerprint change invalidates existing indexes โ€”
352
+ the first `mdss index` after upgrade re-embeds the corpus (identical chunk
353
+ texts do NOT reuse vectors across models; vectors are model-scoped). Users
354
+ pinned by bandwidth or reproducibility keep the old behavior with
355
+ `--model e5-base`. `pooling: 'cls'` (granite) and the `pooledOutput` flag
356
+ (gemma) were already shipped in #200; both fingerprint-relevant and
357
+ unit-tested.
315
358
 
316
359
  ### Reproducing
317
360
 
@@ -319,8 +362,12 @@ golden set (#56) before any default change.
319
362
  node scripts/run-bench.mjs --model e5-base --slice dev
320
363
  node scripts/run-bench.mjs --model granite-multilingual-311m --slice dev
321
364
  node scripts/run-bench.mjs --model embeddinggemma-300m --slice dev
365
+ node scripts/run-bench.mjs --model qwen3-embedding-0.6b --slice dev
322
366
  ```
323
367
 
368
+ (Bare `node scripts/run-bench.mjs --slice dev` now measures the granite
369
+ default and regenerates the committed baseline with `--update-baseline`.)
370
+
324
371
  ---
325
372
 
326
373
  ## 16. Reranker Registry & Multi-Query Expansion (Issue #203)
@@ -421,12 +468,48 @@ corpora keeps **IVF as the default engine**; the criterion's second branch
421
468
  2. Bare `--ann` and the automatic threshold path stay on IVF until either
422
469
  (a) the JS graph build gets an order of magnitude cheaper (worker
423
470
  parallelism, WASM SIMD โ€” see ยง13), or (b) incremental watch rebuilds
424
- start reusing the persisted graph (the `insertHnsw`/`deleteHnsw` API is
425
- already in place for that).
471
+ start reusing the persisted graph. **(b) is now implemented (issue
472
+ #223):** a watch rebuild remaps the persisted graph onto the new chunk
473
+ numbering via `chunkHash` (`remapHnsw` โ€” vector reuse is hash-keyed, so
474
+ every carried edge distance stays valid), tombstones the gone nodes and
475
+ inserts only the new ones; the build-side gap now applies to FULL builds
476
+ only (first run, engine/budget switch, entry-point-unanchorable remap).
426
477
  3. `--ef <n>` tunes the HNSW search breadth (default 64; recall saturates
427
478
  at 100% already at ef 16 on the bench corpora โ€” the default leaves
428
479
  headroom for noisier real-world distributions).
429
480
 
481
+ ### Full-build acceleration attempt (issue #228 โ€” revisit condition (a) closed as DECLINED)
482
+
483
+ Profiling the full build (dim 256): **84โ€“85% of wall time is distance
484
+ computation** โ€” 16.5k calls/node at n=5000, 18.9k at n=20000 โ€” all on a
485
+ sequential dependency chain (node *i*'s graph state depends on every prior
486
+ insert). Two accelerators were measured:
487
+
488
+ 1. **worker_threads parallelism โ€” structurally blocked.** A per-hop distance
489
+ batch is ~13 ยตs of work; a worker roundtrip (postMessage + await) costs
490
+ ~0.1โ€“0.3 ms โ€” 10โ€“20ร— the work per hop. Batching across hops is impossible
491
+ without breaking the dependency chain, and concurrent insertion breaks the
492
+ byte-identical-sidecar determinism contract. Declined.
493
+ 2. **WASM SIMD dot-batch kernel (`src/wasm-dot.ts`) โ€” shipped, gate missed.**
494
+ 363 bytes of hand-written WAT (SIMD128 mul/add, one call per node's
495
+ neighbor list, JS fallback on compile failure). Microbenchmark: **ร—6.0**
496
+ over the unrolled JS dot on a 32-dot batch (dim 256). End-to-end full
497
+ build (equally-warmed interleaved medians): **ร—1.27 at n=5000 and n=20000**
498
+ โ€” the beam mostly expands 1โ€“2 fresh neighbors per hop, so batching rarely
499
+ amortizes, and bookkeeping (visited sets, heap sorts) is untouched. An
500
+ order-optimistic first-run pairing (cold closure vs warm wasm) showed
501
+ ร—2.10/ร—2.88 โ€” the honest equally-warmed number is ร—1.27. **Recall@10 is
502
+ identical across engines** (70.6%/40.0% paired at 5k/20k); per-engine
503
+ byte-identical rebuilds are pinned by tests. The kernel stays wired as
504
+ `buildHnsw(..., { engine: 'auto' })` in the indexer (strictly faster,
505
+ zero risk) but does not change the engine decision.
506
+
507
+ **Conclusion:** the original (a) trigger โ€” "an order of magnitude cheaper" โ€”
508
+ is not reachable by either accelerator. IVF remains the default ANN engine;
509
+ HNSW remains opt-in with the #223 watch-reuse path. The auto-flip condition
510
+ would need an algorithmic change (deferred-distance beam) or a native kernel,
511
+ both out of scope for this project's zero-native-deps constraint.
512
+
430
513
  ### Reproducing
431
514
 
432
515
  ```bash
@@ -434,3 +517,43 @@ node bench/ann-recall.mjs # IVF + HNSW curves side by side
434
517
  mdss index --db ./notes --ann hnsw # hnsw.json sidecar
435
518
  mdss search --db ./notes --ann hnsw "query"
436
519
  ```
520
+
521
+ ## 18. RAG Answer Faithfulness โ€” First Measured Slice (Issue #229)
522
+
523
+ `bench/rag-faithfulness.mjs` closes the measurement gap in the synthesis loop:
524
+ 30 answerable queries (reference docs = the golden set qrels) and 8
525
+ unanswerable queries over the frozen 26-doc corpus, with judge-free metrics โ€”
526
+ citation precision/recall of `citationsUsed` against the golden docs, a
527
+ support rate (answer sentences containing a golden-doc content term), and a
528
+ spurious-citation rate for the unanswerable set. Deterministic, unit-tested,
529
+ no LLM in the metric path.
530
+
531
+ | Profile | Citation precision | Citation recall | Support rate | Spurious rate (unanswerable) | p50 |
532
+ |---|---|---|---|---|---|
533
+ | extractive ยท fake embedder ยท k=6 (committed baseline, ยฑ5pp gate) | 70.0% | 83.3% | 67.6% | 100% | 1 ms |
534
+ | extractive ยท granite-311m ยท k=6 | 73.3% | 85.0% | 68.3% | 100% | 36 ms |
535
+
536
+ Two honest findings:
537
+
538
+ 1. **The extractive fallback cannot refuse.** On unanswerable queries it
539
+ cites whatever the retriever surfaced (spurious rate 100% by construction โ€”
540
+ the fallback's contract is "answer from passages", and it has no
541
+ refusal stage). This is the documented trade-off of the zero-dependency
542
+ mode; the LLM profile (`--endpoint`, nightly report-only via
543
+ `MDSS_SMOKE_ENDPOINT`) is where refusal behavior becomes measurable.
544
+ 2. **Support โ‰ˆ 68%** โ€” a third of extractive answer sentences are rhetorical
545
+ glue (headers, connectors) rather than golden-doc content. This is the
546
+ baseline an LLM-synthesis profile should beat without losing citation
547
+ precision.
548
+
549
+ The nightly runs both profiles report-only (the fake-embedder run also gates
550
+ against `bench/fixtures/rag-faithfulness-baseline.json` at ยฑ5pp to catch
551
+ unintended changes in the deterministic extractive path).
552
+
553
+ ### Reproducing
554
+
555
+ ```bash
556
+ node bench/rag-faithfulness.mjs # deterministic baseline path
557
+ node bench/rag-faithfulness.mjs --model granite-multilingual-311m # real-embedder extractive
558
+ node bench/rag-faithfulness.mjs --endpoint http://127.0.0.1:8080/v1 # + LLM profile
559
+ ```
package/bin/cli.mjs CHANGED
@@ -412,8 +412,13 @@ Options:
412
412
  --output <file> Output file path (export, default: stdout).
413
413
  --ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
414
414
  --path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
415
+ --vault <dir> Federated multi-vault search (repeatable): fuse results from
416
+ --db and every --vault, each with its own index in
417
+ <vault>/.mdss (search/serve).
415
418
  --since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
416
419
  --k <n> Number of results (search, default 6).
420
+ --max-per-file <n> Collapse results to at most n passages per file
421
+ (search; alias: --max-per-doc).
417
422
  --json Machine-readable output (index, stats, search).
418
423
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
419
424
  --rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
@@ -494,6 +499,12 @@ Options:
494
499
  SSE streaming) or "ollama" (native /api/generate,
495
500
  NDJSON streaming) (issue #199).
496
501
  --llm-model <m> Provider-side chat model for --llm-endpoint.
502
+ --rag tui: enable the interactive RAG panel โ€” ask the vault from
503
+ inside the TUI; streams a real LLM answer when
504
+ --llm-endpoint is set, extractive fallback otherwise.
505
+ --session <id> chat: use (or create) a named persistent session โ€”
506
+ multi-turn context with a reproducible source manifest.
507
+ --resume chat: resume the most recent session.
497
508
  --open [N] search: open the top (or Nth) hit in your editor at its
498
509
  startLine โ€” MDSS_EDITOR/VISUAL/EDITOR, then VS Code
499
510
  --goto, then the GUI opener (issue #110). With --json on
@@ -557,6 +568,26 @@ function logErr(s) {
557
568
  process.stderr.write(s + '\n');
558
569
  }
559
570
 
571
+ // ---------------------------------------------------------------------------
572
+ // Node end-of-life notice (issue #224): Node 20 reached EOL on 2026-04-30 and
573
+ // no longer receives security patches, but the published `engines` floor stays
574
+ // >=20.9 until v3.0.0 (a hard bump is breaking โ€” see 18 -> 20.9 in v2.0.0).
575
+ // Running on a pre-22 runtime prints one stderr line per command; stdout stays
576
+ // byte-identical for --json, TUI and MCP stdio consumers. Silenced via env.
577
+ // ---------------------------------------------------------------------------
578
+
579
+ export function nodeDeprecationNotice(version = process.version) {
580
+ if (process.env.MDSS_NO_NODE_WARNING === '1') return null;
581
+ const m = /^v(\d+)\./.exec(version);
582
+ const major = m ? Number(m[1]) : Number.NaN;
583
+ if (Number.isFinite(major) && major >= 22) return null;
584
+ return (
585
+ `โš  Node ${version} is deprecated: Node 20 reached end-of-life on 2026-04-30 and gets no security patches.\n` +
586
+ ` mdss will require Node >= 22 from v3.0.0. Upgrade: https://nodejs.org/en/download\n` +
587
+ ` Set MDSS_NO_NODE_WARNING=1 to silence this notice.`
588
+ );
589
+ }
590
+
560
591
  // ---------------------------------------------------------------------------
561
592
  // Data egress notice (issue #183): two opt-in paths send PRIVATE note content
562
593
  // to non-loopback destinations โ€” external embedders and the RAG LLM bridge.
@@ -1903,6 +1934,9 @@ async function cmdRelated(opts) {
1903
1934
  }
1904
1935
 
1905
1936
  async function main() {
1937
+ const deprecation = nodeDeprecationNotice();
1938
+ if (deprecation) process.stderr.write(deprecation + '\n');
1939
+
1906
1940
  const argv = process.argv.slice(2);
1907
1941
  const opts = parseArgs(argv);
1908
1942
  const configPath = findConfigFile(opts.config);
package/dist/hnsw.d.ts CHANGED
@@ -49,6 +49,27 @@ export interface BuildHnswOptions {
49
49
  */
50
50
  distance: (a: Float32Array | Int8Array | number[], b: number) => number;
51
51
  vectors: Array<Float32Array | Int8Array | number[]>;
52
+ /**
53
+ * Issue #228: 'auto' routes the build's distance work through the WASM
54
+ * SIMD dot-batch kernel (src/wasm-dot.ts) when the vectors are a uniform
55
+ * Float32Array matrix and the runtime supports SIMD โ€” ~6ร— faster dots on a
56
+ * 32-node batch, ~3.4ร— faster full build (RESEARCH.md ยง17). ONLY valid when
57
+ * the intended distance is exactly `1 โˆ’ dot(a, vectors[b])`; any custom
58
+ * distance (int8 norms, asymmetric variants) must keep the default
59
+ * 'closure' engine. Silent fallback to 'closure' when 'auto' cannot apply.
60
+ */
61
+ engine?: 'auto' | 'closure';
62
+ }
63
+ /**
64
+ * Distance plumbing for the build (issue #228). The closure engine wraps the
65
+ * caller-supplied `distance`; the wasm engine replaces BOTH pointwise and
66
+ * batch distances with kernel calls so every distance on the chain uses the
67
+ * same SIMD accumulation order (self-consistent โ†’ deterministic sidecar).
68
+ */
69
+ export interface HnswBuildEngine {
70
+ dist(aIdx: number, bIdx: number): number;
71
+ /** Distances from vectors[aIdx] to each node, same order โ€” null = pointwise. */
72
+ batch?(aIdx: number, nodes: number[]): Float32Array | null;
52
73
  }
53
74
  export declare const DEFAULT_HNSW_M = 16;
54
75
  export declare const DEFAULT_HNSW_EF = 64;
@@ -59,7 +80,7 @@ export declare function buildHnsw(vectors: Array<Float32Array | Int8Array | numb
59
80
  * yet inserted are read from `vectors` when needed, so the caller can insert
60
81
  * lazily. Tombstoned indices can be re-inserted (undeletes).
61
82
  */
62
- export declare function insertHnsw(hnsw: HnswIndex, idx: number, distance: (a: Float32Array | Int8Array | number[], b: number) => number, vectors: Array<Float32Array | Int8Array | number[]>): void;
83
+ export declare function insertHnsw(hnsw: HnswIndex, idx: number, distance: (a: Float32Array | Int8Array | number[], b: number) => number, vectors: Array<Float32Array | Int8Array | number[]>, engine?: HnswBuildEngine): void;
63
84
  /** Tombstone a node โ€” searches skip it; the graph keeps it for connectivity. */
64
85
  export declare function deleteHnsw(hnsw: HnswIndex, idx: number): void;
65
86
  /**
@@ -94,3 +115,12 @@ export declare function serializeHnsw(hnsw: HnswIndex): SerializedHnsw;
94
115
  * falls back to brute force).
95
116
  */
96
117
  export declare function deserializeHnsw(raw: unknown, expectedChunksCount?: number): HnswIndex;
118
+ /**
119
+ * issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
120
+ * is the chunk's NEW index (undefined = the chunk is gone โ†’ tombstone); nodes
121
+ * carry over because vector reuse is keyed by chunkHash โ€” a surviving chunk
122
+ * keeps its exact vector, so every stored edge distance stays valid.
123
+ * Returns null when the remap cannot produce a navigable graph (e.g. the
124
+ * entry point did not survive) โ€” the caller falls back to a full rebuild.
125
+ */
126
+ export declare function remapHnsw(hnsw: HnswIndex, mapping: Array<number | undefined>, newCount: number): HnswIndex | null;