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 +31 -19
- package/bin/cli.mjs +9 -1
- package/package.json +2 -2
- package/src/core.mjs +0 -0
- package/src/indexer.mjs +102 -12
- package/src/models.mjs +14 -2
- package/src/search.mjs +18 -5
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
|
-
- ⚡ **
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
"@
|
|
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
|
|
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
|
|
12
|
-
|
|
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 {
|
|
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) {
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
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
|
|
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]));
|