md-semantic-search 1.0.3 → 2.0.0
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 +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/src/wikilinks.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Supports Obsidian wikilinks [[note]], [[note#heading|alias]], and relative Markdown links.
|
|
4
4
|
*/
|
|
5
5
|
import { DocumentMetadata } from './frontmatter.js';
|
|
6
|
+
import { cosine, decodeVec } from './core.js';
|
|
7
|
+
import { dequantizeFromInt8 } from './quantization.js';
|
|
6
8
|
|
|
7
9
|
export interface RawLink {
|
|
8
10
|
type: 'wikilink' | 'markdown';
|
|
@@ -86,7 +88,7 @@ export function resolveLinks(
|
|
|
86
88
|
for (const doc of docs) {
|
|
87
89
|
const relFile = doc.file;
|
|
88
90
|
pathMap.set(relFile.toLowerCase(), relFile);
|
|
89
|
-
const basename = relFile.replace(/\.md$/i, '').split(
|
|
91
|
+
const basename = relFile.replace(/\.md$/i, '').split(/[/\\]/).pop()?.toLowerCase();
|
|
90
92
|
if (basename && !pathMap.has(basename)) {
|
|
91
93
|
pathMap.set(basename, relFile);
|
|
92
94
|
}
|
|
@@ -374,3 +376,220 @@ export function expandGraphNeighborhood(
|
|
|
374
376
|
return propagationScores;
|
|
375
377
|
}
|
|
376
378
|
|
|
379
|
+
export interface FindRelatedNotesOptions {
|
|
380
|
+
loaded: { index: any; model?: any };
|
|
381
|
+
target: string;
|
|
382
|
+
k?: number;
|
|
383
|
+
direction?: 'both' | 'outgoing' | 'backlinks';
|
|
384
|
+
maxDepth?: number;
|
|
385
|
+
semantic?: boolean;
|
|
386
|
+
minScore?: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface RelatedNoteHit {
|
|
390
|
+
file: string;
|
|
391
|
+
title: string;
|
|
392
|
+
score: number;
|
|
393
|
+
reason: string;
|
|
394
|
+
distance?: number;
|
|
395
|
+
cosine?: number;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export interface RelatedNoteResult {
|
|
399
|
+
target: string;
|
|
400
|
+
resolvedFile: string;
|
|
401
|
+
results: RelatedNoteHit[];
|
|
402
|
+
total: number;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Finds notes related to a target note via graph links (outgoing, backlinks, 2-hop)
|
|
407
|
+
* and semantic similarity (issue #141).
|
|
408
|
+
*/
|
|
409
|
+
export function findRelatedNotes(opts: FindRelatedNotesOptions): RelatedNoteResult {
|
|
410
|
+
const { loaded, target, k = 10, direction = 'both', maxDepth = 2, semantic = true } = opts;
|
|
411
|
+
if (!target || typeof target !== 'string') {
|
|
412
|
+
throw new Error('findRelatedNotes: "target" parameter is required');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const chunks = loaded.index?.chunks || [];
|
|
416
|
+
if (chunks.length === 0) {
|
|
417
|
+
throw new Error('Index has no chunks');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 1. Group chunks by file to construct LinkDoc list and index metadata
|
|
421
|
+
const docMap = new Map<string, { title: string; meta?: DocumentMetadata; texts: string[]; vecs: Float32Array[] }>();
|
|
422
|
+
for (const c of chunks) {
|
|
423
|
+
const existing = docMap.get(c.file);
|
|
424
|
+
let v: Float32Array | null = null;
|
|
425
|
+
if (c.vec) {
|
|
426
|
+
if (c.vec instanceof Float32Array) {
|
|
427
|
+
v = c.vec;
|
|
428
|
+
} else if (Array.isArray(c.vec)) {
|
|
429
|
+
v = Float32Array.from(c.vec);
|
|
430
|
+
} else if (typeof c.vec === 'string') {
|
|
431
|
+
try {
|
|
432
|
+
v = decodeVec(c.vec);
|
|
433
|
+
} catch {
|
|
434
|
+
v = null;
|
|
435
|
+
}
|
|
436
|
+
} else if (c.vec instanceof Int8Array) {
|
|
437
|
+
v = dequantizeFromInt8(c.vec);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (existing) {
|
|
441
|
+
existing.texts.push(c.text || '');
|
|
442
|
+
if (v) existing.vecs.push(v);
|
|
443
|
+
} else {
|
|
444
|
+
docMap.set(c.file, {
|
|
445
|
+
title: c.title || '',
|
|
446
|
+
meta: c.meta,
|
|
447
|
+
texts: [c.text || ''],
|
|
448
|
+
vecs: v ? [v] : [],
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const docs: LinkDoc[] = Array.from(docMap.entries()).map(([file, d]) => ({
|
|
454
|
+
file,
|
|
455
|
+
title: d.title,
|
|
456
|
+
meta: d.meta,
|
|
457
|
+
text: d.texts.join('\n\n'),
|
|
458
|
+
}));
|
|
459
|
+
|
|
460
|
+
// 2. Resolve target note
|
|
461
|
+
const rawTarget = target.trim();
|
|
462
|
+
const normalizedTarget = rawTarget.toLowerCase().replace(/\.md$/i, '').split(/[#|]/)[0].trim();
|
|
463
|
+
let targetFile: string | null = null;
|
|
464
|
+
|
|
465
|
+
for (const [file, d] of docMap.entries()) {
|
|
466
|
+
const normFile = file.toLowerCase().replace(/\.md$/i, '');
|
|
467
|
+
const basename = normFile.split(/[/\\]/).pop() || '';
|
|
468
|
+
const normTitle = (d.title || '').toLowerCase();
|
|
469
|
+
const aliases = (d.meta?.aliases || []).map((a: string) => a.toLowerCase());
|
|
470
|
+
|
|
471
|
+
if (
|
|
472
|
+
file.toLowerCase() === rawTarget.toLowerCase() ||
|
|
473
|
+
normFile === normalizedTarget ||
|
|
474
|
+
basename === normalizedTarget ||
|
|
475
|
+
normTitle === normalizedTarget ||
|
|
476
|
+
aliases.includes(normalizedTarget)
|
|
477
|
+
) {
|
|
478
|
+
targetFile = file;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!targetFile) {
|
|
484
|
+
throw new Error(`Note not found: "${target}"`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 3. Build graph & find graph relationships
|
|
488
|
+
const graph = buildRelationshipGraph(docs);
|
|
489
|
+
const graphRelated = getRelatedNotes(graph, targetFile, { direction, depth: maxDepth });
|
|
490
|
+
|
|
491
|
+
const hitsMap = new Map<string, RelatedNoteHit>();
|
|
492
|
+
|
|
493
|
+
const outgoingSet = new Set((graph.outgoing.get(targetFile) || []).map((e) => e.file));
|
|
494
|
+
const backlinkSet = new Set((graph.backlinks.get(targetFile) || []).map((e) => e.file));
|
|
495
|
+
|
|
496
|
+
for (const rel of graphRelated) {
|
|
497
|
+
if (rel.file === targetFile) continue;
|
|
498
|
+
const d = docMap.get(rel.file);
|
|
499
|
+
const title = d?.title || rel.file;
|
|
500
|
+
|
|
501
|
+
let reason = '';
|
|
502
|
+
let score = 0;
|
|
503
|
+
|
|
504
|
+
if (rel.distance === 1) {
|
|
505
|
+
const isOut = outgoingSet.has(rel.file);
|
|
506
|
+
const isBack = backlinkSet.has(rel.file);
|
|
507
|
+
if (isOut && isBack) {
|
|
508
|
+
reason = 'bi-directional link';
|
|
509
|
+
score = 1.5;
|
|
510
|
+
} else if (isOut) {
|
|
511
|
+
reason = 'outgoing link';
|
|
512
|
+
score = 1.0;
|
|
513
|
+
} else {
|
|
514
|
+
reason = 'backlink';
|
|
515
|
+
score = 1.0;
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
reason = `${rel.distance}-hop connection`;
|
|
519
|
+
score = Math.max(0.1, 1.0 / rel.distance);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
hitsMap.set(rel.file, {
|
|
523
|
+
file: rel.file,
|
|
524
|
+
title,
|
|
525
|
+
score,
|
|
526
|
+
reason,
|
|
527
|
+
distance: rel.distance,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 4. Semantic similarity (if enabled or if graph has 0 links)
|
|
532
|
+
const targetData = docMap.get(targetFile);
|
|
533
|
+
const targetVecs = targetData?.vecs || [];
|
|
534
|
+
|
|
535
|
+
if (semantic && targetVecs.length > 0) {
|
|
536
|
+
// Compute target centroid
|
|
537
|
+
const dim = targetVecs[0].length;
|
|
538
|
+
const centroid = new Float32Array(dim);
|
|
539
|
+
for (const tv of targetVecs) {
|
|
540
|
+
for (let i = 0; i < dim; i++) centroid[i] += tv[i];
|
|
541
|
+
}
|
|
542
|
+
let norm = 0;
|
|
543
|
+
for (let i = 0; i < dim; i++) norm += centroid[i] * centroid[i];
|
|
544
|
+
norm = Math.sqrt(norm) || 1;
|
|
545
|
+
for (let i = 0; i < dim; i++) centroid[i] /= norm;
|
|
546
|
+
|
|
547
|
+
// Compare against every other note's centroid or max chunk cosine
|
|
548
|
+
for (const [file, d] of docMap.entries()) {
|
|
549
|
+
if (file === targetFile || d.vecs.length === 0) continue;
|
|
550
|
+
let maxCos = -1;
|
|
551
|
+
for (const cv of d.vecs) {
|
|
552
|
+
if (cv.length === dim) {
|
|
553
|
+
const cos = cosine(centroid, cv);
|
|
554
|
+
if (cos > maxCos) maxCos = cos;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (maxCos > 0.3) {
|
|
559
|
+
const existing = hitsMap.get(file);
|
|
560
|
+
if (existing) {
|
|
561
|
+
existing.score += maxCos * 0.5;
|
|
562
|
+
existing.reason += ` + semantic (cos: ${maxCos.toFixed(2)})`;
|
|
563
|
+
existing.cosine = maxCos;
|
|
564
|
+
} else {
|
|
565
|
+
hitsMap.set(file, {
|
|
566
|
+
file,
|
|
567
|
+
title: d.title || file,
|
|
568
|
+
score: maxCos,
|
|
569
|
+
reason: `semantic similarity (cos: ${maxCos.toFixed(2)})`,
|
|
570
|
+
cosine: maxCos,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 5. Deterministic sorting: by score descending, ties broken by file ascending
|
|
578
|
+
const sortedHits = Array.from(hitsMap.values()).sort((a, b) => {
|
|
579
|
+
if (Math.abs(b.score - a.score) > 1e-6) {
|
|
580
|
+
return b.score - a.score;
|
|
581
|
+
}
|
|
582
|
+
return a.file.localeCompare(b.file);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const finalHits = sortedHits.slice(0, k);
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
target: rawTarget,
|
|
589
|
+
resolvedFile: targetFile,
|
|
590
|
+
results: finalHits,
|
|
591
|
+
total: finalHits.length,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|