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.
Files changed (52) hide show
  1. package/README.md +44 -1
  2. package/bin/cli.mjs +144 -30
  3. package/dist/binary-format.d.ts +25 -0
  4. package/dist/binary-format.js +178 -0
  5. package/dist/core.d.ts +3 -2
  6. package/dist/core.js +26 -31
  7. package/dist/federation.d.ts +15 -0
  8. package/dist/federation.js +94 -0
  9. package/dist/filter.d.ts +29 -0
  10. package/dist/filter.js +317 -0
  11. package/dist/index-format.js +1 -1
  12. package/dist/index.d.ts +9 -1
  13. package/dist/index.js +8 -0
  14. package/dist/indexer.d.ts +13 -0
  15. package/dist/indexer.js +21 -7
  16. package/dist/markdown-parser.d.ts +1 -0
  17. package/dist/markdown-parser.js +71 -12
  18. package/dist/mcp.d.ts +54 -6
  19. package/dist/mcp.js +59 -11
  20. package/dist/quantization.d.ts +24 -0
  21. package/dist/quantization.js +85 -0
  22. package/dist/rag.d.ts +49 -0
  23. package/dist/rag.js +128 -0
  24. package/dist/search.d.ts +20 -6
  25. package/dist/search.js +175 -23
  26. package/dist/serve.js +44 -3
  27. package/dist/summarize.d.ts +16 -0
  28. package/dist/summarize.js +82 -0
  29. package/dist/tui.d.ts +1 -0
  30. package/dist/tui.js +36 -13
  31. package/dist/watcher.d.ts +11 -0
  32. package/dist/watcher.js +138 -0
  33. package/dist/wikilinks.d.ts +22 -0
  34. package/dist/wikilinks.js +100 -0
  35. package/package.json +3 -1
  36. package/src/binary-format.ts +216 -0
  37. package/src/core.ts +45 -37
  38. package/src/federation.ts +118 -0
  39. package/src/filter.ts +368 -0
  40. package/src/index-format.ts +1 -1
  41. package/src/index.ts +39 -1
  42. package/src/indexer.ts +42 -10
  43. package/src/markdown-parser.ts +80 -10
  44. package/src/mcp.ts +57 -11
  45. package/src/quantization.ts +93 -0
  46. package/src/rag.ts +189 -0
  47. package/src/search.ts +202 -25
  48. package/src/serve.ts +46 -4
  49. package/src/summarize.ts +98 -0
  50. package/src/tui.ts +36 -12
  51. package/src/watcher.ts +157 -0
  52. 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 headerStr = ` mdss interactive search | Query: ${query}${searchStatus}`;
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(4, Math.floor((rows - 4) * 0.4));
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
- buf += `\x1b[1m--- Passage Preview ---\x1b[0m\n`;
94
- const selectedHit = results[selectedIndex];
95
- if (selectedHit) {
96
- const textLines = selectedHit.snippet.split('\n');
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 < textLines.length) {
99
- buf += ` ${textLines[i].slice(0, cols - 3)}\n`;
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 += ' (no passage selected)\n';
108
- for (let i = 1; i < previewHeight; i++)
109
- buf += '\n';
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/j/k]: Navigate | [Enter]: Open in Editor | [Esc/q/Ctrl+C]: Quit';
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;
@@ -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
+ }
@@ -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.5.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
+ }