md-semantic-search 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,10 @@ measurements that shaped its defaults.
36
36
  - 🌍 **Cross-lingual.** Multilingual embeddings (default `multilingual-e5-base`).
37
37
  - 🧠 **Hybrid ranking.** Reciprocal Rank Fusion of vector similarity (meaning)
38
38
  and lexical overlap (exact names like `win32`, `TextIOWrapper`).
39
- - ⚡ **Incremental.** Per-file md5 — re-indexing only re-embeds changed files.
39
+ - ⚡ **Two-level incremental.** Per-file md5 (unchanged files skip work) **plus**
40
+ per-chunk hash — inside a *changed* file, sections whose text is unchanged
41
+ reuse their stored vector. Editing one place in a long file re-embeds only the
42
+ affected section, not the whole file.
40
43
  - 🔒 **Private & offline.** Model downloads once, then no network. Nothing is
41
44
  uploaded anywhere.
42
45
  - 📦 **Zero infra.** One JSON index, brute-force cosine in memory. No Pinecone,
@@ -81,7 +84,14 @@ mdss index --db /path/to/your/markdown
81
84
 
82
85
  First run downloads the model (~280 MB). The index is written to `<db>/.mdss/`
83
86
  by default (override with `--index-dir`). Re-run after editing your notes — it's
84
- incremental, so only changed files are re-embedded.
87
+ incremental at two levels: unchanged files are skipped via their md5, and inside
88
+ a changed file only the sections whose text actually changed are re-embedded
89
+ (chunks carry a `chunkHash` in `vectors.json`; an append to a long log re-embeds
90
+ just the new entry). The index output reports the split, e.g.:
91
+
92
+ ```
93
+ Indexed 64 file(s) → 725 chunks (725 reused [5 chunk-level, 720 file-level], 0 embedded), ... 0.6s
94
+ ```
85
95
 
86
96
  ### 2. Search
87
97
 
@@ -113,6 +123,7 @@ Top 3 for: "how do I add a new translation language"
113
123
  | `--k <n>` | Number of results (default 6). |
114
124
  | `--json` | Machine-readable output. |
115
125
  | `--semantic` | Pure vector ranking, skip lexical fusion. |
126
+ | `--offline` | Never download the model — require a cached one (or `MDSS_OFFLINE=1`). |
116
127
 
117
128
  ### The base can live outside the project
118
129
 
