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
@@ -175,12 +175,80 @@ export function resolveChunkBudget(tokens?: number, defaultMax: number = 1200):
175
175
  return defaultMax;
176
176
  }
177
177
 
178
+ /**
179
+ * issue #202: script-calibrated token estimate for the sentencepiece E5/BGE
180
+ * vocab family — the fast-path alternative to running the real tokenizer per
181
+ * block. Ratios are calibrated so the estimate errs slightly HIGH: a budget
182
+ * checked against this estimate never overflows the model's real maxTokens.
183
+ * Latin/digits/punctuation ≈ 4 chars/token, Cyrillic ≈ 1.8 chars/token
184
+ * (RU systematically under-chunked by the old char budget — the point of
185
+ * this issue), CJK ≈ 1 token/char, whitespace mostly merges into the
186
+ * following subword so it counts a little.
187
+ */
188
+ export function estimateTokens(text: string): number {
189
+ let tokens = 0;
190
+ for (let i = 0; i < text.length; i++) {
191
+ const c = text.charCodeAt(i);
192
+ if (c >= 0x0400 && c <= 0x04ff) tokens += 0.55; // Cyrillic (+ Cyrillic supplement edges)
193
+ else if (
194
+ (c >= 0x3040 && c <= 0x30ff) || // hiragana / katakana
195
+ (c >= 0x3400 && c <= 0x4dbf) || // CJK ext A
196
+ (c >= 0x4e00 && c <= 0x9fff) || // CJK unified
197
+ (c >= 0xff00 && c <= 0xffef) // fullwidth forms
198
+ ) {
199
+ tokens += 1.0;
200
+ } else if (c === 0x20 || (c >= 0x09 && c <= 0x0d)) tokens += 0.08;
201
+ else if (c < 0x80) tokens += 0.25; // ASCII (latin, digits, punctuation, markdown syntax)
202
+ else tokens += 0.35; // other scripts / symbols — conservative middle ground
203
+ }
204
+ return Math.ceil(tokens);
205
+ }
206
+
207
+ /** Token measure: injected exact counter when available, else the calibrated estimate. */
208
+ export type TokenCounter = (text: string) => number;
209
+
210
+ export interface ChunkBudget {
211
+ /** Max measured size a single chunk may reach before it is split/flushed. */
212
+ budget: number;
213
+ /** Measures a text in the same unit the budget is expressed in. */
214
+ measure: TokenCounter;
215
+ /** Unit name for diagnostics/tests. */
216
+ unit: 'chars' | 'tokens';
217
+ }
218
+
219
+ /** Char budget (pre-#202 behavior) vs token budget (issue #202). */
220
+ export function resolveChunkingBudget(opts: {
221
+ maxChunk?: number;
222
+ maxChunkTokens?: number;
223
+ countTokens?: TokenCounter;
224
+ }): ChunkBudget {
225
+ if (opts.maxChunkTokens && Number.isFinite(opts.maxChunkTokens) && opts.maxChunkTokens > 0) {
226
+ const budget = Math.max(32, Math.round(opts.maxChunkTokens));
227
+ return { budget, measure: opts.countTokens ?? estimateTokens, unit: 'tokens' };
228
+ }
229
+ const budget = resolveChunkBudget(undefined, opts.maxChunk ?? 1200);
230
+ return { budget, measure: (t) => t.length, unit: 'chars' };
231
+ }
232
+
233
+ /**
234
+ * issue #202: the DEFAULT chunk budget in tokens, derived from the embedding
235
+ * adapter's context window. Capped at 512 — a passage much larger than that
236
+ * dilutes retrieval granularity even for models with 8k+ context (bge-m3).
237
+ * Returns undefined for adapters without a known maxTokens, which keeps the
238
+ * legacy char budget instead of guessing.
239
+ */
240
+ export function defaultChunkTokenBudget(adapter: { maxTokens?: number }): number | undefined {
241
+ const mt = adapter?.maxTokens;
242
+ if (!mt || !Number.isFinite(mt) || mt <= 0) return undefined;
243
+ return Math.min(Math.round(mt), 512);
244
+ }
245
+
178
246
  export function chunkMarkdownStructural(
179
247
  input: string | MarkdownBlock[],
180
- opts?: number | { maxChunk?: number; targetTokens?: number }
248
+ opts?: number | { maxChunk?: number; targetTokens?: number; maxChunkTokens?: number; countTokens?: TokenCounter }
181
249
  ): StructuralChunk[] {
182
250
  const options = typeof opts === 'number' ? { maxChunk: opts } : (opts || {});
183
- const maxChunk = resolveChunkBudget(options.targetTokens, options.maxChunk ?? 1200);
251
+ const { budget: maxChunk, measure } = resolveChunkingBudget(options);
184
252
  const blocks = typeof input === 'string' ? parseMarkdownBlocks(input) : input;
185
253
  const chunks: StructuralChunk[] = [];
186
254
 
@@ -190,6 +258,8 @@ export function chunkMarkdownStructural(
190
258
  let currentLen = 0;
191
259
  let groupStartLine = 0;
192
260
  let groupEndLine = 0;
261
+ // measured size of the '\n\n' joiner — 2 in char mode, ~1 in token mode
262
+ const joinerSize = measure('\n\n');
193
263
 
194
264
  const flushGroup = () => {
195
265
  if (currentGroup.length === 0) return;
@@ -223,27 +293,26 @@ export function chunkMarkdownStructural(
223
293
  const blockText = block.text.trim();
224
294
  if (!blockText) continue;
225
295
 
226
- if (blockText.length > maxChunk) {
296
+ if (measure(blockText) > maxChunk) {
227
297
  flushGroup();
228
298
  const headingPath = headingStack.map(h => h.heading);
229
- const subPieces = splitOversizedBlock(blockText, maxChunk, block.type);
230
- let lineCursor = block.startLine;
231
- for (let idx = 0; idx < subPieces.length; idx++) {
232
- const piece = subPieces[idx];
233
- const pieceLineCount = piece.split('\n').length;
299
+ // Issue #167: pieces carry the block-relative range of REAL source
300
+ // lines backing them — synthetic fence/header repeats must not advance
301
+ // the line cursor, or every later sub-chunk drifts down the file.
302
+ const subPieces = splitOversizedByMeasure(blockText, maxChunk, measure, block.type);
303
+ for (const piece of subPieces) {
234
304
  chunks.push({
235
305
  heading: headingPath.at(-1) ?? '',
236
306
  headingPath: [...headingPath],
237
- text: piece,
238
- startLine: lineCursor,
239
- endLine: Math.min(block.endLine, lineCursor + pieceLineCount - 1),
307
+ text: piece.text,
308
+ startLine: block.startLine + piece.startLine,
309
+ endLine: Math.min(block.endLine, block.startLine + piece.endLine),
240
310
  });
241
- lineCursor += pieceLineCount;
242
311
  }
243
312
  continue;
244
313
  }
245
314
 
246
- const addedLen = currentLen === 0 ? blockText.length : currentLen + 2 + blockText.length;
315
+ const addedLen = currentLen === 0 ? measure(blockText) : currentLen + joinerSize + measure(blockText);
247
316
  if (addedLen > maxChunk && currentGroup.length > 0) {
248
317
  flushGroup();
249
318
  }
@@ -252,7 +321,7 @@ export function chunkMarkdownStructural(
252
321
  groupStartLine = block.startLine;
253
322
  }
254
323
  currentGroup.push(blockText);
255
- currentLen += (currentGroup.length === 1 ? 0 : 2) + blockText.length;
324
+ currentLen += (currentGroup.length === 1 ? 0 : joinerSize) + measure(blockText);
256
325
  groupEndLine = block.endLine;
257
326
  }
258
327
 
@@ -274,14 +343,154 @@ function splitLongLine(line: string, maxChunk: number): string[] {
274
343
  return pieces;
275
344
  }
276
345
 
346
+ export interface OversizedPiece {
347
+ text: string;
348
+ /** 0-based block-relative range of REAL source lines backing this piece. */
349
+ startLine: number;
350
+ endLine: number;
351
+ }
352
+
277
353
  export function splitOversizedBlock(
278
354
  text: string,
279
355
  maxChunk: number,
280
356
  blockType?: MarkdownBlock['type']
281
357
  ): string[] {
358
+ return splitOversizedBlockWithRanges(text, maxChunk, blockType).map((p) => p.text);
359
+ }
360
+
361
+ /**
362
+ * issue #202: oversized-block splitting under an arbitrary measure (chars or
363
+ * tokens). The heavy lifting (fence repeats, table header repeats, #167 line
364
+ * ranges) stays in the char-based splitOversizedBlockWithRanges; the token
365
+ * budget is converted to a char budget for THIS block via its own chars-per-
366
+ * measured-unit ratio, then every piece is verified — script-skew inside the
367
+ * block (ASCII header + Cyrillic body) is caught by a greedy re-split.
368
+ */
369
+ export function splitOversizedByMeasure(
370
+ text: string,
371
+ budget: number,
372
+ measure: TokenCounter,
373
+ blockType?: MarkdownBlock['type']
374
+ ): OversizedPiece[] {
375
+ if (measure(text) <= budget) {
376
+ return [{ text, startLine: 0, endLine: Number.MAX_SAFE_INTEGER }];
377
+ }
378
+
379
+ // char equivalent of the budget for this block's script mix; fall back to
380
+ // a plain char split with the raw budget when measuring is degenerate
381
+ const measured = Math.max(1, measure(text));
382
+ const charsPerUnit = text.length / measured;
383
+ const charBudget = Math.max(32, Math.floor(budget * charsPerUnit));
384
+
385
+ const pieces = splitOversizedBlockWithRanges(text, charBudget, blockType);
386
+ const out: OversizedPiece[] = [];
387
+ for (const piece of pieces) {
388
+ if (measure(piece.text) <= budget) {
389
+ out.push(piece);
390
+ continue;
391
+ }
392
+ // script-skew safety net: greedy line/word accumulation under measure;
393
+ // the greedy splitter tracks consumed source lines so #167 ranges stay real
394
+ const spanLines = piece.endLine - piece.startLine + 1;
395
+ const textLines = piece.text.split('\n');
396
+ // when synthetic lines (fence/table repeats) inflated the piece, the text
397
+ // has more lines than the source span — clamp keeps ranges inside the block
398
+ const lineStride = textLines.length > spanLines ? spanLines / textLines.length : 1;
399
+ for (const sub of greedySplitByMeasure(piece.text, budget, measure)) {
400
+ const startLine = piece.startLine + Math.min(spanLines - 1, Math.floor(sub.startLine * lineStride));
401
+ const endLine = piece.startLine + Math.min(spanLines - 1, Math.max(sub.startLine, Math.floor(sub.endLine * lineStride)));
402
+ out.push({ text: sub.text, startLine, endLine });
403
+ }
404
+ }
405
+ return out;
406
+ }
407
+
408
+ /** Greedy line-based (then word-based, then hard-slice) split under measure, with line ranges. */
409
+ interface MeasuredPiece {
410
+ text: string;
411
+ /** 0-based indices into the input's line array. */
412
+ startLine: number;
413
+ endLine: number;
414
+ }
415
+
416
+ function greedySplitByMeasure(text: string, budget: number, measure: TokenCounter): MeasuredPiece[] {
417
+ const out: MeasuredPiece[] = [];
418
+ const lines = text.split('\n');
419
+ let bufStart = 0;
420
+ let bufEnd = 0;
421
+ let current = '';
422
+
423
+ const flush = (uptoLine: number) => {
424
+ if (current.trim()) out.push({ text: current.trim(), startLine: bufStart, endLine: uptoLine });
425
+ current = '';
426
+ };
427
+
428
+ for (let i = 0; i < lines.length; i++) {
429
+ const line = lines[i];
430
+ const candidate = current ? `${current}\n${line}` : line;
431
+ if (measure(candidate) <= budget) {
432
+ if (!current) bufStart = i;
433
+ current = candidate;
434
+ bufEnd = i;
435
+ continue;
436
+ }
437
+ flush(i - 1);
438
+ if (measure(line) <= budget) {
439
+ bufStart = i;
440
+ current = line;
441
+ bufEnd = i;
442
+ continue;
443
+ }
444
+ // single line over budget: word-level accumulation, all pieces stay on
445
+ // this line's range
446
+ const words = line.split(/\s+/);
447
+ let wordBuf = '';
448
+ for (const word of words) {
449
+ const wc = wordBuf ? `${wordBuf} ${word}` : word;
450
+ if (measure(wc) <= budget) {
451
+ wordBuf = wc;
452
+ continue;
453
+ }
454
+ if (wordBuf.trim()) out.push({ text: wordBuf.trim(), startLine: i, endLine: i });
455
+ if (measure(word) <= budget) {
456
+ wordBuf = word;
457
+ continue;
458
+ }
459
+ // monster token (no spaces): hard-slice by chars proportional to budget
460
+ const ratio = line.length / Math.max(1, measure(line));
461
+ const sliceLen = Math.max(8, Math.floor(budget * ratio));
462
+ let rest = word;
463
+ while (measure(rest) > budget && rest.length > sliceLen) {
464
+ out.push({ text: rest.slice(0, sliceLen), startLine: i, endLine: i });
465
+ rest = rest.slice(sliceLen);
466
+ }
467
+ wordBuf = rest;
468
+ }
469
+ if (wordBuf.trim()) {
470
+ bufStart = i;
471
+ current = wordBuf;
472
+ bufEnd = i;
473
+ }
474
+ }
475
+ flush(bufEnd);
476
+ return out;
477
+ }
478
+
479
+ /**
480
+ * Issue #167: like splitOversizedBlock, but each piece also reports the
481
+ * block-relative range of source lines it was built from. Synthetic lines
482
+ * added to keep pieces self-contained (fence repeats, table header repeats)
483
+ * do NOT count — only the first piece's opening fence/header and a real
484
+ * closing fence belong to the source.
485
+ */
486
+ export function splitOversizedBlockWithRanges(
487
+ text: string,
488
+ maxChunk: number,
489
+ blockType?: MarkdownBlock['type']
490
+ ): OversizedPiece[] {
282
491
  const lines = text.split('\n');
283
492
  if (lines.length <= 1) {
284
- return splitLongLine(text, maxChunk);
493
+ return splitLongLine(text, maxChunk).map((piece) => ({ text: piece, startLine: 0, endLine: 0 }));
285
494
  }
286
495
 
287
496
  // If code block with opening fence
@@ -291,21 +500,35 @@ export function splitOversizedBlock(
291
500
  const closeFence = hasCloseFence ? lines[lines.length - 1] : '```';
292
501
  const innerLines = lines.slice(1, hasCloseFence ? lines.length - 1 : lines.length);
293
502
 
294
- const pieces: string[] = [];
295
- let currentCode: string[] = [];
503
+ const pieces: OversizedPiece[] = [];
504
+ let currentCode: Array<{ line: string; srcIdx: number }> = [];
296
505
  let currentLen = openFence.length + closeFence.length + 2;
297
506
 
298
- for (const line of innerLines) {
507
+ // The opening fence is a real source line only in the first piece; a
508
+ // real closing fence only in the final one.
509
+ const flush = (isFinal: boolean) => {
510
+ const firstReal = pieces.length === 0 ? 0 : currentCode[0].srcIdx;
511
+ const lastReal = isFinal && hasCloseFence ? lines.length - 1 : currentCode[currentCode.length - 1].srcIdx;
512
+ pieces.push({
513
+ text: [openFence, ...currentCode.map((c) => c.line), closeFence].join('\n'),
514
+ startLine: firstReal,
515
+ endLine: lastReal,
516
+ });
517
+ };
518
+
519
+ for (let offset = 0; offset < innerLines.length; offset++) {
520
+ const line = innerLines[offset];
521
+ const srcIdx = 1 + offset;
299
522
  if (currentLen + line.length + 1 > maxChunk && currentCode.length > 0) {
300
- pieces.push([openFence, ...currentCode, closeFence].join('\n'));
523
+ flush(false);
301
524
  currentCode = [];
302
525
  currentLen = openFence.length + closeFence.length + 2;
303
526
  }
304
- currentCode.push(line);
527
+ currentCode.push({ line, srcIdx });
305
528
  currentLen += line.length + 1;
306
529
  }
307
530
  if (currentCode.length > 0) {
308
- pieces.push([openFence, ...currentCode, closeFence].join('\n'));
531
+ flush(true);
309
532
  }
310
533
  if (pieces.length > 0) return pieces;
311
534
  }
@@ -316,56 +539,75 @@ export function splitOversizedBlock(
316
539
  const dataRows = lines.slice(2);
317
540
  const headerLen = headerRows[0].length + headerRows[1].length + 2;
318
541
 
319
- const pieces: string[] = [];
320
- let currentRows: string[] = [];
542
+ const pieces: OversizedPiece[] = [];
543
+ let currentRows: Array<{ row: string; srcIdx: number }> = [];
321
544
  let currentLen = headerLen;
322
545
 
323
- for (const row of dataRows) {
546
+ const flush = () => {
547
+ const firstReal = pieces.length === 0 ? 0 : currentRows[0].srcIdx;
548
+ pieces.push({
549
+ text: [...headerRows, ...currentRows.map((r) => r.row)].join('\n'),
550
+ startLine: firstReal,
551
+ endLine: currentRows[currentRows.length - 1].srcIdx,
552
+ });
553
+ };
554
+
555
+ for (let offset = 0; offset < dataRows.length; offset++) {
556
+ const row = dataRows[offset];
557
+ const srcIdx = 2 + offset;
324
558
  if (currentLen + row.length + 1 > maxChunk && currentRows.length > 0) {
325
- pieces.push([...headerRows, ...currentRows].join('\n'));
559
+ flush();
326
560
  currentRows = [];
327
561
  currentLen = headerLen;
328
562
  }
329
- currentRows.push(row);
563
+ currentRows.push({ row, srcIdx });
330
564
  currentLen += row.length + 1;
331
565
  }
332
566
  if (currentRows.length > 0) {
333
- pieces.push([...headerRows, ...currentRows].join('\n'));
567
+ flush();
334
568
  }
335
569
  if (pieces.length > 0) return pieces;
336
570
  }
337
571
 
338
572
  // Standard paragraph/list splitting
339
- const pieces: string[] = [];
340
- let currentLines: string[] = [];
573
+ const pieces: OversizedPiece[] = [];
574
+ let currentLines: Array<{ line: string; srcIdx: number }> = [];
341
575
  let currentLen = 0;
342
576
 
343
- for (const line of lines) {
577
+ const flush = () => {
578
+ pieces.push({
579
+ text: currentLines.map((l) => l.line).join('\n').trim(),
580
+ startLine: currentLines[0].srcIdx,
581
+ endLine: currentLines[currentLines.length - 1].srcIdx,
582
+ });
583
+ };
584
+
585
+ for (let srcIdx = 0; srcIdx < lines.length; srcIdx++) {
586
+ const line = lines[srcIdx];
344
587
  if (currentLen + line.length + 1 > maxChunk && currentLines.length > 0) {
345
- pieces.push(currentLines.join('\n').trim());
588
+ flush();
346
589
  currentLines = [];
347
590
  currentLen = 0;
348
591
  }
349
592
 
350
593
  if (line.length > maxChunk) {
351
594
  if (currentLines.length > 0) {
352
- pieces.push(currentLines.join('\n').trim());
595
+ flush();
353
596
  currentLines = [];
354
597
  }
355
- const splitLines = splitLongLine(line, maxChunk);
356
- for (const piece of splitLines) {
357
- pieces.push(piece);
598
+ for (const piece of splitLongLine(line, maxChunk)) {
599
+ pieces.push({ text: piece, startLine: srcIdx, endLine: srcIdx });
358
600
  }
359
601
  currentLen = 0;
360
602
  continue;
361
603
  }
362
604
 
363
- currentLines.push(line);
605
+ currentLines.push({ line, srcIdx });
364
606
  currentLen += line.length + 1;
365
607
  }
366
608
 
367
609
  if (currentLines.length > 0) {
368
- pieces.push(currentLines.join('\n').trim());
610
+ flush();
369
611
  }
370
612
 
371
613
  return pieces;