md-semantic-search 0.3.0 β†’ 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,8 +34,11 @@ measurements that shaped its defaults.
34
34
  - πŸ”Œ **Any folder, anywhere.** Point `--db` at any directory of `.md`/`.markdown`
35
35
  files. It does **not** have to live inside this project. Recursive by default.
36
36
  - 🌍 **Cross-lingual.** Multilingual embeddings (default `multilingual-e5-base`).
37
+ - 🧭 **Markdown-aware context.** Each embedding includes the full active heading
38
+ path (`title > h1 > h2 > ...`), so a deeply nested passage remains searchable
39
+ by its parent topics without an LLM-generated context step.
37
40
  - 🧠 **Hybrid ranking.** Reciprocal Rank Fusion of vector similarity (meaning)
38
- and lexical overlap (exact names like `win32`, `TextIOWrapper`).
41
+ and persisted BM25 postings (exact names like `win32`, `TextIOWrapper`).
39
42
  - ⚑ **Two-level incremental.** Per-file md5 (unchanged files skip work) **plus**
40
43
  per-chunk hash β€” inside a *changed* file, sections whose text is unchanged
41
44
  reuse their stored vector. Editing one place in a long file re-embeds only the
@@ -44,11 +47,65 @@ measurements that shaped its defaults.
44
47
  uploaded anywhere.
45
48
  - πŸ“¦ **Zero infra.** One JSON index, brute-force cosine in memory. No Pinecone,
46
49
  no Qdrant, no pgvector. Scales fine to thousands of chunks.
50
+ - πŸ—œοΈ **Compact index.** Vectors are stored as base64 `Float32Array` blobs
51
+ instead of decimal JSON β€” ~3.2Γ— smaller index files and ~12Γ— faster loads
52
+ on a real corpus. Old decimal indexes are read transparently and migrated
53
+ on the next `index` run.
54
+ - πŸ–₯️ **Daemon mode.** `mdss serve` keeps the parsed index AND the embedding
55
+ model warm in memory across queries (no ~280 MB reload per search), with an
56
+ optional `--watch` flag that re-indexes incrementally when your notes change.
57
+ - πŸ” **Crash-safe concurrent writes.** Every index build takes a
58
+ `<index>/.mdss.lock` (self-healing by pid-liveness), so `mdss index` in one
59
+ terminal plus `mdss serve --watch` never interleave into a torn index. Long
60
+ builds also checkpoint embedding progress and resume after interruption.
47
61
 
48
- ## Requirements
62
+ ---
63
+
64
+ ## Architecture & Retrieval Pipeline
65
+
66
+ `md-semantic-search` implements a fully local, deterministic Markdown-native retrieval pipeline designed for small-to-medium RU/EN engineering knowledge bases.
67
+
68
+ ```
69
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
70
+ β”‚ Markdown Notes β”‚ ──> β”‚ Structural AST Chunkerβ”‚ ──> β”‚ Typed Frontmatter & β”‚
71
+ β”‚ (.md files) β”‚ β”‚ (src/markdown-parser) β”‚ β”‚ Canonical Identity β”‚
72
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
73
+ β”‚
74
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
75
+ β–Ό
76
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
77
+ β”‚ Contextual Embeddingsβ”‚ β”‚ Field BM25F Postings β”‚ β”‚ Bounded Wikilink / β”‚
78
+ β”‚ (src/core.mjs) β”‚ β”‚ (src/lexical.mjs) β”‚ β”‚ Backlink Graph β”‚
79
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
80
+ β”‚ β”‚ β”‚
81
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
82
+ β–Ό
83
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
84
+ β”‚ Reciprocal Rank (RRF)β”‚
85
+ β”‚ + Result Collapse β”‚
86
+ β”‚ (src/search.mjs) β”‚
87
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
88
+ ```
89
+
90
+ 1. **Structural AST Parsing** ([`src/markdown-parser.mjs`](./src/markdown-parser.mjs)): Markdown is parsed into structural blocks (headings, fenced code blocks, tables, blockquotes, lists). Block boundaries are preserved atomically.
91
+ 2. **Typed Frontmatter & Canonical Identity** ([`src/frontmatter.mjs`](./src/frontmatter.mjs)): YAML frontmatter is parsed into typed metadata (`title`, `aliases`, `tags`, `project`, `type`, `status`, `canonical`, `canonicalRef`).
92
+ 3. **Contextual Embeddings** ([`src/core.mjs`](./src/core.mjs)): Passages combine title, full heading hierarchy (`Title > H1 > H2`), and body text, following Anthropic's Contextual Retrieval design to eliminate lost context without an LLM expansion step.
93
+ 4. **Field-Aware BM25F & Fuzzy Lookup** ([`src/lexical.mjs`](./src/lexical.mjs)): Inverted lexical postings score `title`, `aliases`, `headingPath`, and `body` with dedicated field weights, complemented by bounded Damerau-Levenshtein fuzzy title matching.
94
+ 5. **Relationship Graph** ([`src/wikilinks.mjs`](./src/wikilinks.mjs)): Wikilinks (`[[note]]`) and relative Markdown links resolve to canonical target notes to compute outgoing links and backlinks.
95
+ 6. **Reciprocal Rank Fusion & Collapse** ([`src/search.mjs`](./src/search.mjs), [`src/collapse.mjs`](./src/collapse.mjs)): Fuses dense cosine similarity, BM25F lexical scoring, and fuzzy candidates with RRF, followed by optional document collapse (`--max-per-file`) and cross-encoder reranking ([`src/rerank.mjs`](./src/rerank.mjs)).
49
96
 