@@ -147,7 +158,20 @@ mdss models
147
158
  | `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
148
159
 
149
160
  Switching models invalidates the stored vectors automatically — the next
150
- `index` run does a full rebuild. You can also pass any raw `Xenova/*` id.
161
+ `index` run does a full rebuild.
162
+
163
+ Weights are downloaded in quantized (q8) form when the model repo ships them
164
+ (all `Xenova/*` repos do — e5-base is ~280 MB, not ~1.1 GB fp32).
165
+
166
+ You can also pass any raw `Xenova/*` id, optionally pinning a revision:
167
+
168
+ ```bash
169
+ mdss index --db ./docs --model "Xenova/multilingual-e5-small@abc123def"
170
+ ```
171
+
172
+ Pinned ids invalidate the index too (the revision is part of the model key), so
173
+ a `@revision` bump triggers a full rebuild. Custom ids should have quantized
174
+ weights (a `model_quantized.onnx` file) in the repo.
151
175
 
152
176
  ## How it works
153
177
 
@@ -156,28 +180,16 @@ Switching models invalidates the stored vectors automatically — the next
156
180
  2. **Chunk** each file by Markdown headings; oversized sections split on blank
157
181
  lines (~1400 chars/chunk).
158
182
  3. **Embed** each chunk (`passage:` prefix for E5) → store `{file, heading,
159
- text, vec}` in `vectors.json`, plus per-file md5 in `.hashes.json`.
183
+ text, vec}` in `vectors.json`, plus per-file md5 in `.hashes.json`. Each chunk
184
+ also stores `chunkHash` (SHA-256 of the exact passage input + model identity),
185
+ so on re-index an unchanged section inside a modified file reuses its vector
186
+ instead of being embedded again.
160
187
  4. **Search**: embed the query (`query:` prefix), score every chunk by cosine,
161
188
  score by lexical term-overlap, then **fuse with RRF**. Return top-k chunks.
162
189
 
163
190
  No external services, no database — the whole index is one JSON file and search
164
191
  is an in-memory dot-product sweep.
165
192
 
166
- ## Releasing (maintainers)
167
-
168
- Publishing is automated by [`.github/workflows/publish.yml`](./.github/workflows/publish.yml):
169
- pushing a `vX.Y.Z` tag publishes that version to npm with provenance.
170
-
171
- One-time setup: add a repo secret `NPM_TOKEN` — an npm **automation / granular**
172
- token with *bypass 2FA* enabled (npmjs.com → Account → Access Tokens).
173
-
174
- ```bash
175
- npm version patch # bumps package.json + creates the git tag
176
- git push --follow-tags
177
- ```
178
-
179
- The workflow checks that the tag matches `package.json` before publishing.
180
-
181
193
  ## License
182
194
 
183
195
  MIT © chelslava
package/bin/cli.mjs CHANGED
@@ -32,6 +32,7 @@ function parseArgs(argv) {
32
32
  else if (a === '--ignore') opts.ignore.push(argv[++i]);
33
33
  else if (a === '--json') opts.json = true;
34
34
  else if (a === '--semantic') opts.semantic = true;
35
+ else if (a === '--offline') opts.offline = true;
35
36
  else if (a === '-h' || a === '--help') opts.help = true;
36
37
  else opts._.push(a);
37
38
  }
@@ -57,6 +58,9 @@ function resolveIndexDir(opts, db) {
57
58
  const resolveCache = (opts) =>
58
59
  path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || DEFAULT_CACHE);
59
60
 
61
+ /** --offline flag or MDSS_OFFLINE=1 env → never touch the network. */
62
+ const resolveOffline = (opts) => !!opts.offline || !!process.env.MDSS_OFFLINE;
63
+
60
64
  function die(msg) {
61
65
  process.stderr.write(`error: ${msg}\n`);
62
66
  process.exit(1);
@@ -78,6 +82,7 @@ Options:
78
82
  --k <n> Number of results (search, default 6).
79
83
  --json Machine-readable output (search).
80
84
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
85
+ --offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
81
86
  -h, --help Show this help.
82
87
 
83
88
  Examples:
@@ -96,12 +101,14 @@ async function cmdIndex(opts) {
96
101
  db, indexDir, cacheDir,
97
102
  modelName: opts.model || DEFAULT_MODEL,
98
103
  ignore: opts.ignore,
104
+ offline: resolveOffline(opts),
99
105
  log: s => process.stderr.write(s + '\n'),
100
106
  });
101
107
  const secs = ((Date.now() - t0) / 1000).toFixed(1);
102
108
  process.stderr.write(
103
109
  `\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
104
- `(${r.reused} reused, ${r.embedded} embedded), dim=${r.dim}, ` +
110
+ `(${r.reused} reused [${r.reusedChunks} chunk-level, ${r.reusedFiles} file-level], ` +
111
+ `${r.embedded} embedded), dim=${r.dim}, ` +
105
112
  `model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
106
113
  );
107
114
  }
@@ -117,6 +124,7 @@ async function cmdSearch(opts) {
117
124
  indexDir, cacheDir, query,
118
125
  k: opts.k || 6,
119
126
  semanticOnly: !!opts.semantic,
127
+ offline: resolveOffline(opts),
120
128
  });
121
129
 
122
130
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-semantic-search",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,6 @@
43
43
  "url": "https://github.com/chelslava/md-semantic-search/issues"
44
44
  },
45
45
  "dependencies": {
46
- "@xenova/transformers": "^2.17.2"
46
+ "@huggingface/transformers": "^4.2.0"
47
47
  }
48
48
  }
package/src/core.mjs CHANGED
Binary file
package/src/indexer.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Build / refresh the semantic index for a folder of markdown files.
3
- * Incremental: per-file md5; unchanged files reuse their stored embeddings.
3
+ * Incremental at TWO levels:
4
+ * 1. per-file md5 -> completely unchanged files reuse their chunks as-is;
5
+ * 2. per-chunk hash (chunkHash = SHA-256 of the exact passage input that
6
+ * goes to embed()) -> inside a *changed* file, sections whose embedding
7
+ * input is unchanged reuse their stored vector, so an append/edit in one
8
+ * place of a long file no longer re-embeds all of its sections.
4
9
  */
5
10
  import fs from 'node:fs';
6
11
  import path from 'node:path';
@@ -8,10 +13,62 @@ import crypto from 'node:crypto';
8
13
  import { walkMarkdown, parseFile, embed, resolveModel } from './core.mjs';
