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/src/indexer.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
} from './lexical.js';
|
|
37
37
|
import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
|
|
38
38
|
import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
|
|
39
|
+
import { DocumentMetadata } from './frontmatter.js';
|
|
40
|
+
import { serializeBinaryIndex } from './binary-format.js';
|
|
39
41
|
|
|
40
42
|
export interface IndexChunk {
|
|
41
43
|
file: string;
|
|
@@ -45,6 +47,9 @@ export interface IndexChunk {
|
|
|
45
47
|
text: string;
|
|
46
48
|
vec?: number[] | Float32Array | string;
|
|
47
49
|
chunkHash?: string;
|
|
50
|
+
startLine?: number;
|
|
51
|
+
endLine?: number;
|
|
52
|
+
meta?: DocumentMetadata;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export interface PersistedIndex {
|
|
@@ -60,11 +65,19 @@ export interface PersistedIndex {
|
|
|
60
65
|
chunkCount?: number;
|
|
61
66
|
hashes?: Record<string, string>;
|
|
62
67
|
lexical?: LexicalIndex;
|
|
68
|
+
lexicalFormat?: string;
|
|
63
69
|
chunks: IndexChunk[];
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex');
|
|
67
|
-
const sha256 = (s: string) => crypto.createHash('sha256').update(s).digest('hex');
|
|
73
|
+
const sha256 = (s: string): string => crypto.createHash('sha256').update(s).digest('hex');
|
|
74
|
+
|
|
75
|
+
export const _stats = {
|
|
76
|
+
chunkHits: 0,
|
|
77
|
+
fileHits: 0,
|
|
78
|
+
embedCount: 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
68
81
|
const BATCH = 32;
|
|
69
82
|
const CHECKPOINT_BATCHES = 8;
|
|
70
83
|
const INDEX_FORMAT = 'binary-v1';
|
|
@@ -89,16 +102,17 @@ const loadJSON = <T>(p: string, fb: T, warn: (msg: string) => void = () => {}):
|
|
|
89
102
|
}
|
|
90
103
|
};
|
|
91
104
|
|
|
92
|
-
function atomicWrite(target: string, data: string): void {
|
|
105
|
+
function atomicWrite(target: string, data: string | Buffer): void {
|
|
93
106
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
94
107
|
fs.writeFileSync(tmp, data);
|
|
95
108
|
fs.renameSync(tmp, target);
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
const base = path.basename(target);
|
|
111
|
+
if (base === 'vectors.json' || base === 'vectors.bin') {
|
|
98
112
|
const digest = crypto.createHash('sha256').update(data).digest('hex');
|
|
99
113
|
const shaPath = `${target}.sha256`;
|
|
100
114
|
const shaTmp = `${shaPath}.${process.pid}.tmp`;
|
|
101
|
-
fs.writeFileSync(shaTmp, `${digest}
|
|
115
|
+
fs.writeFileSync(shaTmp, `${digest} ${base}\n`);
|
|
102
116
|
fs.renameSync(shaTmp, shaPath);
|
|
103
117
|
}
|
|
104
118
|
}
|
|
@@ -107,16 +121,24 @@ function normalize(s?: string): string {
|
|
|
107
121
|
return (s ?? '').replace(/\r\n?/g, '\n').trim();
|
|
108
122
|
}
|
|
109
123
|
|
|
110
|
-
export function canonicalPassage(chunk: {
|
|
124
|
+
export function canonicalPassage(chunk: {
|
|
125
|
+
title: string;
|
|
126
|
+
heading: string;
|
|
127
|
+
headingPath?: string[];
|
|
128
|
+
text: string;
|
|
129
|
+
meta?: DocumentMetadata;
|
|
130
|
+
}): string {
|
|
111
131
|
const title = normalize(chunk.title);
|
|
112
132
|
const headingPath = (chunk.headingPath ?? (normalize(chunk.heading) ? [chunk.heading] : [])).map(normalize);
|
|
113
133
|
const serializedPath = (headingPath[0] === title ? headingPath.slice(1) : headingPath).join(' > ');
|
|
114
|
-
|
|
134
|
+
const tagsStr = chunk.meta?.tags && chunk.meta.tags.length > 0 ? `tags: ${chunk.meta.tags.map(t => `#${t}`).join(' ')}` : '';
|
|
135
|
+
const parts = [title, serializedPath, tagsStr, normalize(chunk.text)].filter(Boolean);
|
|
136
|
+
return parts.join('\n');
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
export function chunkHash(
|
|
118
140
|
model: { id: string; revision?: string; passagePrefix?: string; pooling?: string },
|
|
119
|
-
chunk: { title: string; heading: string; headingPath?: string[]; text: string }
|
|
141
|
+
chunk: { title: string; heading: string; headingPath?: string[]; text: string; meta?: DocumentMetadata }
|
|
120
142
|
): string {
|
|
121
143
|
const pooling = model.pooling ?? 'mean';
|
|
122
144
|
const input = [
|
|
@@ -143,6 +165,7 @@ export interface BuildIndexOptions {
|
|
|
143
165
|
onProgress?: (done: number, total: number, chunksPerSec: number) => void;
|
|
144
166
|
workers?: number;
|
|
145
167
|
ann?: boolean;
|
|
168
|
+
quantize?: 'fp32' | 'int8';
|
|
146
169
|
}
|
|
147
170
|
|
|
148
171
|
export interface BuildIndexResult {
|
|
@@ -173,11 +196,12 @@ export async function buildIndex(opts: BuildIndexOptions): Promise<BuildIndexRes
|
|
|
173
196
|
onProgress,
|
|
174
197
|
workers = 1,
|
|
175
198
|
ann = false,
|
|
199
|
+
quantize = 'fp32',
|
|
176
200
|
} = opts;
|
|
177
201
|
if (_lockHeld)
|
|
178
|
-
return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann });
|
|
202
|
+
return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize });
|
|
179
203
|
return withIndexLock(indexDir, () =>
|
|
180
|
-
_buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann })
|
|
204
|
+
_buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize })
|
|
181
205
|
);
|
|
182
206
|
}
|
|
183
207
|
|
|
@@ -194,10 +218,12 @@ async function _buildIndexInner({
|
|
|
194
218
|
onProgress,
|
|
195
219
|
workers = 1,
|
|
196
220
|
ann = false,
|
|
197
|
-
|
|
221
|
+
quantize = 'fp32',
|
|
222
|
+
}: Required<Omit<BuildIndexOptions, '_lockHeld' | 'onProgress' | 'workers' | 'ann' | 'quantize'>> & {
|
|
198
223
|
onProgress?: (done: number, total: number, chunksPerSec: number) => void;
|
|
199
224
|
workers?: number;
|
|
200
225
|
ann?: boolean;
|
|
226
|
+
quantize?: 'fp32' | 'int8';
|
|
201
227
|
}): Promise<BuildIndexResult> {
|
|
202
228
|
const model = resolveModel(modelName);
|
|
203
229
|
const modelIdentity = `${model.id}@${model.revision || 'main'}`;
|
|
@@ -557,6 +583,12 @@ async function _buildIndexInner({
|
|
|
557
583
|
atomicWrite(vectorsPath, JSON.stringify(index));
|
|
558
584
|
atomicWrite(hashesPath, JSON.stringify(newHashes, null, 2));
|
|
559
585
|
|
|
586
|
+
const vectorsBinPath = path.join(indexDir, 'vectors.bin');
|
|
587
|
+
if (dim !== undefined && dim > 0) {
|
|
588
|
+
const binBuffer = serializeBinaryIndex(index as any, dim, { quantize });
|
|
589
|
+
atomicWrite(vectorsBinPath, binBuffer);
|
|
590
|
+
}
|
|
591
|
+
|
|
560
592
|
const ivfPath = path.join(indexDir, 'ivf.json');
|
|
561
593
|
if (ann || chunks.length >= ANN_THRESHOLD) {
|
|
562
594
|
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec as number[])));
|
package/src/markdown-parser.ts
CHANGED
|
@@ -226,7 +226,7 @@ export function chunkMarkdownStructural(
|
|
|
226
226
|
if (blockText.length > maxChunk) {
|
|
227
227
|
flushGroup();
|
|
228
228
|
const headingPath = headingStack.map(h => h.heading);
|
|
229
|
-
const subPieces = splitOversizedBlock(blockText, maxChunk);
|
|
229
|
+
const subPieces = splitOversizedBlock(blockText, maxChunk, block.type);
|
|
230
230
|
let lineCursor = block.startLine;
|
|
231
231
|
for (let idx = 0; idx < subPieces.length; idx++) {
|
|
232
232
|
const piece = subPieces[idx];
|
|
@@ -261,8 +261,81 @@ export function chunkMarkdownStructural(
|
|
|
261
261
|
return chunks.filter(c => c.text.replace(/\s/g, '').length >= 24);
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
function
|
|
264
|
+
function splitLongLine(line: string, maxChunk: number): string[] {
|
|
265
|
+
const pieces: string[] = [];
|
|
266
|
+
let rest = line;
|
|
267
|
+
while (rest.length > maxChunk) {
|
|
268
|
+
let cut = rest.lastIndexOf(' ', maxChunk);
|
|
269
|
+
if (cut <= 0) cut = maxChunk;
|
|
270
|
+
pieces.push(rest.slice(0, cut).trim());
|
|
271
|
+
rest = rest.slice(cut).trim();
|
|
272
|
+
}
|
|
273
|
+
if (rest) pieces.push(rest);
|
|
274
|
+
return pieces;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function splitOversizedBlock(
|
|
278
|
+
text: string,
|
|
279
|
+
maxChunk: number,
|
|
280
|
+
blockType?: MarkdownBlock['type']
|
|
281
|
+
): string[] {
|
|
265
282
|
const lines = text.split('\n');
|
|
283
|
+
if (lines.length <= 1) {
|
|
284
|
+
return splitLongLine(text, maxChunk);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// If code block with opening fence
|
|
288
|
+
if (blockType === 'code' && lines.length > 2 && /^(\s*)(`{3,}|~{3,})/.test(lines[0])) {
|
|
289
|
+
const openFence = lines[0];
|
|
290
|
+
const hasCloseFence = /^(\s*)(`{3,}|~{3,})/.test(lines[lines.length - 1]);
|
|
291
|
+
const closeFence = hasCloseFence ? lines[lines.length - 1] : '```';
|
|
292
|
+
const innerLines = lines.slice(1, hasCloseFence ? lines.length - 1 : lines.length);
|
|
293
|
+
|
|
294
|
+
const pieces: string[] = [];
|
|
295
|
+
let currentCode: string[] = [];
|
|
296
|
+
let currentLen = openFence.length + closeFence.length + 2;
|
|
297
|
+
|
|
298
|
+
for (const line of innerLines) {
|
|
299
|
+
if (currentLen + line.length + 1 > maxChunk && currentCode.length > 0) {
|
|
300
|
+
pieces.push([openFence, ...currentCode, closeFence].join('\n'));
|
|
301
|
+
currentCode = [];
|
|
302
|
+
currentLen = openFence.length + closeFence.length + 2;
|
|
303
|
+
}
|
|
304
|
+
currentCode.push(line);
|
|
305
|
+
currentLen += line.length + 1;
|
|
306
|
+
}
|
|
307
|
+
if (currentCode.length > 0) {
|
|
308
|
+
pieces.push([openFence, ...currentCode, closeFence].join('\n'));
|
|
309
|
+
}
|
|
310
|
+
if (pieces.length > 0) return pieces;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// If table with header + separator rows
|
|
314
|
+
if (blockType === 'table' && lines.length > 2 && /^\s*\|?\s*[-:]+[-|\s:]*$/.test(lines[1])) {
|
|
315
|
+
const headerRows = [lines[0], lines[1]];
|
|
316
|
+
const dataRows = lines.slice(2);
|
|
317
|
+
const headerLen = headerRows[0].length + headerRows[1].length + 2;
|
|
318
|
+
|
|
319
|
+
const pieces: string[] = [];
|
|
320
|
+
let currentRows: string[] = [];
|
|
321
|
+
let currentLen = headerLen;
|
|
322
|
+
|
|
323
|
+
for (const row of dataRows) {
|
|
324
|
+
if (currentLen + row.length + 1 > maxChunk && currentRows.length > 0) {
|
|
325
|
+
pieces.push([...headerRows, ...currentRows].join('\n'));
|
|
326
|
+
currentRows = [];
|
|
327
|
+
currentLen = headerLen;
|
|
328
|
+
}
|
|
329
|
+
currentRows.push(row);
|
|
330
|
+
currentLen += row.length + 1;
|
|
331
|
+
}
|
|
332
|
+
if (currentRows.length > 0) {
|
|
333
|
+
pieces.push([...headerRows, ...currentRows].join('\n'));
|
|
334
|
+
}
|
|
335
|
+
if (pieces.length > 0) return pieces;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Standard paragraph/list splitting
|
|
266
339
|
const pieces: string[] = [];
|
|
267
340
|
let currentLines: string[] = [];
|
|
268
341
|
let currentLen = 0;
|
|
@@ -279,15 +352,11 @@ function splitOversizedBlock(text: string, maxChunk: number): string[] {
|
|
|
279
352
|
pieces.push(currentLines.join('\n').trim());
|
|
280
353
|
currentLines = [];
|
|
281
354
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (cut <= 0) cut = maxChunk;
|
|
286
|
-
pieces.push(rest.slice(0, cut).trim());
|
|
287
|
-
rest = rest.slice(cut).trim();
|
|
355
|
+
const splitLines = splitLongLine(line, maxChunk);
|
|
356
|
+
for (const piece of splitLines) {
|
|
357
|
+
pieces.push(piece);
|
|
288
358
|
}
|
|
289
|
-
|
|
290
|
-
currentLen = rest.length;
|
|
359
|
+
currentLen = 0;
|
|
291
360
|
continue;
|
|
292
361
|
}
|
|
293
362
|
|
|
@@ -301,3 +370,4 @@ function splitOversizedBlock(text: string, maxChunk: number): string[] {
|
|
|
301
370
|
|
|
302
371
|
return pieces;
|
|
303
372
|
}
|
|
373
|
+
|
package/src/mcp.ts
CHANGED
|
@@ -8,6 +8,8 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { buildIndex } from './indexer.js';
|
|
10
10
|
import { loadIndex, searchIndex } from './search.js';
|
|
11
|
+
import { searchFederated } from './federation.js';
|
|
12
|
+
import { askQuestion } from './rag.js';
|
|
11
13
|
import { globToRegExp } from './core.js';
|
|
12
14
|
|
|
13
15
|
export const MCP_TOOLS = [
|
|
@@ -20,7 +22,10 @@ export const MCP_TOOLS = [
|
|
|
20
22
|
query: { type: 'string', description: 'Natural language search query' },
|
|
21
23
|
k: { type: 'number', description: 'Number of top results to return (default: 6)' },
|
|
22
24
|
path: { type: 'string', description: 'Optional path glob filter (e.g. "docs/**")' },
|
|
25
|
+
vaults: { type: 'array', items: { type: 'string' }, description: 'Optional list of vault directories for federated search' },
|
|
23
26
|
tag: { type: 'array', items: { type: 'string' }, description: 'Optional frontmatter tag filters' },
|
|
27
|
+
filter: { type: 'string', description: 'Optional rich boolean filter expression (e.g. "tag:engineering AND status != archived")' },
|
|
28
|
+
graphBoost: { type: 'number', description: 'Optional boost weight for graph PageRank / wikilinks (0.0 to 1.0)' },
|
|
24
29
|
},
|
|
25
30
|
required: ['query'],
|
|
26
31
|
},
|
|
@@ -55,6 +60,18 @@ export const MCP_TOOLS = [
|
|
|
55
60
|
properties: {},
|
|
56
61
|
},
|
|
57
62
|
},
|
|
63
|
+
{
|
|
64
|
+
name: 'ask_knowledge_base',
|
|
65
|
+
description: 'Synthesize a grounded direct answer with citations from the local Markdown knowledge base',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
query: { type: 'string', description: 'Question to ask the knowledge base' },
|
|
70
|
+
k: { type: 'number', description: 'Number of candidate passages to retrieve (default: 5)' },
|
|
71
|
+
},
|
|
72
|
+
required: ['query'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
58
75
|
];
|
|
59
76
|
|
|
60
77
|
export async function handleMcpRequest(req: any, state: { loaded: any; cacheDir: string; offline: boolean; embedFn?: any }): Promise<any | null> {
|
|
@@ -76,7 +93,7 @@ export async function handleMcpRequest(req: any, state: { loaded: any; cacheDir:
|
|
|
76
93
|
},
|
|
77
94
|
serverInfo: {
|
|
78
95
|
name: 'md-semantic-search',
|
|
79
|
-
version: '0.
|
|
96
|
+
version: '1.0.0',
|
|
80
97
|
},
|
|
81
98
|
},
|
|
82
99
|
};
|
|
@@ -101,16 +118,34 @@ export async function handleMcpRequest(req: any, state: { loaded: any; cacheDir:
|
|
|
101
118
|
if (name === 'search_markdown') {
|
|
102
119
|
const query = typeof args.query === 'string' ? args.query : '';
|
|
103
120
|
const k = Number.isInteger(args.k) && args.k > 0 ? args.k : 6;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
let results: any[];
|
|
122
|
+
if (Array.isArray(args.vaults) && args.vaults.length > 0) {
|
|
123
|
+
results = await searchFederated({
|
|
124
|
+
vaults: args.vaults,
|
|
125
|
+
cacheDir: state.cacheDir,
|
|
126
|
+
query,
|
|
127
|
+
k,
|
|
128
|
+
path: args.path,
|
|
129
|
+
tag: args.tag,
|
|
130
|
+
filter: typeof args.filter === 'string' ? args.filter : undefined,
|
|
131
|
+
graphBoost: typeof args.graphBoost === 'number' ? args.graphBoost : undefined,
|
|
132
|
+
offline: state.offline,
|
|
133
|
+
embedFn: state.embedFn,
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
results = await searchIndex({
|
|
137
|
+
loaded: state.loaded,
|
|
138
|
+
cacheDir: state.cacheDir,
|
|
139
|
+
query,
|
|
140
|
+
k,
|
|
141
|
+
path: args.path,
|
|
142
|
+
tag: args.tag,
|
|
143
|
+
filter: typeof args.filter === 'string' ? args.filter : undefined,
|
|
144
|
+
graphBoost: typeof args.graphBoost === 'number' ? args.graphBoost : undefined,
|
|
145
|
+
offline: state.offline,
|
|
146
|
+
embedFn: state.embedFn,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
114
149
|
content = [{ type: 'text', text: JSON.stringify(results, null, 2) }];
|
|
115
150
|
} else if (name === 'get_chunk') {
|
|
116
151
|
const file = args.file;
|
|
@@ -141,6 +176,17 @@ export async function handleMcpRequest(req: any, state: { loaded: any; cacheDir:
|
|
|
141
176
|
built: state.loaded.index.built || null,
|
|
142
177
|
};
|
|
143
178
|
content = [{ type: 'text', text: JSON.stringify(info, null, 2) }];
|
|
179
|
+
} else if (name === 'ask_knowledge_base') {
|
|
180
|
+
const query = args.query;
|
|
181
|
+
const k = typeof args.k === 'number' ? args.k : 5;
|
|
182
|
+
const answerResult = await askQuestion({
|
|
183
|
+
loaded: state.loaded,
|
|
184
|
+
cacheDir: state.cacheDir,
|
|
185
|
+
query,
|
|
186
|
+
k,
|
|
187
|
+
embedFn: state.embedFn,
|
|
188
|
+
});
|
|
189
|
+
content = [{ type: 'text', text: JSON.stringify(answerResult, null, 2) }];
|
|
144
190
|
} else {
|
|
145
191
|
return {
|
|
146
192
|
jsonrpc: '2.0',
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Int8 Scalar Quantization Tier (issue #96).
|
|
3
|
+
*
|
|
4
|
+
* Maps normalized Float32 unit vectors in [-1.0, 1.0] to signed 8-bit integers in [-128, 127],
|
|
5
|
+
* providing 4x memory savings with <1% loss in ranking precision.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const INT8_SCALE = 127.0;
|
|
9
|
+
export const INT8_INV_SCALE = 1.0 / 127.0;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Quantizes an L2-normalized float vector into a signed Int8Array.
|
|
13
|
+
*/
|
|
14
|
+
export function quantizeToInt8(vec: ArrayLike<number>): Int8Array {
|
|
15
|
+
const len = vec.length;
|
|
16
|
+
const out = new Int8Array(len);
|
|
17
|
+
for (let i = 0; i < len; i++) {
|
|
18
|
+
const val = vec[i];
|
|
19
|
+
// Clamp to [-1.0, 1.0] and scale to [-128, 127]
|
|
20
|
+
const clamped = val < -1.0 ? -1.0 : val > 1.0 ? 1.0 : val;
|
|
21
|
+
const scaled = Math.round(clamped * INT8_SCALE);
|
|
22
|
+
out[i] = scaled < -128 ? -128 : scaled > 127 ? 127 : scaled;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Dequantizes an Int8Array back into an approximate Float32Array.
|
|
29
|
+
*/
|
|
30
|
+
export function dequantizeFromInt8(vec: Int8Array): Float32Array {
|
|
31
|
+
const len = vec.length;
|
|
32
|
+
const out = new Float32Array(len);
|
|
33
|
+
for (let i = 0; i < len; i++) {
|
|
34
|
+
out[i] = vec[i] * INT8_INV_SCALE;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Computes asymmetric cosine similarity between a normalized Float32 query vector
|
|
41
|
+
* and a quantized Int8 chunk vector.
|
|
42
|
+
*
|
|
43
|
+
* Since query is unit length (||q|| = 1.0),
|
|
44
|
+
* cosine(q, c) = (q · c_fp32) / ||c_fp32|| = (sum(q_i * c_i) / 127) / (||c|| / 127) = sum(q_i * c_i) / ||c||.
|
|
45
|
+
*/
|
|
46
|
+
export function asymmetricCosineInt8(queryFp32: Float32Array, chunkInt8: Int8Array): number {
|
|
47
|
+
const dim = queryFp32.length;
|
|
48
|
+
let dot = 0.0;
|
|
49
|
+
let normSq = 0.0;
|
|
50
|
+
|
|
51
|
+
// Unroll loop in steps of 8 for performance
|
|
52
|
+
let i = 0;
|
|
53
|
+
const limit = dim - (dim % 8);
|
|
54
|
+
for (; i < limit; i += 8) {
|
|
55
|
+
const c0 = chunkInt8[i];
|
|
56
|
+
const c1 = chunkInt8[i + 1];
|
|
57
|
+
const c2 = chunkInt8[i + 2];
|
|
58
|
+
const c3 = chunkInt8[i + 3];
|
|
59
|
+
const c4 = chunkInt8[i + 4];
|
|
60
|
+
const c5 = chunkInt8[i + 5];
|
|
61
|
+
const c6 = chunkInt8[i + 6];
|
|
62
|
+
const c7 = chunkInt8[i + 7];
|
|
63
|
+
|
|
64
|
+
dot +=
|
|
65
|
+
queryFp32[i] * c0 +
|
|
66
|
+
queryFp32[i + 1] * c1 +
|
|
67
|
+
queryFp32[i + 2] * c2 +
|
|
68
|
+
queryFp32[i + 3] * c3 +
|
|
69
|
+
queryFp32[i + 4] * c4 +
|
|
70
|
+
queryFp32[i + 5] * c5 +
|
|
71
|
+
queryFp32[i + 6] * c6 +
|
|
72
|
+
queryFp32[i + 7] * c7;
|
|
73
|
+
|
|
74
|
+
normSq +=
|
|
75
|
+
c0 * c0 +
|
|
76
|
+
c1 * c1 +
|
|
77
|
+
c2 * c2 +
|
|
78
|
+
c3 * c3 +
|
|
79
|
+
c4 * c4 +
|
|
80
|
+
c5 * c5 +
|
|
81
|
+
c6 * c6 +
|
|
82
|
+
c7 * c7;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (; i < dim; i++) {
|
|
86
|
+
const c = chunkInt8[i];
|
|
87
|
+
dot += queryFp32[i] * c;
|
|
88
|
+
normSq += c * c;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (normSq <= 0) return 0.0;
|
|
92
|
+
return dot / Math.sqrt(normSq);
|
|
93
|
+
}
|
package/src/rag.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline QA & Local RAG Answer Synthesis (issue #101).
|
|
3
|
+
*
|
|
4
|
+
* Implements grounded question answering with strict provenance citations ([file#heading]),
|
|
5
|
+
* supporting local LLM bridges (Ollama / LocalAI / OpenAI-compatible endpoint) and a
|
|
6
|
+
* fast built-in zero-dependency extractive synthesis fallback.
|
|
7
|
+
*/
|
|
8
|
+
import { searchIndex, loadIndex, LoadedIndex, SearchResultHit } from './search.js';
|
|
9
|
+
import { tokenize } from './lexical.js';
|
|
10
|
+
|
|
11
|
+
export interface Citation {
|
|
12
|
+
file: string;
|
|
13
|
+
heading?: string;
|
|
14
|
+
score: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SynthesisResult {
|
|
18
|
+
answer: string;
|
|
19
|
+
citations: Citation[];
|
|
20
|
+
passages: SearchResultHit[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AskOptions {
|
|
24
|
+
query: string;
|
|
25
|
+
loaded?: LoadedIndex;
|
|
26
|
+
indexDir?: string;
|
|
27
|
+
cacheDir?: string;
|
|
28
|
+
db?: string;
|
|
29
|
+
k?: number;
|
|
30
|
+
llmEndpoint?: string;
|
|
31
|
+
llmModel?: string;
|
|
32
|
+
llmFn?: (prompt: string) => Promise<string>;
|
|
33
|
+
systemPrompt?: string;
|
|
34
|
+
embedFn?: any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_RAG_SYSTEM_PROMPT =
|
|
38
|
+
'You are a helpful assistant answering questions using only the provided Markdown notes. ' +
|
|
39
|
+
'Always cite your sources using [file#heading] format. ' +
|
|
40
|
+
'If the context does not contain the answer, say that the notes do not have enough information.';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
|
|
44
|
+
*/
|
|
45
|
+
export function extractAnswerFallback(query: string, passages: SearchResultHit[]): string {
|
|
46
|
+
if (passages.length === 0) {
|
|
47
|
+
return 'No relevant passages found in the notes to answer the question.';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const queryTokens = new Set(tokenize(query));
|
|
51
|
+
const candidateSentences: Array<{ sentence: string; score: number; citation: string }> = [];
|
|
52
|
+
|
|
53
|
+
for (const hit of passages) {
|
|
54
|
+
const text = hit.snippet || '';
|
|
55
|
+
const cite = hit.heading ? `[${hit.file} › ${hit.heading}]` : `[${hit.file}]`;
|
|
56
|
+
// Split into sentences
|
|
57
|
+
const sentences = text
|
|
58
|
+
.split(/(?<=[.?!])\s+|\n+/)
|
|
59
|
+
.map((s: string) => s.trim())
|
|
60
|
+
.filter((s: string) => s.length > 15);
|
|
61
|
+
|
|
62
|
+
for (const sent of sentences) {
|
|
63
|
+
const sentTokens = tokenize(sent);
|
|
64
|
+
let matchCount = 0;
|
|
65
|
+
for (const tok of sentTokens) {
|
|
66
|
+
if (queryTokens.has(tok)) matchCount++;
|
|
67
|
+
}
|
|
68
|
+
if (matchCount > 0) {
|
|
69
|
+
const score = (matchCount / (sentTokens.length + 1)) * (hit.score || 1);
|
|
70
|
+
candidateSentences.push({ sentence: sent, score, citation: cite });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (candidateSentences.length === 0) {
|
|
76
|
+
// Return top snippets directly
|
|
77
|
+
const top = passages.slice(0, 2).map((p) => {
|
|
78
|
+
const cite = p.heading ? `[${p.file} › ${p.heading}]` : `[${p.file}]`;
|
|
79
|
+
return `${cite}: ${p.snippet.slice(0, 200)}…`;
|
|
80
|
+
});
|
|
81
|
+
return `Relevant excerpts from notes:\n\n${top.join('\n\n')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
candidateSentences.sort((a, b) => b.score - a.score);
|
|
85
|
+
const selected = candidateSentences.slice(0, 3);
|
|
86
|
+
const body = selected.map((s) => `${s.sentence} ${s.citation}`).join('\n\n');
|
|
87
|
+
|
|
88
|
+
return `Based on your notes:\n\n${body}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Synthesizes an answer from retrieved passages using an LLM or fallback.
|
|
93
|
+
*/
|
|
94
|
+
export async function synthesizeAnswer(
|
|
95
|
+
query: string,
|
|
96
|
+
passages: SearchResultHit[],
|
|
97
|
+
options: {
|
|
98
|
+
llmFn?: (prompt: string) => Promise<string>;
|
|
99
|
+
llmEndpoint?: string;
|
|
100
|
+
llmModel?: string;
|
|
101
|
+
systemPrompt?: string;
|
|
102
|
+
} = {}
|
|
103
|
+
): Promise<SynthesisResult> {
|
|
104
|
+
const citations: Citation[] = passages.map((p) => ({
|
|
105
|
+
file: p.file,
|
|
106
|
+
heading: p.heading,
|
|
107
|
+
score: p.score,
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
if (options.llmFn) {
|
|
111
|
+
const contextBlocks = passages.map((p, i) => {
|
|
112
|
+
const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
|
|
113
|
+
return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
|
|
114
|
+
}).join('\n\n---\n\n');
|
|
115
|
+
|
|
116
|
+
const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
|
|
117
|
+
const answer = await options.llmFn(prompt);
|
|
118
|
+
return { answer, citations, passages };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (options.llmEndpoint) {
|
|
122
|
+
const contextBlocks = passages.map((p, i) => {
|
|
123
|
+
const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
|
|
124
|
+
return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
|
|
125
|
+
}).join('\n\n---\n\n');
|
|
126
|
+
|
|
127
|
+
const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
|
|
128
|
+
try {
|
|
129
|
+
const resp = await fetch(`${options.llmEndpoint}/api/generate`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
model: options.llmModel || 'llama3',
|
|
134
|
+
prompt,
|
|
135
|
+
stream: false,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
if (resp.ok) {
|
|
139
|
+
const data: any = await resp.json();
|
|
140
|
+
const answer = data.response || data.text || '';
|
|
141
|
+
if (answer) return { answer, citations, passages };
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Fallback to extractive
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const answer = extractAnswerFallback(query, passages);
|
|
149
|
+
return { answer, citations, passages };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* End-to-end question answering against a Markdown index.
|
|
154
|
+
*/
|
|
155
|
+
export async function askQuestion(opts: AskOptions): Promise<SynthesisResult> {
|
|
156
|
+
const {
|
|
157
|
+
query,
|
|
158
|
+
loaded: inputLoaded,
|
|
159
|
+
indexDir,
|
|
160
|
+
cacheDir = '',
|
|
161
|
+
k = 5,
|
|
162
|
+
llmFn,
|
|
163
|
+
llmEndpoint,
|
|
164
|
+
llmModel,
|
|
165
|
+
systemPrompt,
|
|
166
|
+
embedFn,
|
|
167
|
+
} = opts;
|
|
168
|
+
|
|
169
|
+
let loaded = inputLoaded;
|
|
170
|
+
if (!loaded) {
|
|
171
|
+
if (!indexDir) throw new Error('askQuestion requires either loaded index or indexDir');
|
|
172
|
+
loaded = loadIndex(indexDir);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const passages = await searchIndex({
|
|
176
|
+
loaded,
|
|
177
|
+
cacheDir,
|
|
178
|
+
query,
|
|
179
|
+
k,
|
|
180
|
+
embedFn,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return synthesizeAnswer(query, passages, {
|
|
184
|
+
llmFn,
|
|
185
|
+
llmEndpoint,
|
|
186
|
+
llmModel,
|
|
187
|
+
systemPrompt,
|
|
188
|
+
});
|
|
189
|
+
}
|