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/search.ts
CHANGED
|
@@ -8,6 +8,10 @@ import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores, LexicalInde
|
|
|
8
8
|
import { collapseResults } from './collapse.js';
|
|
9
9
|
import { DocumentMetadata } from './frontmatter.js';
|
|
10
10
|
import { trainIVF, searchIVFCandidates, deserializeIVF, IVFIndex, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
|
|
11
|
+
import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood, RelationshipGraph } from './wikilinks.js';
|
|
12
|
+
import { evaluateFilter, FilterNode } from './filter.js';
|
|
13
|
+
import { deserializeBinaryIndex } from './binary-format.js';
|
|
14
|
+
import { dequantizeFromInt8 } from './quantization.js';
|
|
11
15
|
|
|
12
16
|
export { tokenize } from './lexical.js';
|
|
13
17
|
|
|
@@ -36,15 +40,22 @@ export function keywordScores(chunks: IndexChunk[], query: string): number[] {
|
|
|
36
40
|
});
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
export function rrf(
|
|
43
|
+
export function rrf(
|
|
44
|
+
rankings: Array<Array<{ idx: number; score: number }>>,
|
|
45
|
+
k = 60,
|
|
46
|
+
weights?: number[]
|
|
47
|
+
): Map<number, number> {
|
|
40
48
|
const fused = new Map<number, number>();
|
|
41
|
-
for (
|
|
49
|
+
for (let rankingIdx = 0; rankingIdx < rankings.length; rankingIdx++) {
|
|
50
|
+
const ranking = rankings[rankingIdx];
|
|
51
|
+
const weight = weights ? (weights[rankingIdx] ?? 1) : 1;
|
|
52
|
+
if (weight <= 0) continue;
|
|
42
53
|
const sorted = [...ranking].sort((a, b) => b.score - a.score);
|
|
43
54
|
let scoreRank = 0;
|
|
44
55
|
sorted.forEach((item, rank) => {
|
|
45
56
|
if (item.score <= 0) return;
|
|
46
57
|
if (rank > 0 && item.score < sorted[rank - 1].score) scoreRank = rank;
|
|
47
|
-
fused.set(item.idx, (fused.get(item.idx) || 0) +
|
|
58
|
+
fused.set(item.idx, (fused.get(item.idx) || 0) + weight / (k + scoreRank + 1));
|
|
48
59
|
});
|
|
49
60
|
}
|
|
50
61
|
return fused;
|
|
@@ -155,7 +166,7 @@ export interface RuntimeChunk {
|
|
|
155
166
|
startLine?: number;
|
|
156
167
|
endLine?: number;
|
|
157
168
|
meta?: DocumentMetadata;
|
|
158
|
-
vec: Float32Array;
|
|
169
|
+
vec: Float32Array | Int8Array;
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
export interface RuntimeIndexState {
|
|
@@ -167,29 +178,66 @@ export interface RuntimeIndexState {
|
|
|
167
178
|
model: ModelDescriptor;
|
|
168
179
|
queryCache: QueryEmbeddingCache;
|
|
169
180
|
ivf?: IVFIndex;
|
|
181
|
+
graph?: RelationshipGraph;
|
|
182
|
+
pageRank?: Map<string, number>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: RelationshipGraph; pageRank: Map<string, number> } {
|
|
186
|
+
if (runtime.graph && runtime.pageRank) {
|
|
187
|
+
return { graph: runtime.graph, pageRank: runtime.pageRank };
|
|
188
|
+
}
|
|
189
|
+
const docsMap = new Map<string, { file: string; title?: string; meta?: DocumentMetadata; text?: string }>();
|
|
190
|
+
for (const chunk of runtime.chunks) {
|
|
191
|
+
const existing = docsMap.get(chunk.file);
|
|
192
|
+
if (!existing) {
|
|
193
|
+
docsMap.set(chunk.file, {
|
|
194
|
+
file: chunk.file,
|
|
195
|
+
title: chunk.title,
|
|
196
|
+
meta: chunk.meta,
|
|
197
|
+
text: chunk.text,
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
existing.text = (existing.text || '') + '\n' + chunk.text;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const docs = Array.from(docsMap.values());
|
|
204
|
+
const graph = buildRelationshipGraph(docs);
|
|
205
|
+
const pageRank = computePageRank(graph);
|
|
206
|
+
runtime.graph = graph;
|
|
207
|
+
runtime.pageRank = pageRank;
|
|
208
|
+
return { graph, pageRank };
|
|
170
209
|
}
|
|
171
210
|
|
|
172
211
|
const runtimeIndexStates = new WeakMap<IndexFile, RuntimeIndexState>();
|
|
173
212
|
|
|
174
213
|
function snapshotRuntimeIndex(
|
|
175
214
|
index: IndexFile,
|
|
176
|
-
validation: {
|
|
215
|
+
validation: {
|
|
216
|
+
schema: number;
|
|
217
|
+
dim: number | undefined;
|
|
218
|
+
model: ModelDescriptor;
|
|
219
|
+
ivf?: IVFIndex;
|
|
220
|
+
rawVectorsBuffer?: Float32Array | Int8Array;
|
|
221
|
+
}
|
|
177
222
|
): RuntimeIndexState {
|
|
178
223
|
const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
|
|
179
224
|
const count = index.chunks.length;
|
|
180
|
-
const
|
|
225
|
+
const isZeroCopy = !!validation.rawVectorsBuffer;
|
|
226
|
+
const vectorsBuffer = validation.rawVectorsBuffer ?? (dim > 0 ? new Float32Array(count * dim) : null);
|
|
181
227
|
|
|
182
228
|
const chunks: RuntimeChunk[] = index.chunks.map((chunk, i) => {
|
|
183
|
-
let vec: Float32Array;
|
|
229
|
+
let vec: Float32Array | Int8Array;
|
|
184
230
|
if (vectorsBuffer && dim > 0) {
|
|
185
231
|
const offset = i * dim;
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
232
|
+
if (!isZeroCopy) {
|
|
233
|
+
if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array) {
|
|
234
|
+
(vectorsBuffer as any).set(chunk.vec, offset);
|
|
235
|
+
} else {
|
|
236
|
+
const arr = chunk.vec as number[];
|
|
237
|
+
for (let j = 0; j < dim; j++) (vectorsBuffer as any)[offset + j] = arr[j];
|
|
238
|
+
}
|
|
191
239
|
}
|
|
192
|
-
vec = vectorsBuffer.subarray(offset, offset + dim);
|
|
240
|
+
vec = (vectorsBuffer as any).subarray(offset, offset + dim);
|
|
193
241
|
} else {
|
|
194
242
|
vec = Float32Array.from(chunk.vec as number[]);
|
|
195
243
|
}
|
|
@@ -280,7 +328,73 @@ function warnIfStale(index: IndexFile): void {
|
|
|
280
328
|
}
|
|
281
329
|
|
|
282
330
|
export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDescriptor } {
|
|
331
|
+
const vectorsBinPath = path.join(indexDir, 'vectors.bin');
|
|
283
332
|
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
333
|
+
|
|
334
|
+
let useBinary = false;
|
|
335
|
+
if (fs.existsSync(vectorsBinPath)) {
|
|
336
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
337
|
+
useBinary = true;
|
|
338
|
+
} else {
|
|
339
|
+
try {
|
|
340
|
+
const binStat = fs.statSync(vectorsBinPath);
|
|
341
|
+
const jsonStat = fs.statSync(vectorsPath);
|
|
342
|
+
if (binStat.mtimeMs >= jsonStat.mtimeMs) {
|
|
343
|
+
useBinary = true;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
useBinary = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (useBinary) {
|
|
352
|
+
try {
|
|
353
|
+
const binBuffer = fs.readFileSync(vectorsBinPath);
|
|
354
|
+
const shaBinPath = path.join(indexDir, 'vectors.bin.sha256');
|
|
355
|
+
if (fs.existsSync(shaBinPath)) {
|
|
356
|
+
const expectedHash = fs.readFileSync(shaBinPath, 'utf8').trim().split(/\s+/)[0];
|
|
357
|
+
const actualHash = crypto.createHash('sha256').update(binBuffer).digest('hex');
|
|
358
|
+
if (expectedHash && actualHash !== expectedHash) {
|
|
359
|
+
throw new Error('vectors.bin integrity check failed (SHA-256 mismatch) — run `mdss index` to rebuild.');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const deserialized = deserializeBinaryIndex(binBuffer);
|
|
363
|
+
const validated = validateIndexEnvelope(deserialized, vectorsBinPath, { encoding: 'loaded', validateVectors: true });
|
|
364
|
+
const index = validated.index as IndexFile;
|
|
365
|
+
const { model, dim: validatedDim } = validated;
|
|
366
|
+
const expectedDim = validatedDim ?? deserialized.dim;
|
|
367
|
+
|
|
368
|
+
warnIfStale(index);
|
|
369
|
+
|
|
370
|
+
const ivfPath = path.join(indexDir, 'ivf.json');
|
|
371
|
+
let ivf: IVFIndex | undefined;
|
|
372
|
+
if (fs.existsSync(ivfPath)) {
|
|
373
|
+
try {
|
|
374
|
+
const raw = JSON.parse(fs.readFileSync(ivfPath, 'utf8'));
|
|
375
|
+
ivf = deserializeIVF(raw);
|
|
376
|
+
} catch {}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
runtimeIndexStates.set(
|
|
380
|
+
index,
|
|
381
|
+
snapshotRuntimeIndex(index, {
|
|
382
|
+
schema: validated.schema,
|
|
383
|
+
dim: expectedDim,
|
|
384
|
+
model,
|
|
385
|
+
ivf,
|
|
386
|
+
rawVectorsBuffer: deserialized.rawVectorsBuffer,
|
|
387
|
+
})
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
return { index, model };
|
|
391
|
+
} catch (e: any) {
|
|
392
|
+
if (e.message?.includes('integrity check failed')) throw e;
|
|
393
|
+
if (!fs.existsSync(vectorsPath)) throw e;
|
|
394
|
+
// If vectors.bin was corrupt or unreadable, fall through to vectors.json fallback
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
284
398
|
if (!fs.existsSync(vectorsPath)) {
|
|
285
399
|
throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
|
|
286
400
|
}
|
|
@@ -358,8 +472,13 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
358
472
|
|
|
359
473
|
export const MAX_QUERY_LENGTH = 2048;
|
|
360
474
|
|
|
475
|
+
export interface LoadedIndex {
|
|
476
|
+
index: IndexFile;
|
|
477
|
+
model: ModelDescriptor;
|
|
478
|
+
}
|
|
479
|
+
|
|
361
480
|
export interface SearchOptions {
|
|
362
|
-
loaded:
|
|
481
|
+
loaded: LoadedIndex;
|
|
363
482
|
cacheDir: string;
|
|
364
483
|
query: string;
|
|
365
484
|
k?: number;
|
|
@@ -383,6 +502,8 @@ export interface SearchOptions {
|
|
|
383
502
|
useQueryCache?: boolean;
|
|
384
503
|
ann?: boolean;
|
|
385
504
|
nprobe?: number;
|
|
505
|
+
graphBoost?: number;
|
|
506
|
+
filter?: string | FilterNode;
|
|
386
507
|
}
|
|
387
508
|
|
|
388
509
|
export interface SearchResultHit {
|
|
@@ -394,10 +515,13 @@ export interface SearchResultHit {
|
|
|
394
515
|
matches: string[];
|
|
395
516
|
snippet: string;
|
|
396
517
|
meta?: DocumentMetadata;
|
|
518
|
+
graphScore?: number;
|
|
397
519
|
explain?: {
|
|
398
520
|
cosine: number;
|
|
399
521
|
rrfScore: number;
|
|
400
522
|
bm25Weights: { title: number; aliases: number; headingPath: number; body: number };
|
|
523
|
+
graphScore?: number;
|
|
524
|
+
pageRank?: number;
|
|
401
525
|
};
|
|
402
526
|
rerankScore?: number;
|
|
403
527
|
}
|
|
@@ -420,8 +544,11 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
420
544
|
status,
|
|
421
545
|
canonicalOnly,
|
|
422
546
|
custom,
|
|
547
|
+
filter: filterExpr,
|
|
423
548
|
} = opts;
|
|
424
549
|
|
|
550
|
+
const graphBoost = typeof opts.graphBoost === 'number' && Number.isFinite(opts.graphBoost) ? opts.graphBoost : 0;
|
|
551
|
+
|
|
425
552
|
if (typeof query === 'string' && query.length > MAX_QUERY_LENGTH) {
|
|
426
553
|
throw new Error(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters`);
|
|
427
554
|
}
|
|
@@ -445,7 +572,8 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
445
572
|
type !== undefined ||
|
|
446
573
|
status !== undefined ||
|
|
447
574
|
canonicalOnly !== undefined ||
|
|
448
|
-
custom !== undefined
|
|
575
|
+
custom !== undefined ||
|
|
576
|
+
filterExpr !== undefined;
|
|
449
577
|
|
|
450
578
|
if (pathRes.length > 0 || sinceMs !== undefined || hasMetaFilter) {
|
|
451
579
|
if (sinceMs !== undefined && !db) {
|
|
@@ -466,6 +594,9 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
466
594
|
}
|
|
467
595
|
if (m < sinceMs) return false;
|
|
468
596
|
}
|
|
597
|
+
if (filterExpr !== undefined) {
|
|
598
|
+
if (!evaluateFilter(filterExpr, chunk.meta, { file: chunk.file, title: chunk.title })) return false;
|
|
599
|
+
}
|
|
469
600
|
if (tag !== undefined) {
|
|
470
601
|
const reqTags = (Array.isArray(tag) ? tag : [tag]).map((t) => String(t).toLowerCase().replace(/^#/, ''));
|
|
471
602
|
const chunkTags = chunk.meta?.tags || [];
|
|
@@ -509,7 +640,7 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
509
640
|
const useAnn = opts.ann ?? (runtime.ivf !== undefined && candidates.length >= ANN_THRESHOLD);
|
|
510
641
|
if (useAnn && candidates.length >= ANN_THRESHOLD) {
|
|
511
642
|
if (!runtime.ivf) {
|
|
512
|
-
const rawVecs = chunks.map((c) => c.vec);
|
|
643
|
+
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
|
|
513
644
|
runtime.ivf = trainIVF(rawVecs);
|
|
514
645
|
}
|
|
515
646
|
const qVecF32 = qVec instanceof Float32Array ? qVec : Float32Array.from(qVec);
|
|
@@ -529,12 +660,45 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
529
660
|
|
|
530
661
|
const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
|
|
531
662
|
|
|
663
|
+
let graphRanking: Array<{ idx: number; score: number }> = [];
|
|
664
|
+
let graphMap: Map<number, number> | undefined;
|
|
665
|
+
if (graphBoost > 0) {
|
|
666
|
+
const { graph, pageRank } = getRuntimeGraph(runtime);
|
|
667
|
+
const seedFilesMap = new Map<string, number>();
|
|
668
|
+
for (const s of semantic) {
|
|
669
|
+
if (s.score > 0) {
|
|
670
|
+
const file = chunks[s.idx].file;
|
|
671
|
+
const curr = seedFilesMap.get(file) || 0;
|
|
672
|
+
if (s.score > curr) seedFilesMap.set(file, s.score);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const seedFiles = Array.from(seedFilesMap.entries()).map(([file, score]) => ({ file, score }));
|
|
676
|
+
const propagationScores = expandGraphNeighborhood(graph, seedFiles, { maxDepth: 2, decay: 0.5 });
|
|
677
|
+
|
|
678
|
+
graphMap = new Map<number, number>();
|
|
679
|
+
graphRanking = candidates.map(({ chunk, idx }) => {
|
|
680
|
+
const pr = pageRank.get(chunk.file) || 0;
|
|
681
|
+
const prop = propagationScores.get(chunk.file) || 0;
|
|
682
|
+
const totalGraph = pr + prop;
|
|
683
|
+
graphMap!.set(idx, totalGraph);
|
|
684
|
+
return { idx, score: totalGraph };
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
532
688
|
let ranked: Array<{ idx: number; fscore: number; cos: number; rerank?: number }>;
|
|
533
689
|
if (semanticOnly) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
.
|
|
537
|
-
|
|
690
|
+
if (graphBoost > 0) {
|
|
691
|
+
const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
|
|
692
|
+
ranked = [...fused.entries()]
|
|
693
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
|
|
694
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
695
|
+
.slice(0, pool);
|
|
696
|
+
} else {
|
|
697
|
+
ranked = [...semantic]
|
|
698
|
+
.sort((a, b) => b.score - a.score)
|
|
699
|
+
.slice(0, pool)
|
|
700
|
+
.map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
|
|
701
|
+
}
|
|
538
702
|
} else {
|
|
539
703
|
const kw =
|
|
540
704
|
lexicalState.kind === 'persisted-bm25'
|
|
@@ -551,11 +715,19 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
551
715
|
score,
|
|
552
716
|
}));
|
|
553
717
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
718
|
+
if (graphBoost > 0) {
|
|
719
|
+
const fused = rrf([semantic, kw, fuzzy, graphRanking], 60, [1, 1, 1, graphBoost]);
|
|
720
|
+
ranked = [...fused.entries()]
|
|
721
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
|
|
722
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
723
|
+
.slice(0, pool);
|
|
724
|
+
} else {
|
|
725
|
+
const fused = rrf([semantic, kw, fuzzy]);
|
|
726
|
+
ranked = [...fused.entries()]
|
|
727
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
|
|
728
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
729
|
+
.slice(0, pool);
|
|
730
|
+
}
|
|
559
731
|
}
|
|
560
732
|
|
|
561
733
|
if (rerank && ranked.length > 0) {
|
|
@@ -590,6 +762,8 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
590
762
|
lexicalState.kind === 'persisted-bm25'
|
|
591
763
|
? matchingTerms(lexicalState.lexical, queryTerms, r.idx)
|
|
592
764
|
: queryTerms.filter((term) => tokenSet(c as any).has(term));
|
|
765
|
+
const gScore = graphMap?.get(r.idx);
|
|
766
|
+
const prScore = runtime.pageRank?.get(c.file);
|
|
593
767
|
return {
|
|
594
768
|
file: c.file,
|
|
595
769
|
title: c.title,
|
|
@@ -599,12 +773,15 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
599
773
|
matches,
|
|
600
774
|
snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
|
|
601
775
|
...(hasMetaFields(c.meta) ? { meta: c.meta } : {}),
|
|
776
|
+
...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
|
|
602
777
|
...(explain
|
|
603
778
|
? {
|
|
604
779
|
explain: {
|
|
605
780
|
cosine: +r.cos.toFixed(4),
|
|
606
781
|
rrfScore: +r.fscore.toFixed(4),
|
|
607
782
|
bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
|
|
783
|
+
...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
|
|
784
|
+
...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
|
|
608
785
|
},
|
|
609
786
|
}
|
|
610
787
|
: {}),
|
package/src/serve.ts
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import { buildIndex } from './indexer.js';
|
|
11
11
|
import { loadIndex, searchIndex } from './search.js';
|
|
12
12
|
import { walkMarkdown, assertSafePath, ModelDescriptor } from './core.js';
|
|
13
|
+
import { createFileWatcher, FileWatcher } from './watcher.js';
|
|
13
14
|
|
|
14
15
|
const DEFAULT_PORT = 8747;
|
|
15
16
|
const DEFAULT_HOST = '127.0.0.1';
|
|
@@ -91,14 +92,20 @@ export async function createServe(opts: CreateServeOptions): Promise<{
|
|
|
91
92
|
healthPublic,
|
|
92
93
|
};
|
|
93
94
|
|
|
95
|
+
const sockets = new Set<import('node:net').Socket>();
|
|
94
96
|
const server = http.createServer((req, res) => {
|
|
95
97
|
handleRequest(req, res, state).catch((e) => {
|
|
96
98
|
json(res, 500, { error: e.message });
|
|
97
99
|
});
|
|
98
100
|
});
|
|
101
|
+
server.on('connection', (socket) => {
|
|
102
|
+
sockets.add(socket);
|
|
103
|
+
socket.on('close', () => sockets.delete(socket));
|
|
104
|
+
});
|
|
99
105
|
|
|
100
106
|
let stopped = false;
|
|
101
107
|
let timer: NodeJS.Timeout | null = null;
|
|
108
|
+
let resolveTimer: (() => void) | null = null;
|
|
102
109
|
|
|
103
110
|
const reload = async () => {
|
|
104
111
|
if (!db) return false;
|
|
@@ -190,14 +197,22 @@ export async function createServe(opts: CreateServeOptions): Promise<{
|
|
|
190
197
|
return out;
|
|
191
198
|
};
|
|
192
199
|
|
|
200
|
+
let lastFingerprint = '';
|
|
201
|
+
|
|
193
202
|
const watchLoop = async () => {
|
|
194
203
|
let indexedHashes = readIndexedHashes();
|
|
195
|
-
|
|
204
|
+
lastFingerprint = treeContentFingerprint(scanTree());
|
|
196
205
|
let lastChangeAt = 0;
|
|
197
206
|
let pending = false;
|
|
198
207
|
while (!stopped) {
|
|
199
|
-
await new Promise((r) => {
|
|
200
|
-
|
|
208
|
+
await new Promise<void>((r) => {
|
|
209
|
+
resolveTimer = r;
|
|
210
|
+
timer = setTimeout(() => {
|
|
211
|
+
timer = null;
|
|
212
|
+
resolveTimer = null;
|
|
213
|
+
r();
|
|
214
|
+
}, watchInterval);
|
|
215
|
+
timer.unref?.();
|
|
201
216
|
});
|
|
202
217
|
if (stopped) return;
|
|
203
218
|
const tree = scanTree();
|
|
@@ -228,14 +243,39 @@ export async function createServe(opts: CreateServeOptions): Promise<{
|
|
|
228
243
|
}
|
|
229
244
|
};
|
|
230
245
|
|
|
246
|
+
let nativeWatcher: FileWatcher | null = null;
|
|
247
|
+
|
|
231
248
|
if (watch) {
|
|
232
249
|
if (!db) throw new Error('serve --watch requires --db');
|
|
250
|
+
nativeWatcher = createFileWatcher(db, () => {
|
|
251
|
+
// Prompt watchLoop settle trigger on native event
|
|
252
|
+
lastFingerprint = '';
|
|
253
|
+
}, {
|
|
254
|
+
debounceMs: watchDelay,
|
|
255
|
+
ignore,
|
|
256
|
+
log,
|
|
257
|
+
});
|
|
233
258
|
watchLoop();
|
|
234
259
|
}
|
|
235
260
|
|
|
236
261
|
const close = async (): Promise<void> => {
|
|
237
262
|
stopped = true;
|
|
238
|
-
if (timer)
|
|
263
|
+
if (timer) {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
timer = null;
|
|
266
|
+
}
|
|
267
|
+
if (resolveTimer) {
|
|
268
|
+
resolveTimer();
|
|
269
|
+
resolveTimer = null;
|
|
270
|
+
}
|
|
271
|
+
if (nativeWatcher) nativeWatcher.close();
|
|
272
|
+
server.closeAllConnections?.();
|
|
273
|
+
for (const socket of sockets) {
|
|
274
|
+
try {
|
|
275
|
+
socket.destroy();
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
sockets.clear();
|
|
239
279
|
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
240
280
|
};
|
|
241
281
|
|
|
@@ -329,6 +369,8 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
|
|
|
329
369
|
maxPerFile: payload.maxPerFile || payload.maxPerDoc,
|
|
330
370
|
ann: payload.ann,
|
|
331
371
|
nprobe: payload.nprobe,
|
|
372
|
+
graphBoost: payload.graphBoost,
|
|
373
|
+
filter: payload.filter,
|
|
332
374
|
});
|
|
333
375
|
json(res, 200, { query, k, count: results.length, results });
|
|
334
376
|
} catch (e: any) {
|
package/src/summarize.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated Note Summarization & Semantic Keyword Tagging Pipeline (issue #103).
|
|
3
|
+
*
|
|
4
|
+
* Implements zero-dependency, deterministic extractive summarization and keyphrase
|
|
5
|
+
* tag extraction to enrich note chunks with discoverable semantic metadata.
|
|
6
|
+
*/
|
|
7
|
+
import { tokenize } from './lexical.js';
|
|
8
|
+
|
|
9
|
+
export interface NoteAnalysis {
|
|
10
|
+
tags: string[];
|
|
11
|
+
summary: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extracts salient topical tags from Markdown text using term frequency and position weighting.
|
|
16
|
+
*/
|
|
17
|
+
export function extractKeyphraseTags(text: string, maxTags: number = 5): string[] {
|
|
18
|
+
if (!text || !text.trim()) return [];
|
|
19
|
+
|
|
20
|
+
const lines = text.split('\n');
|
|
21
|
+
const termScores = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
lines.forEach((line, lineIdx) => {
|
|
24
|
+
const isHeading = /^#{1,6}\s+/.test(line);
|
|
25
|
+
const weight = isHeading ? 3.0 : lineIdx < 3 ? 1.5 : 1.0;
|
|
26
|
+
const tokens = tokenize(line);
|
|
27
|
+
|
|
28
|
+
for (const tok of tokens) {
|
|
29
|
+
if (tok.length < 3) continue;
|
|
30
|
+
// Skip purely numeric tokens
|
|
31
|
+
if (/^\d+$/.test(tok)) continue;
|
|
32
|
+
|
|
33
|
+
const current = termScores.get(tok) || 0;
|
|
34
|
+
termScores.set(tok, current + weight);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const sorted = Array.from(termScores.entries())
|
|
39
|
+
.sort((a, b) => b[1] - a[1])
|
|
40
|
+
.slice(0, maxTags)
|
|
41
|
+
.map(([term]) => term);
|
|
42
|
+
|
|
43
|
+
return sorted;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generates a concise extractive summary (1-2 sentences) representing the salient points of a note.
|
|
48
|
+
*/
|
|
49
|
+
export function summarizePassage(text: string, maxSentences: number = 2): string {
|
|
50
|
+
if (!text || !text.trim()) return '';
|
|
51
|
+
|
|
52
|
+
// Clean Markdown formatting
|
|
53
|
+
const clean = text
|
|
54
|
+
.replace(/^#{1,6}\s+.*$/gm, '')
|
|
55
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
56
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
57
|
+
.replace(/[*_`~]/g, '')
|
|
58
|
+
.trim();
|
|
59
|
+
|
|
60
|
+
const sentences = clean
|
|
61
|
+
.split(/(?<=[.?!])\s+|\n\n+/)
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter((s) => s.length >= 20 && s.length <= 300);
|
|
64
|
+
|
|
65
|
+
if (sentences.length <= maxSentences) {
|
|
66
|
+
return sentences.join(' ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const salientTags = new Set(extractKeyphraseTags(clean, 8));
|
|
70
|
+
|
|
71
|
+
const scored = sentences.map((sent, index) => {
|
|
72
|
+
const tokens = tokenize(sent);
|
|
73
|
+
let tagMatches = 0;
|
|
74
|
+
for (const t of tokens) {
|
|
75
|
+
if (salientTags.has(t)) tagMatches++;
|
|
76
|
+
}
|
|
77
|
+
// Favor earlier sentences and sentences containing salient keywords
|
|
78
|
+
const posScore = 1.0 / (index + 1);
|
|
79
|
+
const salienceScore = tagMatches / (tokens.length + 1);
|
|
80
|
+
const score = posScore * 0.4 + salienceScore * 0.6;
|
|
81
|
+
return { sent, score, index };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
scored.sort((a, b) => b.score - a.score);
|
|
85
|
+
const selected = scored.slice(0, maxSentences).sort((a, b) => a.index - b.index);
|
|
86
|
+
|
|
87
|
+
return selected.map((s) => s.sent).join(' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Analyzes a Markdown note to produce automated semantic tags and summary.
|
|
92
|
+
*/
|
|
93
|
+
export function analyzeNote(text: string): NoteAnalysis {
|
|
94
|
+
return {
|
|
95
|
+
tags: extractKeyphraseTags(text),
|
|
96
|
+
summary: summarizePassage(text),
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/tui.ts
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, SearchResultHit } from './search.js';
|
|
5
|
+
import { extractAnswerFallback } from './rag.js';
|
|
5
6
|
import { ModelDescriptor } from './core.js';
|
|
6
7
|
|
|
7
8
|
export interface TuiOptions {
|
|
@@ -15,6 +16,7 @@ export interface TuiOptions {
|
|
|
15
16
|
path?: string | string[];
|
|
16
17
|
rerank?: boolean;
|
|
17
18
|
rerankPool?: number;
|
|
19
|
+
rag?: boolean;
|
|
18
20
|
embedFn?: any;
|
|
19
21
|
rerankFn?: any;
|
|
20
22
|
debounceMs?: number;
|
|
@@ -74,6 +76,8 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
|
|
|
74
76
|
}, opts.debounceMs ?? 300);
|
|
75
77
|
};
|
|
76
78
|
|
|
79
|
+
let showRag = Boolean(opts.rag);
|
|
80
|
+
|
|
77
81
|
const render = () => {
|
|
78
82
|
const rows = process.stdout.rows || 24;
|
|
79
83
|
const cols = process.stdout.columns || 80;
|
|
@@ -84,11 +88,12 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
|
|
|
84
88
|
|
|
85
89
|
// Header
|
|
86
90
|
const searchStatus = searching ? ' [searching...]' : '';
|
|
87
|
-
const
|
|
91
|
+
const modeLabel = showRag ? 'RAG QA & Synthesis' : 'search';
|
|
92
|
+
const headerStr = ` mdss interactive ${modeLabel} | Query: ${query}${searchStatus}`;
|
|
88
93
|
buf += `\x1b[7m${headerStr.padEnd(cols).slice(0, cols)}\x1b[0m\n`;
|
|
89
94
|
|
|
90
95
|
// Calculate layout heights
|
|
91
|
-
const listHeight = Math.max(
|
|
96
|
+
const listHeight = Math.max(3, Math.floor((rows - 4) * 0.35));
|
|
92
97
|
const previewHeight = Math.max(4, rows - 4 - listHeight);
|
|
93
98
|
|
|
94
99
|
// Render list
|
|
@@ -111,25 +116,38 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
|
|
|
111
116
|
}
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
// Render Preview
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
+
// Render Preview or RAG synthesis
|
|
120
|
+
if (showRag) {
|
|
121
|
+
buf += `\x1b[1m--- Grounded Answer Synthesis & Citations ---\x1b[0m\n`;
|
|
122
|
+
const answer = extractAnswerFallback(query, results);
|
|
123
|
+
const answerLines = answer.split('\n');
|
|
119
124
|
for (let i = 0; i < previewHeight; i++) {
|
|
120
|
-
if (i <
|
|
121
|
-
buf += ` ${
|
|
125
|
+
if (i < answerLines.length) {
|
|
126
|
+
buf += ` ${answerLines[i].slice(0, cols - 3)}\n`;
|
|
122
127
|
} else {
|
|
123
128
|
buf += '\n';
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
} else {
|
|
127
|
-
buf +=
|
|
128
|
-
|
|
132
|
+
buf += `\x1b[1m--- Passage Preview ---\x1b[0m\n`;
|
|
133
|
+
const selectedHit = results[selectedIndex];
|
|
134
|
+
if (selectedHit) {
|
|
135
|
+
const textLines = selectedHit.snippet.split('\n');
|
|
136
|
+
for (let i = 0; i < previewHeight; i++) {
|
|
137
|
+
if (i < textLines.length) {
|
|
138
|
+
buf += ` ${textLines[i].slice(0, cols - 3)}\n`;
|
|
139
|
+
} else {
|
|
140
|
+
buf += '\n';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
buf += ' (no passage selected)\n';
|
|
145
|
+
for (let i = 1; i < previewHeight; i++) buf += '\n';
|
|
146
|
+
}
|
|
129
147
|
}
|
|
130
148
|
|
|
131
149
|
// Footer
|
|
132
|
-
const footerStr = ' [Up/Down
|
|
150
|
+
const footerStr = ' [Tab]: Toggle RAG/Passage | [Up/Down]: Select | [Enter]: Open | [Esc]: Quit';
|
|
133
151
|
buf += `\x1b[7m${footerStr.padEnd(cols).slice(0, cols)}\x1b[0m`;
|
|
134
152
|
|
|
135
153
|
process.stdout.write(buf);
|
|
@@ -171,6 +189,12 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
|
|
|
171
189
|
return;
|
|
172
190
|
}
|
|
173
191
|
|
|
192
|
+
if (key.name === 'tab') {
|
|
193
|
+
showRag = !showRag;
|
|
194
|
+
render();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
if (key.name === 'up' || (key.name === 'k' && key.ctrl)) {
|
|
175
199
|
if (selectedIndex > 0) {
|
|
176
200
|
selectedIndex--;
|