9
14
 
10
15
  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; }
16
+ const sha256 = s => crypto.createHash('sha256').update(s).digest('hex');
17
+
18
+ /**
19
+ * Read JSON, falling back to `fb` when the file is missing or corrupt.
20
+ * A corrupt-but-present file is reported through `warn` (default: silent) so a
21
+ * torn write or manual edit doesn't silently wipe the previous index state.
22
+ */
23
+ const loadJSON = (p, fb, warn = () => {}) => {
24
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
25
+ catch (e) {
26
+ if (e.code !== 'ENOENT') {
27
+ warn(`warning: ${path.basename(p)} is not valid JSON (${e.message}); ` +
28
+ 'rebuilding it from scratch.');
29
+ }
30
+ return fb;
31
+ }
13
32
  };
14
33
 
34
+ /**
35
+ * Atomic write: dump to a temp file in the SAME directory, then rename over the
36
+ * target. Rename is atomic on POSIX and on NTFS, so a crash mid-write can never
37
+ * leave a truncated vectors.json that the next run would parse as "corrupt".
38
+ */
39
+ function atomicWrite(target, data) {
40
+ const tmp = `${target}.${process.pid}.tmp`;
41
+ fs.writeFileSync(tmp, data);
42
+ fs.renameSync(tmp, target);
43
+ }
44
+
45
+ /** Normalize text for stable hashing across runs: CRLF -> LF, trim edges. */
46
+ function normalize(s) {
47
+ return (s ?? '').replace(/\r\n?/g, '\n').trim();
48
+ }
49
+
50
+ /**
51
+ * Stable per-chunk hash: SHA-256 over the exact passage input that is passed
52
+ * to embed() (`title\nheading\ntext`) plus the model identity and its passage
53
+ * prefix. Because the model participates in the key, switching models
54
+ * invalidates every chunk (full rebuild) - by design. CRLF/whitespace-only
55
+ * edits of a section do NOT change its hash, so its vector is reused.
56
+ * @param {{id:string, passagePrefix?:string}} model - resolved model descriptor
57
+ * @param {string} modelName - alias or id as passed on the CLI
58
+ * @param {{title:string, heading:string, text:string}} chunk
59
+ */
60
+ export function chunkHash(model, modelName, chunk) {
61
+ const input = [
62
+ model.id,
63
+ modelName || '',
64
+ model.passagePrefix || '',
65
+ normalize(chunk.title),
66
+ normalize(chunk.heading),
67
+ normalize(chunk.text),
68
+ ].join('\u0000');
69
+ return sha256(input);
70
+ }
71
+
15
72
  /**
16
73
  * @param {object} opts
17
74
  * @param {string} opts.db - base dir of .md files (absolute)
@@ -19,10 +76,16 @@ const loadJSON = (p, fb) => {
19
76
  * @param {string} opts.cacheDir - model cache dir
20
77
  * @param {string} opts.modelName - model alias or id
21
78
  * @param {string[]} opts.ignore - glob patterns to skip
79
+ * @param {boolean} [opts.offline=false] - never download the model; require a cached one
22
80
  * @param {(s:string)=>void} [opts.log]
81
+ * @param {Function} [opts.embedFn] - embed override for tests/dependency injection;
82
+ * signature (texts, kind, model, cacheDir, offline) => Promise<number[][]>
23
83
  */
