md-semantic-search 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +88 -14
  2. package/RESEARCH.md +186 -0
  3. package/bin/cli.mjs +197 -8
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +5 -0
  6. package/dist/core.d.ts +16 -4
  7. package/dist/core.js +105 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +105 -0
  13. package/dist/hnsw.js +398 -0
  14. package/dist/indexer.d.ts +17 -1
  15. package/dist/indexer.js +122 -17
  16. package/dist/lexical.d.ts +10 -0
  17. package/dist/lexical.js +98 -15
  18. package/dist/markdown-parser.d.ts +62 -0
  19. package/dist/markdown-parser.js +227 -30
  20. package/dist/mcp.d.ts +86 -3
  21. package/dist/mcp.js +259 -31
  22. package/dist/models.d.ts +7 -1
  23. package/dist/models.js +32 -1
  24. package/dist/providers.d.ts +56 -0
  25. package/dist/providers.js +305 -0
  26. package/dist/quantization.d.ts +12 -0
  27. package/dist/quantization.js +54 -0
  28. package/dist/rag.d.ts +60 -0
  29. package/dist/rag.js +133 -35
  30. package/dist/repair.d.ts +1 -1
  31. package/dist/repair.js +53 -2
  32. package/dist/rerank.d.ts +32 -3
  33. package/dist/rerank.js +58 -8
  34. package/dist/search.d.ts +48 -3
  35. package/dist/search.js +384 -78
  36. package/dist/serve.d.ts +35 -0
  37. package/dist/serve.js +478 -58
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +52 -2
  40. package/dist/watcher.js +23 -12
  41. package/dist/webui.d.ts +3 -3
  42. package/dist/webui.js +201 -2
  43. package/package.json +3 -3
  44. package/src/binary-format.ts +13 -0
  45. package/src/completions.ts +5 -0
  46. package/src/core.ts +115 -12
  47. package/src/federation.ts +51 -17
  48. package/src/filter.ts +14 -0
  49. package/src/hnsw.ts +490 -0
  50. package/src/indexer.ts +142 -18
  51. package/src/lexical.ts +91 -13
  52. package/src/markdown-parser.ts +279 -37
  53. package/src/mcp.ts +323 -34
  54. package/src/models.ts +39 -2
  55. package/src/providers.ts +345 -0
  56. package/src/quantization.ts +61 -0
  57. package/src/rag.ts +189 -34
  58. package/src/repair.ts +51 -2
  59. package/src/rerank.ts +81 -8
  60. package/src/search.ts +417 -82
  61. package/src/serve.ts +499 -66
  62. package/src/tui.ts +56 -2
  63. package/src/watcher.ts +21 -10
  64. package/src/webui.ts +201 -2
package/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,11 +386,25 @@ 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
 
390
402
  ### 3. Ask & Interactive Multi-turn Chat (`mdss ask`, `mdss chat`)
391
403
 
392
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).
393
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
+
394
408
  ```bash
395
409
  # One-shot grounded QA with citations:
396
410
  mdss ask --db ./notes "What is the token expiration policy?"
@@ -407,6 +421,17 @@ mdss chat --db ./notes --session my-session-id "What about database failover?"
407
421
  ```
408
422
 
409
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.
410
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.
411
436
  - **Context Carry-Over**: Follow-up turns re-rank and expand query tokens using prior conversational context.
412
437
  - **Interactive REPL Commands**: `/session` (info), `/sources` (manifest of last turn), `/help`, `/exit`.
@@ -444,6 +469,22 @@ curl localhost:8747/health # → {ok, chunks, model, dim, built, watchi
444
469
  score, matches, snippet, rerankScore?}]}`. The CLI's `search --json` prints
445
470
  the same hit shape.
446
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
+
447
488
  Or from a script / editor extension:
448
489
 
449
490
  ```js
@@ -537,10 +578,30 @@ mdss mcp --db /path/to/your/markdown
537
578
 
