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.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. 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('/').pop()?.toLowerCase();
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
+