md-semantic-search 0.3.0 β†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +438 -20
  2. package/RESEARCH.md +117 -37
  3. package/bin/cli.mjs +931 -36
  4. package/dist/collapse.d.ts +6 -0
  5. package/dist/collapse.js +20 -0
  6. package/dist/core.d.ts +84 -0
  7. package/dist/core.js +479 -0
  8. package/dist/frontmatter.d.ts +20 -0
  9. package/dist/frontmatter.js +150 -0
  10. package/dist/index-format.d.ts +23 -0
  11. package/dist/index-format.js +125 -0
  12. package/dist/index.d.ts +29 -0
  13. package/dist/index.js +29 -0
  14. package/dist/indexer.d.ts +70 -0
  15. package/dist/indexer.js +452 -0
  16. package/dist/ivf.d.ts +33 -0
  17. package/dist/ivf.js +144 -0
  18. package/dist/lexical.d.ts +44 -0
  19. package/dist/lexical.js +226 -0
  20. package/dist/markdown-parser.d.ts +27 -0
  21. package/dist/markdown-parser.js +252 -0
  22. package/dist/mcp.d.ts +111 -0
  23. package/dist/mcp.js +216 -0
  24. package/dist/metrics.d.ts +43 -0
  25. package/dist/metrics.js +97 -0
  26. package/dist/models.d.ts +38 -0
  27. package/dist/models.js +181 -0
  28. package/dist/rerank.d.ts +10 -0
  29. package/dist/rerank.js +41 -0
  30. package/dist/search.d.ts +135 -0
  31. package/dist/search.js +492 -0
  32. package/dist/serve.d.ts +49 -0
  33. package/dist/serve.js +321 -0
  34. package/dist/tui.d.ts +17 -0
  35. package/dist/tui.js +196 -0
  36. package/dist/wikilinks.d.ts +47 -0
  37. package/dist/wikilinks.js +171 -0
  38. package/package.json +25 -2
  39. package/src/collapse.ts +26 -0
  40. package/src/core.ts +546 -0
  41. package/src/frontmatter.ts +190 -0
  42. package/src/index-format.ts +151 -0
  43. package/src/index.ts +55 -0
  44. package/src/indexer.ts +585 -0
  45. package/src/ivf.ts +190 -0
  46. package/src/lexical.ts +257 -0
  47. package/src/markdown-parser.ts +303 -0
  48. package/src/mcp.ts +243 -0
  49. package/src/metrics.ts +135 -0
  50. package/src/models.ts +219 -0
  51. package/src/rerank.ts +54 -0
  52. package/src/search.ts +628 -0
  53. package/src/serve.ts +372 -0
  54. package/src/tui.ts +227 -0
  55. package/src/wikilinks.ts +244 -0
  56. package/src/core.mjs +0 -171
  57. package/src/indexer.mjs +0 -197
  58. package/src/models.mjs +0 -67
  59. package/src/search.mjs +0 -107
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)).
96
+
97
+ ---
98
+
99
+ ## Comparison with Alternatives
49
100
 