50
- - Node.js β‰₯ 18
51
- - ~280 MB disk for the default model (downloaded once into a cache dir)
97
+ ---
98
+
99
+ ## Comparison with Alternatives
100
+
101
+ | Approach | Stronger than mdss at | mdss advantage for its target segment |
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 |
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
+ | **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
+ | **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 |
107
+
108
+ ---
52
109
 
53
110
  ## Install
54
111
 
@@ -74,6 +131,44 @@ npm install
74
131
  node bin/cli.mjs --help
75
132
  ```
76
133
 
134
+ ---
135
+
136
+ ## Library usage
137
+
138
+ The package exports a stable programmatic API (no CLI required) β€” for scripts,
139
+ agents, and editors that want to run many queries in one process.
140
+
141
+ ```js
142
+ import { buildIndex, search, loadIndex, searchIndex, resolveModel, MODELS } from 'md-semantic-search';
143
+
144
+ // 1. Build (or refresh) an index β€” same as `mdss index --db ./docs`.
145
+ await buildIndex({ db: './docs', indexDir: './.mdss', cacheDir: '~/.cache/mdss' });
146
+
147
+ // 2a. One-shot search β€” parses the index on every call (same as `mdss search`).
148
+ const hits = await search({
149
+ indexDir: './.mdss', cacheDir: '~/.cache/mdss',
150
+ query: 'how do I rotate the API token', k: 6,
151
+ });
152
+ for (const h of hits) console.log(h.file, h.heading, h.cosine);
153
+
154
+ // 2b. Repeated queries in one process β€” parse the index ONCE and reuse it.
155
+ // The embedding model is cached in-process too (issue #2).
156
+ const idx = loadIndex('./.mdss'); // parse once
157
+ const r1 = await searchIndex({ loaded: idx, cacheDir: '~/.cache/mdss', query: 'failover runbook' });
158
+ const r2 = await searchIndex({ loaded: idx, cacheDir: '~/.cache/mdss', query: 'backup schedule' });
159
+ // ^^ reuses parsed chunks + warm model
160
+ ```
161
+
162
+ The extractor pipeline is cached per model id (see `getExtractor`), so the
163
+ second and later queries skip both the file read and the model load.
164
+
165
+ Also exported: `resolveModel`, `MODELS`, `DEFAULT_MODEL`, `chunkHash`,
166
+ `tokenize`, `keywordScores`, `rrf`, `cosine`, `encodeVec`/`decodeVec`,
167
+ `walkMarkdown`/`parseFile`/`chunkMarkdown`, `splitFrontmatter`, `extractTitle`,
168
+ `globToRegExp`. Types come from JSDoc (`// @ts-check` + `checkJs`).
169
+ `keywordScores` remains the legacy exact-overlap helper for schema-v0/v1/v2
170
+ indexes and existing library consumers; schema-v3 search uses persisted BM25.
171
+
77
172
  ## Usage
78
173
 
79
174
  ### 1. Build the index
@@ -93,6 +188,107 @@ just the new entry). The index output reports the split, e.g.:
93
188
  Indexed 64 file(s) β†’ 725 chunks (725 reused [5 chunk-level, 720 file-level], 0 embedded), ... 0.6s
