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/README.md CHANGED
@@ -100,7 +100,7 @@ measurements that shaped its defaults.
100
100
 
101
101
  | Approach | Stronger than mdss at | mdss advantage for its target segment |
102
102
  |---|---|---|
103
- | **QMD** | Query expansion, HyDE, LLM reranking, MCP/SDK ecosystem | Smaller default footprint, Node 18, zero native C++ runtime (pure ONNX.js), fast incremental chunk reuse |
103
+ | **QMD** | Query expansion, HyDE, LLM reranking, MCP/SDK ecosystem | Smaller default footprint, Node 20.9+, zero native C++ runtime (pure ONNX.js), fast incremental chunk reuse |
104
104
  | **Obsidian Hybrid Search** | Obsidian GUI integration, Obsidian vault plugins | Standalone CLI & HTTP API, not tied to Obsidian, crash-safe atomic indexing, robust incremental re-index |
105
105
  | **Vector Databases (Qdrant/Pinecone)** | Multi-million chunk scale, distributed vector index, cloud replication | Zero infra, single JSON index, no server/daemon required, fast in-memory sweep for 10k–50k chunks |
106
106
  | **Grep / Fzf** | Zero model load time, instant literal string matching | Cross-lingual matching (RU/EN), paraphrase retrieval, semantic understanding while retaining exact BM25 lexical precision |
@@ -378,7 +378,7 @@ mdss search --db /path/to/your/markdown "how do I rotate the api token" --rerank
378
378
  ```
379
379
 
380
380
  - The first pass pulls a wider candidate pool (default `max(20, k*3)` chunks),
381
- the cross-encoder (`Xenova/bge-reranker-base`, ~280 MB, downloaded on first
381
+ the cross-encoder (`gte-reranker-modernbert`, ~144 MB, downloaded on first
382
382
  use) re-scores them as query↔passage pairs, and the best `k` survive.
383
383
  - Results from a re-ranked search carry a `rerankScore` field in `--json` mode.
384
384
  - Programmatic use: `rerank: true` in `search()`/`searchIndex()`, `rerankPool`
@@ -386,9 +386,57 @@ mdss search --db /path/to/your/markdown "how do I rotate the api token" --rerank
386
386
  accepts `"rerank": true` in the `/search` body.
387
387
  - The reranker is lazy: nothing loads unless `--rerank` is actually requested,
388
388
  so searches without it pay zero extra cost.
389
+ - **Reranker registry** (issue #203): `--rerank-model <alias>` picks the model —
390
+ `gte-reranker-modernbert` (Alibaba gte-reranker-modernbert-base, ~144 MB,
391
+ ModernBERT 8k context, Apache-2.0; **default since the #203 measurement** —
392
+ nDCG@10 0.9318 vs 0.8839) or the pre-#203 incumbent `bge-reranker-base`
393
+ (~278 MB, kept for reproducibility); raw `id@revision` also accepted.
394
+ Programmatic: `rerankModel: 'bge-reranker-base'`.
395
+ - **Multi-query expansion** (issue #203): `--multi-query <n>` embeds N query
396
+ variants (the original + N-1 LLM paraphrases from `--llm-endpoint`, one extra
397
+ embedding each) and fuses the dense rankings with union-RRF — a recall lift
398
+ on heavy paraphrases, at the cost of N−1 extra embeddings and one LLM call.
399
+ Without an endpoint (or on any LLM failure) it silently degrades to the
400
+ single-query baseline. Programmatic: `multiQuery: 3` (+ `llmEndpoint`).
389
401
 
402
+ ### 3. Ask & Interactive Multi-turn Chat (`mdss ask`, `mdss chat`)
390
403
 
391
- ### 3. Serve (daemon, warm index + model)
404
+ `md-semantic-search` includes a grounded RAG QA engine that operates fully offline by default with zero external dependencies, or connects optionally to local/remote LLMs (Ollama, OpenAI-compatible APIs).
405
+
406
+ > ⚠️ **Data egress**: a non-loopback `--llm-endpoint` (or `--embedder openai`) sends **note content** to that external service. The CLI prints a one-time warning before the first send; see [SECURITY.md → Data egress](./SECURITY.md#data-egress-issue-183). Loopback endpoints (local Ollama/llama.cpp) and the default extractive/local-embedder paths keep everything on your machine.
407
+
408
+ ```bash
409
+ # One-shot grounded QA with citations:
410
+ mdss ask --db ./notes "What is the token expiration policy?"
411
+
412
+ # Multi-turn persistent chat session:
413
+ mdss chat --db ./notes
414
+
415
+ # Resume the most recent session or a specific session:
416
+ mdss chat --db ./notes --resume
417
+ mdss chat --db ./notes --session my-session-id
418
+
419
+ # One-shot query into a persistent session:
420
+ mdss chat --db ./notes --session my-session-id "What about database failover?"
421
+ ```
422
+
423
+ - **Offline Extractive Synthesis**: Zero-dependency default extracts salient factual sentences with strict `[file › heading]` source provenance.
424
+ - **OpenAI-compatible chat with SSE streaming** (issue #199): with `--llm-endpoint`, synthesis speaks the `/chat/completions` lingua franca — llama.cpp, LM Studio, vLLM, and Ollama (`.../v1`) all work **without code** — and answers **stream token-by-token** into `mdss ask`, `mdss chat`, the TUI RAG panel (`mdss search -i --rag`), and the web UI (`mdss serve` → ✦ Ask): the first token renders in milliseconds instead of after the whole generation.
425
+ ```bash
426
+ # llama.cpp / LM Studio / vLLM (OpenAI-compatible):
427
+ mdss ask --db ./notes "What is the token expiration policy?" \
428
+ --llm-endpoint http://127.0.0.1:8080/v1 --llm-model qwen2.5
429
+ # Ollama's OpenAI-compatible surface:
430
+ mdss chat --db ./notes --llm-endpoint http://127.0.0.1:11434/v1 --resume
431
+ # Ollama's native API (pre-#199 wire format, also streamed):
432
+ mdss ask --db ./notes "..." --llm-endpoint http://127.0.0.1:11434 --llm-provider ollama
433
+ ```
434
+ An endpoint that does not answer `/chat/completions` (404/405) is retried once on the Ollama-native `/api/generate`, so existing Ollama-root setups keep working with default flags; `--llm-provider` pins the wire format explicitly. A hung endpoint fails on the **idle timeout** (`MDSS_FETCH_TIMEOUT_MS` — armed on connect, reset on every token, issue #184), so a long generation is never killed mid-answer, and any failure degrades to the extractive synthesis.
435
+ - **Persistent Sessions**: Multi-turn sessions are stored in `<indexDir>/sessions/<id>.json` preserving full conversation turns, retrieved chunk provenance manifests (file, heading, exact line numbers, chunk hashes), and model metadata.
436
+ - **Context Carry-Over**: Follow-up turns re-rank and expand query tokens using prior conversational context.
437
+ - **Interactive REPL Commands**: `/session` (info), `/sources` (manifest of last turn), `/help`, `/exit`.
438
+
439
+ ### 4. Serve (daemon, warm index + model)
392
440
 
393
441
  ```bash
