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 +25 -18
- package/RESEARCH.md +152 -29
- package/bin/cli.mjs +34 -0
- package/dist/hnsw.d.ts +31 -1
- package/dist/hnsw.js +164 -10
- package/dist/indexer.js +47 -12
- package/dist/models.d.ts +1 -1
- package/dist/models.js +6 -1
- package/dist/wasm-dot.d.ts +36 -0
- package/dist/wasm-dot.js +106 -0
- package/package.json +1 -1
- package/src/hnsw.ts +190 -9
- package/src/indexer.ts +47 -8
- package/src/models.ts +6 -1
- package/src/wasm-dot.ts +115 -0
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-
|
|
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 โฅ
|
|
113
|
-
|
|
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
|
|
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 `
|
|
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`
|
|
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 #
|
|
794
|
-
| `embeddinggemma-300m` | `onnx-community/embeddinggemma-300m-ONNX` | 768 | ~300 MB q8.
|
|
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
|
-
(
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
the
|
|
804
|
-
|
|
805
|
-
`
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 (
|
|
425
|
-
|
|
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[]
|
|
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;
|