94
189
  ```
95
190
 
191
+ Schema-v3 indexes persist each chunk's full Markdown heading path plus a compact
192
+ BM25 inverted index inside `vectors.json`. Search tokenizes only the query;
193
+ unchanged files and a model-independent hash of the exact lexical document
194
+ (`title + ancestor headings + leaf heading + text`) reuse lexical analysis
195
+ independently of vector reuse. Parent heading changes reanalyze only the affected
196
+ lexical subtree, while model-only changes reuse unchanged lexical records.
197
+ New indexes write `bm25-v2`; existing `bm25-v1` indexes remain searchable and
198
+ upgrade on the next index build. Schema-v0/v1/v2 remain searchable through the
199
+ legacy exact-token overlap lane. A v2 rebuild reuses its contextual vectors
200
+ while adding BM25; v0/v1 still re-embed once because they lack contextual
201
+ passages.
202
+
203
+ During long builds, mdss atomically checkpoints progress every eight embedding
204
+ batches (about 256 chunks) to `<index>/.checkpoint.json`. The canonical
205
+ `vectors.json` and `.hashes.json` remain the last complete searchable generation;
206
+ after an interruption, the next build resumes from the compatible checkpoint.
207
+ The sidecar is removed after the completed generation is published. This is
208
+ automatic and does not change the CLI or canonical index format.
209
+
210
+ For scripts/CI, `mdss index --json` emits the build result as JSON (the exact
211
+ `buildIndex` return value: `files`, `chunks`, `reused`, `embedded`, `dim`,
212
+ `model`, `vectorsPath`) instead of the prose β€” e.g. to assert "0 embedded"
213
+ (fully incremental re-index) in a cron job.
214
+
215
+ ### Inspect the index (`mdss stats`)
216
+
217
+ `mdss stats` prints index statistics by parsing only `vectors.json` +
218
+ `.hashes.json` β€” **no model load, no network**, so it's a cheap sanity check
219
+ after a re-index or for staleness detection in scripts:
220
+
221
+ ```bash
222
+ mdss stats --db /path/to/your/markdown
223
+ # Index at /path/to/your/markdown/.mdss
224
+ # format: binary-v1 Β· model: e5-base (dim 768)
225
+ # schema: v3 Β· lexical: bm25-v2
226
+ # chunks: 725 Β· files: 64
227
+ # size: 42.1 KiB (vectors.json)
228
+ # built: 2026-08-08T09:42:58.331Z (2m 12s ago)
229
+ # db: /path/to/your/markdown
230
+ ```
231
+
232
+ `mdss stats --json` prints machine-readable JSON: `indexDir`, `schemaVersion`,
233
+ `format` (`binary-v1` vs legacy `decimal`), `lexicalFormat`, `lexicalStatus`,
234
+ `model` (id@revision) / `modelAlias`, `dim`, `chunks`, `files`, `indexBytes`,
235
+ `built`, `ageSeconds`, `db`. Stats validates the same schema-v3 envelope and
236
+ stored vectors as the loader; malformed current metadata, unknown formats, and
237
+ corrupt vector storage exit with an actionable error instead of reporting a
238
+ healthy index.
239
+
240
+ Indexes also carry an explicit `schemaVersion` (issue #39): reading or
241
+ re-indexing over an index written by a **newer** mdss fails with a clear
242
+ "upgrade md-semantic-search" error instead of silently misparsing, and corrupt
243
+ root/schema/chunk shapes, unknown current formats, malformed v3 lexical data,
244
+ and corrupt vectors (non-canonical/truncated base64, NaN/Infinity, wrong or
245
+ model-inconsistent dimensions) are
246
+ rejected at load with an actionable rebuild/upgrade hint.
247
+
248
+ Index writes are **serialized across processes** with a lockfile
249
+ (`<index>/.mdss.lock`, issue #37): `mdss serve --watch` re-indexes on a timer
250
+ while a manual `mdss index` (or a second terminal) may run β€” without a lock two
251
+ builds interleave and can leave `vectors.json`/`.hashes.json` from different
252
+ runs. `buildIndex` acquires the lock for the whole build and releases it in a
253
+ `finally`; a second process gets a clear `index is being written by pid NNN`
254
+ error, and `serve --watch` politely defers its cycle until the other writer
255
+ finishes. A crashed writer can't wedge the index β€” the lock self-heals by
256
+ pid-liveness + staleness (a dead-pid or abandoned lock is reclaimed).
257
+
258
+ ### Diagnose problems (`mdss check` / `mdss doctor`)
259
+
260
+ `mdss check` (alias: `mdss doctor`) is an **offline, read-only** diagnostic for
261
+ the index/db/model-cache trio β€” like `mdss stats`, it never loads the embedding
262
+ model and never touches the network, but its job is answering *"why is search
263
+ broken?"* rather than *"what's in the index?"*:
264
+
265
+ ```bash
266
+ mdss check --db /path/to/your/markdown
267
+ # check: /path/to/your/markdown/.mdss
268
+ # ok vectors.json: parses (schema v3, binary-v1)
269
+ # ok .hashes.json: parses (64 file(s))
270
+ # ok chunks: 725/725 vectors valid (dim from index)
271
+ # ok db /path/to/your/markdown: fresh
272
+ # ok model cache: e5-base present at ~/.cache/mdss/Xenova/multilingual-e5-base
273
+ # check: healthy (exit 0)
274
+ ```
275
+
276
+ It validates, in order: `vectors.json` exists / parses; root, `schemaVersion`,
277
+ schema-v3 BM25 data, required current chunk metadata, model dimension, and
278
+ `format` are recognized;
279
+ `.hashes.json` parses; **every chunk's decoded vector**
280
+ with the same validator the loader uses (dim mismatch, NaN/Infinity, truncated
281
+ base64, vec-less legacy chunks β€” the first five offenders are named); db
282
+ staleness (newest file mtime vs `built`, same 5s grace as the stale warning);
283
+ and the Transformers.js FileCache layout (`<cache-dir>/<model-id>` for `main`,
284
+ with a further `<revision>` directory for pinned revisions). A missing model
285
+ cache is a *warning* by default
286
+ (the first online run downloads it) and a *failure* under `--offline`.
287
+
288
+ Exit code is `0` when healthy and `1` with a problem summary otherwise;
289
+ `--json` prints the full structured report (`healthy`, `index`, `hashes`,
290
+ `chunks`, `db`, `model`) for scripting and CI gates.
291
+
96
292
  ### 2. Search
97
293
 
98
294
  ```bash