394
442
  mdss serve --db /path/to/your/markdown [--port 8747] [--host 127.0.0.1] [--watch]
@@ -421,6 +469,22 @@ curl localhost:8747/health # → {ok, chunks, model, dim, built, watchi
421
469
  score, matches, snippet, rerankScore?}]}`. The CLI's `search --json` prints
422
470
  the same hit shape.
423
471
 
472
+ **`POST /ask` — streamed grounded QA over SSE** (issue #199): start the daemon
473
+ with `--llm-endpoint` (chat backend — llama.cpp, LM Studio, vLLM, Ollama `.../v1`)
474
+ and `{"query": string, "k"?: number}` answers as `text/event-stream` frames:
475
+ `sources` (citations from retrieval) → `delta` (`{"text": "…"}` per token) →
476
+ `done` (`{answer, mode, citations, citationsUsed}`; `mode` is `endpoint` for the
477
+ LLM answer or `extractive` when it degraded, `citationsUsed` lists the citations
478
+ the answer text actually cites). **Multi-turn chat**: pass `"chat": true` to
479
+ start a persistent session or `"sessionId": "…"` to continue one — the same
480
+ `<indexDir>/sessions/` store `mdss chat` uses; a `session` frame opens the
481
+ stream and the `done` frame returns `sessionId`/`turnsCount`. The built-in web
482
+ UI has a **chat** toggle with history and a "new chat" reset. The same fairness gates as `/search` apply —
483
+ rate limit `429`, concurrency `503` — and an LLM call holds one concurrency
484
+ slot for its whole generation. Without `--llm-endpoint` the route answers
485
+ `400` instead of silently degrading. The built-in web UI ships an
486
+ **✦ Ask the notes** button that consumes this stream.
487
+
424
488
  Or from a script / editor extension:
425
489
 
426
490
  ```js