50
- - Node.js β‰₯ 18
51
- - ~280 MB disk for the default model (downloaded once into a cache dir)
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,153 @@ 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
+ ### Export the index (`mdss export`)
259
+
260
+ Export chunks and decoded embedding vectors to JSONL, CSV, or Parquet for downstream pipelines (LangChain, LlamaIndex, Qdrant, Pandas):
261
+
262
+ ```bash
263
+ # Export all chunks with decoded float vectors as JSONL:
264
+ mdss export --db ./notes --format jsonl > chunks.jsonl
265
+
266
+ # Export metadata only without vectors (fast & compact):
267
+ mdss export --db ./notes --format jsonl --no-vectors > chunks_meta.jsonl
268
+
269
+ # Export as CSV (metadata only):
270
+ mdss export --db ./notes --format csv > chunks.csv
271
+
272
+ # Export directly to file:
273
+ mdss export --db ./notes --format jsonl --output ./data/corpus.jsonl
274
+ ```
275
+
276
+ #### JSONL output schema:
277
+ ```json
278
+ {
279
+ "file": "runbooks/failover.md",
280
+ "title": "Database Failover",
281
+ "heading": "Step 2: Promote Replica",
282
+ "headingPath": ["Database Failover", "Step 2: Promote Replica"],
283
+ "text": "Run the promote script...",
284
+ "vec": [0.012, -0.045, 0.089],
285
+ "chunkHash": "a1b2c3...",
286
+ "startLine": 14,
287
+ "endLine": 28
288
+ }
289
+ ```
290
+
291
+ #### Downstream integration example (Python / LangChain / LlamaIndex):
292
+ ```python
293
+ import json
294
+
295
+ # Load into custom vector store / pipeline
296
+ with open("chunks.jsonl") as f:
297
+ for line in f:
298
+ chunk = json.loads(line)
299
+ text = chunk["text"]
300
+ vector = chunk["vec"]
301
+ meta = {"file": chunk["file"], "heading": chunk["heading"]}
302
+ ```
303
+
304
+ ### Diagnose problems (`mdss check` / `mdss doctor`)
305
+
306
+ `mdss check` (alias: `mdss doctor`) is an **offline, read-only** diagnostic for
307
+ the index/db/model-cache trio β€” like `mdss stats`, it never loads the embedding
308
+ model and never touches the network, but its job is answering *"why is search
309
+ broken?"* rather than *"what's in the index?"*:
310
+
311
+ ```bash
312
+ mdss check --db /path/to/your/markdown
313
+ # check: /path/to/your/markdown/.mdss
314
+ # ok vectors.json: parses (schema v3, binary-v1)
315
+ # ok .hashes.json: parses (64 file(s))
316
+ # ok chunks: 725/725 vectors valid (dim from index)
317
+ # ok db /path/to/your/markdown: fresh
318
+ # ok model cache: e5-base present at ~/.cache/mdss/Xenova/multilingual-e5-base
319
+ # check: healthy (exit 0)
320
+ ```
321
+
322
+ It validates, in order: `vectors.json` exists / parses; root, `schemaVersion`,
323
+ schema-v3 BM25 data, required current chunk metadata, model dimension, and
324
+ `format` are recognized;
325
+ `.hashes.json` parses; **every chunk's decoded vector**
326
+ with the same validator the loader uses (dim mismatch, NaN/Infinity, truncated
327
+ base64, vec-less legacy chunks β€” the first five offenders are named); db
328
+ staleness (newest file mtime vs `built`, same 5s grace as the stale warning);
329
+ and the Transformers.js FileCache layout (`<cache-dir>/<model-id>` for `main`,
330
+ with a further `<revision>` directory for pinned revisions). A missing model
331
+ cache is a *warning* by default
332
+ (the first online run downloads it) and a *failure* under `--offline`.
333
+
334
+ Exit code is `0` when healthy and `1` with a problem summary otherwise;
335
+ `--json` prints the full structured report (`healthy`, `index`, `hashes`,
336
+ `chunks`, `db`, `model`) for scripting and CI gates.
337
+
96
338
  ### 2. Search
97
339
 
98
340
  ```bash
@@ -111,19 +353,108 @@ Top 3 for: "how do I add a new translation language"
111
353
  2. ...
112
354
  ```
113
355
 
