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/markdown-parser.js
CHANGED
|
@@ -183,7 +183,7 @@ export function chunkMarkdownStructural(input, opts) {
|
|
|
183
183
|
if (blockText.length > maxChunk) {
|
|
184
184
|
flushGroup();
|
|
185
185
|
const headingPath = headingStack.map(h => h.heading);
|
|
186
|
-
const subPieces = splitOversizedBlock(blockText, maxChunk);
|
|
186
|
+
const subPieces = splitOversizedBlock(blockText, maxChunk, block.type);
|
|
187
187
|
let lineCursor = block.startLine;
|
|
188
188
|
for (let idx = 0; idx < subPieces.length; idx++) {
|
|
189
189
|
const piece = subPieces[idx];
|
|
@@ -213,8 +213,73 @@ export function chunkMarkdownStructural(input, opts) {
|
|
|
213
213
|
flushGroup();
|
|
214
214
|
return chunks.filter(c => c.text.replace(/\s/g, '').length >= 24);
|
|
215
215
|
}
|
|
216
|
-
function
|
|
216
|
+
function splitLongLine(line, maxChunk) {
|
|
217
|
+
const pieces = [];
|
|
218
|
+
let rest = line;
|
|
219
|
+
while (rest.length > maxChunk) {
|
|
220
|
+
let cut = rest.lastIndexOf(' ', maxChunk);
|
|
221
|
+
if (cut <= 0)
|
|
222
|
+
cut = maxChunk;
|
|
223
|
+
pieces.push(rest.slice(0, cut).trim());
|
|
224
|
+
rest = rest.slice(cut).trim();
|
|
225
|
+
}
|
|
226
|
+
if (rest)
|
|
227
|
+
pieces.push(rest);
|
|
228
|
+
return pieces;
|
|
229
|
+
}
|
|
230
|
+
export function splitOversizedBlock(text, maxChunk, blockType) {
|
|
217
231
|
const lines = text.split('\n');
|
|
232
|
+
if (lines.length <= 1) {
|
|
233
|
+
return splitLongLine(text, maxChunk);
|
|
234
|
+
}
|
|
235
|
+
// If code block with opening fence
|
|
236
|
+
if (blockType === 'code' && lines.length > 2 && /^(\s*)(`{3,}|~{3,})/.test(lines[0])) {
|
|
237
|
+
const openFence = lines[0];
|
|
238
|
+
const hasCloseFence = /^(\s*)(`{3,}|~{3,})/.test(lines[lines.length - 1]);
|
|
239
|
+
const closeFence = hasCloseFence ? lines[lines.length - 1] : '```';
|
|
240
|
+
const innerLines = lines.slice(1, hasCloseFence ? lines.length - 1 : lines.length);
|
|
241
|
+
const pieces = [];
|
|
242
|
+
let currentCode = [];
|
|
243
|
+
let currentLen = openFence.length + closeFence.length + 2;
|
|
244
|
+
for (const line of innerLines) {
|
|
245
|
+
if (currentLen + line.length + 1 > maxChunk && currentCode.length > 0) {
|
|
246
|
+
pieces.push([openFence, ...currentCode, closeFence].join('\n'));
|
|
247
|
+
currentCode = [];
|
|
248
|
+
currentLen = openFence.length + closeFence.length + 2;
|
|
249
|
+
}
|
|
250
|
+
currentCode.push(line);
|
|
251
|
+
currentLen += line.length + 1;
|
|
252
|
+
}
|
|
253
|
+
if (currentCode.length > 0) {
|
|
254
|
+
pieces.push([openFence, ...currentCode, closeFence].join('\n'));
|
|
255
|
+
}
|
|
256
|
+
if (pieces.length > 0)
|
|
257
|
+
return pieces;
|
|
258
|
+
}
|
|
259
|
+
// If table with header + separator rows
|
|
260
|
+
if (blockType === 'table' && lines.length > 2 && /^\s*\|?\s*[-:]+[-|\s:]*$/.test(lines[1])) {
|
|
261
|
+
const headerRows = [lines[0], lines[1]];
|
|
262
|
+
const dataRows = lines.slice(2);
|
|
263
|
+
const headerLen = headerRows[0].length + headerRows[1].length + 2;
|
|
264
|
+
const pieces = [];
|
|
265
|
+
let currentRows = [];
|
|
266
|
+
let currentLen = headerLen;
|
|
267
|
+
for (const row of dataRows) {
|
|
268
|
+
if (currentLen + row.length + 1 > maxChunk && currentRows.length > 0) {
|
|
269
|
+
pieces.push([...headerRows, ...currentRows].join('\n'));
|
|
270
|
+
currentRows = [];
|
|
271
|
+
currentLen = headerLen;
|
|
272
|
+
}
|
|
273
|
+
currentRows.push(row);
|
|
274
|
+
currentLen += row.length + 1;
|
|
275
|
+
}
|
|
276
|
+
if (currentRows.length > 0) {
|
|
277
|
+
pieces.push([...headerRows, ...currentRows].join('\n'));
|
|
278
|
+
}
|
|
279
|
+
if (pieces.length > 0)
|
|
280
|
+
return pieces;
|
|
281
|
+
}
|
|
282
|
+
// Standard paragraph/list splitting
|
|
218
283
|
const pieces = [];
|
|
219
284
|
let currentLines = [];
|
|
220
285
|
let currentLen = 0;
|
|
@@ -229,17 +294,11 @@ function splitOversizedBlock(text, maxChunk) {
|
|
|
229
294
|
pieces.push(currentLines.join('\n').trim());
|
|
230
295
|
currentLines = [];
|
|
231
296
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (cut <= 0)
|
|
236
|
-
cut = maxChunk;
|
|
237
|
-
pieces.push(rest.slice(0, cut).trim());
|
|
238
|
-
rest = rest.slice(cut).trim();
|
|
297
|
+
const splitLines = splitLongLine(line, maxChunk);
|
|
298
|
+
for (const piece of splitLines) {
|
|
299
|
+
pieces.push(piece);
|
|
239
300
|
}
|
|
240
|
-
|
|
241
|
-
currentLines.push(rest);
|
|
242
|
-
currentLen = rest.length;
|
|
301
|
+
currentLen = 0;
|
|
243
302
|
continue;
|
|
244
303
|
}
|
|
245
304
|
currentLines.push(line);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export declare const MCP_TOOLS: ({
|
|
|
22
22
|
type: string;
|
|
23
23
|
description: string;
|
|
24
24
|
};
|
|
25
|
+
vaults: {
|
|
26
|
+
type: string;
|
|
27
|
+
items: {
|
|
28
|
+
type: string;
|
|
29
|
+
};
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
25
32
|
tag: {
|
|
26
33
|
type: string;
|
|
27
34
|
items: {
|
|
@@ -29,6 +36,14 @@ export declare const MCP_TOOLS: ({
|
|
|
29
36
|
};
|
|
30
37
|
description: string;
|
|
31
38
|
};
|
|
39
|
+
filter: {
|
|
40
|
+
type: string;
|
|
41
|
+
description: string;
|
|
42
|
+
};
|
|
43
|
+
graphBoost: {
|
|
44
|
+
type: string;
|
|
45
|
+
description: string;
|
|
46
|
+
};
|
|
32
47
|
file?: undefined;
|
|
33
48
|
heading?: undefined;
|
|
34
49
|
};
|
|
@@ -40,9 +55,10 @@ export declare const MCP_TOOLS: ({
|
|
|
40
55
|
inputSchema: {
|
|
41
56
|
type: string;
|
|
42
57
|
properties: {
|
|
43
|
-
|
|
44
|
-
k?: undefined;
|
|
58
|
+
vaults?: undefined;
|
|
45
59
|
tag?: undefined;
|
|
60
|
+
filter?: undefined;
|
|
61
|
+
graphBoost?: undefined;
|
|
46
62
|
file: {
|
|
47
63
|
type: string;
|
|
48
64
|
description: string;
|
|
@@ -52,6 +68,8 @@ export declare const MCP_TOOLS: ({
|
|
|
52
68
|
description: string;
|
|
53
69
|
};
|
|
54
70
|
path?: undefined;
|
|
71
|
+
query?: undefined;
|
|
72
|
+
k?: undefined;
|
|
55
73
|
};
|
|
56
74
|
required: string[];
|
|
57
75
|
};
|
|
@@ -59,34 +77,64 @@ export declare const MCP_TOOLS: ({
|
|
|
59
77
|
name: string;
|
|
60
78
|
description: string;
|
|
61
79
|
inputSchema: {
|
|
62
|
-
required?: undefined;
|
|
63
80
|
type: string;
|
|
64
81
|
properties: {
|
|
65
|
-
|
|
66
|
-
k?: undefined;
|
|
82
|
+
vaults?: undefined;
|
|
67
83
|
tag?: undefined;
|
|
84
|
+
filter?: undefined;
|
|
85
|
+
graphBoost?: undefined;
|
|
68
86
|
file?: undefined;
|
|
69
87
|
heading?: undefined;
|
|
70
88
|
path: {
|
|
71
89
|
type: string;
|
|
72
90
|
description: string;
|
|
73
91
|
};
|
|
92
|
+
query?: undefined;
|
|
93
|
+
k?: undefined;
|
|
74
94
|
};
|
|
95
|
+
required?: undefined;
|
|
75
96
|
};
|
|
76
97
|
} | {
|
|
77
98
|
name: string;
|
|
78
99
|
description: string;
|
|
79
100
|
inputSchema: {
|
|
80
|
-
required?: undefined;
|
|
81
101
|
type: string;
|
|
82
102
|
properties: {
|
|
103
|
+
vaults?: undefined;
|
|
104
|
+
tag?: undefined;
|
|
105
|
+
filter?: undefined;
|
|
106
|
+
graphBoost?: undefined;
|
|
107
|
+
file?: undefined;
|
|
108
|
+
heading?: undefined;
|
|
109
|
+
path?: undefined;
|
|
83
110
|
query?: undefined;
|
|
84
111
|
k?: undefined;
|
|
112
|
+
};
|
|
113
|
+
required?: undefined;
|
|
114
|
+
};
|
|
115
|
+
} | {
|
|
116
|
+
name: string;
|
|
117
|
+
description: string;
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: string;
|
|
120
|
+
properties: {
|
|
121
|
+
vaults?: undefined;
|
|
85
122
|
tag?: undefined;
|
|
123
|
+
filter?: undefined;
|
|
124
|
+
graphBoost?: undefined;
|
|
86
125
|
file?: undefined;
|
|
87
126
|
heading?: undefined;
|
|
88
127
|
path?: undefined;
|
|
128
|
+
query: {
|
|
129
|
+
type: string;
|
|
130
|
+
description: string;
|
|
131
|
+
};
|
|
132
|
+
k: {
|
|
133
|
+
type: string;
|
|
134
|
+
description: string;
|
|
135
|
+
};
|
|
89
136
|
};
|
|
137
|
+
required: string[];
|
|
90
138
|
};
|
|
91
139
|
})[];
|
|
92
140
|
export declare function handleMcpRequest(req: any, state: {
|
package/dist/mcp.js
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
|
export const MCP_TOOLS = [
|
|
13
15
|
{
|
|
@@ -19,7 +21,10 @@ export const MCP_TOOLS = [
|
|
|
19
21
|
query: { type: 'string', description: 'Natural language search query' },
|
|
20
22
|
k: { type: 'number', description: 'Number of top results to return (default: 6)' },
|
|
21
23
|
path: { type: 'string', description: 'Optional path glob filter (e.g. "docs/**")' },
|
|
24
|
+
vaults: { type: 'array', items: { type: 'string' }, description: 'Optional list of vault directories for federated search' },
|
|
22
25
|
tag: { type: 'array', items: { type: 'string' }, description: 'Optional frontmatter tag filters' },
|
|
26
|
+
filter: { type: 'string', description: 'Optional rich boolean filter expression (e.g. "tag:engineering AND status != archived")' },
|
|
27
|
+
graphBoost: { type: 'number', description: 'Optional boost weight for graph PageRank / wikilinks (0.0 to 1.0)' },
|
|
23
28
|
},
|
|
24
29
|
required: ['query'],
|
|
25
30
|
},
|
|
@@ -54,6 +59,18 @@ export const MCP_TOOLS = [
|
|
|
54
59
|
properties: {},
|
|
55
60
|
},
|
|
56
61
|
},
|
|
62
|
+
{
|
|
63
|
+
name: 'ask_knowledge_base',
|
|
64
|
+
description: 'Synthesize a grounded direct answer with citations from the local Markdown knowledge base',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
query: { type: 'string', description: 'Question to ask the knowledge base' },
|
|
69
|
+
k: { type: 'number', description: 'Number of candidate passages to retrieve (default: 5)' },
|
|
70
|
+
},
|
|
71
|
+
required: ['query'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
57
74
|
];
|
|
58
75
|
export async function handleMcpRequest(req, state) {
|
|
59
76
|
if (!req || typeof req !== 'object')
|
|
@@ -73,7 +90,7 @@ export async function handleMcpRequest(req, state) {
|
|
|
73
90
|
},
|
|
74
91
|
serverInfo: {
|
|
75
92
|
name: 'md-semantic-search',
|
|
76
|
-
version: '0.
|
|
93
|
+
version: '1.0.0',
|
|
77
94
|
},
|
|
78
95
|
},
|
|
79
96
|
};
|
|
@@ -95,16 +112,35 @@ export async function handleMcpRequest(req, state) {
|
|
|
95
112
|
if (name === 'search_markdown') {
|
|
96
113
|
const query = typeof args.query === 'string' ? args.query : '';
|
|
97
114
|
const k = Number.isInteger(args.k) && args.k > 0 ? args.k : 6;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
let results;
|
|
116
|
+
if (Array.isArray(args.vaults) && args.vaults.length > 0) {
|
|
117
|
+
results = await searchFederated({
|
|
118
|
+
vaults: args.vaults,
|
|
119
|
+
cacheDir: state.cacheDir,
|
|
120
|
+
query,
|
|
121
|
+
k,
|
|
122
|
+
path: args.path,
|
|
123
|
+
tag: args.tag,
|
|
124
|
+
filter: typeof args.filter === 'string' ? args.filter : undefined,
|
|
125
|
+
graphBoost: typeof args.graphBoost === 'number' ? args.graphBoost : undefined,
|
|
126
|
+
offline: state.offline,
|
|
127
|
+
embedFn: state.embedFn,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
results = await searchIndex({
|
|
132
|
+
loaded: state.loaded,
|
|
133
|
+
cacheDir: state.cacheDir,
|
|
134
|
+
query,
|
|
135
|
+
k,
|
|
136
|
+
path: args.path,
|
|
137
|
+
tag: args.tag,
|
|
138
|
+
filter: typeof args.filter === 'string' ? args.filter : undefined,
|
|
139
|
+
graphBoost: typeof args.graphBoost === 'number' ? args.graphBoost : undefined,
|
|
140
|
+
offline: state.offline,
|
|
141
|
+
embedFn: state.embedFn,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
108
144
|
content = [{ type: 'text', text: JSON.stringify(results, null, 2) }];
|
|
109
145
|
}
|
|
110
146
|
else if (name === 'get_chunk') {
|
|
@@ -141,6 +177,18 @@ export async function handleMcpRequest(req, state) {
|
|
|
141
177
|
};
|
|
142
178
|
content = [{ type: 'text', text: JSON.stringify(info, null, 2) }];
|
|
143
179
|
}
|
|
180
|
+
else if (name === 'ask_knowledge_base') {
|
|
181
|
+
const query = args.query;
|
|
182
|
+
const k = typeof args.k === 'number' ? args.k : 5;
|
|
183
|
+
const answerResult = await askQuestion({
|
|
184
|
+
loaded: state.loaded,
|
|
185
|
+
cacheDir: state.cacheDir,
|
|
186
|
+
query,
|
|
187
|
+
k,
|
|
188
|
+
embedFn: state.embedFn,
|
|
189
|
+
});
|
|
190
|
+
content = [{ type: 'text', text: JSON.stringify(answerResult, null, 2) }];
|
|
191
|
+
}
|
|
144
192
|
else {
|
|
145
193
|
return {
|
|
146
194
|
jsonrpc: '2.0',
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
export declare const INT8_SCALE = 127;
|
|
8
|
+
export declare const INT8_INV_SCALE: number;
|
|
9
|
+
/**
|
|
10
|
+
* Quantizes an L2-normalized float vector into a signed Int8Array.
|
|
11
|
+
*/
|
|
12
|
+
export declare function quantizeToInt8(vec: ArrayLike<number>): Int8Array;
|
|
13
|
+
/**
|
|
14
|
+
* Dequantizes an Int8Array back into an approximate Float32Array.
|
|
15
|
+
*/
|
|
16
|
+
export declare function dequantizeFromInt8(vec: Int8Array): Float32Array;
|
|
17
|
+
/**
|
|
18
|
+
* Computes asymmetric cosine similarity between a normalized Float32 query vector
|
|
19
|
+
* and a quantized Int8 chunk vector.
|
|
20
|
+
*
|
|
21
|
+
* Since query is unit length (||q|| = 1.0),
|
|
22
|
+
* cosine(q, c) = (q · c_fp32) / ||c_fp32|| = (sum(q_i * c_i) / 127) / (||c|| / 127) = sum(q_i * c_i) / ||c||.
|
|
23
|
+
*/
|
|
24
|
+
export declare function asymmetricCosineInt8(queryFp32: Float32Array, chunkInt8: Int8Array): number;
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
export const INT8_SCALE = 127.0;
|
|
8
|
+
export const INT8_INV_SCALE = 1.0 / 127.0;
|
|
9
|
+
/**
|
|
10
|
+
* Quantizes an L2-normalized float vector into a signed Int8Array.
|
|
11
|
+
*/
|
|
12
|
+
export function quantizeToInt8(vec) {
|
|
13
|
+
const len = vec.length;
|
|
14
|
+
const out = new Int8Array(len);
|
|
15
|
+
for (let i = 0; i < len; i++) {
|
|
16
|
+
const val = vec[i];
|
|
17
|
+
// Clamp to [-1.0, 1.0] and scale to [-128, 127]
|
|
18
|
+
const clamped = val < -1.0 ? -1.0 : val > 1.0 ? 1.0 : val;
|
|
19
|
+
const scaled = Math.round(clamped * INT8_SCALE);
|
|
20
|
+
out[i] = scaled < -128 ? -128 : scaled > 127 ? 127 : scaled;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Dequantizes an Int8Array back into an approximate Float32Array.
|
|
26
|
+
*/
|
|
27
|
+
export function dequantizeFromInt8(vec) {
|
|
28
|
+
const len = vec.length;
|
|
29
|
+
const out = new Float32Array(len);
|
|
30
|
+
for (let i = 0; i < len; i++) {
|
|
31
|
+
out[i] = vec[i] * INT8_INV_SCALE;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Computes asymmetric cosine similarity between a normalized Float32 query vector
|
|
37
|
+
* and a quantized Int8 chunk vector.
|
|
38
|
+
*
|
|
39
|
+
* Since query is unit length (||q|| = 1.0),
|
|
40
|
+
* cosine(q, c) = (q · c_fp32) / ||c_fp32|| = (sum(q_i * c_i) / 127) / (||c|| / 127) = sum(q_i * c_i) / ||c||.
|
|
41
|
+
*/
|
|
42
|
+
export function asymmetricCosineInt8(queryFp32, chunkInt8) {
|
|
43
|
+
const dim = queryFp32.length;
|
|
44
|
+
let dot = 0.0;
|
|
45
|
+
let normSq = 0.0;
|
|
46
|
+
// Unroll loop in steps of 8 for performance
|
|
47
|
+
let i = 0;
|
|
48
|
+
const limit = dim - (dim % 8);
|
|
49
|
+
for (; i < limit; i += 8) {
|
|
50
|
+
const c0 = chunkInt8[i];
|
|
51
|
+
const c1 = chunkInt8[i + 1];
|
|
52
|
+
const c2 = chunkInt8[i + 2];
|
|
53
|
+
const c3 = chunkInt8[i + 3];
|
|
54
|
+
const c4 = chunkInt8[i + 4];
|
|
55
|
+
const c5 = chunkInt8[i + 5];
|
|
56
|
+
const c6 = chunkInt8[i + 6];
|
|
57
|
+
const c7 = chunkInt8[i + 7];
|
|
58
|
+
dot +=
|
|
59
|
+
queryFp32[i] * c0 +
|
|
60
|
+
queryFp32[i + 1] * c1 +
|
|
61
|
+
queryFp32[i + 2] * c2 +
|
|
62
|
+
queryFp32[i + 3] * c3 +
|
|
63
|
+
queryFp32[i + 4] * c4 +
|
|
64
|
+
queryFp32[i + 5] * c5 +
|
|
65
|
+
queryFp32[i + 6] * c6 +
|
|
66
|
+
queryFp32[i + 7] * c7;
|
|
67
|
+
normSq +=
|
|
68
|
+
c0 * c0 +
|
|
69
|
+
c1 * c1 +
|
|
70
|
+
c2 * c2 +
|
|
71
|
+
c3 * c3 +
|
|
72
|
+
c4 * c4 +
|
|
73
|
+
c5 * c5 +
|
|
74
|
+
c6 * c6 +
|
|
75
|
+
c7 * c7;
|
|
76
|
+
}
|
|
77
|
+
for (; i < dim; i++) {
|
|
78
|
+
const c = chunkInt8[i];
|
|
79
|
+
dot += queryFp32[i] * c;
|
|
80
|
+
normSq += c * c;
|
|
81
|
+
}
|
|
82
|
+
if (normSq <= 0)
|
|
83
|
+
return 0.0;
|
|
84
|
+
return dot / Math.sqrt(normSq);
|
|
85
|
+
}
|
package/dist/rag.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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 { LoadedIndex, SearchResultHit } from './search.js';
|
|
9
|
+
export interface Citation {
|
|
10
|
+
file: string;
|
|
11
|
+
heading?: string;
|
|
12
|
+
score: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SynthesisResult {
|
|
15
|
+
answer: string;
|
|
16
|
+
citations: Citation[];
|
|
17
|
+
passages: SearchResultHit[];
|
|
18
|
+
}
|
|
19
|
+
export interface AskOptions {
|
|
20
|
+
query: string;
|
|
21
|
+
loaded?: LoadedIndex;
|
|
22
|
+
indexDir?: string;
|
|
23
|
+
cacheDir?: string;
|
|
24
|
+
db?: string;
|
|
25
|
+
k?: number;
|
|
26
|
+
llmEndpoint?: string;
|
|
27
|
+
llmModel?: string;
|
|
28
|
+
llmFn?: (prompt: string) => Promise<string>;
|
|
29
|
+
systemPrompt?: string;
|
|
30
|
+
embedFn?: any;
|
|
31
|
+
}
|
|
32
|
+
export declare const DEFAULT_RAG_SYSTEM_PROMPT: string;
|
|
33
|
+
/**
|
|
34
|
+
* Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractAnswerFallback(query: string, passages: SearchResultHit[]): string;
|
|
37
|
+
/**
|
|
38
|
+
* Synthesizes an answer from retrieved passages using an LLM or fallback.
|
|
39
|
+
*/
|
|
40
|
+
export declare function synthesizeAnswer(query: string, passages: SearchResultHit[], options?: {
|
|
41
|
+
llmFn?: (prompt: string) => Promise<string>;
|
|
42
|
+
llmEndpoint?: string;
|
|
43
|
+
llmModel?: string;
|
|
44
|
+
systemPrompt?: string;
|
|
45
|
+
}): Promise<SynthesisResult>;
|
|
46
|
+
/**
|
|
47
|
+
* End-to-end question answering against a Markdown index.
|
|
48
|
+
*/
|
|
49
|
+
export declare function askQuestion(opts: AskOptions): Promise<SynthesisResult>;
|
package/dist/rag.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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 } from './search.js';
|
|
9
|
+
import { tokenize } from './lexical.js';
|
|
10
|
+
export const DEFAULT_RAG_SYSTEM_PROMPT = 'You are a helpful assistant answering questions using only the provided Markdown notes. ' +
|
|
11
|
+
'Always cite your sources using [file#heading] format. ' +
|
|
12
|
+
'If the context does not contain the answer, say that the notes do not have enough information.';
|
|
13
|
+
/**
|
|
14
|
+
* Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
|
|
15
|
+
*/
|
|
16
|
+
export function extractAnswerFallback(query, passages) {
|
|
17
|
+
if (passages.length === 0) {
|
|
18
|
+
return 'No relevant passages found in the notes to answer the question.';
|
|
19
|
+
}
|
|
20
|
+
const queryTokens = new Set(tokenize(query));
|
|
21
|
+
const candidateSentences = [];
|
|
22
|
+
for (const hit of passages) {
|
|
23
|
+
const text = hit.snippet || '';
|
|
24
|
+
const cite = hit.heading ? `[${hit.file} › ${hit.heading}]` : `[${hit.file}]`;
|
|
25
|
+
// Split into sentences
|
|
26
|
+
const sentences = text
|
|
27
|
+
.split(/(?<=[.?!])\s+|\n+/)
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter((s) => s.length > 15);
|
|
30
|
+
for (const sent of sentences) {
|
|
31
|
+
const sentTokens = tokenize(sent);
|
|
32
|
+
let matchCount = 0;
|
|
33
|
+
for (const tok of sentTokens) {
|
|
34
|
+
if (queryTokens.has(tok))
|
|
35
|
+
matchCount++;
|
|
36
|
+
}
|
|
37
|
+
if (matchCount > 0) {
|
|
38
|
+
const score = (matchCount / (sentTokens.length + 1)) * (hit.score || 1);
|
|
39
|
+
candidateSentences.push({ sentence: sent, score, citation: cite });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (candidateSentences.length === 0) {
|
|
44
|
+
// Return top snippets directly
|
|
45
|
+
const top = passages.slice(0, 2).map((p) => {
|
|
46
|
+
const cite = p.heading ? `[${p.file} › ${p.heading}]` : `[${p.file}]`;
|
|
47
|
+
return `${cite}: ${p.snippet.slice(0, 200)}…`;
|
|
48
|
+
});
|
|
49
|
+
return `Relevant excerpts from notes:\n\n${top.join('\n\n')}`;
|
|
50
|
+
}
|
|
51
|
+
candidateSentences.sort((a, b) => b.score - a.score);
|
|
52
|
+
const selected = candidateSentences.slice(0, 3);
|
|
53
|
+
const body = selected.map((s) => `${s.sentence} ${s.citation}`).join('\n\n');
|
|
54
|
+
return `Based on your notes:\n\n${body}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Synthesizes an answer from retrieved passages using an LLM or fallback.
|
|
58
|
+
*/
|
|
59
|
+
export async function synthesizeAnswer(query, passages, options = {}) {
|
|
60
|
+
const citations = passages.map((p) => ({
|
|
61
|
+
file: p.file,
|
|
62
|
+
heading: p.heading,
|
|
63
|
+
score: p.score,
|
|
64
|
+
}));
|
|
65
|
+
if (options.llmFn) {
|
|
66
|
+
const contextBlocks = passages.map((p, i) => {
|
|
67
|
+
const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
|
|
68
|
+
return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
|
|
69
|
+
}).join('\n\n---\n\n');
|
|
70
|
+
const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
|
|
71
|
+
const answer = await options.llmFn(prompt);
|
|
72
|
+
return { answer, citations, passages };
|
|
73
|
+
}
|
|
74
|
+
if (options.llmEndpoint) {
|
|
75
|
+
const contextBlocks = passages.map((p, i) => {
|
|
76
|
+
const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
|
|
77
|
+
return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
|
|
78
|
+
}).join('\n\n---\n\n');
|
|
79
|
+
const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
|
|
80
|
+
try {
|
|
81
|
+
const resp = await fetch(`${options.llmEndpoint}/api/generate`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
model: options.llmModel || 'llama3',
|
|
86
|
+
prompt,
|
|
87
|
+
stream: false,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
if (resp.ok) {
|
|
91
|
+
const data = await resp.json();
|
|
92
|
+
const answer = data.response || data.text || '';
|
|
93
|
+
if (answer)
|
|
94
|
+
return { answer, citations, passages };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Fallback to extractive
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const answer = extractAnswerFallback(query, passages);
|
|
102
|
+
return { answer, citations, passages };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* End-to-end question answering against a Markdown index.
|
|
106
|
+
*/
|
|
107
|
+
export async function askQuestion(opts) {
|
|
108
|
+
const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmEndpoint, llmModel, systemPrompt, embedFn, } = opts;
|
|
109
|
+
let loaded = inputLoaded;
|
|
110
|
+
if (!loaded) {
|
|
111
|
+
if (!indexDir)
|
|
112
|
+
throw new Error('askQuestion requires either loaded index or indexDir');
|
|
113
|
+
loaded = loadIndex(indexDir);
|
|
114
|
+
}
|
|
115
|
+
const passages = await searchIndex({
|
|
116
|
+
loaded,
|
|
117
|
+
cacheDir,
|
|
118
|
+
query,
|
|
119
|
+
k,
|
|
120
|
+
embedFn,
|
|
121
|
+
});
|
|
122
|
+
return synthesizeAnswer(query, passages, {
|
|
123
|
+
llmFn,
|
|
124
|
+
llmEndpoint,
|
|
125
|
+
llmModel,
|
|
126
|
+
systemPrompt,
|
|
127
|
+
});
|
|
128
|
+
}
|