md-semantic-search 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chelslava
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # md-semantic-search
2
+
3
+ **Local, private semantic (vector) search over any folder of Markdown files.**
4
+
5
+ Find passages by *meaning*, not just keywords — and across languages (ask in
6
+ one language, match documents written in another). Runs fully on your machine
7
+ via [transformers.js](https://github.com/xenova/transformers.js): no API keys,
8
+ no cloud calls, no vector database. Your notes never leave the disk.
9
+
10
+ ```bash
11
+ npx md-semantic-search index --db ./docs
12
+ npx md-semantic-search search --db ./docs "how do I rotate the API token"
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Why
18
+
19
+ Keyword search misses paraphrases. A query like *"как починить зависший ввод на
20
+ windows"* will never match a page titled *"win32 stdin re-wrap closes buffer"* —
21
+ zero shared words, different language. Semantic search embeds both into the same
22
+ vector space and matches on meaning. This tool was extracted from a real wiki
23
+ where exactly that gap kept biting; see **[RESEARCH.md](./RESEARCH.md)** for the
24
+ measurements that shaped its defaults.
25
+
26
+ ## Features
27
+
28
+ - 🔌 **Any folder, anywhere.** Point `--db` at any directory of `.md`/`.markdown`
29
+ files. It does **not** have to live inside this project. Recursive by default.
30
+ - 🌍 **Cross-lingual.** Multilingual embeddings (default `multilingual-e5-base`).
31
+ - 🧠 **Hybrid ranking.** Reciprocal Rank Fusion of vector similarity (meaning)
32
+ and lexical overlap (exact names like `win32`, `TextIOWrapper`).
33
+ - ⚡ **Incremental.** Per-file md5 — re-indexing only re-embeds changed files.
34
+ - 🔒 **Private & offline.** Model downloads once, then no network. Nothing is
35
+ uploaded anywhere.
36
+ - 📦 **Zero infra.** One JSON index, brute-force cosine in memory. No Pinecone,
37
+ no Qdrant, no pgvector. Scales fine to thousands of chunks.
38
+
39
+ ## Requirements
40
+
41
+ - Node.js ≥ 18
42
+ - ~280 MB disk for the default model (downloaded once into a cache dir)
43
+
44
+ ## Install
45
+
46
+ Run on demand with `npx` (no install):
47
+
48
+ ```bash
49
+ npx md-semantic-search --help
50
+ ```
51
+
52
+ Or install globally for the short `mdss` alias:
53
+
54
+ ```bash
55
+ npm install -g md-semantic-search
56
+ mdss --help
57
+ ```
58
+
59
+ Or from source:
60
+
61
+ ```bash
62
+ git clone https://github.com/chelslava/md-semantic-search
63
+ cd md-semantic-search
64
+ npm install
65
+ node bin/cli.mjs --help
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ ### 1. Build the index
71
+
72
+ ```bash
73
+ mdss index --db /path/to/your/markdown
74
+ ```
75
+
76
+ First run downloads the model (~280 MB). The index is written to `<db>/.mdss/`
77
+ by default (override with `--index-dir`). Re-run after editing your notes — it's
78
+ incremental, so only changed files are re-embedded.
79
+
80
+ ### 2. Search
81
+
82
+ ```bash
83
+ mdss search --db /path/to/your/markdown "your question in plain language"
84
+ ```
85
+
86
+ Example output:
87
+
88
+ ```
89
+ Top 3 for: "how do I add a new translation language"
90
+
91
+ 1. [cos 0.833] i18n Application Analysis
92
+ i18n-analysis.md › Language status
93
+ | Language | File | Status | ... English | en/shared.json | complete | ...
94
+
95
+ 2. ...
96
+ ```
97
+
98
+ ### Options
99
+
100
+ | Flag | Meaning |
101
+ |------|---------|
102
+ | `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
103
+ | `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
104
+ | `--cache-dir <dir>` | Model cache dir (default: package `.cache`, or `MDSS_CACHE_DIR`). |
105
+ | `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
106
+ | `--ignore <glob>` | Skip files/paths; repeatable. e.g. `--ignore "log.md" --ignore "**/archive/**"`. |
107
+ | `--k <n>` | Number of results (default 6). |
108
+ | `--json` | Machine-readable output. |
109
+ | `--semantic` | Pure vector ranking, skip lexical fusion. |
110
+
111
+ ### The base can live outside the project
112
+
113
+ The index does not need write access to your notes if they're read-only — just
114
+ point the index somewhere writable:
115
+
116
+ ```bash
117
+ mdss index --db /mnt/shared/team-wiki --index-dir ~/.cache/team-wiki-index
118
+ mdss search --db /mnt/shared/team-wiki --index-dir ~/.cache/team-wiki-index "incident runbook for db failover"
119
+ ```
120
+
121
+ Or drive everything from environment variables:
122
+
123
+ ```bash
124
+ export MDSS_DB=/mnt/shared/team-wiki
125
+ export MDSS_INDEX_DIR=~/.cache/team-wiki-index
126
+ mdss index
127
+ mdss search "incident runbook for db failover"
128
+ ```
129
+
130
+ ## Models
131
+
132
+ ```bash
133
+ mdss models
134
+ ```
135
+
136
+ | Alias | Model | Dim | Notes |
137
+ |-------|-------|-----|-------|
138
+ | `e5-small` | `Xenova/multilingual-e5-small` | 384 | Fastest (~120 MB). **Weak cross-lingual** — see RESEARCH. |
139
+ | `e5-base` ⭐ | `Xenova/multilingual-e5-base` | 768 | Default. Best balance. |
140
+ | `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
141
+ | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
142
+
143
+ Switching models invalidates the stored vectors automatically — the next
144
+ `index` run does a full rebuild. You can also pass any raw `Xenova/*` id.
145
+
146
+ ## How it works
147
+
148
+ 1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
149
+ globs skipped).
150
+ 2. **Chunk** each file by Markdown headings; oversized sections split on blank
151
+ lines (~1400 chars/chunk).
152
+ 3. **Embed** each chunk (`passage:` prefix for E5) → store `{file, heading,
153
+ text, vec}` in `vectors.json`, plus per-file md5 in `.hashes.json`.
154
+ 4. **Search**: embed the query (`query:` prefix), score every chunk by cosine,
155
+ score by lexical term-overlap, then **fuse with RRF**. Return top-k chunks.
156
+
157
+ No external services, no database — the whole index is one JSON file and search
158
+ is an in-memory dot-product sweep.
159
+
160
+ ## License
161
+
162
+ MIT © chelslava
package/RESEARCH.md ADDED
@@ -0,0 +1,152 @@
1
+ # RESEARCH — design notes & measurements
2
+
3
+ This tool's defaults aren't guesses. They come from building semantic search
4
+ over a real, bilingual (Russian/English) engineering wiki and measuring what
5
+ actually worked. This document records those experiments so the choices are
6
+ auditable and reproducible.
7
+
8
+ > **TL;DR**
9
+ > - For a small corpus you do **not** need a vector database — brute-force
10
+ > cosine over a JSON file is microseconds.
11
+ > - **`multilingual-e5-small` is not good enough for cross-lingual search.** It
12
+ > ranked an irrelevant page *above* the correct one. `e5-base` fixed it.
13
+ > - **Quantization is not the problem** people assume — int8 vs fp32 changed
14
+ > cosine scores by <0.003 and never changed the ranking.
15
+ > - **Hybrid (vector + keyword via RRF)** beats either alone: vectors catch
16
+ > paraphrases, keywords catch exact identifiers.
17
+
18
+ ---
19
+
20
+ ## 1. The problem
21
+
22
+ The starting point was a keyword-only wiki search (substring + tag matching).
23
+ It failed on the most common real query shape: a paraphrase, often in a
24
+ different language than the document.
25
+
26
+ Concrete failure: the query
27
+
28
+ > «как починить зависший ввод консоли на windows»
29
+ > *(how to fix frozen console input on windows)*
30
+
31
+ should surface a page documenting a Windows `stdin` bridge bug
32
+ (*"win32 stdin re-wrap closes the shared buffer"*). Keyword search returns
33
+ **nothing** — there is not one shared token, and the document is in English
34
+ while the query is in Russian.
35
+
36
+ ## 2. Corpus scale — why no vector DB
37
+
38
+ The wiki under test:
39
+
40
+ | Metric | Value |
41
+ |--------|-------|
42
+ | Pages | 46 markdown files |
43
+ | Raw size | 238 KB (~140,800 chars ≈ 35K tokens) |
44
+ | Chunks (by heading) | 220 |
45
+ | Vector dim (e5-base) | 768 |
46
+
47
+ 220 × 768 floats is a ~1.5 MB JSON file. A full cosine sweep over it is well
48
+ under a millisecond. **Pinecone / Qdrant / Chroma / pgvector would be pure
49
+ overhead here.** The entire index is one file; search is an in-memory
50
+ dot-product loop. This holds comfortably into the low tens of thousands of
51
+ chunks before you'd want an ANN index.
52
+
53
+ ## 3. Experiment A — does quantization hurt?
54
+
55
+ transformers.js loads **int8-quantized** ONNX weights by default. A common
56
+ worry is that quantization collapses the embedding space. We tested the same
57
+ query against three passages, quantized vs full fp32 (`quantized: false`):
58
+
59
+ Query: «редактор кода съедает пробелы при наборе» *(code editor eats spaces while typing)*
60
+
61
+ | Passage | quantized (int8) | full (fp32) |
62
+ |---------|------------------:|------------:|
63
+ | win32 stdin re-wrap closes the shared buffer | 0.816 | 0.818 |
64
+ | Monaco editor drops every space while typing | 0.806 | 0.806 |
65
+ | Excel library writes cells / formats workbooks | 0.779 | 0.767 |
66
+
67
+ **Conclusion:** differences are ≤ 0.003 and the **ranking is identical**.
68
+ Quantization is not worth disabling — you'd pay 4× the download and memory for
69
+ no ranking change. (Note the *other* problem visible here, addressed next: the
70
+ irrelevant `win32` passage outscores the correct `Monaco` one — that's the
71
+ model, not the quantization.)
72
+
73
+ ## 4. Experiment B — model choice decides cross-lingual quality
74
+
75
+ Same query («редактор кода съедает пробелы при наборе»), where the **correct**
76
+ answer is the Monaco "spaces" passage. We compared three multilingual models.
77
+ A model passes only if it ranks Monaco **above** the unrelated passages.
78
+
79
+ | Model | win32 stdin | **Monaco (correct)** | Excel | Correct #1? | Margin over runner-up |
80
+ |-------|------------:|---------------------:|------:|:-----------:|----------------------:|
81
+ | `multilingual-e5-small` | **0.818** | 0.806 | 0.767 | ❌ no | −0.012 |
82
+ | `multilingual-e5-base` | 0.784 | **0.810** | 0.741 | ✅ yes | +0.026 |
83
+ | `bge-m3` | 0.700 | **0.765** | 0.664 | ✅ yes | **+0.065** |
84
+
85
+ **Findings:**
86
+
87
+ - **`e5-small` fails.** It puts the unrelated `win32` passage *above* the
88
+ correct one. On a real corpus this means the right page never reaches the top.
89
+ Its cosine scores also sit in a narrow band (~0.77–0.82 for everything),
90
+ i.e. it barely separates documents.
91
+ - **`e5-base` fixes the ordering** at ~280 MB and stays fast — chosen as the
92
+ **default**.
93
+ - **`bge-m3` gives the widest separation** (+0.065 margin), the best quality,
94
+ but costs ~2.3 GB and is slower. Offered as an opt-in for quality-critical use.
95
+
96
+ This is why the default is `e5-base`, not the tempting lightweight `e5-small`.
97
+
98
+ ## 5. Design decisions that follow
99
+
100
+ ### E5 prefixes are mandatory
101
+ The E5 family is trained with instruction prefixes: documents must be embedded
102
+ as `passage: <text>` and queries as `query: <text>`. Omitting them measurably
103
+ degrades retrieval. The model registry encodes this per-model, because
104
+ **`bge-m3` is the opposite — it wants no prefix at all.** Getting this wrong is
105
+ a silent quality bug.
106
+
107
+ ### Chunk by heading, not by page
108
+ Embedding a whole multi-section page into one vector blurs distinct topics.
109
+ We split on Markdown headings (`#`..`######`); sections over ~1400 chars split
110
+ again on blank lines. Search then returns the relevant *section*, with its
111
+ heading, not just "the page where the word appears somewhere."
112
+
113
+ ### Hybrid ranking via Reciprocal Rank Fusion
114
+ Pure vectors miss exact identifiers (`win32`, `TextIOWrapper`, error codes);
115
+ pure keywords miss paraphrases. We compute two rankings — cosine and lexical
116
+ term-overlap — and fuse them with RRF (`score = Σ 1/(k + rank)`, `k=60`). RRF
117
+ needs no score normalization or weight tuning, which is what makes it robust
118
+ across very different score scales.
119
+
120
+ ### Stop-words protect the lexical lane
121
+ Cross-lingual queries share function words ("при", "the", "кода") with
122
+ irrelevant documents, letting lexical overlap promote noise. A small ru/en
123
+ stop-list is removed before lexical scoring so RRF fuses signal, not filler.
124
+
125
+ ### Incremental by content hash
126
+ Each file's md5 is stored. Re-indexing reuses embeddings for unchanged files
127
+ and only re-embeds what changed — a no-op re-index of 46 files is sub-second.
128
+ Changing the model invalidates all vectors and forces a clean rebuild
129
+ (dimensions and prefix semantics differ).
130
+
131
+ ## 6. Reproducing
132
+
133
+ ```bash
134
+ # Build with the default, then with the heavy model, and compare a known query:
135
+ mdss index --db ./your-wiki
136
+ mdss search --db ./your-wiki --semantic "a paraphrase of something you know is in there"
137
+
138
+ mdss index --db ./your-wiki --model bge-m3
139
+ mdss search --db ./your-wiki --semantic "the same paraphrase"
140
+ ```
141
+
142
+ Use `--semantic` to see the raw vector ranking (no lexical fusion) when
143
+ evaluating a model — that isolates embedding quality from the keyword lane.
144
+
145
+ ## 7. Recommendations
146
+
147
+ | If you want… | Use |
148
+ |--------------|-----|
149
+ | Sensible default, fast, ~280 MB | `e5-base` (default) |
150
+ | Maximum cross-lingual quality, disk/time no object | `bge-m3` |
151
+ | Smallest footprint and your queries are same-language & literal | `e5-small` (with eyes open) |
152
+ | Best precision on exact identifiers | keep hybrid on (don't pass `--semantic`) |
package/bin/cli.mjs ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * md-semantic-search (mdss) — local semantic search over any folder of .md files.
4
+ *
5
+ * mdss index --db <dir> [--model e5-base] [--index-dir <dir>] [--ignore <glob>]
6
+ * mdss search --db <dir> [--k 6] [--json] [--semantic] "query text"
7
+ * mdss models
8
+ *
9
+ * The markdown base (--db) can live anywhere on disk — it does NOT need to be
10
+ * inside this project. The index defaults to <db>/.mdss unless --index-dir is given.
11
+ */
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import fs from 'node:fs';
15
+ import { buildIndex } from '../src/indexer.mjs';
16
+ import { search } from '../src/search.mjs';
17
+ import { MODELS, DEFAULT_MODEL } from '../src/models.mjs';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const PKG_ROOT = path.resolve(HERE, '..');
21
+ const DEFAULT_CACHE = path.join(PKG_ROOT, '.cache');
22
+
23
+ function parseArgs(argv) {
24
+ const opts = { _: [], ignore: [] };
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a === '--db') opts.db = argv[++i];
28
+ else if (a === '--index-dir') opts.indexDir = argv[++i];
29
+ else if (a === '--cache-dir') opts.cacheDir = argv[++i];
30
+ else if (a === '--model') opts.model = argv[++i];
31
+ else if (a === '--k') opts.k = parseInt(argv[++i], 10);
32
+ else if (a === '--ignore') opts.ignore.push(argv[++i]);
33
+ else if (a === '--json') opts.json = true;
34
+ else if (a === '--semantic') opts.semantic = true;
35
+ else if (a === '-h' || a === '--help') opts.help = true;
36
+ else opts._.push(a);
37
+ }
38
+ return opts;
39
+ }
40
+
41
+ function resolveDb(opts) {
42
+ const db = opts.db || process.env.MDSS_DB;
43
+ if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
44
+ const abs = path.resolve(db);
45
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
46
+ die(`--db is not a directory: ${abs}`);
47
+ }
48
+ return abs;
49
+ }
50
+
51
+ function resolveIndexDir(opts, db) {
52
+ // Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
53
+ const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
54
+ return path.resolve(dir);
55
+ }
56
+
57
+ const resolveCache = (opts) =>
58
+ path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || DEFAULT_CACHE);
59
+
60
+ function die(msg) {
61
+ process.stderr.write(`error: ${msg}\n`);
62
+ process.exit(1);
63
+ }
64
+
65
+ const HELP = `md-semantic-search (mdss) — local, private semantic search over Markdown
66
+
67
+ Usage:
68
+ mdss index --db <dir> [options] Build/refresh the index
69
+ mdss search --db <dir> [options] "query" Search by meaning
70
+ mdss models List available models
71
+
72
+ Options:
73
+ --db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
74
+ --index-dir <dir> Where to store the index (default: <db>/.mdss).
75
+ --cache-dir <dir> Model cache dir (default: <pkg>/.cache or MDSS_CACHE_DIR).
76
+ --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
77
+ --ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
78
+ --k <n> Number of results (search, default 6).
79
+ --json Machine-readable output (search).
80
+ --semantic Pure vector ranking, skip lexical/RRF fusion (search).
81
+ -h, --help Show this help.
82
+
83
+ Examples:
84
+ mdss index --db ./docs
85
+ mdss index --db /abs/path/to/wiki --model bge-m3 --ignore "log.md" --ignore "**/archive/**"
86
+ mdss search --db ./docs "how do I rotate the api token"
87
+ MDSS_DB=./docs mdss search "rollback a failed migration" --k 8 --json
88
+ `;
89
+
90
+ async function cmdIndex(opts) {
91
+ const db = resolveDb(opts);
92
+ const indexDir = resolveIndexDir(opts, db);
93
+ const cacheDir = resolveCache(opts);
94
+ const t0 = Date.now();
95
+ const r = await buildIndex({
96
+ db, indexDir, cacheDir,
97
+ modelName: opts.model || DEFAULT_MODEL,
98
+ ignore: opts.ignore,
99
+ log: s => process.stderr.write(s + '\n'),
100
+ });
101
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
102
+ process.stderr.write(
103
+ `\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
104
+ `(${r.reused} reused, ${r.embedded} embedded), dim=${r.dim}, ` +
105
+ `model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
106
+ );
107
+ }
108
+
109
+ async function cmdSearch(opts) {
110
+ const db = resolveDb(opts);
111
+ const indexDir = resolveIndexDir(opts, db);
112
+ const cacheDir = resolveCache(opts);
113
+ const query = opts._.join(' ').trim();
114
+ if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
115
+
116
+ const results = await search({
117
+ indexDir, cacheDir, query,
118
+ k: opts.k || 6,
119
+ semanticOnly: !!opts.semantic,
120
+ });
121
+
122
+ if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
123
+ if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
124
+
125
+ process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
126
+ results.forEach((r, i) => {
127
+ const loc = r.heading ? `${r.file} › ${r.heading}` : r.file;
128
+ process.stdout.write(`${i + 1}. [cos ${r.cosine}] ${r.title}\n`);
129
+ process.stdout.write(` ${loc}\n`);
130
+ process.stdout.write(` ${r.snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
131
+ });
132
+ }
133
+
134
+ function cmdModels() {
135
+ process.stdout.write('Available models (alias → id):\n\n');
136
+ for (const [alias, m] of Object.entries(MODELS)) {
137
+ const star = alias === DEFAULT_MODEL ? ' (default)' : '';
138
+ process.stdout.write(` ${alias}${star}\n ${m.id} · dim ${m.dim}\n ${m.note}\n\n`);
139
+ }
140
+ process.stdout.write('You can also pass any raw Xenova/* model id to --model.\n');
141
+ }
142
+
143
+ async function main() {
144
+ const argv = process.argv.slice(2);
145
+ const opts = parseArgs(argv);
146
+ const cmd = opts._.shift();
147
+
148
+ if (opts.help || !cmd) { process.stdout.write(HELP); return; }
149
+ switch (cmd) {
150
+ case 'index': return cmdIndex(opts);
151
+ case 'search': return cmdSearch(opts);
152
+ case 'models': return cmdModels();
153
+ default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
154
+ }
155
+ }
156
+
157
+ main().catch(e => die(e.message));
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "md-semantic-search",
3
+ "version": "0.1.0",
4
+ "description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
5
+ "type": "module",
6
+ "bin": {
7
+ "md-semantic-search": "bin/cli.mjs",
8
+ "mdss": "bin/cli.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md",
14
+ "RESEARCH.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "keywords": [
21
+ "semantic-search",
22
+ "vector-search",
23
+ "embeddings",
24
+ "markdown",
25
+ "rag",
26
+ "transformers",
27
+ "e5",
28
+ "local",
29
+ "offline",
30
+ "cli"
31
+ ],
32
+ "author": "chelslava",
33
+ "license": "MIT",
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/chelslava/md-semantic-search.git"
40
+ },
41
+ "homepage": "https://github.com/chelslava/md-semantic-search#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/chelslava/md-semantic-search/issues"
44
+ },
45
+ "dependencies": {
46
+ "@xenova/transformers": "^2.17.2"
47
+ }
48
+ }
package/src/core.mjs ADDED
Binary file
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Build / refresh the semantic index for a folder of markdown files.
3
+ * Incremental: per-file md5; unchanged files reuse their stored embeddings.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import crypto from 'node:crypto';
8
+ import { walkMarkdown, parseFile, embed, resolveModel } from './core.mjs';
9
+
10
+ const md5 = s => crypto.createHash('md5').update(s).digest('hex');
11
+ const loadJSON = (p, fb) => {
12
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fb; }
13
+ };
14
+
15
+ /**
16
+ * @param {object} opts
17
+ * @param {string} opts.db - base dir of .md files (absolute)
18
+ * @param {string} opts.indexDir - where vectors.json / .hashes.json live
19
+ * @param {string} opts.cacheDir - model cache dir
20
+ * @param {string} opts.modelName - model alias or id
21
+ * @param {string[]} opts.ignore - glob patterns to skip
22
+ * @param {(s:string)=>void} [opts.log]
23
+ */
24
+ export async function buildIndex(opts) {
25
+ const { db, indexDir, cacheDir, modelName, ignore = [], log = () => {} } = opts;
26
+ const model = resolveModel(modelName);
27
+ const vectorsPath = path.join(indexDir, 'vectors.json');
28
+ const hashesPath = path.join(indexDir, '.hashes.json');
29
+
30
+ fs.mkdirSync(indexDir, { recursive: true });
31
+
32
+ const files = walkMarkdown(db, ignore);
33
+ const oldHashes = loadJSON(hashesPath, {});
34
+ const oldIndex = loadJSON(vectorsPath, { chunks: [], model: null });
35
+
36
+ // If the model changed, all stored vectors are incompatible → full rebuild.
37
+ const modelChanged = oldIndex.model && oldIndex.model !== model.id;
38
+
39
+ const oldByFile = new Map();
40
+ if (!modelChanged) {
41
+ for (const c of oldIndex.chunks) {
42
+ if (!oldByFile.has(c.file)) oldByFile.set(c.file, []);
43
+ oldByFile.get(c.file).push(c);
44
+ }
45
+ }
46
+
47
+ const newHashes = {};
48
+ const chunks = [];
49
+ const toEmbed = [];
50
+ let reused = 0, changedFiles = 0;
51
+
52
+ for (const abs of files) {
53
+ const raw = fs.readFileSync(abs, 'utf8');
54
+ const rel = path.relative(db, abs).split(path.sep).join('/');
55
+ const h = md5(raw);
56
+ newHashes[rel] = h;
57
+
58
+ if (!modelChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
59
+ const old = oldByFile.get(rel);
60
+ chunks.push(...old);
61
+ reused += old.length;
62
+ continue;
63
+ }
64
+ changedFiles++;
65
+ const parsed = parseFile(abs, db);
66
+ for (const c of parsed) { chunks.push(c); toEmbed.push(c); }
67
+ }
68
+
69
+ if (toEmbed.length > 0) {
70
+ log(`Embedding ${toEmbed.length} chunks from ${changedFiles} changed file(s) ` +
71
+ `with ${model.id}...`);
72
+ const BATCH = 32;
73
+ for (let i = 0; i < toEmbed.length; i += BATCH) {
74
+ const slice = toEmbed.slice(i, i + BATCH);
75
+ const vecs = await embed(
76
+ slice.map(c => `${c.title}\n${c.heading}\n${c.text}`),
77
+ 'passage', model, cacheDir,
78
+ );
79
+ slice.forEach((c, j) => { c.vec = vecs[j]; });
80
+ log(` ${Math.min(i + BATCH, toEmbed.length)}/${toEmbed.length}`);
81
+ }
82
+ }
83
+
84
+ const dim = chunks[0]?.vec?.length ?? model.dim ?? 0;
85
+ const index = {
86
+ model: model.id,
87
+ modelAlias: modelName || 'e5-base',
88
+ dim,
89
+ db,
90
+ built: new Date().toISOString(),
91
+ chunkCount: chunks.length,
92
+ chunks,
93
+ };
94
+
95
+ fs.writeFileSync(vectorsPath, JSON.stringify(index));
96
+ fs.writeFileSync(hashesPath, JSON.stringify(newHashes, null, 2));
97
+
98
+ return {
99
+ files: files.length,
100
+ chunks: chunks.length,
101
+ reused,
102
+ embedded: toEmbed.length,
103
+ dim,
104
+ model: model.id,
105
+ vectorsPath,
106
+ };
107
+ }
package/src/models.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Embedding model registry.
3
+ *
4
+ * `prefix` matters: the E5 family is trained with instruction prefixes
5
+ * ("query: " / "passage: ") and ranks poorly without them. BGE-M3 is trained
6
+ * WITHOUT prefixes, so forcing them in would hurt. Each entry declares how to
7
+ * format inputs so the rest of the code stays model-agnostic.
8
+ */
9
+ export const MODELS = {
10
+ 'e5-small': {
11
+ id: 'Xenova/multilingual-e5-small',
12
+ dim: 384,
13
+ queryPrefix: 'query: ',
14
+ passagePrefix: 'passage: ',
15
+ note: 'Fastest, ~120MB. Weak cross-lingual ranking — see RESEARCH.md.',
16
+ },
17
+ 'e5-base': {
18
+ id: 'Xenova/multilingual-e5-base',
19
+ dim: 768,
20
+ queryPrefix: 'query: ',
21
+ passagePrefix: 'passage: ',
22
+ note: 'Default. ~280MB. Solid multilingual + cross-lingual balance.',
23
+ },
24
+ 'e5-large': {
25
+ id: 'Xenova/multilingual-e5-large',
26
+ dim: 1024,
27
+ queryPrefix: 'query: ',
28
+ passagePrefix: 'passage: ',
29
+ note: '~2.2GB. Higher quality, slower.',
30
+ },
31
+ 'bge-m3': {
32
+ id: 'Xenova/bge-m3',
33
+ dim: 1024,
34
+ queryPrefix: '',
35
+ passagePrefix: '',
36
+ note: '~2.3GB. Best cross-lingual separation in our tests; no prefixes.',
37
+ },
38
+ };
39
+
40
+ export const DEFAULT_MODEL = 'e5-base';
41
+
42
+ /** Resolve a model alias OR a raw Xenova/... id into a model descriptor. */
43
+ export function resolveModel(name) {
44
+ if (!name) return MODELS[DEFAULT_MODEL];
45
+ if (MODELS[name]) return MODELS[name];
46
+ // Allow passing a raw HF/Xenova id; assume E5-style prefixes unless it's bge.
47
+ const isBge = /bge/i.test(name);
48
+ return {
49
+ id: name,
50
+ dim: 0,
51
+ queryPrefix: isBge ? '' : 'query: ',
52
+ passagePrefix: isBge ? '' : 'passage: ',
53
+ note: 'Custom model id.',
54
+ };
55
+ }
package/src/search.mjs ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Hybrid semantic + lexical search over a prebuilt index.
3
+ * Ranking = Reciprocal Rank Fusion of cosine similarity (meaning) and
4
+ * term-overlap (exact names). The model is read from the index, so callers
5
+ * never have to repeat --model at search time.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { embed, cosine, resolveModel } from './core.mjs';
10
+
11
+ // Common ru/en function words — they match everywhere and pollute lexical scores.
12
+ const STOP = new Set([
13
+ 'the', 'and', 'for', 'are', 'was', 'has', 'with', 'this', 'that', 'from',
14
+ 'not', 'but', 'you', 'your', 'can', 'all', 'any', 'its',
15
+ 'все', 'как', 'что', 'это', 'при', 'для', 'или', 'был', 'без', 'над',
16
+ 'под', 'так', 'его', 'нет', 'код', 'кода', 'есть',
17
+ ]);
18
+
19
+ function tokenize(text) {
20
+ const m = text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu);
21
+ return m ? m.filter(t => !STOP.has(t)) : [];
22
+ }
23
+
24
+ function keywordScores(chunks, query) {
25
+ const qTerms = new Set(tokenize(query));
26
+ return chunks.map(c => {
27
+ const hay = `${c.title} ${c.heading} ${c.text}`.toLowerCase();
28
+ let s = 0;
29
+ for (const t of qTerms) if (hay.includes(t)) s++;
30
+ return s;
31
+ });
32
+ }
33
+
34
+ /** Reciprocal Rank Fusion. rankings: arrays of {idx, score}; higher = better. */
35
+ function rrf(rankings, k = 60) {
36
+ const fused = new Map();
37
+ for (const ranking of rankings) {
38
+ const sorted = [...ranking].sort((a, b) => b.score - a.score);
39
+ sorted.forEach((item, rank) => {
40
+ if (item.score <= 0) return;
41
+ fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + rank + 1));
42
+ });
43
+ }
44
+ return fused;
45
+ }
46
+
47
+ /**
48
+ * @param {object} opts
49
+ * @param {string} opts.indexDir
50
+ * @param {string} opts.cacheDir
51
+ * @param {string} opts.query
52
+ * @param {number} [opts.k=6]
53
+ * @param {boolean} [opts.semanticOnly=false]
54
+ * @returns {Promise<Array>} results with file, title, heading, cosine, score, snippet
55
+ */
56
+ export async function search(opts) {
57
+ const { indexDir, cacheDir, query, k = 6, semanticOnly = false } = opts;
58
+ const vectorsPath = path.join(indexDir, 'vectors.json');
59
+ if (!fs.existsSync(vectorsPath)) {
60
+ throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
61
+ }
62
+ const index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
63
+ const model = resolveModel(index.modelAlias || index.model);
64
+
65
+ const [qVec] = await embed([query], 'query', model, cacheDir);
66
+
67
+ const semantic = index.chunks.map((c, idx) => ({ idx, score: cosine(qVec, c.vec) }));
68
+ const cosByIdx = new Map(semantic.map(s => [s.idx, s.score]));
69
+
70
+ let ranked;
71
+ if (semanticOnly) {
72
+ ranked = [...semantic].sort((a, b) => b.score - a.score).slice(0, k)
73
+ .map(s => ({ idx: s.idx, fscore: s.score, cos: s.score }));
74
+ } else {
75
+ const kw = keywordScores(index.chunks, query).map((score, idx) => ({ idx, score }));
76
+ const fused = rrf([semantic, kw]);
77
+ ranked = [...fused.entries()]
78
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
79
+ .sort((a, b) => b.fscore - a.fscore)
80
+ .slice(0, k);
81
+ }
82
+
83
+ return ranked.map(r => {
84
+ const c = index.chunks[r.idx];
85
+ return {
86
+ file: c.file,
87
+ title: c.title,
88
+ heading: c.heading,
89
+ cosine: +r.cos.toFixed(3),
90
+ score: +r.fscore.toFixed(4),
91
+ snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
92
+ };
93
+ });
94
+ }