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/search.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { ModelDescriptor } from './core.js';
|
|
|
2
2
|
import { LexicalIndex } from './lexical.js';
|
|
3
3
|
import { DocumentMetadata } from './frontmatter.js';
|
|
4
4
|
import { IVFIndex } from './ivf.js';
|
|
5
|
+
import { RelationshipGraph } from './wikilinks.js';
|
|
6
|
+
import { FilterNode } from './filter.js';
|
|
5
7
|
export { tokenize } from './lexical.js';
|
|
6
8
|
export declare const _stats: {
|
|
7
9
|
corpusTokenizedChars: number;
|
|
@@ -10,7 +12,7 @@ export declare function keywordScores(chunks: IndexChunk[], query: string): numb
|
|
|
10
12
|
export declare function rrf(rankings: Array<Array<{
|
|
11
13
|
idx: number;
|
|
12
14
|
score: number;
|
|
13
|
-
}>>, k?: number): Map<number, number>;
|
|
15
|
+
}>>, k?: number, weights?: number[]): Map<number, number>;
|
|
14
16
|
export interface IndexChunk {
|
|
15
17
|
file: string;
|
|
16
18
|
title: string;
|
|
@@ -61,7 +63,7 @@ export interface RuntimeChunk {
|
|
|
61
63
|
startLine?: number;
|
|
62
64
|
endLine?: number;
|
|
63
65
|
meta?: DocumentMetadata;
|
|
64
|
-
vec: Float32Array;
|
|
66
|
+
vec: Float32Array | Int8Array;
|
|
65
67
|
}
|
|
66
68
|
export interface RuntimeIndexState {
|
|
67
69
|
schema: number;
|
|
@@ -72,17 +74,24 @@ export interface RuntimeIndexState {
|
|
|
72
74
|
model: ModelDescriptor;
|
|
73
75
|
queryCache: QueryEmbeddingCache;
|
|
74
76
|
ivf?: IVFIndex;
|
|
77
|
+
graph?: RelationshipGraph;
|
|
78
|
+
pageRank?: Map<string, number>;
|
|
75
79
|
}
|
|
80
|
+
export declare function getRuntimeGraph(runtime: RuntimeIndexState): {
|
|
81
|
+
graph: RelationshipGraph;
|
|
82
|
+
pageRank: Map<string, number>;
|
|
83
|
+
};
|
|
76
84
|
export declare function loadIndex(indexDir: string): {
|
|
77
85
|
index: IndexFile;
|
|
78
86
|
model: ModelDescriptor;
|
|
79
87
|
};
|
|
80
88
|
export declare const MAX_QUERY_LENGTH = 2048;
|
|
89
|
+
export interface LoadedIndex {
|
|
90
|
+
index: IndexFile;
|
|
91
|
+
model: ModelDescriptor;
|
|
92
|
+
}
|
|
81
93
|
export interface SearchOptions {
|
|
82
|
-
loaded:
|
|
83
|
-
index: IndexFile;
|
|
84
|
-
model: ModelDescriptor;
|
|
85
|
-
};
|
|
94
|
+
loaded: LoadedIndex;
|
|
86
95
|
cacheDir: string;
|
|
87
96
|
query: string;
|
|
88
97
|
k?: number;
|
|
@@ -106,6 +115,8 @@ export interface SearchOptions {
|
|
|
106
115
|
useQueryCache?: boolean;
|
|
107
116
|
ann?: boolean;
|
|
108
117
|
nprobe?: number;
|
|
118
|
+
graphBoost?: number;
|
|
119
|
+
filter?: string | FilterNode;
|
|
109
120
|
}
|
|
110
121
|
export interface SearchResultHit {
|
|
111
122
|
file: string;
|
|
@@ -116,6 +127,7 @@ export interface SearchResultHit {
|
|
|
116
127
|
matches: string[];
|
|
117
128
|
snippet: string;
|
|
118
129
|
meta?: DocumentMetadata;
|
|
130
|
+
graphScore?: number;
|
|
119
131
|
explain?: {
|
|
120
132
|
cosine: number;
|
|
121
133
|
rrfScore: number;
|
|
@@ -125,6 +137,8 @@ export interface SearchResultHit {
|
|
|
125
137
|
headingPath: number;
|
|
126
138
|
body: number;
|
|
127
139
|
};
|
|
140
|
+
graphScore?: number;
|
|
141
|
+
pageRank?: number;
|
|
128
142
|
};
|
|
129
143
|
rerankScore?: number;
|
|
130
144
|
}
|
package/dist/search.js
CHANGED
|
@@ -7,6 +7,10 @@ import { rerankScores } from './rerank.js';
|
|
|
7
7
|
import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores } from './lexical.js';
|
|
8
8
|
import { collapseResults } from './collapse.js';
|
|
9
9
|
import { trainIVF, searchIVFCandidates, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
|
|
10
|
+
import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood } from './wikilinks.js';
|
|
11
|
+
import { evaluateFilter } from './filter.js';
|
|
12
|
+
import { deserializeBinaryIndex } from './binary-format.js';
|
|
13
|
+
import { dequantizeFromInt8 } from './quantization.js';
|
|
10
14
|
export { tokenize } from './lexical.js';
|
|
11
15
|
const chunkTokens = new WeakMap();
|
|
12
16
|
export const _stats = { corpusTokenizedChars: 0 };
|
|
@@ -33,9 +37,13 @@ export function keywordScores(chunks, query) {
|
|
|
33
37
|
return s;
|
|
34
38
|
});
|
|
35
39
|
}
|
|
36
|
-
export function rrf(rankings, k = 60) {
|
|
40
|
+
export function rrf(rankings, k = 60, weights) {
|
|
37
41
|
const fused = new Map();
|
|
38
|
-
for (
|
|
42
|
+
for (let rankingIdx = 0; rankingIdx < rankings.length; rankingIdx++) {
|
|
43
|
+
const ranking = rankings[rankingIdx];
|
|
44
|
+
const weight = weights ? (weights[rankingIdx] ?? 1) : 1;
|
|
45
|
+
if (weight <= 0)
|
|
46
|
+
continue;
|
|
39
47
|
const sorted = [...ranking].sort((a, b) => b.score - a.score);
|
|
40
48
|
let scoreRank = 0;
|
|
41
49
|
sorted.forEach((item, rank) => {
|
|
@@ -43,7 +51,7 @@ export function rrf(rankings, k = 60) {
|
|
|
43
51
|
return;
|
|
44
52
|
if (rank > 0 && item.score < sorted[rank - 1].score)
|
|
45
53
|
scoreRank = rank;
|
|
46
|
-
fused.set(item.idx, (fused.get(item.idx) || 0) +
|
|
54
|
+
fused.set(item.idx, (fused.get(item.idx) || 0) + weight / (k + scoreRank + 1));
|
|
47
55
|
});
|
|
48
56
|
}
|
|
49
57
|
return fused;
|
|
@@ -116,22 +124,51 @@ export class QueryEmbeddingCache {
|
|
|
116
124
|
return this.cache.size;
|
|
117
125
|
}
|
|
118
126
|
}
|
|
127
|
+
export function getRuntimeGraph(runtime) {
|
|
128
|
+
if (runtime.graph && runtime.pageRank) {
|
|
129
|
+
return { graph: runtime.graph, pageRank: runtime.pageRank };
|
|
130
|
+
}
|
|
131
|
+
const docsMap = new Map();
|
|
132
|
+
for (const chunk of runtime.chunks) {
|
|
133
|
+
const existing = docsMap.get(chunk.file);
|
|
134
|
+
if (!existing) {
|
|
135
|
+
docsMap.set(chunk.file, {
|
|
136
|
+
file: chunk.file,
|
|
137
|
+
title: chunk.title,
|
|
138
|
+
meta: chunk.meta,
|
|
139
|
+
text: chunk.text,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
existing.text = (existing.text || '') + '\n' + chunk.text;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const docs = Array.from(docsMap.values());
|
|
147
|
+
const graph = buildRelationshipGraph(docs);
|
|
148
|
+
const pageRank = computePageRank(graph);
|
|
149
|
+
runtime.graph = graph;
|
|
150
|
+
runtime.pageRank = pageRank;
|
|
151
|
+
return { graph, pageRank };
|
|
152
|
+
}
|
|
119
153
|
const runtimeIndexStates = new WeakMap();
|
|
120
154
|
function snapshotRuntimeIndex(index, validation) {
|
|
121
155
|
const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
|
|
122
156
|
const count = index.chunks.length;
|
|
123
|
-
const
|
|
157
|
+
const isZeroCopy = !!validation.rawVectorsBuffer;
|
|
158
|
+
const vectorsBuffer = validation.rawVectorsBuffer ?? (dim > 0 ? new Float32Array(count * dim) : null);
|
|
124
159
|
const chunks = index.chunks.map((chunk, i) => {
|
|
125
160
|
let vec;
|
|
126
161
|
if (vectorsBuffer && dim > 0) {
|
|
127
162
|
const offset = i * dim;
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
163
|
+
if (!isZeroCopy) {
|
|
164
|
+
if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array) {
|
|
165
|
+
vectorsBuffer.set(chunk.vec, offset);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const arr = chunk.vec;
|
|
169
|
+
for (let j = 0; j < dim; j++)
|
|
170
|
+
vectorsBuffer[offset + j] = arr[j];
|
|
171
|
+
}
|
|
135
172
|
}
|
|
136
173
|
vec = vectorsBuffer.subarray(offset, offset + dim);
|
|
137
174
|
}
|
|
@@ -224,7 +261,69 @@ function warnIfStale(index) {
|
|
|
224
261
|
process.stderr.write(`warning: index is ${mins} min older than the newest change in ${index.db}; run \`mdss index\` to refresh.\n`);
|
|
225
262
|
}
|
|
226
263
|
export function loadIndex(indexDir) {
|
|
264
|
+
const vectorsBinPath = path.join(indexDir, 'vectors.bin');
|
|
227
265
|
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
266
|
+
let useBinary = false;
|
|
267
|
+
if (fs.existsSync(vectorsBinPath)) {
|
|
268
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
269
|
+
useBinary = true;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
try {
|
|
273
|
+
const binStat = fs.statSync(vectorsBinPath);
|
|
274
|
+
const jsonStat = fs.statSync(vectorsPath);
|
|
275
|
+
if (binStat.mtimeMs >= jsonStat.mtimeMs) {
|
|
276
|
+
useBinary = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
useBinary = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (useBinary) {
|
|
285
|
+
try {
|
|
286
|
+
const binBuffer = fs.readFileSync(vectorsBinPath);
|
|
287
|
+
const shaBinPath = path.join(indexDir, 'vectors.bin.sha256');
|
|
288
|
+
if (fs.existsSync(shaBinPath)) {
|
|
289
|
+
const expectedHash = fs.readFileSync(shaBinPath, 'utf8').trim().split(/\s+/)[0];
|
|
290
|
+
const actualHash = crypto.createHash('sha256').update(binBuffer).digest('hex');
|
|
291
|
+
if (expectedHash && actualHash !== expectedHash) {
|
|
292
|
+
throw new Error('vectors.bin integrity check failed (SHA-256 mismatch) — run `mdss index` to rebuild.');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const deserialized = deserializeBinaryIndex(binBuffer);
|
|
296
|
+
const validated = validateIndexEnvelope(deserialized, vectorsBinPath, { encoding: 'loaded', validateVectors: true });
|
|
297
|
+
const index = validated.index;
|
|
298
|
+
const { model, dim: validatedDim } = validated;
|
|
299
|
+
const expectedDim = validatedDim ?? deserialized.dim;
|
|
300
|
+
warnIfStale(index);
|
|
301
|
+
const ivfPath = path.join(indexDir, 'ivf.json');
|
|
302
|
+
let ivf;
|
|
303
|
+
if (fs.existsSync(ivfPath)) {
|
|
304
|
+
try {
|
|
305
|
+
const raw = JSON.parse(fs.readFileSync(ivfPath, 'utf8'));
|
|
306
|
+
ivf = deserializeIVF(raw);
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
}
|
|
310
|
+
runtimeIndexStates.set(index, snapshotRuntimeIndex(index, {
|
|
311
|
+
schema: validated.schema,
|
|
312
|
+
dim: expectedDim,
|
|
313
|
+
model,
|
|
314
|
+
ivf,
|
|
315
|
+
rawVectorsBuffer: deserialized.rawVectorsBuffer,
|
|
316
|
+
}));
|
|
317
|
+
return { index, model };
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
if (e.message?.includes('integrity check failed'))
|
|
321
|
+
throw e;
|
|
322
|
+
if (!fs.existsSync(vectorsPath))
|
|
323
|
+
throw e;
|
|
324
|
+
// If vectors.bin was corrupt or unreadable, fall through to vectors.json fallback
|
|
325
|
+
}
|
|
326
|
+
}
|
|
228
327
|
if (!fs.existsSync(vectorsPath)) {
|
|
229
328
|
throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
|
|
230
329
|
}
|
|
@@ -297,7 +396,8 @@ export function loadIndex(indexDir) {
|
|
|
297
396
|
}
|
|
298
397
|
export const MAX_QUERY_LENGTH = 2048;
|
|
299
398
|
export async function searchIndex(opts) {
|
|
300
|
-
const { loaded, cacheDir, query, k = 6, semanticOnly = false, offline = false, path: pathFilter, since, rerank = false, rerankPool, tag, project, type, status, canonicalOnly, custom, } = opts;
|
|
399
|
+
const { loaded, cacheDir, query, k = 6, semanticOnly = false, offline = false, path: pathFilter, since, rerank = false, rerankPool, tag, project, type, status, canonicalOnly, custom, filter: filterExpr, } = opts;
|
|
400
|
+
const graphBoost = typeof opts.graphBoost === 'number' && Number.isFinite(opts.graphBoost) ? opts.graphBoost : 0;
|
|
301
401
|
if (typeof query === 'string' && query.length > MAX_QUERY_LENGTH) {
|
|
302
402
|
throw new Error(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters`);
|
|
303
403
|
}
|
|
@@ -319,7 +419,8 @@ export async function searchIndex(opts) {
|
|
|
319
419
|
type !== undefined ||
|
|
320
420
|
status !== undefined ||
|
|
321
421
|
canonicalOnly !== undefined ||
|
|
322
|
-
custom !== undefined
|
|
422
|
+
custom !== undefined ||
|
|
423
|
+
filterExpr !== undefined;
|
|
323
424
|
if (pathRes.length > 0 || sinceMs !== undefined || hasMetaFilter) {
|
|
324
425
|
if (sinceMs !== undefined && !db) {
|
|
325
426
|
throw new Error('--since requires an index that knows its --db (index.db missing). Re-run `mdss index`.');
|
|
@@ -342,6 +443,10 @@ export async function searchIndex(opts) {
|
|
|
342
443
|
if (m < sinceMs)
|
|
343
444
|
return false;
|
|
344
445
|
}
|
|
446
|
+
if (filterExpr !== undefined) {
|
|
447
|
+
if (!evaluateFilter(filterExpr, chunk.meta, { file: chunk.file, title: chunk.title }))
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
345
450
|
if (tag !== undefined) {
|
|
346
451
|
const reqTags = (Array.isArray(tag) ? tag : [tag]).map((t) => String(t).toLowerCase().replace(/^#/, ''));
|
|
347
452
|
const chunkTags = chunk.meta?.tags || [];
|
|
@@ -390,7 +495,7 @@ export async function searchIndex(opts) {
|
|
|
390
495
|
const useAnn = opts.ann ?? (runtime.ivf !== undefined && candidates.length >= ANN_THRESHOLD);
|
|
391
496
|
if (useAnn && candidates.length >= ANN_THRESHOLD) {
|
|
392
497
|
if (!runtime.ivf) {
|
|
393
|
-
const rawVecs = chunks.map((c) => c.vec);
|
|
498
|
+
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
|
|
394
499
|
runtime.ivf = trainIVF(rawVecs);
|
|
395
500
|
}
|
|
396
501
|
const qVecF32 = qVec instanceof Float32Array ? qVec : Float32Array.from(qVec);
|
|
@@ -409,12 +514,45 @@ export async function searchIndex(opts) {
|
|
|
409
514
|
}
|
|
410
515
|
const cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
|
|
411
516
|
const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
|
|
517
|
+
let graphRanking = [];
|
|
518
|
+
let graphMap;
|
|
519
|
+
if (graphBoost > 0) {
|
|
520
|
+
const { graph, pageRank } = getRuntimeGraph(runtime);
|
|
521
|
+
const seedFilesMap = new Map();
|
|
522
|
+
for (const s of semantic) {
|
|
523
|
+
if (s.score > 0) {
|
|
524
|
+
const file = chunks[s.idx].file;
|
|
525
|
+
const curr = seedFilesMap.get(file) || 0;
|
|
526
|
+
if (s.score > curr)
|
|
527
|
+
seedFilesMap.set(file, s.score);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const seedFiles = Array.from(seedFilesMap.entries()).map(([file, score]) => ({ file, score }));
|
|
531
|
+
const propagationScores = expandGraphNeighborhood(graph, seedFiles, { maxDepth: 2, decay: 0.5 });
|
|
532
|
+
graphMap = new Map();
|
|
533
|
+
graphRanking = candidates.map(({ chunk, idx }) => {
|
|
534
|
+
const pr = pageRank.get(chunk.file) || 0;
|
|
535
|
+
const prop = propagationScores.get(chunk.file) || 0;
|
|
536
|
+
const totalGraph = pr + prop;
|
|
537
|
+
graphMap.set(idx, totalGraph);
|
|
538
|
+
return { idx, score: totalGraph };
|
|
539
|
+
});
|
|
540
|
+
}
|
|
412
541
|
let ranked;
|
|
413
542
|
if (semanticOnly) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
.
|
|
417
|
-
|
|
543
|
+
if (graphBoost > 0) {
|
|
544
|
+
const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
|
|
545
|
+
ranked = [...fused.entries()]
|
|
546
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
|
|
547
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
548
|
+
.slice(0, pool);
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
ranked = [...semantic]
|
|
552
|
+
.sort((a, b) => b.score - a.score)
|
|
553
|
+
.slice(0, pool)
|
|
554
|
+
.map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
|
|
555
|
+
}
|
|
418
556
|
}
|
|
419
557
|
else {
|
|
420
558
|
const kw = lexicalState.kind === 'persisted-bm25'
|
|
@@ -427,11 +565,20 @@ export async function searchIndex(opts) {
|
|
|
427
565
|
idx: candidates[position].idx,
|
|
428
566
|
score,
|
|
429
567
|
}));
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
568
|
+
if (graphBoost > 0) {
|
|
569
|
+
const fused = rrf([semantic, kw, fuzzy, graphRanking], 60, [1, 1, 1, graphBoost]);
|
|
570
|
+
ranked = [...fused.entries()]
|
|
571
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
|
|
572
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
573
|
+
.slice(0, pool);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const fused = rrf([semantic, kw, fuzzy]);
|
|
577
|
+
ranked = [...fused.entries()]
|
|
578
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
|
|
579
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
580
|
+
.slice(0, pool);
|
|
581
|
+
}
|
|
435
582
|
}
|
|
436
583
|
if (rerank && ranked.length > 0) {
|
|
437
584
|
const texts = ranked.map((r) => chunks[r.idx].text);
|
|
@@ -458,6 +605,8 @@ export async function searchIndex(opts) {
|
|
|
458
605
|
const matches = lexicalState.kind === 'persisted-bm25'
|
|
459
606
|
? matchingTerms(lexicalState.lexical, queryTerms, r.idx)
|
|
460
607
|
: queryTerms.filter((term) => tokenSet(c).has(term));
|
|
608
|
+
const gScore = graphMap?.get(r.idx);
|
|
609
|
+
const prScore = runtime.pageRank?.get(c.file);
|
|
461
610
|
return {
|
|
462
611
|
file: c.file,
|
|
463
612
|
title: c.title,
|
|
@@ -467,12 +616,15 @@ export async function searchIndex(opts) {
|
|
|
467
616
|
matches,
|
|
468
617
|
snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
|
|
469
618
|
...(hasMetaFields(c.meta) ? { meta: c.meta } : {}),
|
|
619
|
+
...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
|
|
470
620
|
...(explain
|
|
471
621
|
? {
|
|
472
622
|
explain: {
|
|
473
623
|
cosine: +r.cos.toFixed(4),
|
|
474
624
|
rrfScore: +r.fscore.toFixed(4),
|
|
475
625
|
bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
|
|
626
|
+
...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
|
|
627
|
+
...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
|
|
476
628
|
},
|
|
477
629
|
}
|
|
478
630
|
: {}),
|
package/dist/serve.js
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 } from './core.js';
|
|
13
|
+
import { createFileWatcher } from './watcher.js';
|
|
13
14
|
const DEFAULT_PORT = 8747;
|
|
14
15
|
const DEFAULT_HOST = '127.0.0.1';
|
|
15
16
|
const WATCH_INTERVAL_MS = 3000;
|
|
@@ -39,13 +40,19 @@ export async function createServe(opts) {
|
|
|
39
40
|
apiKey,
|
|
40
41
|
healthPublic,
|
|
41
42
|
};
|
|
43
|
+
const sockets = new Set();
|
|
42
44
|
const server = http.createServer((req, res) => {
|
|
43
45
|
handleRequest(req, res, state).catch((e) => {
|
|
44
46
|
json(res, 500, { error: e.message });
|
|
45
47
|
});
|
|
46
48
|
});
|
|
49
|
+
server.on('connection', (socket) => {
|
|
50
|
+
sockets.add(socket);
|
|
51
|
+
socket.on('close', () => sockets.delete(socket));
|
|
52
|
+
});
|
|
47
53
|
let stopped = false;
|
|
48
54
|
let timer = null;
|
|
55
|
+
let resolveTimer = null;
|
|
49
56
|
const reload = async () => {
|
|
50
57
|
if (!db)
|
|
51
58
|
return false;
|
|
@@ -140,14 +147,21 @@ export async function createServe(opts) {
|
|
|
140
147
|
}
|
|
141
148
|
return out;
|
|
142
149
|
};
|
|
150
|
+
let lastFingerprint = '';
|
|
143
151
|
const watchLoop = async () => {
|
|
144
152
|
let indexedHashes = readIndexedHashes();
|
|
145
|
-
|
|
153
|
+
lastFingerprint = treeContentFingerprint(scanTree());
|
|
146
154
|
let lastChangeAt = 0;
|
|
147
155
|
let pending = false;
|
|
148
156
|
while (!stopped) {
|
|
149
157
|
await new Promise((r) => {
|
|
150
|
-
|
|
158
|
+
resolveTimer = r;
|
|
159
|
+
timer = setTimeout(() => {
|
|
160
|
+
timer = null;
|
|
161
|
+
resolveTimer = null;
|
|
162
|
+
r();
|
|
163
|
+
}, watchInterval);
|
|
164
|
+
timer.unref?.();
|
|
151
165
|
});
|
|
152
166
|
if (stopped)
|
|
153
167
|
return;
|
|
@@ -182,15 +196,40 @@ export async function createServe(opts) {
|
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
};
|
|
199
|
+
let nativeWatcher = null;
|
|
185
200
|
if (watch) {
|
|
186
201
|
if (!db)
|
|
187
202
|
throw new Error('serve --watch requires --db');
|
|
203
|
+
nativeWatcher = createFileWatcher(db, () => {
|
|
204
|
+
// Prompt watchLoop settle trigger on native event
|
|
205
|
+
lastFingerprint = '';
|
|
206
|
+
}, {
|
|
207
|
+
debounceMs: watchDelay,
|
|
208
|
+
ignore,
|
|
209
|
+
log,
|
|
210
|
+
});
|
|
188
211
|
watchLoop();
|
|
189
212
|
}
|
|
190
213
|
const close = async () => {
|
|
191
214
|
stopped = true;
|
|
192
|
-
if (timer)
|
|
215
|
+
if (timer) {
|
|
193
216
|
clearTimeout(timer);
|
|
217
|
+
timer = null;
|
|
218
|
+
}
|
|
219
|
+
if (resolveTimer) {
|
|
220
|
+
resolveTimer();
|
|
221
|
+
resolveTimer = null;
|
|
222
|
+
}
|
|
223
|
+
if (nativeWatcher)
|
|
224
|
+
nativeWatcher.close();
|
|
225
|
+
server.closeAllConnections?.();
|
|
226
|
+
for (const socket of sockets) {
|
|
227
|
+
try {
|
|
228
|
+
socket.destroy();
|
|
229
|
+
}
|
|
230
|
+
catch { }
|
|
231
|
+
}
|
|
232
|
+
sockets.clear();
|
|
194
233
|
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
195
234
|
};
|
|
196
235
|
return { server, state, close };
|
|
@@ -282,6 +321,8 @@ async function handleRequest(req, res, state) {
|
|
|
282
321
|
maxPerFile: payload.maxPerFile || payload.maxPerDoc,
|
|
283
322
|
ann: payload.ann,
|
|
284
323
|
nprobe: payload.nprobe,
|
|
324
|
+
graphBoost: payload.graphBoost,
|
|
325
|
+
filter: payload.filter,
|
|
285
326
|
});
|
|
286
327
|
json(res, 200, { query, k, count: results.length, results });
|
|
287
328
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface NoteAnalysis {
|
|
2
|
+
tags: string[];
|
|
3
|
+
summary: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Extracts salient topical tags from Markdown text using term frequency and position weighting.
|
|
7
|
+
*/
|
|
8
|
+
export declare function extractKeyphraseTags(text: string, maxTags?: number): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Generates a concise extractive summary (1-2 sentences) representing the salient points of a note.
|
|
11
|
+
*/
|
|
12
|
+
export declare function summarizePassage(text: string, maxSentences?: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* Analyzes a Markdown note to produce automated semantic tags and summary.
|
|
15
|
+
*/
|
|
16
|
+
export declare function analyzeNote(text: string): NoteAnalysis;
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
* Extracts salient topical tags from Markdown text using term frequency and position weighting.
|
|
10
|
+
*/
|
|
11
|
+
export function extractKeyphraseTags(text, maxTags = 5) {
|
|
12
|
+
if (!text || !text.trim())
|
|
13
|
+
return [];
|
|
14
|
+
const lines = text.split('\n');
|
|
15
|
+
const termScores = new Map();
|
|
16
|
+
lines.forEach((line, lineIdx) => {
|
|
17
|
+
const isHeading = /^#{1,6}\s+/.test(line);
|
|
18
|
+
const weight = isHeading ? 3.0 : lineIdx < 3 ? 1.5 : 1.0;
|
|
19
|
+
const tokens = tokenize(line);
|
|
20
|
+
for (const tok of tokens) {
|
|
21
|
+
if (tok.length < 3)
|
|
22
|
+
continue;
|
|
23
|
+
// Skip purely numeric tokens
|
|
24
|
+
if (/^\d+$/.test(tok))
|
|
25
|
+
continue;
|
|
26
|
+
const current = termScores.get(tok) || 0;
|
|
27
|
+
termScores.set(tok, current + weight);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const sorted = Array.from(termScores.entries())
|
|
31
|
+
.sort((a, b) => b[1] - a[1])
|
|
32
|
+
.slice(0, maxTags)
|
|
33
|
+
.map(([term]) => term);
|
|
34
|
+
return sorted;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Generates a concise extractive summary (1-2 sentences) representing the salient points of a note.
|
|
38
|
+
*/
|
|
39
|
+
export function summarizePassage(text, maxSentences = 2) {
|
|
40
|
+
if (!text || !text.trim())
|
|
41
|
+
return '';
|
|
42
|
+
// Clean Markdown formatting
|
|
43
|
+
const clean = text
|
|
44
|
+
.replace(/^#{1,6}\s+.*$/gm, '')
|
|
45
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
46
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
47
|
+
.replace(/[*_`~]/g, '')
|
|
48
|
+
.trim();
|
|
49
|
+
const sentences = clean
|
|
50
|
+
.split(/(?<=[.?!])\s+|\n\n+/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length >= 20 && s.length <= 300);
|
|
53
|
+
if (sentences.length <= maxSentences) {
|
|
54
|
+
return sentences.join(' ');
|
|
55
|
+
}
|
|
56
|
+
const salientTags = new Set(extractKeyphraseTags(clean, 8));
|
|
57
|
+
const scored = sentences.map((sent, index) => {
|
|
58
|
+
const tokens = tokenize(sent);
|
|
59
|
+
let tagMatches = 0;
|
|
60
|
+
for (const t of tokens) {
|
|
61
|
+
if (salientTags.has(t))
|
|
62
|
+
tagMatches++;
|
|
63
|
+
}
|
|
64
|
+
// Favor earlier sentences and sentences containing salient keywords
|
|
65
|
+
const posScore = 1.0 / (index + 1);
|
|
66
|
+
const salienceScore = tagMatches / (tokens.length + 1);
|
|
67
|
+
const score = posScore * 0.4 + salienceScore * 0.6;
|
|
68
|
+
return { sent, score, index };
|
|
69
|
+
});
|
|
70
|
+
scored.sort((a, b) => b.score - a.score);
|
|
71
|
+
const selected = scored.slice(0, maxSentences).sort((a, b) => a.index - b.index);
|
|
72
|
+
return selected.map((s) => s.sent).join(' ');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Analyzes a Markdown note to produce automated semantic tags and summary.
|
|
76
|
+
*/
|
|
77
|
+
export function analyzeNote(text) {
|
|
78
|
+
return {
|
|
79
|
+
tags: extractKeyphraseTags(text),
|
|
80
|
+
summary: summarizePassage(text),
|
|
81
|
+
};
|
|
82
|
+
}
|