@@ -514,16 +578,53 @@ mdss mcp --db /path/to/your/markdown
514
578
 
515
579
  **Streamable HTTP transport (issue #123):** a long-running daemon can expose
516
580
  the same tools over HTTP — `mdss serve --db ./notes --mcp` mounts `/mcp`
517
- (POST JSON-RPC, SSE channel on GET, `Mcp-Session-Id` sessions, protocol
518
- `2025-03-26`). It sits behind the exact same auth, Host-allowlist and
519
- rate-limit gates as `/search`, so remote agents inherit the hardening. stdio
520
- via `mdss mcp` remains the default transport.
581
+ (POST JSON-RPC, SSE channel on GET, `Mcp-Session-Id` sessions). It sits behind
582
+ the exact same auth, Host-allowlist and rate-limit gates as `/search`, so
583
+ remote agents inherit the hardening. stdio via `mdss mcp` remains the default
584
+ transport.
585
+
586
+ **Protocol negotiation & `ping` (issue #197):** `initialize` speaks
587
+ `2025-11-25`, `2025-06-18`, `2025-03-26`, and `2024-11-05` — the client's
588
+ requested version is echoed when supported, otherwise the server answers with
589
+ its newest (`2025-11-25`), so modern clients never warn about an outdated
590
+ protocol and older clients keep working. `ping` is answered promptly with an
591
+ empty result on both transports.
592
+
593
+ **Sampling — LLM answers via the client (issue #197):** when the client
594
+ declares the `sampling` capability at `initialize`, `ask_knowledge_base` hands
595
+ the synthesis to the client's own model with a server→client
596
+ `sampling/createMessage` request — grounded passages go out, a cited answer
597
+ comes back — with **no local LLM and no extra user configuration** (Claude
598
+ Desktop sampling, for example). Over stdio the exchange rides the same
599
+ stdin/stdout JSON-RPC lines; over HTTP it rides the GET `/mcp` SSE stream with
600
+ the answer POSTed back. Sampling is strictly best-effort: a client without the
601
+ capability, a refusal, or a timeout (default 60 s, override with
602
+ `MDSS_SAMPLING_TIMEOUT_MS`) degrades to the built-in extractive answer. The
603
+ tool payload reports which path produced the result in `answerMode`
604
+ (`sampling` / `extractive`).
521
605
 
522
606
  #### Available MCP Tools:
523
- - **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob and `tag` filters.
607
+ - **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob, `tag`, rich `filter`, and `graphBoost`.
524
608
  - **`get_chunk`**: Retrieve a specific section by file and heading.
609
+ - **`get_lines`**: Retrieve exact line spans (`fromLine`, `maxLines`) from an indexed Markdown document.
610
+ - **`related_notes`**: Find graph-connected (outgoing, backlinks, 2-hop) and semantically related notes for a document.
525
611
  - **`list_files`**: List indexed Markdown files with chunk counts.
526
612
  - **`index_status`**: Inspect index health, chunk counts, and model metadata.
613
+ - **`ask_knowledge_base`**: Grounded direct Q&A synthesis with citations; uses the client's own LLM via MCP sampling when available (see below), else the built-in extractive answerer — `answerMode` in the payload tells which.
614
+ - *All tools return `structuredContent` alongside text and include `readOnly: true, idempotent: true` annotations.*
615
+
616
+ #### Available MCP Resources & Templates:
617
+ - **`mdss://note/{path}`**: Read full content of an indexed Markdown document.
618
+ - **`mdss://note/{path}?fromLine=1&maxLines=50`**: Addressable line-span slices of notes.
619
+ - **`mdss://vault/{vault}`**: List all indexed notes within a vault.
620
+ - **`mdss://status`**: Live index status and chunk count statistics.
621
+
622
+ #### Available MCP Prompts:
623
+ - **`search-and-cite`**: Synthesizes a grounded answer referencing `mdss://note/{path}` resource URIs.
624
+ - **`summarize-note`**: Produces a structured summary of core concepts and linked notes.
625
+ - **`compare-notes`**: Identifies agreements, divergences, and synergies across two notes.
626
+ - **`find-contradictions`**: Audits notes for conflicting statements or outdated ADRs.
627
+ - **`timeline`**: Builds a chronological milestone / event timeline from notes.
527
628
 
528
629
  #### Claude Desktop Configuration:
529
630
  Add to your `claude_desktop_config.json`:
@@ -571,12 +672,14 @@ Add to your `claude_desktop_config.json`:
571
672
  | `--json` | Machine-readable output: `index` → build result JSON, `stats` → index stats JSON, `search` → hit list JSON (each hit includes `matches` — the query terms found in the chunk). |
572
673
  | `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
573
674
  | `--open [N]` | `search`: open the top (or Nth) hit in your editor at its `startLine` — `$MDSS_EDITOR`/`$VISUAL`/`$EDITOR`, VS Code `--goto`, or the GUI opener. With `--json` on a non-TTY prints the intent instead of launching (issue #110). |
574
- | `--fix` | `check`: apply safe auto-repairs — stale `.mdss.lock`, SHA-mismatched `vectors.bin` sidecar, corrupt `ivf.json`; rebuilds a corrupt index / recomputes `.hashes.json` when `--db` is available. Never touches source notes (issue #117). |
675
+ | `--fix` | `check`: apply safe auto-repairs — stale `.mdss.lock`, SHA-mismatched `vectors.bin` sidecar, corrupt `ivf.json`/`hnsw.json`; rebuilds a corrupt index / recomputes `.hashes.json` when `--db` is available. Never touches source notes (issue #117). |
575
676
  | `--dry-run` | `check --fix`: print the planned repairs without mutating anything. |
576
- | `--rerank` | Re-rank the candidate pool with a cross-encoder (`Xenova/bge-reranker-base`, ~280 MB model, downloaded on first use). Slower, sharper results — see *Reranking* below. |
677
+ | `--rerank` | Re-rank the candidate pool with a cross-encoder (~144 MB default model, downloaded on first use). Slower, sharper results — see *Reranking* below. |
678
+ | `--rerank-model <m>` | Reranker registry alias (issue #203): `bge-reranker-base` (default) or `gte-reranker-modernbert` (144 MB, 8k ctx, Apache-2.0); raw `id@revision` accepted. |
679
+ | `--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. |
577
680
  | `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
578
681
  | `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
579
- | `--ann` | Approximate nearest neighbor search via IVF clustering (search; auto-enables for large corpora). |
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. |
580
683
  | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
581
684
  | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
582
685
  | `--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`). |
@@ -590,7 +693,7 @@ Add to your `claude_desktop_config.json`:
590
693
  | `--watch` | `serve`: re-index incrementally when files change. |
591
694
  | `--watch-debug` | `serve --watch`: verbose trace of polls, FS-error classifications and retries (issue #116). |
592
695
  | `--recency <days>` | `search`: time-decay half-life — passages get a `0.5^(age/halfLife)` boost after fusion; age from frontmatter `created`/`updated`, then file mtime; surfaced in `explain` (issue #127). |
593
- | `--embedder <name>` | Use an external embedding provider (`ollama` or `openai`) instead of local transformers.js, together with `--embedder-model` (+ `--embedder-base-url`, `--embedder-key-file`). The provider id/model/dim are recorded in the index — switching providers invalidates vectors with the usual full re-embed. Local remains the zero-config default and the only fully-offline path (issue #124). |
696
+ | `--embedder <name>` | Use an external embedding provider (`ollama` or `openai`) instead of local transformers.js, together with `--embedder-model` (+ `--embedder-base-url`, `--embedder-key-file`). The provider id/model/dim are recorded in the index — switching providers invalidates vectors with the usual full re-embed. Local remains the zero-config default and the only fully-offline path (issue #124). ⚠️ A non-loopback endpoint receives note content as embedding input — see [SECURITY.md → Data egress](./SECURITY.md#data-egress-issue-183). |
594
697
  | `--no-query-cache` | `search`: skip the persistent query-embedding cache — repeated queries in fresh processes re-embed otherwise (issue #114). |
595
698
  | `--no-ui` | `serve`: disable the built-in web UI at `/` — restores JSON API help there (issue #111). |
596
699
  | `--watch-interval <ms>` | `serve --watch`: poll every N ms (default 3000). |
@@ -687,6 +790,8 @@ mdss models
687
790
  | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
688
791
  | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
689
792
  | `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). |
690
795
 
691
796
  Switching models invalidates the stored vectors automatically — the next
692
797
  `index` run does a full rebuild.
@@ -709,12 +814,16 @@ The adapter declares exactly which embedding semantics it needs — the shared
709
814
  `embed()` path never guesses from the model name:
710
815
 
711
816
  - `queryPrefix` / `passagePrefix` — text prepended to queries/passages (E5 uses
712
- `query: `/`passage: `, BGE none, Qwen3 a retrieval instruction on queries);
713
- - `pooling` — `'mean'`, `'last_token'`, or `'none'`;
817
+ `query: `/`passage: `, BGE/Granite none, Qwen3 a retrieval instruction on
818
+ queries, EmbeddingGemma its task/title prefixes);
819
+ - `pooling` — `'mean'`, `'last_token'`, `'cls'`, or `'none'`;
714
820
  - `normalize` — whether vectors are L2-normalized;
715
821
  - `nativeDim` / `dim` — embedding dimension (and optional `dimensions` for MRL);
716
822
  - `dtype` — the ONNX runtime data type loaded at runtime (default `q8`);
717
823
  - `maxTokens` — the tokenizer context budget (Qwen3-0.6B is 32768);
824
+ - `pooledOutput` — the ONNX graph already emits a pooled `sentence_embedding`
825
+ (EmbeddingGemma); embedding reads that output directly instead of the
826
+ feature-extraction pipeline;
718
827
  - `family` — an informational formatting/pooling label surfaced by `stats`.
719
828
 
720
829
  `mdss models` prints each adapter's pooling, normalization, dimension, and
@@ -760,7 +869,12 @@ section instead of silently producing wrong vectors.
760
869
  1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
761
870
  globs skipped).
762
871
  2. **Chunk** each file by Markdown headings while retaining the active heading
763
- stack; oversized sections split on blank lines (~1400 body chars/chunk).
872
+ stack; oversized sections split on blank lines. The budget is in **tokens**
873
+ (issue #202): `min(model maxTokens, 512)` by default with a script-
874
+ calibrated estimator, so Cyrillic notes get the same real budget as English
875
+ ones (the legacy English-calibrated `~1400 chars` budget overflowed the
876
+ model's context for RU ~2×). Override with `--max-chunk-tokens`, or set `0`
877
+ to restore the legacy char budget.
764
878
  3. **Embed** the document title, full heading path, and chunk body (`passage:`
765
879
  prefix for E5) → store `{file, title, heading, headingPath, text, vec}` in
766
880
  `vectors.json`, plus per-file md5 in `.hashes.json`. The index stores an
@@ -796,7 +910,9 @@ Contributions are welcome — bug reports with reproduction steps, docs fixes,
796
910
  tests, and features all help. Start with
797
911
  [CONTRIBUTING.md](./CONTRIBUTING.md) (dev setup, project tour, conventions)
798
912
  and look for issues labeled **`good first issue`** — they are scoped for a
799
- first-time contributor. By participating you agree to the
913
+ first-time contributor. For the internals — module map, data-flow diagrams,
914
+ on-disk formats, and extension points — see
915
+ [ARCHITECTURE.md](./ARCHITECTURE.md). By participating you agree to the
800
916
  [Code of Conduct](./CODE_OF_CONDUCT.md).
801
917
 
802
918
  ## License
package/RESEARCH.md CHANGED
@@ -155,6 +155,13 @@ Baseline on dev (e5-base, hybrid RRF, k=10, 2026-08-14):
155
155
  | Hit@10 | 0.9792 |
156
156
  | Recall@10 | 1.0000 |
157
157
 
158
+ The **committed regression baseline** (issue #198) lives in
159
+ `bench/fixtures/golden-baseline.json` (2026-10-28: nDCG@10 0.8856, Recall 1.0 —
160
+ re-measured against the current ranking stack; the 2026-08-14 numbers above
161
+ predate later fusion/chunking changes). The nightly workflow gates against it
162
+ (`nDCG@10` drop ≤ 0.01, `Recall@10` = 1.0); see *Retrieval quality gate* in
163
+ CONTRIBUTING.md for the local `--gate` / `--update-baseline` workflow.
164
+
158
165
  ## 8. Reproducing
159
166
 
160
167
  ```bash
@@ -226,7 +233,204 @@ If no candidate meets the multilingual non-inferiority gate, `bge-reranker-base`
226
233
  - **Trigger:** WebAssembly SIMD dot-product kernels will be considered **only if** CPU profiling on >50,000 chunks shows JS float multiplication accounts for >40% of warm search latency.
227
234
  - **Constraint:** Zero native Node C++ bindings; must maintain pure JS fallback for non-WASM environments.
228
235
 
229
- ### ANN / IVF Scale Threshold (Issue #32)
236
+ ### ANN / IVF Scale Threshold & K-Means++ Seeding (Issues #32 & #139)
230
237
  - **Baseline:** Exact $O(N \cdot d)$ linear sweep.
231
- - **Trigger:** ANN (Inverted File / HNSW) indexing is a scale contingency. It shall **not** be introduced until corpus size exceeds **100,000+ chunks** and exact warm sweep latency exceeds 50 ms.
232
- - **Requirement:** Exact pre/post-filter correctness must be preserved; small knowledge bases (<100k chunks) will always use exact linear search.
238
+ - **K-Means++ Seeding:** Uses deterministic pseudo-random $D^2$-weighted centroid seeding (Mulberry32) to prevent initial centroid collapse on correlated notes corpora.
239
+ - **Measured Quality Gate:** `bench/ann-recall.mjs` benchmarks Recall@10 across $nprobe \in \{1, 4, 8, 16\}$ on clustered corpora, demonstrating $\ge 99.5\%$ Recall@10 at default $nprobe=8$ with sub-millisecond candidate pruning.
240
+ - **Trigger:** ANN indexing is auto-engaged above `ANN_THRESHOLD = 500` chunks when `--ann` is passed or IVF index is persisted, while exact pre/post-filter correctness is strictly preserved.
241
+
242
+ ---
243
+
244
+ ## 14. Matryoshka Representation Learning (MRL) Dimension Selection (Issue #143)
245
+
246
+ ### Mechanism
247
+ Models trained with Matryoshka Representation Learning (MRL) loss (such as `onnx-community/Qwen3-Embedding-0.6B-ONNX` and `Xenova/bge-m3`) embed semantic information hierarchically, allowing vectors to be sliced at predefined sub-dimensions (`32`, `64`, `128`, `256`, `512`, `768`, `1024`) and re-normalized to unit L2 norm without significant loss in semantic retrieval fidelity.
248
+
249
+ ### Benefits & Trade-offs
250
+ - **Storage & RAM Savings:** 1024 → 256 dimensions reduces `vectors.bin` size and memory consumption by 4× (from ~19.5 MB to ~4.88 MB per 5,000 chunks).
251
+ - **Sweep Latency:** Dot-product cosine sweep latency is halved (e.g. from ~14.2 ms to ~6.8 ms for 5,000 chunks in single-threaded pure JS).
252
+ - **Integrity & Cache Invalidation:** `embeddingAdapterFingerprint` binds the selected dimension (`model.dim`). Changing `--dimensions` automatically invalidates previous chunk hashes and forces clean re-indexing rather than silent vector dimension mismatch.
253
+
254
+ ### Usage
255
+ ```bash
256
+ # Build index truncated to 256 dims (4x smaller vectors)
257
+ mdss index --db ./notes --model qwen3-embedding-0.6b --dimensions 256
258
+
259
+ # Search with dimension safety validation
260
+ mdss search --db ./notes --dimensions 256 "query"
261
+ ```
262
+
263
+ ---
264
+
265
+ ## 15. Research Evaluation: Modern Embedding Adapter Candidates (Issue #200)
266
+
267
+ ### Context & Decision Gate
268
+
269
+ The registry default (`e5-base`, 2022) predates the 2024-2025 multilingual
270
+ embedding generation. Issue #200 asked for 2-3 measured candidates, each a
271
+ registry entry with a pinned revision + q8 ONNX artifact, evaluated on the
272
+ golden set (#56) before any default change.
273
+
274
+ **Candidate screening (Hugging Face artifact probe, 2026-11):**
275
+
276
+ | Candidate | Verdict |
277
+ |---|---|
278
+ | `Xenova/gte-multilingual-base-1` | **Rejected** — no public transformers.js artifact (gte-multilingual needs remote-code modeling, unsupported by the JS runtime) |
279
+ | `Xenova/arctic-embed-m` | **Rejected** — no ONNX conversion published |
280
+ | `Xenova/jina-embeddings-v3` | **Rejected** — no transformers.js-compatible artifact; task-LoRA switch is not expressible through the feature-extraction pipeline |
281
+ | `onnx-community/Qwen3-Embedding-4B-ONNX` | **Rejected** — fp32-only (16 GB); no quantized export; unusable for a CLI embedder |
282
+ | `onnx-community/granite-embedding-311m-multilingual-r2-ONNX` | **Accepted** — q8 298 MB, Apache-2.0, ModernBERT (8k ctx), CLS pooling, no prefixes |
283
+ | `onnx-community/embeddinggemma-300m-ONNX` | **Accepted** — q8 ~300 MB, pre-pooled `sentence_embedding` graph output (needed a new `pooledOutput` adapter path in `core.embed`), MRL 64–768, Gemma license |
284
+
285
+ ### Golden-Set Results (frozen corpus, hash `dev-golden.json`)
286
+
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
300
+
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.
315
+
316
+ ### Reproducing
317
+
318
+ ```bash
319
+ node scripts/run-bench.mjs --model e5-base --slice dev
320
+ node scripts/run-bench.mjs --model granite-multilingual-311m --slice dev
321
+ node scripts/run-bench.mjs --model embeddinggemma-300m --slice dev
322
+ ```
323
+
324
+ ---
325
+
326
+ ## 16. Reranker Registry & Multi-Query Expansion (Issue #203)
327
+
328
+ ### Candidate screening
329
+
330
+ The issue's proposed `bge-reranker-v2-m3` has **no transformers.js artifact**
331
+ (`Xenova/bge-reranker-v2-m3` and `onnx-community/bge-reranker-v2-m3` do not
332
+ exist — 401 on every resolve). The screened replacement:
333
+
334
+ | Candidate | Verdict |
335
+ |---|---|
336
+ | `BAAI/bge-reranker-v2-m3` | **Rejected** — no JS-runtime ONNX conversion |
337
+ | `Alibaba-NLP/gte-reranker-modernbert-base` | **Accepted** — ONNX exports in-repo, q8 144 MB, Apache-2.0, ModernBERT 8k ctx, single-logit head (same extraction contract as bge-reranker-base) |
338
+ | `Xenova/bge-reranker-large` | available but unmeasured (~2× base size; superseded by gte-modernbert's result) |
339
+
340
+ ### Golden-Set Results (e5-base dense retrieval, frozen corpus, dev slice n=48)
341
+
342
+ | Config | nDCG@10 | MRR | Hit@10 | Recall@10 |
343
+ |---|---|---|---|---|
344
+ | no rerank (baseline) | 0.8797 | 0.8628 | 0.979 | 1.000 |
345
+ | `--rerank` (bge-reranker-base, ~278 MB) | 0.8839 | 0.8646 | 1.000 | 1.000 |
346
+ | `--rerank --rerank-model gte-reranker-modernbert` (~144 MB) | **0.9318** | **0.9253** | 1.000 | 1.000 |
347
+
348
+ **gte-reranker-modernbert passes the non-inferiority gate with a decisive
349
+ margin: +0.052 nDCG@10 over the incumbent reranker, at half the size, under
350
+ Apache-2.0.** Unlike the embedding default (#200), no license blocker applies —
351
+ **the default reranker switches to `gte-reranker-modernbert`**; the incumbent
352
+ stays in the registry as `--rerank-model bge-reranker-base` for reproducibility.
353
+
354
+ ### Multi-query expansion
355
+
356
+ `--multi-query N` (N = total variants: original + N−1 LLM paraphrases, one
357
+ extra embedding each) fuses per-variant dense rankings by union-RRF; ANN
358
+ probing unions the per-variant candidate sets; the `cos` field stays anchored
359
+ to the original query. Quality of the lift depends entirely on the LLM's
360
+ paraphrases, so the pinned contract is the MECHANISM (unit tests with a fake
361
+ LLM): variants embed through the shared query cache, the union never drops
362
+ baseline hits, and — the acceptance criterion — **a paraphrase-only hit enters
363
+ the top-k with the flag on and is absent without it**, while `--multi-query`
364
+ without an endpoint (or on any LLM failure) is byte-identical to the
365
+ single-query baseline. Production lift numbers require a real local LLM and
366
+ are deliberately not claimed from a rigged fixture.
367
+
368
+ ### Reproducing
369
+
370
+ ```bash
371
+ node scripts/run-bench.mjs --rerank
372
+ node scripts/run-bench.mjs --rerank --rerank-model gte-reranker-modernbert
373
+ ```
374
+
375
+ ---
376
+
377
+ ## 17. HNSW Graph ANN alongside IVF (Issue #201)
378
+
379
+ ### Implementation
380
+
381
+ `src/hnsw.ts` — pure JS/TS HNSW (Malkov & Yashunin 2016) sharing the IVF
382
+ contract: build → serialize → `hnsw.json` → search as a candidate pre-filter.
383
+ Key properties:
384
+
385
+ - **No training phase.** The graph builds by incremental insertion
386
+ (`insertHnsw`), which is also the watch-rebuild path: tombstoned deletes
387
+ (`deleteHnsw`) keep removed chunks searchable-through-but-hidden, so a
388
+ persisted graph can grow without a full rebuild.
389
+ - **Diversity heuristic pruning** (Algorithm 4). The first implementation used
390
+ blind top-M pruning, which severed the just-added edge at overflowing
391
+ neighbors — on the 5000-vector clustered corpus recall collapsed to 44.4%
392
+ and did NOT respond to `ef` (stranded regions). The heuristic restores
393
+ connectivity: 100% recall at every ef on both bench corpora.
394
+ - **Determinism**: level assignment is seeded per node (`seed + idx`), so
395
+ full rebuilds from the same insertion order are identical.
396
+ - **Hardened `deserializeHnsw`** with the same discipline as
397
+ `deserializeIVF` (format, header, per-level link caps, self/duplicate
398
+ links, entry-point-on-top-layer, tombstone range, count-vs-chunks).
399
+
400
+ ### Measured (bench/ann-recall.mjs, clustered corpus, dim 256)
401
+
402
+ | n | Engine | Build | Config | Recall@10 | p50 latency |
403
+ |---|---|---|---|---|---|
404
+ | 1000 | IVF | 85 ms | nprobe 4 | 100% | 0.098 ms |
405
+ | 1000 | HNSW | 2605 ms | ef 16 | 100% | 0.212 ms |
406
+ | 5000 | IVF | 1025 ms | nprobe 4 | 100% | 0.252 ms |
407
+ | 5000 | HNSW | 20027 ms | ef 16 | 100% | 0.215 ms |
408
+
409
+ **Query-side: HNSW ≥ IVF** — 100% recall at equal-or-lower p50 latency
410
+ (satisfying the acceptance criterion's first branch). **Build-side: IVF wins
411
+ ~20×** (no training vs O(n log n) graph construction in JS), and the sidecar
412
+ is rebuilt by every full index run — so the automatic sidecar for large
413
+ corpora keeps **IVF as the default engine**; the criterion's second branch
414
+ (documented result, IVF remains default) covers the build-time trade-off.
415
+
416
+ ### Decision
417
+
418
+ 1. `--ann hnsw` opts into the graph engine end-to-end (indexer sidecar +
419
+ search probe + `check --fix` corrupt-sidecar repair); corrupt `hnsw.json`
420
+ degrades to brute force silently, like `ivf.json`.
421
+ 2. Bare `--ann` and the automatic threshold path stay on IVF until either
422
+ (a) the JS graph build gets an order of magnitude cheaper (worker
423
+ 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).
426
+ 3. `--ef <n>` tunes the HNSW search breadth (default 64; recall saturates
427
+ at 100% already at ef 16 on the bench corpora — the default leaves
428
+ headroom for noisier real-world distributions).
429
+
430
+ ### Reproducing
431
+
432
+ ```bash
433
+ node bench/ann-recall.mjs # IVF + HNSW curves side by side
434
+ mdss index --db ./notes --ann hnsw # hnsw.json sidecar
435
+ mdss search --db ./notes --ann hnsw "query"
436
+ ```