md-semantic-search 0.5.0 → 1.0.1
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 +44 -1
- package/bin/cli.mjs +121 -27
- package/dist/binary-format.d.ts +25 -0
- package/dist/binary-format.js +178 -0
- package/dist/core.d.ts +3 -2
- package/dist/core.js +23 -31
- package/dist/federation.d.ts +15 -0
- package/dist/federation.js +94 -0
- package/dist/filter.d.ts +29 -0
- package/dist/filter.js +317 -0
- package/dist/index-format.js +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -0
- package/dist/indexer.d.ts +13 -0
- package/dist/indexer.js +21 -7
- package/dist/markdown-parser.d.ts +1 -0
- package/dist/markdown-parser.js +71 -12
- package/dist/mcp.d.ts +54 -6
- package/dist/mcp.js +59 -11
- package/dist/quantization.d.ts +24 -0
- package/dist/quantization.js +85 -0
- package/dist/rag.d.ts +49 -0
- package/dist/rag.js +128 -0
- package/dist/search.d.ts +20 -6
- package/dist/search.js +175 -23
- package/dist/serve.js +21 -1
- package/dist/summarize.d.ts +16 -0
- package/dist/summarize.js +82 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +36 -13
- package/dist/watcher.d.ts +11 -0
- package/dist/watcher.js +130 -0
- package/dist/wikilinks.d.ts +22 -0
- package/dist/wikilinks.js +100 -0
- package/package.json +3 -1
- package/src/binary-format.ts +216 -0
- package/src/core.ts +42 -37
- package/src/federation.ts +118 -0
- package/src/filter.ts +368 -0
- package/src/index-format.ts +1 -1
- package/src/index.ts +39 -1
- package/src/indexer.ts +42 -10
- package/src/markdown-parser.ts +80 -10
- package/src/mcp.ts +57 -11
- package/src/quantization.ts +93 -0
- package/src/rag.ts +189 -0
- package/src/search.ts +202 -25
- package/src/serve.ts +22 -1
- package/src/summarize.ts +98 -0
- package/src/tui.ts +36 -12
- package/src/watcher.ts +149 -0
- package/src/wikilinks.ts +132 -0
package/README.md
CHANGED
|
@@ -419,13 +419,53 @@ const r = await fetch('http://localhost:8747/search', {
|
|
|
419
419
|
const { results } = await r.json();
|
|
420
420
|
```
|
|
421
421
|
|
|
422
|
-
The API is unauthenticated — by default it binds to **loopback only**
|
|
422
|
+
The API is unauthenticated by default (or secured via `--api-key`) — by default it binds to **loopback only**
|
|
423
423
|
(`127.0.0.1`), so other machines on your LAN cannot reach it. Pass `--host
|
|
424
424
|
0.0.0.0` (or `MDSS_HOST`) only if you intend to expose it on a network, and put
|
|
425
425
|
a reverse proxy (or at least a firewall rule) in front of it. Request bodies
|
|
426
426
|
are capped at 64 KB (oversized → `413`), and malformed JSON gets a clear `400`
|
|
427
427
|
instead of being silently swallowed.
|
|
428
428
|
|
|
429
|
+
### 4. MCP Server (Claude Desktop, Cursor, Copilot, Antigravity)
|
|
430
|
+
|
|
431
|
+
`md-semantic-search` provides a zero-dependency **Model Context Protocol (MCP)** server over standard I/O (JSON-RPC 2.0). This allows AI agents and IDEs to query your local notes privately by meaning without uploading text to external clouds.
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
mdss mcp --db /path/to/your/markdown
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### Available MCP Tools:
|
|
438
|
+
- **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob and `tag` filters.
|
|
439
|
+
- **`get_chunk`**: Retrieve a specific section by file and heading.
|
|
440
|
+
- **`list_files`**: List indexed Markdown files with chunk counts.
|
|
441
|
+
- **`index_status`**: Inspect index health, chunk counts, and model metadata.
|
|
442
|
+
|
|
443
|
+
#### Claude Desktop Configuration:
|
|
444
|
+
Add to your `claude_desktop_config.json`:
|
|
445
|
+
|
|
446
|
+
```json
|
|
447
|
+
{
|
|
448
|
+
"mcpServers": {
|
|
449
|
+
"md-semantic-search": {
|
|
450
|
+
"command": "npx",
|
|
451
|
+
"args": ["-y", "md-semantic-search", "mcp", "--db", "C:\\path\\to\\your\\notes"]
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
#### Cursor Configuration (`.cursor/mcp.json`):
|
|
458
|
+
```json
|
|
459
|
+
{
|
|
460
|
+
"mcpServers": {
|
|
461
|
+
"md-semantic-search": {
|
|
462
|
+
"command": "npx",
|
|
463
|
+
"args": ["-y", "md-semantic-search", "mcp", "--db", "./notes"]
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
429
469
|
### Options
|
|
430
470
|
|
|
431
471
|
| Flag | Meaning |
|
|
@@ -446,6 +486,8 @@ instead of being silently swallowed.
|
|
|
446
486
|
| `--json` | Machine-readable output: `index` → build result JSON, `stats` → index stats JSON, `search` → hit list JSON (each hit includes `matches` — the query terms found in the chunk). |
|
|
447
487
|
| `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
|
|
448
488
|
| `--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. |
|
|
489
|
+
| `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
|
|
490
|
+
| `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
|
|
449
491
|
| `--ann` | Approximate nearest neighbor search via IVF clustering (search; auto-enables for large corpora). |
|
|
450
492
|
| `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
|
|
451
493
|
| `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
|
|
@@ -607,6 +649,7 @@ is an in-memory dot-product sweep.
|
|
|
607
649
|
## Integrations
|
|
608
650
|
|
|
609
651
|
- **Obsidian**: An official plugin is available in [`integrations/obsidian`](./integrations/obsidian) providing a live semantic search panel directly in your vault connected to `mdss serve`.
|
|
652
|
+
- **Model Context Protocol (MCP)**: Native stdio JSON-RPC 2.0 server (`mdss mcp`) integrating with Claude Desktop, Cursor, Copilot, Antigravity, and AI agents for local knowledge retrieval.
|
|
610
653
|
|
|
611
654
|
## License
|
|
612
655
|
|
package/bin/cli.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import os from 'node:os';
|
|
|
17
17
|
import crypto from 'node:crypto';
|
|
18
18
|
import { buildIndex } from '../dist/indexer.js';
|
|
19
19
|
import { search } from '../dist/search.js';
|
|
20
|
+
import { searchFederated } from '../dist/federation.js';
|
|
20
21
|
import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
|
|
21
22
|
import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
|
|
22
23
|
import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
|
|
@@ -41,7 +42,7 @@ function defaultCacheDir() {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function parseArgs(argv) {
|
|
44
|
-
const opts = { _: [], ignore: [], path: [] };
|
|
45
|
+
const opts = { _: [], ignore: [], path: [], vaults: [] };
|
|
45
46
|
for (let i = 0; i < argv.length; i++) {
|
|
46
47
|
const a = argv[i];
|
|
47
48
|
if (a === '--json') opts.json = true;
|
|
@@ -60,6 +61,7 @@ function parseArgs(argv) {
|
|
|
60
61
|
else if (a === '--no-vectors' || a === '--no-vector') opts.noVectors = true;
|
|
61
62
|
else if (a === '--output' || a === '-o') opts.output = nextValue(argv, ++i, a);
|
|
62
63
|
else if (a === '--db') opts.db = nextValue(argv, ++i, a);
|
|
64
|
+
else if (a === '--vault') opts.vaults.push(nextValue(argv, ++i, a));
|
|
63
65
|
else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
|
|
64
66
|
else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
|
|
65
67
|
else if (a === '--model') opts.model = nextValue(argv, ++i, a);
|
|
@@ -86,6 +88,15 @@ function parseArgs(argv) {
|
|
|
86
88
|
else if (a === '--type') opts.type = nextValue(argv, ++i, a);
|
|
87
89
|
else if (a === '--status') opts.status = nextValue(argv, ++i, a);
|
|
88
90
|
else if (a === '--canonical') opts.canonical = true;
|
|
91
|
+
else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
|
|
92
|
+
else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
|
|
93
|
+
else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
|
|
94
|
+
else if (a === '--rag') opts.rag = true;
|
|
95
|
+
else if (a === '--auto-tag') opts.autoTag = true;
|
|
96
|
+
else if (a === '--auto-summarize') opts.autoSummarize = true;
|
|
97
|
+
else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
|
|
98
|
+
else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
|
|
99
|
+
else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
|
|
89
100
|
else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
|
|
90
101
|
else opts._.push(a);
|
|
91
102
|
}
|
|
@@ -107,13 +118,23 @@ function nextInt(argv, i, flag) {
|
|
|
107
118
|
return k;
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
/** Like nextValue but requires a non-negative float. */
|
|
122
|
+
function nextFloat(argv, i, flag) {
|
|
123
|
+
const v = nextValue(argv, i, flag);
|
|
124
|
+
const f = Number.parseFloat(v);
|
|
125
|
+
if (!Number.isFinite(f) || f < 0) die(`${flag} must be a non-negative number, got "${v}"`);
|
|
126
|
+
return f;
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
111
130
|
'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
|
|
112
131
|
'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
|
|
113
132
|
'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
|
|
114
133
|
'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
|
|
115
134
|
'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
|
|
116
|
-
'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe'
|
|
135
|
+
'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
|
|
136
|
+
'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
|
|
137
|
+
'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt'
|
|
117
138
|
]);
|
|
118
139
|
|
|
119
140
|
function findConfigFile(explicitPath) {
|
|
@@ -252,6 +273,8 @@ Options:
|
|
|
252
273
|
--json Machine-readable output (index, stats, search).
|
|
253
274
|
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
254
275
|
--rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
|
|
276
|
+
--graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
|
|
277
|
+
--filter <expr> Rich boolean filter across frontmatter tags and properties (search).
|
|
255
278
|
--ann Approximate nearest neighbor search via IVF (search).
|
|
256
279
|
--nprobe <n> Number of centroids to probe for ANN (search, default: 8).
|
|
257
280
|
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
@@ -319,6 +342,7 @@ async function cmdIndex(opts) {
|
|
|
319
342
|
offline: resolveOffline(opts),
|
|
320
343
|
workers: opts.workers,
|
|
321
344
|
ann: opts.ann,
|
|
345
|
+
quantize: opts.quantize,
|
|
322
346
|
log: s => {
|
|
323
347
|
if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
|
|
324
348
|
process.stderr.write(s + '\n');
|
|
@@ -863,35 +887,103 @@ async function cmdTui(opts) {
|
|
|
863
887
|
offline: resolveOffline(opts),
|
|
864
888
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
865
889
|
rerank: !!opts.rerank,
|
|
890
|
+
rag: !!opts.rag,
|
|
866
891
|
});
|
|
867
892
|
}
|
|
868
893
|
|
|
869
|
-
async function
|
|
870
|
-
|
|
894
|
+
async function cmdAsk(opts) {
|
|
895
|
+
const query = opts._.join(' ').trim();
|
|
896
|
+
if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
|
|
897
|
+
|
|
871
898
|
const db = resolveDb(opts);
|
|
872
899
|
const indexDir = resolveIndexDir(opts, db);
|
|
873
900
|
const cacheDir = resolveCache(opts);
|
|
901
|
+
const k = opts.k || 5;
|
|
902
|
+
|
|
903
|
+
const { askQuestion } = await import('../dist/rag.js');
|
|
904
|
+
const result = await askQuestion({
|
|
905
|
+
query,
|
|
906
|
+
indexDir,
|
|
907
|
+
cacheDir,
|
|
908
|
+
k,
|
|
909
|
+
llmEndpoint: opts.llmEndpoint,
|
|
910
|
+
llmModel: opts.llmModel,
|
|
911
|
+
systemPrompt: opts.systemPrompt,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
if (opts.json) {
|
|
915
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
process.stdout.write(`\n${result.answer}\n\n`);
|
|
920
|
+
if (result.citations && result.citations.length > 0) {
|
|
921
|
+
process.stdout.write('Sources:\n');
|
|
922
|
+
for (const c of result.citations) {
|
|
923
|
+
const heading = c.heading ? ` › ${c.heading}` : '';
|
|
924
|
+
process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
|
|
925
|
+
}
|
|
926
|
+
process.stdout.write('\n');
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function cmdSearch(opts) {
|
|
931
|
+
if (opts.interactive) return cmdTui(opts);
|
|
932
|
+
const cacheDir = resolveCache(opts);
|
|
874
933
|
const query = opts._.join(' ').trim();
|
|
875
934
|
if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
|
|
876
935
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
936
|
+
let results;
|
|
937
|
+
const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
|
|
938
|
+
|
|
939
|
+
if (isFederated) {
|
|
940
|
+
results = await searchFederated({
|
|
941
|
+
vaults: opts.vaults,
|
|
942
|
+
cacheDir,
|
|
943
|
+
query,
|
|
944
|
+
k: opts.k || 6,
|
|
945
|
+
semanticOnly: !!opts.semantic,
|
|
946
|
+
offline: resolveOffline(opts),
|
|
947
|
+
path: opts.path.length > 0 ? opts.path : undefined,
|
|
948
|
+
since: opts.since,
|
|
949
|
+
rerank: !!opts.rerank,
|
|
950
|
+
tag: opts.tag,
|
|
951
|
+
project: opts.project,
|
|
952
|
+
type: opts.type,
|
|
953
|
+
status: opts.status,
|
|
954
|
+
canonicalOnly: opts.canonical,
|
|
955
|
+
explain: !!opts.explain,
|
|
956
|
+
maxPerFile: opts.maxPerFile || opts.maxPerDoc,
|
|
957
|
+
ann: opts.ann,
|
|
958
|
+
nprobe: opts.nprobe,
|
|
959
|
+
graphBoost: opts.graphBoost,
|
|
960
|
+
filter: opts.filter,
|
|
961
|
+
});
|
|
962
|
+
} else {
|
|
963
|
+
const db = resolveDb(opts);
|
|
964
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
965
|
+
|
|
966
|
+
results = await search({
|
|
967
|
+
indexDir, cacheDir, query,
|
|
968
|
+
k: opts.k || 6,
|
|
969
|
+
semanticOnly: !!opts.semantic,
|
|
970
|
+
offline: resolveOffline(opts),
|
|
971
|
+
path: opts.path.length > 0 ? opts.path : undefined,
|
|
972
|
+
since: opts.since,
|
|
973
|
+
rerank: !!opts.rerank,
|
|
974
|
+
tag: opts.tag,
|
|
975
|
+
project: opts.project,
|
|
976
|
+
type: opts.type,
|
|
977
|
+
status: opts.status,
|
|
978
|
+
canonicalOnly: opts.canonical,
|
|
979
|
+
explain: !!opts.explain,
|
|
980
|
+
maxPerFile: opts.maxPerFile || opts.maxPerDoc,
|
|
981
|
+
ann: opts.ann,
|
|
982
|
+
nprobe: opts.nprobe,
|
|
983
|
+
graphBoost: opts.graphBoost,
|
|
984
|
+
filter: opts.filter,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
895
987
|
|
|
896
988
|
if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
897
989
|
if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
|
|
@@ -901,13 +993,14 @@ async function cmdSearch(opts) {
|
|
|
901
993
|
// Group by file (issue #13): each file becomes a header with its hits below.
|
|
902
994
|
const byFile = new Map();
|
|
903
995
|
for (const r of results) {
|
|
904
|
-
|
|
905
|
-
byFile.
|
|
996
|
+
const fileKey = r.vault ? `[${r.vault}] ${r.file}` : r.file;
|
|
997
|
+
if (!byFile.has(fileKey)) byFile.set(fileKey, []);
|
|
998
|
+
byFile.get(fileKey).push(r);
|
|
906
999
|
}
|
|
907
1000
|
const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
|
|
908
1001
|
let rank = 0;
|
|
909
|
-
for (const [
|
|
910
|
-
process.stdout.write(`${
|
|
1002
|
+
for (const [fileHeader, hits] of byFile) {
|
|
1003
|
+
process.stdout.write(`${fileHeader}\n`);
|
|
911
1004
|
for (const r of hits) {
|
|
912
1005
|
rank++;
|
|
913
1006
|
let snippet = r.snippet;
|
|
@@ -1034,6 +1127,7 @@ async function main() {
|
|
|
1034
1127
|
case 'doctor': return cmdCheck(opts);
|
|
1035
1128
|
case 'export': return cmdExport(opts);
|
|
1036
1129
|
case 'search': return cmdSearch(opts);
|
|
1130
|
+
case 'ask': return cmdAsk(opts);
|
|
1037
1131
|
case 'tui': return cmdTui(opts);
|
|
1038
1132
|
case 'serve': return cmdServe(opts);
|
|
1039
1133
|
case 'mcp': return cmdMcp(opts);
|
|
@@ -1052,7 +1146,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
1052
1146
|
// functions are unit-tested directly; die() is asserted via a mocked
|
|
1053
1147
|
// process.exit so a bad flag cannot kill the test runner.
|
|
1054
1148
|
export {
|
|
1055
|
-
parseArgs, nextInt, nextValue,
|
|
1149
|
+
parseArgs, nextInt, nextFloat, nextValue,
|
|
1056
1150
|
resolveDb, resolveIndexDir, resolveCache, resolveOffline,
|
|
1057
1151
|
findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
|
|
1058
1152
|
checkHealth, cmdExport,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { PersistedIndex } from './indexer.js';
|
|
2
|
+
export declare const BINARY_MAGIC = "MDSSBIN1";
|
|
3
|
+
export declare const BINARY_HEADER_SIZE = 64;
|
|
4
|
+
export declare const BINARY_VERSION = 1;
|
|
5
|
+
export declare const BINARY_FLAG_FP32 = 0;
|
|
6
|
+
export declare const BINARY_FLAG_INT8 = 1;
|
|
7
|
+
export interface BinaryIndexHeader {
|
|
8
|
+
version: number;
|
|
9
|
+
flags: number;
|
|
10
|
+
dim: number;
|
|
11
|
+
chunkCount: number;
|
|
12
|
+
vectorsOffset: number;
|
|
13
|
+
vectorsBytes: number;
|
|
14
|
+
metadataOffset: number;
|
|
15
|
+
metadataBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export interface SerializeBinaryOptions {
|
|
18
|
+
quantize?: 'fp32' | 'int8';
|
|
19
|
+
}
|
|
20
|
+
export declare function serializeBinaryIndex(index: PersistedIndex, dim: number, options?: SerializeBinaryOptions): Buffer;
|
|
21
|
+
export declare function readBinaryHeader(buffer: Buffer): BinaryIndexHeader;
|
|
22
|
+
export declare function deserializeBinaryIndex(buffer: Buffer): PersistedIndex & {
|
|
23
|
+
rawVectorsBuffer: Float32Array | Int8Array;
|
|
24
|
+
quantized?: 'fp32' | 'int8';
|
|
25
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-Copy Binary Index Storage (vectors.bin) format (issue #95).
|
|
3
|
+
*
|
|
4
|
+
* Binary Layout:
|
|
5
|
+
* ┌────────────────────────────────────────────────────────┐
|
|
6
|
+
* │ Header (64 bytes): │
|
|
7
|
+
* │ 0..7: Magic 'MDSSBIN1' │
|
|
8
|
+
* │ 8..11: Version (uint32 = 1) │
|
|
9
|
+
* │ 12..15: Flags (uint32: 0=FP32, 1=INT8) │
|
|
10
|
+
* │ 16..19: Dim (uint32) │
|
|
11
|
+
* │ 20..23: ChunkCount (uint32) │
|
|
12
|
+
* │ 24..31: VectorsOffset (uint64) │
|
|
13
|
+
* │ 32..39: VectorsBytes (uint64) │
|
|
14
|
+
* │ 40..47: MetadataOffset (uint64) │
|
|
15
|
+
* │ 48..55: MetadataBytes (uint64) │
|
|
16
|
+
* │ 56..63: Reserved (8 bytes zeros) │
|
|
17
|
+
* ├────────────────────────────────────────────────────────┤
|
|
18
|
+
* │ Vectors Section: │
|
|
19
|
+
* │ Contiguous float32 array: [chunkCount * dim * 4 bytes]│
|
|
20
|
+
* ├────────────────────────────────────────────────────────┤
|
|
21
|
+
* │ Metadata Section: │
|
|
22
|
+
* │ UTF-8 JSON string (chunks metadata + lexical state) │
|
|
23
|
+
* └────────────────────────────────────────────────────────┘
|
|
24
|
+
*/
|
|
25
|
+
import { decodeVec } from './core.js';
|
|
26
|
+
import { quantizeToInt8 } from './quantization.js';
|
|
27
|
+
export const BINARY_MAGIC = 'MDSSBIN1';
|
|
28
|
+
export const BINARY_HEADER_SIZE = 64;
|
|
29
|
+
export const BINARY_VERSION = 1;
|
|
30
|
+
export const BINARY_FLAG_FP32 = 0;
|
|
31
|
+
export const BINARY_FLAG_INT8 = 1;
|
|
32
|
+
export function serializeBinaryIndex(index, dim, options = {}) {
|
|
33
|
+
const isInt8 = options.quantize === 'int8';
|
|
34
|
+
const bytesPerElement = isInt8 ? 1 : 4;
|
|
35
|
+
const count = index.chunks.length;
|
|
36
|
+
const vectorsBytes = count * dim * bytesPerElement;
|
|
37
|
+
const vectorsOffset = BINARY_HEADER_SIZE;
|
|
38
|
+
const flags = isInt8 ? BINARY_FLAG_INT8 : BINARY_FLAG_FP32;
|
|
39
|
+
// Build contiguous typed array
|
|
40
|
+
const rawArray = isInt8 ? new Int8Array(count * dim) : new Float32Array(count * dim);
|
|
41
|
+
for (let i = 0; i < count; i++) {
|
|
42
|
+
const chunk = index.chunks[i];
|
|
43
|
+
let v;
|
|
44
|
+
if (typeof chunk.vec === 'string') {
|
|
45
|
+
v = decodeVec(chunk.vec, dim);
|
|
46
|
+
}
|
|
47
|
+
else if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array || Array.isArray(chunk.vec)) {
|
|
48
|
+
v = chunk.vec;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
throw new Error(`chunk ${i} is missing vector for binary serialization`);
|
|
52
|
+
}
|
|
53
|
+
if (isInt8) {
|
|
54
|
+
const q = v instanceof Int8Array ? v : quantizeToInt8(v);
|
|
55
|
+
rawArray.set(q, i * dim);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
rawArray.set(v, i * dim);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Build metadata JSON omitting per-chunk vec strings to save space
|
|
62
|
+
const metadataChunks = index.chunks.map((c) => ({
|
|
63
|
+
file: c.file,
|
|
64
|
+
title: c.title,
|
|
65
|
+
heading: c.heading,
|
|
66
|
+
headingPath: c.headingPath,
|
|
67
|
+
text: c.text,
|
|
68
|
+
chunkHash: c.chunkHash,
|
|
69
|
+
startLine: c.startLine,
|
|
70
|
+
endLine: c.endLine,
|
|
71
|
+
meta: c.meta,
|
|
72
|
+
}));
|
|
73
|
+
const metadataObj = {
|
|
74
|
+
schemaVersion: index.schemaVersion,
|
|
75
|
+
format: 'binary-v1',
|
|
76
|
+
model: index.model,
|
|
77
|
+
modelAlias: index.modelAlias,
|
|
78
|
+
adapterFingerprint: index.adapterFingerprint,
|
|
79
|
+
dim,
|
|
80
|
+
db: index.db,
|
|
81
|
+
built: index.built,
|
|
82
|
+
complete: index.complete,
|
|
83
|
+
chunkCount: count,
|
|
84
|
+
quantized: isInt8 ? 'int8' : 'fp32',
|
|
85
|
+
lexical: index.lexical,
|
|
86
|
+
lexicalFormat: index.lexicalFormat,
|
|
87
|
+
chunks: metadataChunks,
|
|
88
|
+
};
|
|
89
|
+
const metadataBuffer = Buffer.from(JSON.stringify(metadataObj), 'utf8');
|
|
90
|
+
const metadataOffset = vectorsOffset + vectorsBytes;
|
|
91
|
+
const metadataBytes = metadataBuffer.length;
|
|
92
|
+
const totalSize = metadataOffset + metadataBytes;
|
|
93
|
+
const out = Buffer.alloc(totalSize);
|
|
94
|
+
// Write 64-byte header
|
|
95
|
+
out.write(BINARY_MAGIC, 0, 8, 'ascii');
|
|
96
|
+
out.writeUInt32LE(BINARY_VERSION, 8);
|
|
97
|
+
out.writeUInt32LE(flags, 12);
|
|
98
|
+
out.writeUInt32LE(dim, 16);
|
|
99
|
+
out.writeUInt32LE(count, 20);
|
|
100
|
+
out.writeBigUInt64LE(BigInt(vectorsOffset), 24);
|
|
101
|
+
out.writeBigUInt64LE(BigInt(vectorsBytes), 32);
|
|
102
|
+
out.writeBigUInt64LE(BigInt(metadataOffset), 40);
|
|
103
|
+
out.writeBigUInt64LE(BigInt(metadataBytes), 48);
|
|
104
|
+
// Copy vectors
|
|
105
|
+
Buffer.from(rawArray.buffer, rawArray.byteOffset, rawArray.byteLength).copy(out, vectorsOffset);
|
|
106
|
+
// Copy metadata
|
|
107
|
+
metadataBuffer.copy(out, metadataOffset);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
export function readBinaryHeader(buffer) {
|
|
111
|
+
if (buffer.length < BINARY_HEADER_SIZE) {
|
|
112
|
+
throw new Error(`Invalid binary index: file too small (${buffer.length} bytes, expected at least ${BINARY_HEADER_SIZE})`);
|
|
113
|
+
}
|
|
114
|
+
const magic = buffer.toString('ascii', 0, 8);
|
|
115
|
+
if (magic !== BINARY_MAGIC) {
|
|
116
|
+
throw new Error(`Invalid binary index magic: expected "${BINARY_MAGIC}", got "${magic}"`);
|
|
117
|
+
}
|
|
118
|
+
const version = buffer.readUInt32LE(8);
|
|
119
|
+
const flags = buffer.readUInt32LE(12);
|
|
120
|
+
const dim = buffer.readUInt32LE(16);
|
|
121
|
+
const chunkCount = buffer.readUInt32LE(20);
|
|
122
|
+
const vectorsOffset = Number(buffer.readBigUInt64LE(24));
|
|
123
|
+
const vectorsBytes = Number(buffer.readBigUInt64LE(32));
|
|
124
|
+
const metadataOffset = Number(buffer.readBigUInt64LE(40));
|
|
125
|
+
const metadataBytes = Number(buffer.readBigUInt64LE(48));
|
|
126
|
+
return {
|
|
127
|
+
version,
|
|
128
|
+
flags,
|
|
129
|
+
dim,
|
|
130
|
+
chunkCount,
|
|
131
|
+
vectorsOffset,
|
|
132
|
+
vectorsBytes,
|
|
133
|
+
metadataOffset,
|
|
134
|
+
metadataBytes,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function deserializeBinaryIndex(buffer) {
|
|
138
|
+
const header = readBinaryHeader(buffer);
|
|
139
|
+
const { flags, dim, chunkCount, vectorsOffset, vectorsBytes, metadataOffset, metadataBytes } = header;
|
|
140
|
+
const isInt8 = (flags & BINARY_FLAG_INT8) !== 0;
|
|
141
|
+
const expectedVectorsBytes = chunkCount * dim * (isInt8 ? 1 : 4);
|
|
142
|
+
if (buffer.length < vectorsOffset + expectedVectorsBytes || buffer.length < metadataOffset + metadataBytes) {
|
|
143
|
+
throw new Error('Corrupt binary index: unexpected truncated file');
|
|
144
|
+
}
|
|
145
|
+
const byteOffset = buffer.byteOffset + vectorsOffset;
|
|
146
|
+
let rawVectorsBuffer;
|
|
147
|
+
if (isInt8) {
|
|
148
|
+
rawVectorsBuffer = new Int8Array(buffer.buffer, byteOffset, chunkCount * dim);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Aligned 4-byte Float32Array view
|
|
152
|
+
if (byteOffset % 4 === 0) {
|
|
153
|
+
rawVectorsBuffer = new Float32Array(buffer.buffer, byteOffset, chunkCount * dim);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const aligned = Buffer.alloc(vectorsBytes);
|
|
157
|
+
buffer.copy(aligned, 0, vectorsOffset, vectorsOffset + vectorsBytes);
|
|
158
|
+
rawVectorsBuffer = new Float32Array(aligned.buffer, aligned.byteOffset, chunkCount * dim);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Deserialize metadata JSON
|
|
162
|
+
const metaJson = buffer.toString('utf8', metadataOffset, metadataOffset + metadataBytes);
|
|
163
|
+
const parsed = JSON.parse(metaJson);
|
|
164
|
+
// Re-attach subarray views to chunks
|
|
165
|
+
const chunks = parsed.chunks.map((c, i) => {
|
|
166
|
+
const vecSlice = rawVectorsBuffer.subarray(i * dim, (i + 1) * dim);
|
|
167
|
+
return {
|
|
168
|
+
...c,
|
|
169
|
+
vec: vecSlice,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
...parsed,
|
|
174
|
+
chunks,
|
|
175
|
+
rawVectorsBuffer,
|
|
176
|
+
quantized: isInt8 ? 'int8' : 'fp32',
|
|
177
|
+
};
|
|
178
|
+
}
|
package/dist/core.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolveModel, ModelAdapter, Pooling, DType } from './models.js';
|
|
2
2
|
import { PARSER_VERSION, StructuralChunk } from './markdown-parser.js';
|
|
3
3
|
import { parseFrontmatter, DocumentMetadata } from './frontmatter.js';
|
|
4
|
-
|
|
4
|
+
import { asymmetricCosineInt8 } from './quantization.js';
|
|
5
|
+
export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType, asymmetricCosineInt8 };
|
|
5
6
|
export declare const LOCK_FILENAME = ".mdss.lock";
|
|
6
7
|
export declare function pidAlive(pid: number | null): boolean;
|
|
7
8
|
export interface LockInfo {
|
|
@@ -47,7 +48,7 @@ export declare function embed(texts: string[], kind: 'query' | 'passage', model:
|
|
|
47
48
|
delays?: number[];
|
|
48
49
|
log?: (msg: string) => void;
|
|
49
50
|
}): Promise<number[][]>;
|
|
50
|
-
export declare function cosine(a: Float32Array | number[], b: Float32Array | number[], bOffset?: number): number;
|
|
51
|
+
export declare function cosine(a: Float32Array | number[], b: Float32Array | Int8Array | number[], bOffset?: number): number;
|
|
51
52
|
export declare function encodeVec(vec: number[] | Float32Array): string;
|
|
52
53
|
export declare function decodeVec(s: string, dim?: number): Float32Array;
|
|
53
54
|
export declare function isBinaryIndex(index: {
|
package/dist/core.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import os from 'node:os';
|
|
9
8
|
import { resolveModel } from './models.js';
|
|
10
9
|
import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.js';
|
|
11
10
|
import { parseFrontmatter } from './frontmatter.js';
|
|
12
|
-
|
|
11
|
+
import { asymmetricCosineInt8 } from './quantization.js';
|
|
12
|
+
export { PARSER_VERSION, parseFrontmatter, resolveModel, asymmetricCosineInt8 };
|
|
13
13
|
const _extractors = new Map();
|
|
14
14
|
export const LOCK_FILENAME = '.mdss.lock';
|
|
15
15
|
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
@@ -248,6 +248,11 @@ export async function embed(texts, kind, model, cacheDir, offline = false, retry
|
|
|
248
248
|
return out.tolist();
|
|
249
249
|
}
|
|
250
250
|
export function cosine(a, b, bOffset = 0) {
|
|
251
|
+
if (b instanceof Int8Array) {
|
|
252
|
+
const qF32 = a instanceof Float32Array ? a : Float32Array.from(a);
|
|
253
|
+
const chunkInt8 = bOffset === 0 && b.length === qF32.length ? b : b.subarray(bOffset, bOffset + qF32.length);
|
|
254
|
+
return asymmetricCosineInt8(qF32, chunkInt8);
|
|
255
|
+
}
|
|
251
256
|
const len = a.length;
|
|
252
257
|
let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
|
|
253
258
|
let s4 = 0, s5 = 0, s6 = 0, s7 = 0;
|
|
@@ -364,6 +369,12 @@ export function assertSafePath(targetPath, allowedRoots) {
|
|
|
364
369
|
if (typeof targetPath !== 'string' || !targetPath.trim()) {
|
|
365
370
|
throw new Error('path must be a non-empty string');
|
|
366
371
|
}
|
|
372
|
+
if (targetPath.includes('\0')) {
|
|
373
|
+
throw new Error('Forbidden path: null byte detected in path');
|
|
374
|
+
}
|
|
375
|
+
if (targetPath.startsWith('\\\\') || targetPath.startsWith('//')) {
|
|
376
|
+
throw new Error('Forbidden path: UNC network paths not allowed');
|
|
377
|
+
}
|
|
367
378
|
const resolved = path.resolve(targetPath);
|
|
368
379
|
let canonical = resolved;
|
|
369
380
|
try {
|
|
@@ -377,40 +388,21 @@ export function assertSafePath(targetPath, allowedRoots) {
|
|
|
377
388
|
if (process.env.MDSS_ROOT_GUARD) {
|
|
378
389
|
roots = process.env.MDSS_ROOT_GUARD.split(path.delimiter).map((p) => path.resolve(p.trim())).filter(Boolean);
|
|
379
390
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (parent)
|
|
385
|
-
roots.push(parent);
|
|
386
|
-
}
|
|
387
|
-
catch { }
|
|
388
|
-
try {
|
|
389
|
-
const home = os.homedir();
|
|
390
|
-
if (home)
|
|
391
|
-
roots.push(path.resolve(home));
|
|
392
|
-
}
|
|
393
|
-
catch { }
|
|
391
|
+
}
|
|
392
|
+
if (roots && roots.length > 0) {
|
|
393
|
+
const isAllowed = roots.some((root) => {
|
|
394
|
+
let canonicalRoot = root;
|
|
394
395
|
try {
|
|
395
|
-
|
|
396
|
-
if (tmp)
|
|
397
|
-
roots.push(path.resolve(tmp));
|
|
396
|
+
canonicalRoot = fs.realpathSync(root);
|
|
398
397
|
}
|
|
399
398
|
catch { }
|
|
399
|
+
const rel = path.relative(canonicalRoot, canonical);
|
|
400
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
401
|
+
});
|
|
402
|
+
if (!isAllowed) {
|
|
403
|
+
throw new Error(`path traversal guard: "${targetPath}" (resolving to "${canonical}") is outside allowed directory roots`);
|
|
400
404
|
}
|
|
401
405
|
}
|
|
402
|
-
const isAllowed = roots.some((root) => {
|
|
403
|
-
let canonicalRoot = root;
|
|
404
|
-
try {
|
|
405
|
-
canonicalRoot = fs.realpathSync(root);
|
|
406
|
-
}
|
|
407
|
-
catch { }
|
|
408
|
-
const rel = path.relative(canonicalRoot, canonical);
|
|
409
|
-
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
410
|
-
});
|
|
411
|
-
if (!isAllowed) {
|
|
412
|
-
throw new Error(`path traversal guard: "${targetPath}" (resolving to "${canonical}") is outside allowed directory roots`);
|
|
413
|
-
}
|
|
414
406
|
return canonical;
|
|
415
407
|
}
|
|
416
408
|
export function validateGlob(glob) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SearchOptions, SearchResultHit } from './search.js';
|
|
2
|
+
export interface VaultConfig {
|
|
3
|
+
name?: string;
|
|
4
|
+
path: string;
|
|
5
|
+
indexDir?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
|
|
8
|
+
vaults: Array<string | VaultConfig>;
|
|
9
|
+
cacheDir?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FederatedSearchResultHit extends SearchResultHit {
|
|
12
|
+
vault: string;
|
|
13
|
+
vaultPath: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResultHit[]>;
|