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.
- package/README.md +438 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +931 -36
- package/dist/collapse.d.ts +6 -0
- package/dist/collapse.js +20 -0
- package/dist/core.d.ts +84 -0
- package/dist/core.js +479 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +150 -0
- package/dist/index-format.d.ts +23 -0
- package/dist/index-format.js +125 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +29 -0
- package/dist/indexer.d.ts +70 -0
- package/dist/indexer.js +452 -0
- package/dist/ivf.d.ts +33 -0
- package/dist/ivf.js +144 -0
- package/dist/lexical.d.ts +44 -0
- package/dist/lexical.js +226 -0
- package/dist/markdown-parser.d.ts +27 -0
- package/dist/markdown-parser.js +252 -0
- package/dist/mcp.d.ts +111 -0
- package/dist/mcp.js +216 -0
- package/dist/metrics.d.ts +43 -0
- package/dist/metrics.js +97 -0
- package/dist/models.d.ts +38 -0
- package/dist/models.js +181 -0
- package/dist/rerank.d.ts +10 -0
- package/dist/rerank.js +41 -0
- package/dist/search.d.ts +135 -0
- package/dist/search.js +492 -0
- package/dist/serve.d.ts +49 -0
- package/dist/serve.js +321 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +196 -0
- package/dist/wikilinks.d.ts +47 -0
- package/dist/wikilinks.js +171 -0
- package/package.json +25 -2
- package/src/collapse.ts +26 -0
- package/src/core.ts +546 -0
- package/src/frontmatter.ts +190 -0
- package/src/index-format.ts +151 -0
- package/src/index.ts +55 -0
- package/src/indexer.ts +585 -0
- package/src/ivf.ts +190 -0
- package/src/lexical.ts +257 -0
- package/src/markdown-parser.ts +303 -0
- package/src/mcp.ts +243 -0
- package/src/metrics.ts +135 -0
- package/src/models.ts +219 -0
- package/src/rerank.ts +54 -0
- package/src/search.ts +628 -0
- package/src/serve.ts +372 -0
- package/src/tui.ts +227 -0
- package/src/wikilinks.ts +244 -0
- package/src/core.mjs +0 -171
- package/src/indexer.mjs +0 -197
- package/src/models.mjs +0 -67
- 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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
| `--
|
|
124
|
-
| `--
|
|
125
|
-
| `--
|
|
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
|
-
(
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
181
|
-
lines (~1400 chars/chunk).
|
|
182
|
-
3. **Embed**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|