md-semantic-search 1.1.0 → 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.
- package/README.md +88 -14
- package/RESEARCH.md +182 -0
- package/bin/cli.mjs +197 -8
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +5 -0
- package/dist/core.d.ts +16 -4
- package/dist/core.js +105 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/indexer.d.ts +17 -1
- package/dist/indexer.js +91 -17
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +86 -3
- package/dist/mcp.js +259 -31
- package/dist/models.d.ts +7 -1
- package/dist/models.js +32 -1
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +60 -0
- package/dist/rag.js +133 -35
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +32 -3
- package/dist/rerank.js +58 -8
- package/dist/search.d.ts +48 -3
- package/dist/search.js +384 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +478 -58
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +52 -2
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +201 -2
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +5 -0
- package/src/core.ts +115 -12
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/indexer.ts +107 -18
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +323 -34
- package/src/models.ts +39 -2
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +189 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +81 -8
- package/src/search.ts +417 -82
- package/src/serve.ts +499 -66
- package/src/tui.ts +56 -2
- package/src/watcher.ts +21 -10
- 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
|
|
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 (`
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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 (
|
|
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
|
|
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. |
|
|
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
|
|
753
|
-
|
|
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
|
|
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,178 @@ 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 (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
|
+
```
|