356
+ ### Reranking (optional, sharper results)
357
+
358
+ The default ranking scores each chunk *independently* (vector cosine + BM25
359
+ fused with RRF; legacy indexes use exact overlap). A **cross-encoder** reads the query and the
360
+ passage **together**, capturing pairwise relevance that independent scores can
361
+ miss β€” at the cost of one forward pass per candidate.
362
+
363
+ ```bash
364
+ mdss search --db /path/to/your/markdown "how do I rotate the api token" --rerank
365
+ ```
366
+
367
+ - The first pass pulls a wider candidate pool (default `max(20, k*3)` chunks),
368
+ the cross-encoder (`Xenova/bge-reranker-base`, ~280 MB, downloaded on first
369
+ use) re-scores them as query↔passage pairs, and the best `k` survive.
370
+ - Results from a re-ranked search carry a `rerankScore` field in `--json` mode.
371
+ - Programmatic use: `rerank: true` in `search()`/`searchIndex()`, `rerankPool`
372
+ to size the pool, or `rerankFn` to swap in your own scorer. The API server
373
+ accepts `"rerank": true` in the `/search` body.
374
+ - The reranker is lazy: nothing loads unless `--rerank` is actually requested,
375
+ so searches without it pay zero extra cost.
376
+
377
+
378
+ ### 3. Serve (daemon, warm index + model)
379
+
380
+ ```bash
381
+ mdss serve --db /path/to/your/markdown [--port 8747] [--host 127.0.0.1] [--watch]
382
+ ```
383
+
384
+ `serve` is the long-running mode for editors, scripts, or anything that asks
385
+ many questions in a row: the index is parsed **once** and the embedding model
386
+ stays loaded in memory, so every request skips the ~280 MB model load and the
387
+ full `vectors.json` parse. `--watch` polls the base every **3 s** (configurable
388
+ via `--watch-interval`, no extra dependencies) and runs the incremental
389
+ re-index automatically. Detection is **content-confirmed**, not mtime-based
390
+ (issue #42): each candidate file's md5 is compared against the authoritative
391
+ per-file hashes the indexer wrote to `.hashes.json`, so a no-op write (touch,
392
+ chmod, an editor rewriting identical bytes) never triggers a re-index, and a
393
+ real edit caught inside a coarse mtime window (FAT, older SMB) is still picked
394
+ up. A **1 s settle debounce** (`--watch-delay`) collapses a burst of successive
395
+ saves (editor save β†’ linter β†’ save) into ONE re-index instead of a back-to-back
396
+ burst. New, changed, and removed files are all handled without restarting.
397
+
398
+ ```bash
399
+ # query it with curl…
400
+ curl -X POST localhost:8747/search \
401
+ -H 'content-type: application/json' \
402
+ -d '{"query":"rotate api token","k":5}'
403
+ curl localhost:8747/health # β†’ {ok, chunks, model, dim, built, watching}
404
+ ```
405
+
406
+ `POST /search` accepts `{"query": string, "k"?: number, "semanticOnly"?: bool,
407
+ "rerank"?: bool}` and replies `{"results": [{file, title, heading, cosine,
408
+ score, matches, snippet, rerankScore?}]}`. The CLI's `search --json` prints
409
+ the same hit shape.
410
+
411
+ Or from a script / editor extension:
412
+
413
+ ```js
414
+ const r = await fetch('http://localhost:8747/search', {
415
+ method: 'POST',
416
+ headers: { 'content-type': 'application/json' },
417
+ body: JSON.stringify({ query: 'failover runbook', k: 3 }),
418
+ });
419
+ const { results } = await r.json();
420
+ ```
421
+
422
+ The API is unauthenticated β€” by default it binds to **loopback only**
423
+ (`127.0.0.1`), so other machines on your LAN cannot reach it. Pass `--host
424
+ 0.0.0.0` (or `MDSS_HOST`) only if you intend to expose it on a network, and put
425
+ a reverse proxy (or at least a firewall rule) in front of it. Request bodies
426
+ are capped at 64 KB (oversized β†’ `413`), and malformed JSON gets a clear `400`
427
+ instead of being silently swallowed.
428
+
114
429
  ### Options
115
430
 
116
431
  | Flag | Meaning |
117
432
  |------|---------|
433
+ | `--config <file>` | Path to config file (default: `.mdssrc.json`, `mdss.config.json`, `.mdssrc`). |
118
434
  | `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
119
435
  | `--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`). |
436
+ | `--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
437
  | `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
438
+ | `--workers <n>` | Number of parallel batch workers for embedding (default: 1). |
439
+ | `--format <fmt>` | Export format: `jsonl` (default), `csv`, `parquet` (`export`). |
440
+ | `--no-vectors` | Omit embeddings from export (`export`). |
441
+ | `--output <file>` | Output file path (`export`, default: stdout). |
122
442
  | `--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. |
443
+ | `--path <glob>` | Search only files matching glob; repeatable. e.g. `--path "docs/**"`. |
444
+ | `--since <date>` | Search only files modified at/after date (`YYYY-MM-DD` or ISO 8601). |
445
+ | `--k <n>` | Number of results, positive integer (default 6). |
446
+ | `--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). |
447
+ | `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
448
+ | `--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. |
449
+ | `--ann` | Approximate nearest neighbor search via IVF clustering (search; auto-enables for large corpora). |
450
+ | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
451
+ | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
452
+ | `--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`). |
453
+ | `--watch` | `serve`: re-index incrementally when files change. |
454
+ | `--watch-interval <ms>` | `serve --watch`: poll every N ms (default 3000). |
455
+ | `--watch-delay <ms>` | `serve --watch`: quiet-period debounce before a burst of saves triggers ONE re-index (default 1000; issue #42). |
126
456
  | `--offline` | Never download the model β€” require a cached one (or `MDSS_OFFLINE=1`). |
457
+ | `--version` | Print the installed version. |
127
458
 
128
459
  ### The base can live outside the project
129
460
 
@@ -144,6 +475,30 @@ mdss index
144
475
  mdss search "incident runbook for db failover"
145
476
  ```
146
477
 
478
+ ### Configuration file (`.mdssrc` / `mdss.config.json`)
479
+
480
+ Instead of repeating CLI options on every command, create a `.mdssrc.json` or `mdss.config.json` file.
481
+ `mdss` discovers config files by searching:
482
+ 1. An explicit path passed via `--config <path>` or the `MDSS_CONFIG` environment variable.
483
+ 2. Current working directory up through ancestor directories (`.mdssrc.json`, `mdss.config.json`, `.mdssrc`).
484
+ 3. User global config at `~/.mdssrc.json` or `~/.mdssrc`.
485
+
486
+ CLI flags always override configuration values.
487
+
488
+ Example `.mdssrc.json`:
489
+
490
+ ```json
491
+ {
492
+ "db": "./notes",
493
+ "model": "e5-base",
494
+ "indexDir": "./notes/.mdss",
495
+ "ignore": ["archive/**", "drafts/**"],
496
+ "k": 6
497
+ }
498
+ ```
499
+
500
+ Run `mdss check` to validate your configuration file schema and detect unknown keys.
501
+
147
502
  ## Models
148
503
 
149
504
  ```bash
@@ -156,14 +511,44 @@ mdss models
156
511
  | `e5-base` ⭐ | `Xenova/multilingual-e5-base` | 768 | Default. Best balance. |
157
512
  | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
158
513
  | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
514
+ | `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
515
 
160
516
  Switching models invalidates the stored vectors automatically β€” the next
161
517
  `index` run does a full rebuild.
162
518
 
163
519
  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:
520
+ (e5-base is ~280 MB instead of ~1.1 GB fp32).
521
+
522
+ The Qwen3 alias is pinned to a tested model revision and automatically applies
523
+ the retrieval instruction required for queries; indexed passages remain
524
+ unprefixed. Its upstream benchmark is promising, but it has not replaced
525
+ `e5-base` as the default because the project corpus has not been benchmarked
526
+ against it yet. This is experimental enablement rather than completion of issue
527
+ #50: release provenance and the versioned RU/EN benchmark remain tracked by
528
+ #55 and #56 (the explicit model adapter contract landed in #60).
529
+
530
+ ### How to add a model
531
+
532
+ Every supported model is an *explicit adapter* in `src/models.mjs` (issue #60).
533
+ The adapter declares exactly which embedding semantics it needs β€” the shared
534
+ `embed()` path never guesses from the model name:
535
+
536
+ - `queryPrefix` / `passagePrefix` β€” text prepended to queries/passages (E5 uses
537
+ `query: `/`passage: `, BGE none, Qwen3 a retrieval instruction on queries);
538
+ - `pooling` β€” `'mean'`, `'last_token'`, or `'none'`;
539
+ - `normalize` β€” whether vectors are L2-normalized;
540
+ - `nativeDim` / `dim` β€” embedding dimension (and optional `dimensions` for MRL);
541
+ - `dtype` β€” the ONNX runtime data type loaded at runtime (default `q8`);
542
+ - `maxTokens` β€” the tokenizer context budget (Qwen3-0.6B is 32768);
543
+ - `family` β€” an informational formatting/pooling label surfaced by `stats`.
544
+
545
+ `mdss models` prints each adapter's pooling, normalization, dimension, and
546
+ token budget. `mdss stats` and `mdss check` expose the same adapter fields for
547
+ an existing index, plus its `adapterFingerprint`.
548
+
549
+ **A raw Hugging Face id that is not a registered adapter is rejected at embed
550
+ time β€” it does NOT silently inherit E5 prefixes and mean pooling.** Use a
551
+ registered alias (or the explicit object descriptor below):
167
552
 
168
553
  ```bash
169
554
  mdss index --db ./docs --model "Xenova/multilingual-e5-small@abc123def"
@@ -173,23 +558,56 @@ Pinned ids invalidate the index too (the revision is part of the model key), so
173
558
  a `@revision` bump triggers a full rebuild. Custom ids should have quantized
174
559
  weights (a `model_quantized.onnx` file) in the repo.
175
560
 
561
+ Library consumers adding a one-off model can pass an explicit adapter object to
562
+ `buildIndex`/`search` instead of editing the registry:
563
+
564
+ ```js
565
+ import { buildIndex } from 'md-semantic-search';
566
+ await buildIndex({
567
+ db: './docs', indexDir: './.mdss',
568
+ modelName: {
569
+ id: 'Xenova/some-embedding-model',
570
+ nativeDim: 384,
571
+ queryPrefix: 'query: ',
572
+ passagePrefix: 'passage: ',
573
+ pooling: 'mean',
574
+ normalize: true,
575
+ },
576
+ });
577
+ ```
578
+
579
+ Without `queryPrefix`/`passagePrefix`/`pooling`/`dim`, a raw id is still treated
580
+ as an unconfigured adapter and indexing fails with a message pointing at this
581
+ section instead of silently producing wrong vectors.
582
+
176
583
  ## How it works
177
584
 
178
585
  1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
179
586
  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.
587
+ 2. **Chunk** each file by Markdown headings while retaining the active heading
588
+ stack; oversized sections split on blank lines (~1400 body chars/chunk).
589
+ 3. **Embed** the document title, full heading path, and chunk body (`passage:`
590
+ prefix for E5) β†’ store `{file, title, heading, headingPath, text, vec}` in
591
+ `vectors.json`, plus per-file md5 in `.hashes.json`. The index stores an
592
+ adapter fingerprint so formatting, pooling, normalization, or dimension
593
+ changes invalidate vectors and checkpoints without invalidating lexical
594
+ records. Each chunk also stores
595
+ `chunkHash` (SHA-256 of that exact contextual passage + model identity), so a
596
+ parent rename invalidates its subtree while unchanged sibling branches reuse
597
+ their vectors.
598
+ 4. **Search**: embed the query with the selected model's formatter (`query:` for
599
+ E5, no prefix for BGE-M3, or the retrieval instruction for Qwen3), score every
600
+ chunk by cosine,
601
+ score query terms from the persisted BM25 postings, then **fuse with RRF**.
602
+ Legacy schema-v0/v1/v2 indexes retain exact token-overlap scoring.
189
603
 
190
604
  No external services, no database β€” the whole index is one JSON file and search
191
605
  is an in-memory dot-product sweep.
192
606
 
607
+ ## Integrations
608
+
609
+ - **Obsidian**: An official plugin is available in [`integrations/obsidian`](./integrations/obsidian) providing a live semantic search panel directly in your vault connected to `mdss serve`.
610
+
193
611
  ## License
194
612
 
195
613
  MIT Β© chelslava