md-semantic-search 0.5.0 → 1.0.2
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 +144 -30
- 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 +26 -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 +44 -3
- 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 +138 -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 +45 -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 +46 -4
- package/src/summarize.ts +98 -0
- package/src/tui.ts +36 -12
- package/src/watcher.ts +157 -0
- package/src/wikilinks.ts +132 -0
package/dist/tui.js
CHANGED
|
@@ -2,6 +2,7 @@ import readline from 'node:readline';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { loadIndex, searchIndex } from './search.js';
|
|
5
|
+
import { extractAnswerFallback } from './rag.js';
|
|
5
6
|
export async function runTui(opts) {
|
|
6
7
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
7
8
|
throw new Error('interactive TUI requires a TTY terminal stdin and stdout');
|
|
@@ -55,6 +56,7 @@ export async function runTui(opts) {
|
|
|
55
56
|
performSearch();
|
|
56
57
|
}, opts.debounceMs ?? 300);
|
|
57
58
|
};
|
|
59
|
+
let showRag = Boolean(opts.rag);
|
|
58
60
|
const render = () => {
|
|
59
61
|
const rows = process.stdout.rows || 24;
|
|
60
62
|
const cols = process.stdout.columns || 80;
|
|
@@ -63,10 +65,11 @@ export async function runTui(opts) {
|
|
|
63
65
|
buf += '\x1b[H\x1b[J'; // clear screen from cursor down
|
|
64
66
|
// Header
|
|
65
67
|
const searchStatus = searching ? ' [searching...]' : '';
|
|
66
|
-
const
|
|
68
|
+
const modeLabel = showRag ? 'RAG QA & Synthesis' : 'search';
|
|
69
|
+
const headerStr = ` mdss interactive ${modeLabel} | Query: ${query}${searchStatus}`;
|
|
67
70
|
buf += `\x1b[7m${headerStr.padEnd(cols).slice(0, cols)}\x1b[0m\n`;
|
|
68
71
|
// Calculate layout heights
|
|
69
|
-
const listHeight = Math.max(
|
|
72
|
+
const listHeight = Math.max(3, Math.floor((rows - 4) * 0.35));
|
|
70
73
|
const previewHeight = Math.max(4, rows - 4 - listHeight);
|
|
71
74
|
// Render list
|
|
72
75
|
buf += `\x1b[1m--- Results (${results.length}) ---\x1b[0m\n`;
|
|
@@ -89,14 +92,14 @@ export async function runTui(opts) {
|
|
|
89
92
|
buf += '~ \n';
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
|
-
// Render Preview
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
95
|
+
// Render Preview or RAG synthesis
|
|
96
|
+
if (showRag) {
|
|
97
|
+
buf += `\x1b[1m--- Grounded Answer Synthesis & Citations ---\x1b[0m\n`;
|
|
98
|
+
const answer = extractAnswerFallback(query, results);
|
|
99
|
+
const answerLines = answer.split('\n');
|
|
97
100
|
for (let i = 0; i < previewHeight; i++) {
|
|
98
|
-
if (i <
|
|
99
|
-
buf += ` ${
|
|
101
|
+
if (i < answerLines.length) {
|
|
102
|
+
buf += ` ${answerLines[i].slice(0, cols - 3)}\n`;
|
|
100
103
|
}
|
|
101
104
|
else {
|
|
102
105
|
buf += '\n';
|
|
@@ -104,12 +107,27 @@ export async function runTui(opts) {
|
|
|
104
107
|
}
|
|
105
108
|
}
|
|
106
109
|
else {
|
|
107
|
-
buf +=
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
buf += `\x1b[1m--- Passage Preview ---\x1b[0m\n`;
|
|
111
|
+
const selectedHit = results[selectedIndex];
|
|
112
|
+
if (selectedHit) {
|
|
113
|
+
const textLines = selectedHit.snippet.split('\n');
|
|
114
|
+
for (let i = 0; i < previewHeight; i++) {
|
|
115
|
+
if (i < textLines.length) {
|
|
116
|
+
buf += ` ${textLines[i].slice(0, cols - 3)}\n`;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
buf += '\n';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
buf += ' (no passage selected)\n';
|
|
125
|
+
for (let i = 1; i < previewHeight; i++)
|
|
126
|
+
buf += '\n';
|
|
127
|
+
}
|
|
110
128
|
}
|
|
111
129
|
// Footer
|
|
112
|
-
const footerStr = ' [Up/Down
|
|
130
|
+
const footerStr = ' [Tab]: Toggle RAG/Passage | [Up/Down]: Select | [Enter]: Open | [Esc]: Quit';
|
|
113
131
|
buf += `\x1b[7m${footerStr.padEnd(cols).slice(0, cols)}\x1b[0m`;
|
|
114
132
|
process.stdout.write(buf);
|
|
115
133
|
};
|
|
@@ -144,6 +162,11 @@ export async function runTui(opts) {
|
|
|
144
162
|
resolve(null);
|
|
145
163
|
return;
|
|
146
164
|
}
|
|
165
|
+
if (key.name === 'tab') {
|
|
166
|
+
showRag = !showRag;
|
|
167
|
+
render();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
147
170
|
if (key.name === 'up' || (key.name === 'k' && key.ctrl)) {
|
|
148
171
|
if (selectedIndex > 0) {
|
|
149
172
|
selectedIndex--;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface WatcherOptions {
|
|
2
|
+
debounceMs?: number;
|
|
3
|
+
ignore?: string[];
|
|
4
|
+
log?: (msg: string) => void;
|
|
5
|
+
fallbackIntervalMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface FileWatcher {
|
|
8
|
+
close: () => void;
|
|
9
|
+
isNative: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function createFileWatcher(dbPath: string, onChange: () => void | Promise<void>, options?: WatcherOptions): FileWatcher;
|
package/dist/watcher.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-Native File Watcher with Zero-Polling Kernel Events (issue #100).
|
|
3
|
+
*
|
|
4
|
+
* Utilizes OS kernel events (ReadDirectoryChangesW on Windows, FSEvents on macOS, inotify on Linux)
|
|
5
|
+
* via recursive `fs.watch`, with automatic debounce coalescing and graceful fallback to interval polling.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { globToRegExp } from './core.js';
|
|
10
|
+
export function createFileWatcher(dbPath, onChange, options = {}) {
|
|
11
|
+
const { debounceMs = 250, ignore = [], log = () => { }, fallbackIntervalMs = 50, } = options;
|
|
12
|
+
let closed = false;
|
|
13
|
+
let debounceTimer = null;
|
|
14
|
+
const ignoreRegexes = [
|
|
15
|
+
/\.mdss($|[\\/])/,
|
|
16
|
+
/\.git($|[\\/])/,
|
|
17
|
+
/\.cache($|[\\/])/,
|
|
18
|
+
...ignore.map((g) => globToRegExp(g)),
|
|
19
|
+
];
|
|
20
|
+
const shouldIgnore = (filename) => {
|
|
21
|
+
if (!filename)
|
|
22
|
+
return false;
|
|
23
|
+
const normalized = filename.split(path.sep).join('/');
|
|
24
|
+
for (const re of ignoreRegexes) {
|
|
25
|
+
if (re.test(normalized))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
// Only care about markdown files or directory changes
|
|
29
|
+
if (path.extname(filename) && !/\.(md|markdown)$/i.test(filename)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
const scheduleChange = () => {
|
|
35
|
+
if (closed)
|
|
36
|
+
return;
|
|
37
|
+
if (debounceTimer) {
|
|
38
|
+
clearTimeout(debounceTimer);
|
|
39
|
+
}
|
|
40
|
+
debounceTimer = setTimeout(async () => {
|
|
41
|
+
if (closed)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
await onChange();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
log(`watcher onChange error: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}, debounceMs);
|
|
50
|
+
debounceTimer.unref?.();
|
|
51
|
+
};
|
|
52
|
+
// Attempt recursive native fs.watch first
|
|
53
|
+
try {
|
|
54
|
+
const fsWatcher = fs.watch(dbPath, { recursive: true }, (_eventType, filename) => {
|
|
55
|
+
if (closed)
|
|
56
|
+
return;
|
|
57
|
+
if (filename && shouldIgnore(filename)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
scheduleChange();
|
|
61
|
+
});
|
|
62
|
+
fsWatcher.on('error', (err) => {
|
|
63
|
+
log(`Native watcher error (${err.message}), continuing.`);
|
|
64
|
+
});
|
|
65
|
+
// unref() lets the event loop exit even if the watcher is still active.
|
|
66
|
+
// Without this, on Node 18/Linux the inotify handle keeps the process
|
|
67
|
+
// alive indefinitely after all tests finish (or after close() is called
|
|
68
|
+
// but the handle hasn't been fully released by the kernel yet).
|
|
69
|
+
fsWatcher.unref?.();
|
|
70
|
+
return {
|
|
71
|
+
isNative: true,
|
|
72
|
+
close: () => {
|
|
73
|
+
closed = true;
|
|
74
|
+
if (debounceTimer)
|
|
75
|
+
clearTimeout(debounceTimer);
|
|
76
|
+
try {
|
|
77
|
+
fsWatcher.close();
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (nativeErr) {
|
|
84
|
+
log(`Native recursive watch unavailable (${nativeErr.message}); falling back to interval polling.`);
|
|
85
|
+
// Polling fallback
|
|
86
|
+
let lastMtime = 0;
|
|
87
|
+
const getDirMtime = (dir) => {
|
|
88
|
+
try {
|
|
89
|
+
let max = 0;
|
|
90
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.name.startsWith('.') || entry.name === '.mdss')
|
|
93
|
+
continue;
|
|
94
|
+
const full = path.join(dir, entry.name);
|
|
95
|
+
if (shouldIgnore(entry.name))
|
|
96
|
+
continue;
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
const sub = getDirMtime(full);
|
|
99
|
+
if (sub > max)
|
|
100
|
+
max = sub;
|
|
101
|
+
}
|
|
102
|
+
else if (/\.(md|markdown)$/i.test(entry.name)) {
|
|
103
|
+
try {
|
|
104
|
+
const fileStat = fs.statSync(full);
|
|
105
|
+
if (fileStat.mtimeMs > max)
|
|
106
|
+
max = fileStat.mtimeMs;
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return max;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
lastMtime = getDirMtime(dbPath);
|
|
118
|
+
const pollInterval = setInterval(() => {
|
|
119
|
+
if (closed)
|
|
120
|
+
return;
|
|
121
|
+
const currentMtime = getDirMtime(dbPath);
|
|
122
|
+
if (currentMtime > lastMtime) {
|
|
123
|
+
lastMtime = currentMtime;
|
|
124
|
+
scheduleChange();
|
|
125
|
+
}
|
|
126
|
+
}, fallbackIntervalMs);
|
|
127
|
+
pollInterval.unref?.();
|
|
128
|
+
return {
|
|
129
|
+
isNative: false,
|
|
130
|
+
close: () => {
|
|
131
|
+
closed = true;
|
|
132
|
+
if (debounceTimer)
|
|
133
|
+
clearTimeout(debounceTimer);
|
|
134
|
+
clearInterval(pollInterval);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/wikilinks.d.ts
CHANGED
|
@@ -45,3 +45,25 @@ export declare function getRelatedNotes(graph: RelationshipGraph, targetFile: st
|
|
|
45
45
|
direction?: 'both' | 'outgoing' | 'backlinks';
|
|
46
46
|
depth?: number;
|
|
47
47
|
}): RelatedNote[];
|
|
48
|
+
export interface PageRankOptions {
|
|
49
|
+
damping?: number;
|
|
50
|
+
maxIterations?: number;
|
|
51
|
+
tolerance?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Computes in-memory PageRank centrality scores for all nodes in the RelationshipGraph.
|
|
55
|
+
* Handles dangling nodes (nodes with 0 out-degree) and normalizes scores.
|
|
56
|
+
*/
|
|
57
|
+
export declare function computePageRank(graph: RelationshipGraph, opts?: PageRankOptions): Map<string, number>;
|
|
58
|
+
export interface GraphExpansionOptions {
|
|
59
|
+
maxDepth?: number;
|
|
60
|
+
decay?: number;
|
|
61
|
+
topSeeds?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 2-hop neighborhood expansion: propagates relevance scores across graph links.
|
|
65
|
+
*/
|
|
66
|
+
export declare function expandGraphNeighborhood(graph: RelationshipGraph, seedFiles: Array<{
|
|
67
|
+
file: string;
|
|
68
|
+
score: number;
|
|
69
|
+
}>, opts?: GraphExpansionOptions): Map<string, number>;
|
package/dist/wikilinks.js
CHANGED
|
@@ -169,3 +169,103 @@ export function getRelatedNotes(graph, targetFile, opts = {}) {
|
|
|
169
169
|
}
|
|
170
170
|
return results;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Computes in-memory PageRank centrality scores for all nodes in the RelationshipGraph.
|
|
174
|
+
* Handles dangling nodes (nodes with 0 out-degree) and normalizes scores.
|
|
175
|
+
*/
|
|
176
|
+
export function computePageRank(graph, opts = {}) {
|
|
177
|
+
const damping = opts.damping ?? 0.85;
|
|
178
|
+
const maxIterations = opts.maxIterations ?? 30;
|
|
179
|
+
const tolerance = opts.tolerance ?? 1e-6;
|
|
180
|
+
const nodes = new Set();
|
|
181
|
+
for (const node of graph.outgoing.keys())
|
|
182
|
+
nodes.add(node);
|
|
183
|
+
for (const node of graph.backlinks.keys())
|
|
184
|
+
nodes.add(node);
|
|
185
|
+
const nodeList = Array.from(nodes);
|
|
186
|
+
const n = nodeList.length;
|
|
187
|
+
const rankMap = new Map();
|
|
188
|
+
if (n === 0)
|
|
189
|
+
return rankMap;
|
|
190
|
+
if (n === 1) {
|
|
191
|
+
rankMap.set(nodeList[0], 1.0);
|
|
192
|
+
return rankMap;
|
|
193
|
+
}
|
|
194
|
+
const outNeighbors = new Map();
|
|
195
|
+
for (const node of nodeList) {
|
|
196
|
+
const edges = graph.outgoing.get(node) || [];
|
|
197
|
+
const targets = new Set();
|
|
198
|
+
for (const e of edges) {
|
|
199
|
+
if (nodes.has(e.file) && e.file !== node) {
|
|
200
|
+
targets.add(e.file);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
outNeighbors.set(node, targets);
|
|
204
|
+
}
|
|
205
|
+
const inNeighbors = new Map();
|
|
206
|
+
for (const node of nodeList) {
|
|
207
|
+
inNeighbors.set(node, new Set());
|
|
208
|
+
}
|
|
209
|
+
for (const [src, targets] of outNeighbors.entries()) {
|
|
210
|
+
for (const tgt of targets) {
|
|
211
|
+
inNeighbors.get(tgt)?.add(src);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
let ranks = new Map();
|
|
215
|
+
const initialRank = 1.0 / n;
|
|
216
|
+
for (const node of nodeList) {
|
|
217
|
+
ranks.set(node, initialRank);
|
|
218
|
+
}
|
|
219
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
220
|
+
const nextRanks = new Map();
|
|
221
|
+
let danglingSum = 0;
|
|
222
|
+
for (const node of nodeList) {
|
|
223
|
+
const outDeg = outNeighbors.get(node)?.size || 0;
|
|
224
|
+
if (outDeg === 0) {
|
|
225
|
+
danglingSum += ranks.get(node) || 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
let maxDiff = 0;
|
|
229
|
+
const baseRank = (1.0 - damping) / n + (damping * danglingSum) / n;
|
|
230
|
+
for (const node of nodeList) {
|
|
231
|
+
let inSum = 0;
|
|
232
|
+
const inNodes = inNeighbors.get(node) || new Set();
|
|
233
|
+
for (const inNode of inNodes) {
|
|
234
|
+
const outDeg = outNeighbors.get(inNode)?.size || 1;
|
|
235
|
+
inSum += (ranks.get(inNode) || 0) / outDeg;
|
|
236
|
+
}
|
|
237
|
+
const newRank = baseRank + damping * inSum;
|
|
238
|
+
nextRanks.set(node, newRank);
|
|
239
|
+
const diff = Math.abs(newRank - (ranks.get(node) || 0));
|
|
240
|
+
if (diff > maxDiff)
|
|
241
|
+
maxDiff = diff;
|
|
242
|
+
}
|
|
243
|
+
ranks = nextRanks;
|
|
244
|
+
if (maxDiff < tolerance)
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
return ranks;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 2-hop neighborhood expansion: propagates relevance scores across graph links.
|
|
251
|
+
*/
|
|
252
|
+
export function expandGraphNeighborhood(graph, seedFiles, opts = {}) {
|
|
253
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
254
|
+
const decay = opts.decay ?? 0.5;
|
|
255
|
+
const topSeeds = opts.topSeeds ?? 10;
|
|
256
|
+
const propagationScores = new Map();
|
|
257
|
+
const sortedSeeds = [...seedFiles].sort((a, b) => b.score - a.score).slice(0, topSeeds);
|
|
258
|
+
for (const seed of sortedSeeds) {
|
|
259
|
+
if (seed.score <= 0)
|
|
260
|
+
continue;
|
|
261
|
+
const related = getRelatedNotes(graph, seed.file, { direction: 'both', depth: maxDepth });
|
|
262
|
+
for (const rel of related) {
|
|
263
|
+
const weight = seed.score * Math.pow(decay, rel.distance);
|
|
264
|
+
const current = propagationScores.get(rel.file) || 0;
|
|
265
|
+
if (weight > current) {
|
|
266
|
+
propagationScores.set(rel.file, weight);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return propagationScores;
|
|
271
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-semantic-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.2",
|
|
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
|
"main": "./dist/index.js",
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
+
"@types/node": "^26.2.0",
|
|
67
|
+
"@types/vscode": "^1.125.0",
|
|
66
68
|
"eslint": "^10.8.1",
|
|
67
69
|
"fast-check": "^4.9.0",
|
|
68
70
|
"globals": "^17.11.0",
|
|
@@ -0,0 +1,216 @@
|
|
|
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 { PersistedIndex, IndexChunk } from './indexer.js';
|
|
27
|
+
import { quantizeToInt8 } from './quantization.js';
|
|
28
|
+
|
|
29
|
+
export const BINARY_MAGIC = 'MDSSBIN1';
|
|
30
|
+
export const BINARY_HEADER_SIZE = 64;
|
|
31
|
+
export const BINARY_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
export const BINARY_FLAG_FP32 = 0;
|
|
34
|
+
export const BINARY_FLAG_INT8 = 1;
|
|
35
|
+
|
|
36
|
+
export interface BinaryIndexHeader {
|
|
37
|
+
version: number;
|
|
38
|
+
flags: number;
|
|
39
|
+
dim: number;
|
|
40
|
+
chunkCount: number;
|
|
41
|
+
vectorsOffset: number;
|
|
42
|
+
vectorsBytes: number;
|
|
43
|
+
metadataOffset: number;
|
|
44
|
+
metadataBytes: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SerializeBinaryOptions {
|
|
48
|
+
quantize?: 'fp32' | 'int8';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function serializeBinaryIndex(
|
|
52
|
+
index: PersistedIndex,
|
|
53
|
+
dim: number,
|
|
54
|
+
options: SerializeBinaryOptions = {}
|
|
55
|
+
): Buffer {
|
|
56
|
+
const isInt8 = options.quantize === 'int8';
|
|
57
|
+
const bytesPerElement = isInt8 ? 1 : 4;
|
|
58
|
+
const count = index.chunks.length;
|
|
59
|
+
const vectorsBytes = count * dim * bytesPerElement;
|
|
60
|
+
const vectorsOffset = BINARY_HEADER_SIZE;
|
|
61
|
+
const flags = isInt8 ? BINARY_FLAG_INT8 : BINARY_FLAG_FP32;
|
|
62
|
+
|
|
63
|
+
// Build contiguous typed array
|
|
64
|
+
const rawArray = isInt8 ? new Int8Array(count * dim) : new Float32Array(count * dim);
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < count; i++) {
|
|
67
|
+
const chunk = index.chunks[i];
|
|
68
|
+
let v: ArrayLike<number>;
|
|
69
|
+
if (typeof chunk.vec === 'string') {
|
|
70
|
+
v = decodeVec(chunk.vec, dim);
|
|
71
|
+
} else if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array || Array.isArray(chunk.vec)) {
|
|
72
|
+
v = chunk.vec;
|
|
73
|
+
} else {
|
|
74
|
+
throw new Error(`chunk ${i} is missing vector for binary serialization`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isInt8) {
|
|
78
|
+
const q = v instanceof Int8Array ? v : quantizeToInt8(v);
|
|
79
|
+
(rawArray as Int8Array).set(q, i * dim);
|
|
80
|
+
} else {
|
|
81
|
+
(rawArray as Float32Array).set(v, i * dim);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build metadata JSON omitting per-chunk vec strings to save space
|
|
86
|
+
const metadataChunks: Array<Omit<IndexChunk, 'vec'>> = index.chunks.map((c) => ({
|
|
87
|
+
file: c.file,
|
|
88
|
+
title: c.title,
|
|
89
|
+
heading: c.heading,
|
|
90
|
+
headingPath: c.headingPath,
|
|
91
|
+
text: c.text,
|
|
92
|
+
chunkHash: c.chunkHash,
|
|
93
|
+
startLine: c.startLine,
|
|
94
|
+
endLine: c.endLine,
|
|
95
|
+
meta: c.meta,
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
const metadataObj = {
|
|
99
|
+
schemaVersion: index.schemaVersion,
|
|
100
|
+
format: 'binary-v1',
|
|
101
|
+
model: index.model,
|
|
102
|
+
modelAlias: index.modelAlias,
|
|
103
|
+
adapterFingerprint: index.adapterFingerprint,
|
|
104
|
+
dim,
|
|
105
|
+
db: index.db,
|
|
106
|
+
built: index.built,
|
|
107
|
+
complete: index.complete,
|
|
108
|
+
chunkCount: count,
|
|
109
|
+
quantized: isInt8 ? 'int8' : 'fp32',
|
|
110
|
+
lexical: index.lexical,
|
|
111
|
+
lexicalFormat: index.lexicalFormat,
|
|
112
|
+
chunks: metadataChunks,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const metadataBuffer = Buffer.from(JSON.stringify(metadataObj), 'utf8');
|
|
116
|
+
const metadataOffset = vectorsOffset + vectorsBytes;
|
|
117
|
+
const metadataBytes = metadataBuffer.length;
|
|
118
|
+
|
|
119
|
+
const totalSize = metadataOffset + metadataBytes;
|
|
120
|
+
const out = Buffer.alloc(totalSize);
|
|
121
|
+
|
|
122
|
+
// Write 64-byte header
|
|
123
|
+
out.write(BINARY_MAGIC, 0, 8, 'ascii');
|
|
124
|
+
out.writeUInt32LE(BINARY_VERSION, 8);
|
|
125
|
+
out.writeUInt32LE(flags, 12);
|
|
126
|
+
out.writeUInt32LE(dim, 16);
|
|
127
|
+
out.writeUInt32LE(count, 20);
|
|
128
|
+
out.writeBigUInt64LE(BigInt(vectorsOffset), 24);
|
|
129
|
+
out.writeBigUInt64LE(BigInt(vectorsBytes), 32);
|
|
130
|
+
out.writeBigUInt64LE(BigInt(metadataOffset), 40);
|
|
131
|
+
out.writeBigUInt64LE(BigInt(metadataBytes), 48);
|
|
132
|
+
|
|
133
|
+
// Copy vectors
|
|
134
|
+
Buffer.from(rawArray.buffer, rawArray.byteOffset, rawArray.byteLength).copy(out, vectorsOffset);
|
|
135
|
+
|
|
136
|
+
// Copy metadata
|
|
137
|
+
metadataBuffer.copy(out, metadataOffset);
|
|
138
|
+
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readBinaryHeader(buffer: Buffer): BinaryIndexHeader {
|
|
143
|
+
if (buffer.length < BINARY_HEADER_SIZE) {
|
|
144
|
+
throw new Error(`Invalid binary index: file too small (${buffer.length} bytes, expected at least ${BINARY_HEADER_SIZE})`);
|
|
145
|
+
}
|
|
146
|
+
const magic = buffer.toString('ascii', 0, 8);
|
|
147
|
+
if (magic !== BINARY_MAGIC) {
|
|
148
|
+
throw new Error(`Invalid binary index magic: expected "${BINARY_MAGIC}", got "${magic}"`);
|
|
149
|
+
}
|
|
150
|
+
const version = buffer.readUInt32LE(8);
|
|
151
|
+
const flags = buffer.readUInt32LE(12);
|
|
152
|
+
const dim = buffer.readUInt32LE(16);
|
|
153
|
+
const chunkCount = buffer.readUInt32LE(20);
|
|
154
|
+
const vectorsOffset = Number(buffer.readBigUInt64LE(24));
|
|
155
|
+
const vectorsBytes = Number(buffer.readBigUInt64LE(32));
|
|
156
|
+
const metadataOffset = Number(buffer.readBigUInt64LE(40));
|
|
157
|
+
const metadataBytes = Number(buffer.readBigUInt64LE(48));
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
version,
|
|
161
|
+
flags,
|
|
162
|
+
dim,
|
|
163
|
+
chunkCount,
|
|
164
|
+
vectorsOffset,
|
|
165
|
+
vectorsBytes,
|
|
166
|
+
metadataOffset,
|
|
167
|
+
metadataBytes,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function deserializeBinaryIndex(
|
|
172
|
+
buffer: Buffer
|
|
173
|
+
): PersistedIndex & { rawVectorsBuffer: Float32Array | Int8Array; quantized?: 'fp32' | 'int8' } {
|
|
174
|
+
const header = readBinaryHeader(buffer);
|
|
175
|
+
const { flags, dim, chunkCount, vectorsOffset, vectorsBytes, metadataOffset, metadataBytes } = header;
|
|
176
|
+
const isInt8 = (flags & BINARY_FLAG_INT8) !== 0;
|
|
177
|
+
const expectedVectorsBytes = chunkCount * dim * (isInt8 ? 1 : 4);
|
|
178
|
+
if (buffer.length < vectorsOffset + expectedVectorsBytes || buffer.length < metadataOffset + metadataBytes) {
|
|
179
|
+
throw new Error('Corrupt binary index: unexpected truncated file');
|
|
180
|
+
}
|
|
181
|
+
const byteOffset = buffer.byteOffset + vectorsOffset;
|
|
182
|
+
let rawVectorsBuffer: Float32Array | Int8Array;
|
|
183
|
+
|
|
184
|
+
if (isInt8) {
|
|
185
|
+
rawVectorsBuffer = new Int8Array(buffer.buffer, byteOffset, chunkCount * dim);
|
|
186
|
+
} else {
|
|
187
|
+
// Aligned 4-byte Float32Array view
|
|
188
|
+
if (byteOffset % 4 === 0) {
|
|
189
|
+
rawVectorsBuffer = new Float32Array(buffer.buffer, byteOffset, chunkCount * dim);
|
|
190
|
+
} else {
|
|
191
|
+
const aligned = Buffer.alloc(vectorsBytes);
|
|
192
|
+
buffer.copy(aligned, 0, vectorsOffset, vectorsOffset + vectorsBytes);
|
|
193
|
+
rawVectorsBuffer = new Float32Array(aligned.buffer, aligned.byteOffset, chunkCount * dim);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Deserialize metadata JSON
|
|
198
|
+
const metaJson = buffer.toString('utf8', metadataOffset, metadataOffset + metadataBytes);
|
|
199
|
+
const parsed = JSON.parse(metaJson);
|
|
200
|
+
|
|
201
|
+
// Re-attach subarray views to chunks
|
|
202
|
+
const chunks: IndexChunk[] = parsed.chunks.map((c: any, i: number) => {
|
|
203
|
+
const vecSlice = rawVectorsBuffer.subarray(i * dim, (i + 1) * dim);
|
|
204
|
+
return {
|
|
205
|
+
...c,
|
|
206
|
+
vec: vecSlice,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
...parsed,
|
|
212
|
+
chunks,
|
|
213
|
+
rawVectorsBuffer,
|
|
214
|
+
quantized: isInt8 ? 'int8' : 'fp32',
|
|
215
|
+
};
|
|
216
|
+
}
|