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/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
- if (path.basename(target) === 'vectors.json') {
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} vectors.json\n`);
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: { title: string; heading: string; headingPath?: string[]; text: string }): string {
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
- return [title, serializedPath, normalize(chunk.text)].join('\n');
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
- }: Required<Omit<BuildIndexOptions, '_lockHeld' | 'onProgress' | 'workers' | 'ann'>> & {
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[])));
@@ -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 splitOversizedBlock(text: string, maxChunk: number): string[] {
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
- let rest = line;
283
- while (rest.length > maxChunk) {
284
- let cut = rest.lastIndexOf(' ', maxChunk);
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
- if (rest) currentLines.push(rest);
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.4.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
- const results = await searchIndex({
105
- loaded: state.loaded,
106
- cacheDir: state.cacheDir,
107
- query,
108
- k,
109
- path: args.path,
110
- tag: args.tag,
111
- offline: state.offline,
112
- embedFn: state.embedFn,
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
+ }