@@ -111,19 +307,101 @@ Top 3 for: "how do I add a new translation language"
111
307
  2. ...
112
308
  ```
113
309
 
310
+ ### Reranking (optional, sharper results)
311
+
312
+ The default ranking scores each chunk *independently* (vector cosine + BM25
313
+ fused with RRF; legacy indexes use exact overlap). A **cross-encoder** reads the query and the
314
+ passage **together**, capturing pairwise relevance that independent scores can
315
+ miss β€” at the cost of one forward pass per candidate.
316
+
317
+ ```bash
318
+ mdss search --db /path/to/your/markdown "how do I rotate the api token" --rerank
319
+ ```
320
+
321
+ - The first pass pulls a wider candidate pool (default `max(20, k*3)` chunks),
322
+ the cross-encoder (`Xenova/bge-reranker-base`, ~280 MB, downloaded on first
323
+ use) re-scores them as query↔passage pairs, and the best `k` survive.
324
+ - Results from a re-ranked search carry a `rerankScore` field in `--json` mode.
325
+ - Programmatic use: `rerank: true` in `search()`/`searchIndex()`, `rerankPool`
326
+ to size the pool, or `rerankFn` to swap in your own scorer. The API server
327
+ accepts `"rerank": true` in the `/search` body.
328
+ - The reranker is lazy: nothing loads unless `--rerank` is actually requested,
329
+ so searches without it pay zero extra cost.
330
+
331
+
332
+ ### 3. Serve (daemon, warm index + model)
333
+
334
+ ```bash
335
+ mdss serve --db /path/to/your/markdown [--port 8747] [--host 127.0.0.1] [--watch]
336
+ ```
337
+
338
+ `serve` is the long-running mode for editors, scripts, or anything that asks
339
+ many questions in a row: the index is parsed **once** and the embedding model
340
+ stays loaded in memory, so every request skips the ~280 MB model load and the
341
+ full `vectors.json` parse. `--watch` polls the base every **3 s** (configurable
342
+ via `--watch-interval`, no extra dependencies) and runs the incremental
343
+ re-index automatically. Detection is **content-confirmed**, not mtime-based
344
+ (issue #42): each candidate file's md5 is compared against the authoritative
345
+ per-file hashes the indexer wrote to `.hashes.json`, so a no-op write (touch,
346
+ chmod, an editor rewriting identical bytes) never triggers a re-index, and a
347
+ real edit caught inside a coarse mtime window (FAT, older SMB) is still picked
348
+ up. A **1 s settle debounce** (`--watch-delay`) collapses a burst of successive
349
+ saves (editor save β†’ linter β†’ save) into ONE re-index instead of a back-to-back
350
+ burst. New, changed, and removed files are all handled without restarting.
351
+
352
+ ```bash
353
+ # query it with curl…
354
+ curl -X POST localhost:8747/search \
355
+ -H 'content-type: application/json' \
356
+ -d '{"query":"rotate api token","k":5}'
357
+ curl localhost:8747/health # β†’ {ok, chunks, model, dim, built, watching}
358
+ ```
359
+
360
+ `POST /search` accepts `{"query": string, "k"?: number, "semanticOnly"?: bool,
361
+ "rerank"?: bool}` and replies `{"results": [{file, title, heading, cosine,
362
+ score, matches, snippet, rerankScore?}]}`. The CLI's `search --json` prints
363
+ the same hit shape.
364
+
365
+ Or from a script / editor extension:
366
+
367
+ ```js
368
+ const r = await fetch('http://localhost:8747/search', {
369
+ method: 'POST',
370
+ headers: { 'content-type': 'application/json' },
371
+ body: JSON.stringify({ query: 'failover runbook', k: 3 }),
372
+ });
373
+ const { results } = await r.json();
374
+ ```
375
+
376
+ The API is unauthenticated β€” by default it binds to **loopback only**
377
+ (`127.0.0.1`), so other machines on your LAN cannot reach it. Pass `--host
378
+ 0.0.0.0` (or `MDSS_HOST`) only if you intend to expose it on a network, and put
379
+ a reverse proxy (or at least a firewall rule) in front of it. Request bodies
380
+ are capped at 64 KB (oversized β†’ `413`), and malformed JSON gets a clear `400`
381
+ instead of being silently swallowed.
382
+
114
383
  ### Options
115
384
 
116
385
  | Flag | Meaning |
117
386
  |------|---------|
118
387
  | `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