538
579
  **Streamable HTTP transport (issue #123):** a long-running daemon can expose
539
580
  the same tools over HTTP — `mdss serve --db ./notes --mcp` mounts `/mcp`
540
- (POST JSON-RPC, SSE channel on GET, `Mcp-Session-Id` sessions, protocol
541
- `2025-03-26`). It sits behind the exact same auth, Host-allowlist and
542
- rate-limit gates as `/search`, so remote agents inherit the hardening. stdio
543
- 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`).
544
605
 
545
606
  #### Available MCP Tools:
546
607
  - **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob, `tag`, rich `filter`, and `graphBoost`.
@@ -549,7 +610,7 @@ via `mdss mcp` remains the default transport.
549
610
  - **`related_notes`**: Find graph-connected (outgoing, backlinks, 2-hop) and semantically related notes for a document.
550
611
  - **`list_files`**: List indexed Markdown files with chunk counts.
551
612
  - **`index_status`**: Inspect index health, chunk counts, and model metadata.
552
- - **`ask_knowledge_base`**: Grounded direct Q&A synthesis with citations.
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.
553
614
  - *All tools return `structuredContent` alongside text and include `readOnly: true, idempotent: true` annotations.*
554
615
 
555
616
  #### Available MCP Resources & Templates:
@@ -611,12 +672,14 @@ Add to your `claude_desktop_config.json`:
611
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). |
612
673
  | `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
613
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). |
614
- | `--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). |
615
676
  | `--dry-run` | `check --fix`: print the planned repairs without mutating anything. |
616
- | `--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. |
617
680
  | `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
618
681
  | `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
619
- | `--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; watch rebuilds reuse the persisted graph via chunk-hash remapping instead of rebuilding it — issue #223). Bare `--ann` prefers an existing sidecar. |
620
683
  | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
621
684
  | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
622
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`). |
@@ -630,7 +693,7 @@ Add to your `claude_desktop_config.json`:
630
693
  | `--watch` | `serve`: re-index incrementally when files change. |
631
694
  | `--watch-debug` | `serve --watch`: verbose trace of polls, FS-error classifications and retries (issue #116). |
632
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). |
633
- | `--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). |
634
697
  | `--no-query-cache` | `search`: skip the persistent query-embedding cache — repeated queries in fresh processes re-embed otherwise (issue #114). |
635
698
  | `--no-ui` | `serve`: disable the built-in web UI at `/` — restores JSON API help there (issue #111). |
636
699
  | `--watch-interval <ms>` | `serve --watch`: poll every N ms (default 3000). |
@@ -727,6 +790,8 @@ mdss models
727
790
  | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
728
791
  | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
729
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). |
730
795
 
731
796
  Switching models invalidates the stored vectors automatically — the next
732
797
  `index` run does a full rebuild.
@@ -749,12 +814,16 @@ The adapter declares exactly which embedding semantics it needs — the shared
749
814
  `embed()` path never guesses from the model name:
750
815
 
751
816
  - `queryPrefix` / `passagePrefix` — text prepended to queries/passages (E5 uses
752
- `query: `/`passage: `, BGE none, Qwen3 a retrieval instruction on queries);
753
- - `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'`;
754
820
  - `normalize` — whether vectors are L2-normalized;
755
821
  - `nativeDim` / `dim` — embedding dimension (and optional `dimensions` for MRL);
756
822
  - `dtype` — the ONNX runtime data type loaded at runtime (default `q8`);
757
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;
758
827
  - `family` — an informational formatting/pooling label surfaced by `stats`.
759
828
 
760
829
  `mdss models` prints each adapter's pooling, normalization, dimension, and
@@ -800,7 +869,12 @@ section instead of silently producing wrong vectors.
800
869
  1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
801
870
  globs skipped).
802
871
  2. **Chunk** each file by Markdown headings while retaining the active heading
803
- 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.
804
878
  3. **Embed** the document title, full heading path, and chunk body (`passage:`
805
879
  prefix for E5) → store `{file, title, heading, headingPath, text, vec}` in
806
880
  `vectors.json`, plus per-file md5 in `.hashes.json`. The index stores an
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
@@ -252,3 +259,182 @@ mdss index --db ./notes --model qwen3-embedding-0.6b --dimensions 256
252
259
  # Search with dimension safety validation
253
260
  mdss search --db ./notes --dimensions 256 "query"
254
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. **(b) is now implemented (issue
425
+ #223):** a watch rebuild remaps the persisted graph onto the new chunk
426
+ numbering via `chunkHash` (`remapHnsw` — vector reuse is hash-keyed, so
427
+ every carried edge distance stays valid), tombstones the gone nodes and
428
+ inserts only the new ones; the build-side gap now applies to FULL builds
429
+ only (first run, engine/budget switch, entry-point-unanchorable remap).
430
+ 3. `--ef <n>` tunes the HNSW search breadth (default 64; recall saturates
431
+ at 100% already at ef 16 on the bench corpora — the default leaves
432
+ headroom for noisier real-world distributions).
433
+
434
+ ### Reproducing
435
+
436
+ ```bash
437
+ node bench/ann-recall.mjs # IVF + HNSW curves side by side
438
+ mdss index --db ./notes --ann hnsw # hnsw.json sidecar
439
+ mdss search --db ./notes --ann hnsw "query"
440
+ ```