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