119
388
  | `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
120
- | `--cache-dir <dir>` | Model cache dir (default: package `.cache`, or `MDSS_CACHE_DIR`). |
389
+ | `--cache-dir <dir>` | Model cache dir. Default: `$XDG_CACHE_HOME/mdss` if set, else `~/.cache/mdss` on **all** platforms (so on Windows that's `C:\Users\<you>\.cache\mdss` β€” *not* `%LOCALAPPDATA%`; override with `MDSS_CACHE_DIR`). |
121
390
  | `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
122
391
  | `--ignore <glob>` | Skip files/paths; repeatable. e.g. `--ignore "log.md" --ignore "**/archive/**"`. |
123
- | `--k <n>` | Number of results (default 6). |
124
- | `--json` | Machine-readable output. |
125
- | `--semantic` | Pure vector ranking, skip lexical fusion. |
392
+ | `--path <glob>` | Search only files matching glob; repeatable. e.g. `--path "docs/**"`. |
393
+ | `--since <date>` | Search only files modified at/after date (`YYYY-MM-DD` or ISO 8601). |
394
+ | `--k <n>` | Number of results, positive integer (default 6). |
395
+ | `--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). |
396
+ | `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
397
+ | `--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. |
398
+ | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
399
+ | `--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`). |
400
+ | `--watch` | `serve`: re-index incrementally when files change. |
401
+ | `--watch-interval <ms>` | `serve --watch`: poll every N ms (default 3000). |
402
+ | `--watch-delay <ms>` | `serve --watch`: quiet-period debounce before a burst of saves triggers ONE re-index (default 1000; issue #42). |
126
403
  | `--offline` | Never download the model β€” require a cached one (or `MDSS_OFFLINE=1`). |
404
+ | `--version` | Print the installed version. |
127
405
 
128
406
  ### The base can live outside the project
129
407
 
@@ -156,14 +434,44 @@ mdss models
156
434
  | `e5-base` ⭐ | `Xenova/multilingual-e5-base` | 768 | Default. Best balance. |
157
435
  | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
158
436
  | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
437
+ | `qwen3-embedding-0.6b` | `onnx-community/Qwen3-Embedding-0.6B-ONNX` | 1024 | ~613 MB q8. Opt-in; instruction-prefixed queries and last-token pooling. |
159
438
 
160
439
  Switching models invalidates the stored vectors automatically β€” the next
161
440
  `index` run does a full rebuild.
162
441
 
163
442
  Weights are downloaded in quantized (q8) form when the model repo ships them
164
- (all `Xenova/*` repos do β€” e5-base is ~280 MB, not ~1.1 GB fp32).
165
-
166
- You can also pass any raw `Xenova/*` id, optionally pinning a revision:
443
+ (e5-base is ~280 MB instead of ~1.1 GB fp32).
444
+
445
+ The Qwen3 alias is pinned to a tested model revision and automatically applies
446
+ the retrieval instruction required for queries; indexed passages remain
447
+ unprefixed. Its upstream benchmark is promising, but it has not replaced
448
+ `e5-base` as the default because the project corpus has not been benchmarked
449
+ against it yet. This is experimental enablement rather than completion of issue
450
+ #50: release provenance and the versioned RU/EN benchmark remain tracked by
451
+ #55 and #56 (the explicit model adapter contract landed in #60).
452
+
453
+ ### How to add a model
454
+
455
+ Every supported model is an *explicit adapter* in `src/models.mjs` (issue #60).
456
+ The adapter declares exactly which embedding semantics it needs β€” the shared
457
+ `embed()` path never guesses from the model name:
458
+
459
+ - `queryPrefix` / `passagePrefix` β€” text prepended to queries/passages (E5 uses
460
+ `query: `/`passage: `, BGE none, Qwen3 a retrieval instruction on queries);
461
+ - `pooling` β€” `'mean'`, `'last_token'`, or `'none'`;
462
+ - `normalize` β€” whether vectors are L2-normalized;
463
+ - `nativeDim` / `dim` β€” embedding dimension (and optional `dimensions` for MRL);
464
+ - `dtype` β€” the ONNX runtime data type loaded at runtime (default `q8`);
465
+ - `maxTokens` β€” the tokenizer context budget (Qwen3-0.6B is 32768);
466
+ - `family` β€” an informational formatting/pooling label surfaced by `stats`.
467
+
468
+ `mdss models` prints each adapter's pooling, normalization, dimension, and
469
+ token budget. `mdss stats` and `mdss check` expose the same adapter fields for
470
+ an existing index, plus its `adapterFingerprint`.
471
+
472
+ **A raw Hugging Face id that is not a registered adapter is rejected at embed
473
+ time β€” it does NOT silently inherit E5 prefixes and mean pooling.** Use a
474
+ registered alias (or the explicit object descriptor below):
167
475
 
168
476
  ```bash
169
477
  mdss index --db ./docs --model "Xenova/multilingual-e5-small@abc123def"
@@ -173,19 +481,48 @@ Pinned ids invalidate the index too (the revision is part of the model key), so
173
481
  a `@revision` bump triggers a full rebuild. Custom ids should have quantized
174
482
  weights (a `model_quantized.onnx` file) in the repo.
175
483
 
484
+ Library consumers adding a one-off model can pass an explicit adapter object to
485
+ `buildIndex`/`search` instead of editing the registry:
486
+
487
+ ```js
488
+ import { buildIndex } from 'md-semantic-search';
489
+ await buildIndex({
490
+ db: './docs', indexDir: './.mdss',
491
+ modelName: {
492
+ id: 'Xenova/some-embedding-model',
493
+ nativeDim: 384,
494
+ queryPrefix: 'query: ',
495
+ passagePrefix: 'passage: ',
496
+ pooling: 'mean',
497
+ normalize: true,
498
+ },
499
+ });
500
+ ```
501
+
502
+ Without `queryPrefix`/`passagePrefix`/`pooling`/`dim`, a raw id is still treated
503
+ as an unconfigured adapter and indexing fails with a message pointing at this
504
+ section instead of silently producing wrong vectors.
505
+
176
506
  ## How it works
177
507
 
178
508
  1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
179
509
  globs skipped).
180
- 2. **Chunk** each file by Markdown headings; oversized sections split on blank
181
- lines (~1400 chars/chunk).
182
- 3. **Embed** each chunk (`passage:` prefix for E5) β†’ store `{file, heading,
183
- text, vec}` in `vectors.json`, plus per-file md5 in `.hashes.json`. Each chunk
184
- also stores `chunkHash` (SHA-256 of the exact passage input + model identity),
185
- so on re-index an unchanged section inside a modified file reuses its vector
186
- instead of being embedded again.
187
- 4. **Search**: embed the query (`query:` prefix), score every chunk by cosine,
188
- score by lexical term-overlap, then **fuse with RRF**. Return top-k chunks.
510
+ 2. **Chunk** each file by Markdown headings while retaining the active heading
511
+ stack; oversized sections split on blank lines (~1400 body chars/chunk).
512
+ 3. **Embed** the document title, full heading path, and chunk body (`passage:`
513
+ prefix for E5) β†’ store `{file, title, heading, headingPath, text, vec}` in
514
+ `vectors.json`, plus per-file md5 in `.hashes.json`. The index stores an
515
+ adapter fingerprint so formatting, pooling, normalization, or dimension
516
+ changes invalidate vectors and checkpoints without invalidating lexical
517
+ records. Each chunk also stores
518
+ `chunkHash` (SHA-256 of that exact contextual passage + model identity), so a
519
+ parent rename invalidates its subtree while unchanged sibling branches reuse
520
+ their vectors.
521
+ 4. **Search**: embed the query with the selected model's formatter (`query:` for
522
+ E5, no prefix for BGE-M3, or the retrieval instruction for Qwen3), score every
523
+ chunk by cosine,
524
+ score query terms from the persisted BM25 postings, then **fuse with RRF**.
525
+ Legacy schema-v0/v1/v2 indexes retain exact token-overlap scoring.
189
526
 
190
527
  No external services, no database β€” the whole index is one JSON file and search
191
528
  is an in-memory dot-product sweep.
package/RESEARCH.md CHANGED
@@ -12,7 +12,7 @@ auditable and reproducible.
12
12
  > ranked an irrelevant page *above* the correct one. `e5-base` fixed it.
13
13
  > - **Quantization is not the problem** people assume β€” int8 vs fp32 changed
14
14
  > cosine scores by <0.003 and never changed the ranking.
15
- > - **Hybrid (vector + keyword via RRF)** beats either alone: vectors catch
15
+ > - **Hybrid (vector + lexical via RRF)** beats either alone: vectors catch
16
16
  > paraphrases, keywords catch exact identifiers.
17
17
 
18
18
  ---
@@ -98,55 +98,135 @@ This is why the default is `e5-base`, not the tempting lightweight `e5-small`.
98
98
  ## 5. Design decisions that follow
99
99
 
100
100
  ### E5 prefixes are mandatory
101
- The E5 family is trained with instruction prefixes: documents must be embedded
102
- as `passage: <text>` and queries as `query: <text>`. Omitting them measurably
103
- degrades retrieval. The model registry encodes this per-model, because
104
- **`bge-m3` is the opposite β€” it wants no prefix at all.** Getting this wrong is
105
- a silent quality bug.
106
-
107
- ### Chunk by heading, not by page
108
- Embedding a whole multi-section page into one vector blurs distinct topics.
109
- We split on Markdown headings (`#`..`######`); sections over ~1400 chars split
110
- again on blank lines. Search then returns the relevant *section*, with its
111
- heading, not just "the page where the word appears somewhere."
112
-
113
- ### Hybrid ranking via Reciprocal Rank Fusion
114
- Pure vectors miss exact identifiers (`win32`, `TextIOWrapper`, error codes);
115
- pure keywords miss paraphrases. We compute two rankings β€” cosine and lexical
116
- term-overlap β€” and fuse them with RRF (`score = Ξ£ 1/(k + rank)`, `k=60`). RRF
117
- needs no score normalization or weight tuning, which is what makes it robust
118
- across very different score scales.
119
-
120
- ### Stop-words protect the lexical lane
121
- Cross-lingual queries share function words ("ΠΏΡ€ΠΈ", "the", "ΠΊΠΎΠ΄Π°") with
122
- irrelevant documents, letting lexical overlap promote noise. A small ru/en
123
- stop-list is removed before lexical scoring so RRF fuses signal, not filler.
124
-
125
- ### Incremental by content hash
126
- Each file's md5 is stored. Re-indexing reuses embeddings for unchanged files
127
- and only re-embeds what changed β€” a no-op re-index of 46 files is sub-second.
128
- Changing the model invalidates all vectors and forces a clean rebuild
129
- (dimensions and prefix semantics differ).
130
-
131
- ## 6. Reproducing
101
+ `multilingual-e5-*` models require `"query: "` before queries and `"passage: "`
102
+ before documents. Omitting them degrades cosine separation.
103
+
104
+ ### Lexical + vector fusion (RRF)
105
+ Vector search catches concepts ("frozen console" β†’ "win32 stdin"), but can blur
106
+ exact function names or error codes (`E_FAIL_WIN32`). Combined rank via
107
+ Reciprocal Rank Fusion ($k=60$) yields the best overall precision.
108
+
109
+ ## 6. Reranking architecture (issue #15)
110
+
111
+ First-pass vector + lexical retrieval is shallow: query and document are
112
+ embedded independently. A cross-encoder reranker (`Xenova/bge-reranker-base`)
113
+ processes the query and document together through a sequence classification
114
+ head.
115
+
116
+ - **Lazy loading:** The reranker model is loaded only when `--rerank` is passed.
117
+ - **Candidate pool:** First-pass retrieval returns candidate pool $k_{\text{pool}} = \max(20, k \times 3)$, which is then re-ordered by the cross-encoder.
118
+ - **Head semantics:** `bge-reranker-base` uses a single-class output head (`logits[0]`). Softmax must **not** be applied over single-class heads (which would yield 1.0 for all inputs).
119
+
120
+ ## 7. Golden-set benchmark (issue #56)
121
+
122
+ The experiments above are qualitative. To make quality claims reproducible and
123
+ regression-detectable, the repo ships a **frozen golden set**: a small synthetic
124
+ RU/EN corpus plus graded relevance judgements.
125
+
126
+ - `bench/corpus/` β€” 16 markdown files, 8 topics Γ— 2 languages (tokens, i18n,
127
+ windows stdin, db failover, model cache, obsidian links, backups, search
128
+ index/server). Content is deliberately plain so embedding quality, not
129
+ domain vocabulary, drives the scores.
130
+ - `bench/fixtures/dev-golden.json` β€” 60 queries across six categories
131
+ (`natural-question`, `paraphrase`, `keyword`, `alias`, `hard-negative`,
132
+ `identifier`), 48 dev / 8 test / 4 holdout. Grades: 3 = direct answer,
133
+ 2 = useful support, 1 = related, 0 = hard negative. The fixture pins
134
+ `corpusHash` (sha256 of paths+content) β€” a changed corpus fails the run.
135
+ - `bench/fixture.mjs` β€” schema-v1 validator, `loadFixture`, `corpusFingerprint`,
136
+ deterministic `splitIntoSlices`.
137
+ - `src/metrics.mjs` β€” nDCG@k (graded, log2 discount), MRR, Hit@k, Recall@k,
138
+ `queryMetrics`/`aggregateMetrics`, win/tie/loss for paired model comparison.
139
+ - `scripts/run-bench.mjs` β€” builds the index over the frozen corpus, runs every
140
+ query of the selected slice, prints aggregate + per-category metrics.
141
+
142
+ ```bash
143
+ node scripts/run-bench.mjs --slice dev # full dev eval (k=10, e5-base)
144
+ node scripts/run-bench.mjs --fast --json # CI smoke (k=3)
145
+ node scripts/run-bench.mjs --model bge-m3 --slice dev > /tmp/bge-m3.json
146
+ node scripts/run-bench.mjs --model e5-base --slice dev > /tmp/e5-base.json
147
+ ```
148
+
149
+ Baseline on dev (e5-base, hybrid RRF, k=10, 2026-08-14):
150
+
151
+ | Metric | Value |
152
+ |--------|-------|
153
+ | nDCG@10 | 0.9254 |
154
+ | MRR | 0.9167 |
155
+ | Hit@10 | 0.9792 |
156
+ | Recall@10 | 1.0000 |
157
+
158
+ ## 8. Reproducing
132
159
 
133
160
  ```bash
134
- # Build with the default, then with the heavy model, and compare a known query:
135
161
  mdss index --db ./your-wiki
136
162
  mdss search --db ./your-wiki --semantic "a paraphrase of something you know is in there"
137
163
 
138
164
  mdss index --db ./your-wiki --model bge-m3
139
165
  mdss search --db ./your-wiki --semantic "the same paraphrase"
140
- ```
141
166
 
142
- Use `--semantic` to see the raw vector ranking (no lexical fusion) when
143
- evaluating a model β€” that isolates embedding quality from the keyword lane.
167
+ mdss index --db ./your-wiki --model qwen3-embedding-0.6b
168
+ mdss search --db ./your-wiki --semantic "the same paraphrase"
169
+ ```
144
170
 
145
- ## 7. Recommendations
171
+ ## 9. Recommendations
146
172
 
147
173
  | If you want… | Use |
148
174
  |--------------|-----|
149
175
  | Sensible default, fast, ~280 MB | `e5-base` (default) |
150
176
  | Maximum cross-lingual quality, disk/time no object | `bge-m3` |
177
+ | Evaluate a newer 0.6B multilingual model locally | `qwen3-embedding-0.6b` (~613 MB q8) |
151
178
  | Smallest footprint and your queries are same-language & literal | `e5-small` (with eyes open) |
152
179
  | Best precision on exact identifiers | keep hybrid on (don't pass `--semantic`) |
180
+ | Sharpest top-k on a larger corpus | add `--rerank` (cross-encoder, +280 MB) |
181
+
182
+ ---
183
+
184
+ ## 10. Research Evaluation: Lightweight Multilingual Reranker Tier (Issue #51)
185
+
186
+ ### Context & Decision Gate
187
+ - **Baseline:** `Xenova/bge-reranker-base` (278MB q8, XLM-RoBERTa architecture, single-class logit head).
188
+ - **Rejected Candidate:** `cross-encoder/ms-marco-MiniLM-L6-v2` is English-only and collapses on Russian/bilingual technical queries despite smaller size.
189
+ - **Criteria for New Candidate:**
190
+ 1. Verified ONNX/Transformers.js artifact with `q8` quantization.
191
+ 2. Proven multilingual / cross-lingual ranking capability on RU/EN.
192
+ 3. Commercial MIT/Apache-2.0 compatible license.
193
+ 4. Non-inferiority on the golden set (#56): nDCG@10 drop $\le 0.01$ vs BGE-base.
194
+ 5. Single-class raw logit verification (no 1-class softmax wrapper).
195
+
196
+ If no candidate meets the multilingual non-inferiority gate, `bge-reranker-base` remains the sole default reranker.
197
+
198
+ ---
199
+
200
+ ## 11. Research Evaluation: Persisted Vector int8 & Matryoshka (MRL) Scaling (Issue #52)
201
+
202
+ ### Disambiguation & Rules
203
+ - **Model Inference Quantization (`dtype: q8`):** Already used for transformer weight inference in `src/core.mjs`.
204
+ - **Stored Vector Quantization:** Compressing stored document float vectors (`Float32Array`) to `int8` with per-vector scale factors.
205
+ - **Matryoshka Representation Learning (MRL):** Truncating embedding output dimension (e.g., 1024 β†’ 256).
206
+ - **Contract Rule:** Dimension truncation is allowed **only** for models with explicit MRL training contracts (e.g. `Qwen3-Embedding-0.6B`). Models like `BGE-M3` do **not** support arbitrary dimension slicing without severe recall degradation.
207
+
208
+ ### Scale Trigger
209
+ - Do not introduce persisted vector quantization or MRL index format breaking changes until corpus size exceeds **100,000 chunks** or stored index size dominates disk/RAM budgets beyond 500 MB.
210
+
211
+ ---
212
+
213
+ ## 12. Research Evaluation: Late Chunking vs Heading-Path Context (Issue #53)
214
+
215
+ ### Baseline vs Late Chunking
216
+ - **Current Baseline:** `mdss` prepends document title + full heading hierarchy path (`headingPath: ['Section', 'Subsection']`) to every chunk before tokenization.
217
+ - **Late Chunking Concept:** Passing full documents through a long-context transformer, extracting token-level hidden states, and pooling chunk token spans post-hoc.
218
+ - **Evaluation Gate:** Late chunking adds high memory overhead, token-offset alignment complexity, and custom model batching. It will be prototyped **only if** heading-path contextualization exhibits a measured context-recall deficit on long-document ablation benchmarks.
219
+
220
+ ---
221
+
222
+ ## 13. Research Evaluation: WASM SIMD & ANN/IVF Scale Triggers (Issues #31 & #32)
223
+
224
+ ### WebAssembly SIMD Kernel Gate (Issue #31)
225
+ - **Baseline:** Linear sweep over contiguous `Float32Array` runtime vector buffer (Issue #30).
226
+ - **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
+ - **Constraint:** Zero native Node C++ bindings; must maintain pure JS fallback for non-WASM environments.
228
+
229
+ ### ANN / IVF Scale Threshold (Issue #32)
230
+ - **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.