24
84
  export async function buildIndex(opts) {
25
- const { db, indexDir, cacheDir, modelName, ignore = [], log = () => {} } = opts;
85
+ const {
86
+ db, indexDir, cacheDir, modelName, ignore = [], log = () => {},
87
+ offline = false, embedFn = embed,
88
+ } = opts;
26
89
  const model = resolveModel(modelName);
27
90
  const vectorsPath = path.join(indexDir, 'vectors.json');
28
91
  const hashesPath = path.join(indexDir, '.hashes.json');
@@ -30,24 +93,31 @@ export async function buildIndex(opts) {
30
93
  fs.mkdirSync(indexDir, { recursive: true });
31
94
 
32
95
  const files = walkMarkdown(db, ignore);
33
- const oldHashes = loadJSON(hashesPath, {});
34
- const oldIndex = loadJSON(vectorsPath, { chunks: [], model: null });
96
+ const oldHashes = loadJSON(hashesPath, {}, log);
97
+ const oldIndex = loadJSON(vectorsPath, { chunks: [], model: null }, log);
35
98
 
36
99
  // If the model changed, all stored vectors are incompatible → full rebuild.
37
100
  const modelChanged = oldIndex.model && oldIndex.model !== model.id;
38
101
 
39
102
  const oldByFile = new Map();
103
+ // chunk-level cache: hash of the passage input -> stored vector. Built from
104
+ // the OLD index so changed files can reuse vectors of unchanged sections.
105
+ // Old chunks (from a pre-chunkHash index) get their hash computed the same
106
+ // way, so reuse works even on the first run after an upgrade.
107
+ const vecByChunkHash = new Map();
40
108
  if (!modelChanged) {
41
109
  for (const c of oldIndex.chunks) {
42
110
  if (!oldByFile.has(c.file)) oldByFile.set(c.file, []);
43
111
  oldByFile.get(c.file).push(c);
112
+ const key = c.chunkHash || chunkHash(model, modelName, c);
113
+ if (c.vec) vecByChunkHash.set(key, c.vec);
44
114
  }
45
115
  }
46
116
 
47
117
  const newHashes = {};
48
118
  const chunks = [];
49
119
  const toEmbed = [];
50
- let reused = 0, changedFiles = 0;
120
+ let reused = 0, reusedChunks = 0, changedFiles = 0;
51
121
 
52
122
  for (const abs of files) {
53
123
  const raw = fs.readFileSync(abs, 'utf8');
@@ -57,13 +127,31 @@ export async function buildIndex(opts) {
57
127
 
58
128
  if (!modelChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
59
129
  const old = oldByFile.get(rel);
130
+ // Backfill chunkHash for chunks that predate the chunk-level cache, so
131
+ // the persisted index is self-contained and no recompute is needed later.
132
+ for (const c of old) {
133
+ if (!c.chunkHash) c.chunkHash = chunkHash(model, modelName, c);
134
+ }
60
135
  chunks.push(...old);
61
136
  reused += old.length;
62
137
  continue;
63
138
  }
64
139
  changedFiles++;
65
140
  const parsed = parseFile(abs, db);
66
- for (const c of parsed) { chunks.push(c); toEmbed.push(c); }
141
+ for (const c of parsed) {
142
+ const key = chunkHash(model, modelName, c);
143
+ const cached = vecByChunkHash.get(key);
144
+ if (cached) {
145
+ c.vec = cached;
146
+ c.chunkHash = key;
147
+ reused++;
148
+ reusedChunks++;
149
+ } else {
150
+ c.chunkHash = key;
151
+ toEmbed.push(c);
152
+ }
153
+ chunks.push(c);
154
+ }
67
155
  }
68
156
 
69
157
  if (toEmbed.length > 0) {
@@ -72,9 +160,9 @@ export async function buildIndex(opts) {
72
160
  const BATCH = 32;
73
161
  for (let i = 0; i < toEmbed.length; i += BATCH) {
74
162
  const slice = toEmbed.slice(i, i + BATCH);
75
- const vecs = await embed(
163
+ const vecs = await embedFn(
76
164
  slice.map(c => `${c.title}\n${c.heading}\n${c.text}`),
77
- 'passage', model, cacheDir,
165
+ 'passage', model, cacheDir, offline,
78
166
  );
79
167
  slice.forEach((c, j) => { c.vec = vecs[j]; });
80
168
  log(` ${Math.min(i + BATCH, toEmbed.length)}/${toEmbed.length}`);
@@ -92,13 +180,15 @@ export async function buildIndex(opts) {
92
180
  chunks,
93
181
  };
94
182
 
95
- fs.writeFileSync(vectorsPath, JSON.stringify(index));
96
- fs.writeFileSync(hashesPath, JSON.stringify(newHashes, null, 2));
183
+ atomicWrite(vectorsPath, JSON.stringify(index));
184
+ atomicWrite(hashesPath, JSON.stringify(newHashes, null, 2));
97
185
 
98
186
  return {
99
187
  files: files.length,
100
188
  chunks: chunks.length,
101
189
  reused,
190
+ reusedChunks,
191
+ reusedFiles: reused - reusedChunks, // via the file-level fast path
102
192
  embedded: toEmbed.length,
103
193
  dim,
104
194
  model: model.id,
package/src/models.mjs CHANGED
@@ -39,14 +39,26 @@ export const MODELS = {
39
39
 
40
40
  export const DEFAULT_MODEL = 'e5-base';
41
41
 
42
- /** Resolve a model alias OR a raw Xenova/... id into a model descriptor. */
42
+ /**
43
+ * Resolve a model alias OR a raw HF id into a model descriptor.
44
+ * A raw id may pin a revision with the `id@revision` syntax (defaults to
45
+ * `main`), e.g. `--model "Xenova/multilingual-e5-small@a1b2c3d"`.
46
+ */
43
47
  export function resolveModel(name) {
44
48
  if (!name) return MODELS[DEFAULT_MODEL];
45
49
  if (MODELS[name]) return MODELS[name];
46
50
  // Allow passing a raw HF/Xenova id; assume E5-style prefixes unless it's bge.
47
51
  const isBge = /bge/i.test(name);
52
+ let id = name;
53
+ let revision = 'main';
54
+ const at = name.indexOf('@');
55
+ if (at > 0) {
56
+ id = name.slice(0, at);
57
+ revision = name.slice(at + 1) || 'main';
58
+ }
48
59
  return {
49
- id: name,
60
+ id,
61
+ revision,
50
62
  dim: 0,
51
63
  queryPrefix: isBge ? '' : 'query: ',
52
64
  passagePrefix: isBge ? '' : 'passage: ',
package/src/search.mjs CHANGED
@@ -16,12 +16,12 @@ const STOP = new Set([
16
16
  'под', 'так', 'его', 'нет', 'код', 'кода', 'есть',
17
17
  ]);
18
18
 
19
- function tokenize(text) {
19
+ export function tokenize(text) {
20
20
  const m = text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu);
21
21
  return m ? m.filter(t => !STOP.has(t)) : [];
22
22
  }
23
23
 
24
- function keywordScores(chunks, query) {
24
+ export function keywordScores(chunks, query) {
25
25
  const qTerms = new Set(tokenize(query));
26
26
  return chunks.map(c => {
27
27
  const hay = `${c.title} ${c.heading} ${c.text}`.toLowerCase();
@@ -32,7 +32,7 @@ function keywordScores(chunks, query) {
32
32
  }
33
33
 
34
34
  /** Reciprocal Rank Fusion. rankings: arrays of {idx, score}; higher = better. */
35
- function rrf(rankings, k = 60) {
35
+ export function rrf(rankings, k = 60) {
36
36
  const fused = new Map();
37
37
  for (const ranking of rankings) {
38
38
  const sorted = [...ranking].sort((a, b) => b.score - a.score);
@@ -51,18 +51,31 @@ function rrf(rankings, k = 60) {
51
51
  * @param {string} opts.query
52
52
  * @param {number} [opts.k=6]
53
53
  * @param {boolean} [opts.semanticOnly=false]
54
+ * @param {boolean} [opts.offline=false] - never download the model; require a cached one
55
+ * @param {Function} [opts.embedFn] - embed override for tests/dependency injection;
56
+ * signature (texts, kind, model, cacheDir, offline) => Promise<number[][]>
54
57
  * @returns {Promise<Array>} results with file, title, heading, cosine, score, snippet
55
58
  */
56
59
  export async function search(opts) {
57
- const { indexDir, cacheDir, query, k = 6, semanticOnly = false } = opts;
60
+ const {
61
+ indexDir, cacheDir, query, k = 6, semanticOnly = false,
62
+ offline = false, embedFn = embed,
63
+ } = opts;
58
64
  const vectorsPath = path.join(indexDir, 'vectors.json');
59
65
  if (!fs.existsSync(vectorsPath)) {
60
66
  throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
61
67
  }
62
68
  const index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
69
+
70
+ // Validate the model the index was built with. Indexes written by v0.1.x have
71
+ // no "model" field at all — warn instead of silently assuming a default.
72
+ if (!index.model && !index.modelAlias) {
73
+ process.stderr.write('warning: index has no "model" field (built by an old ' +
74
+ 'version); assuming the default model. Re-run `mdss index` to refresh.\n');
75
+ }
63
76
  const model = resolveModel(index.modelAlias || index.model);
64
77
 
65
- const [qVec] = await embed([query], 'query', model, cacheDir);
78
+ const [qVec] = await embedFn([query], 'query', model, cacheDir, offline);
66
79
 
67
80
  const semantic = index.chunks.map((c, idx) => ({ idx, score: cosine(qVec, c.vec) }));
68
81
  const cosByIdx = new Map(semantic.map(s => [s.idx, s.score]));