md-semantic-search 0.1.1 β†’ 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,18 +34,78 @@ 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`).
39
- - ⚑ **Incremental.** Per-file md5 β€” re-indexing only re-embeds changed files.
41
+ and persisted BM25 postings (exact names like `win32`, `TextIOWrapper`).
42
+ - ⚑ **Two-level incremental.** Per-file md5 (unchanged files skip work) **plus**
43
+ per-chunk hash β€” inside a *changed* file, sections whose text is unchanged
44
+ reuse their stored vector. Editing one place in a long file re-embeds only the
45
+ affected section, not the whole file.
40
46
  - πŸ”’ **Private & offline.** Model downloads once, then no network. Nothing is
41
47
  uploaded anywhere.
42
48
  - πŸ“¦ **Zero infra.** One JSON index, brute-force cosine in memory. No Pinecone,
43
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.
44
61
 
45
- ## 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)).
96
+
97
+ ---
46
98
 
47
- - Node.js β‰₯ 18
48
- - ~280 MB disk for the default model (downloaded once into a cache dir)
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
+ ---
49
109
 
50
110
  ## Install
51
111
 
@@ -71,6 +131,44 @@ npm install
71
131
  node bin/cli.mjs --help
72
132
  ```
73
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
+
74
172
  ## Usage
75
173
 
76
174
  ### 1. Build the index
@@ -81,7 +179,115 @@ mdss index --db /path/to/your/markdown
81
179
 
82
180
  First run downloads the model (~280 MB). The index is written to `<db>/.mdss/`
83
181
  by default (override with `--index-dir`). Re-run after editing your notes β€” it's
84
- incremental, so only changed files are re-embedded.
182
+ incremental at two levels: unchanged files are skipped via their md5, and inside
183
+ a changed file only the sections whose text actually changed are re-embedded
184
+ (chunks carry a `chunkHash` in `vectors.json`; an append to a long log re-embeds
185
+ just the new entry). The index output reports the split, e.g.:
186
+
187
+ ```
188
+ Indexed 64 file(s) β†’ 725 chunks (725 reused [5 chunk-level, 720 file-level], 0 embedded), ... 0.6s
189
+ ```
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.
85
291
 
86
292
  ### 2. Search
87
293
 
@@ -101,18 +307,101 @@ Top 3 for: "how do I add a new translation language"
101
307
  2. ...
102
308
  ```
103
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
+
104
383
  ### Options
105
384
 
106
385
  | Flag | Meaning |
107
386
  |------|---------|
108
387
  | `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
109
388
  | `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
110
- | `--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`). |
111
390
  | `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
112
391
  | `--ignore <glob>` | Skip files/paths; repeatable. e.g. `--ignore "log.md" --ignore "**/archive/**"`. |
113
- | `--k <n>` | Number of results (default 6). |
114
- | `--json` | Machine-readable output. |
115
- | `--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). |
403
+ | `--offline` | Never download the model β€” require a cached one (or `MDSS_OFFLINE=1`). |
404
+ | `--version` | Print the installed version. |
116
405
 
117
406
  ### The base can live outside the project
118
407
 
@@ -145,39 +434,99 @@ mdss models
145
434
  | `e5-base` ⭐ | `Xenova/multilingual-e5-base` | 768 | Default. Best balance. |
146
435
  | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
147
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. |
148
438
 
149
439
  Switching models invalidates the stored vectors automatically β€” the next
150
- `index` run does a full rebuild. You can also pass any raw `Xenova/*` id.
440
+ `index` run does a full rebuild.
441
+
442
+ Weights are downloaded in quantized (q8) form when the model repo ships them
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):
475
+
476
+ ```bash
477
+ mdss index --db ./docs --model "Xenova/multilingual-e5-small@abc123def"
478
+ ```
479
+
480
+ Pinned ids invalidate the index too (the revision is part of the model key), so
481
+ a `@revision` bump triggers a full rebuild. Custom ids should have quantized
482
+ weights (a `model_quantized.onnx` file) in the repo.
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.
151
505
 
152
506
  ## How it works
153
507
 
154
508
  1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
155
509
  globs skipped).
156
- 2. **Chunk** each file by Markdown headings; oversized sections split on blank
157
- lines (~1400 chars/chunk).
158
- 3. **Embed** each chunk (`passage:` prefix for E5) β†’ store `{file, heading,
159
- text, vec}` in `vectors.json`, plus per-file md5 in `.hashes.json`.
160
- 4. **Search**: embed the query (`query:` prefix), score every chunk by cosine,
161
- 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.
162
526
 
163
527
  No external services, no database β€” the whole index is one JSON file and search
164
528
  is an in-memory dot-product sweep.
165
529
 
166
- ## Releasing (maintainers)
167
-
168
- Publishing is automated by [`.github/workflows/publish.yml`](./.github/workflows/publish.yml):
169
- pushing a `vX.Y.Z` tag publishes that version to npm with provenance.
170
-
171
- One-time setup: add a repo secret `NPM_TOKEN` β€” an npm **automation / granular**
172
- token with *bypass 2FA* enabled (npmjs.com β†’ Account β†’ Access Tokens).
173
-
174
- ```bash
175
- npm version patch # bumps package.json + creates the git tag
176
- git push --follow-tags
177
- ```
178
-
179
- The workflow checks that the tag matches `package.json` before publishing.
180
-
181
530
  ## License
182
531
 
183
532
  MIT Β© chelslava