@tobilu/qmd 1.1.1 → 1.1.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/dist/store.js CHANGED
@@ -706,15 +706,24 @@ export function getDocid(hash) {
706
706
  * - Preserve folder structure (a/b/c/d.md stays structured)
707
707
  * - Preserve file extension
708
708
  */
709
+ /** Replace emoji/symbol codepoints with their hex representation (e.g. 🐘 → 1f418) */
710
+ function emojiToHex(str) {
711
+ return str.replace(/(?:\p{So}\p{Mn}?|\p{Sk})+/gu, (run) => {
712
+ // Split the run into individual emoji and convert each to hex, dash-separated
713
+ return [...run].filter(c => /\p{So}|\p{Sk}/u.test(c))
714
+ .map(c => c.codePointAt(0).toString(16)).join('-');
715
+ });
716
+ }
709
717
  export function handelize(path) {
710
718
  if (!path || path.trim() === '') {
711
719
  throw new Error('handelize: path cannot be empty');
712
720
  }
713
721
  // Allow route-style "$" filenames while still rejecting paths with no usable content.
722
+ // Emoji (\p{So}) counts as valid content — they get converted to hex codepoints below.
714
723
  const segments = path.split('/').filter(Boolean);
715
724
  const lastSegment = segments[segments.length - 1] || '';
716
725
  const filenameWithoutExt = lastSegment.replace(/\.[^.]+$/, '');
717
- const hasValidContent = /[\p{L}\p{N}$]/u.test(filenameWithoutExt);
726
+ const hasValidContent = /[\p{L}\p{N}\p{So}\p{Sk}$]/u.test(filenameWithoutExt);
718
727
  if (!hasValidContent) {
719
728
  throw new Error(`handelize: path "${path}" has no valid filename content`);
720
729
  }
@@ -724,6 +733,8 @@ export function handelize(path) {
724
733
  .split('/')
725
734
  .map((segment, idx, arr) => {
726
735
  const isLastSegment = idx === arr.length - 1;
736
+ // Convert emoji to hex codepoints before cleaning
737
+ segment = emojiToHex(segment);
727
738
  if (isLastSegment) {
728
739
  // For the filename (last segment), preserve the extension
729
740
  const extMatch = segment.match(/(\.[a-z0-9]+)$/i);
@@ -1745,7 +1756,7 @@ export async function searchVec(db, query, model, limit = 20, collectionName, se
1745
1756
  // =============================================================================
1746
1757
  async function getEmbedding(text, model, isQuery, session) {
1747
1758
  // Format text using the appropriate prompt template
1748
- const formattedText = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text);
1759
+ const formattedText = isQuery ? formatQueryForEmbedding(text, model) : formatDocForEmbedding(text, undefined, model);
1749
1760
  const result = session
1750
1761
  ? await session.embed(formattedText, { model, isQuery })
1751
1762
  : await getDefaultLlamaCpp().embed(formattedText, { model, isQuery });
@@ -1817,35 +1828,40 @@ export async function expandQuery(query, model = DEFAULT_QUERY_MODEL, db) {
1817
1828
  // =============================================================================
1818
1829
  export async function rerank(query, documents, model = DEFAULT_RERANK_MODEL, db) {
1819
1830
  const cachedResults = new Map();
1820
- const uncachedDocs = [];
1831
+ const uncachedDocsByChunk = new Map();
1821
1832
  // Check cache for each document
1822
1833
  // Cache key includes chunk text — different queries can select different chunks
1823
1834
  // from the same file, and the reranker score depends on which chunk was sent.
1835
+ // File path is excluded from the new cache key because the reranker score
1836
+ // depends on the chunk content, not where it came from.
1824
1837
  for (const doc of documents) {
1825
- const cacheKey = getCacheKey("rerank", { query, file: doc.file, model, chunk: doc.text });
1826
- const cached = getCachedResult(db, cacheKey);
1838
+ const cacheKey = getCacheKey("rerank", { query, model, chunk: doc.text });
1839
+ const legacyCacheKey = getCacheKey("rerank", { query, file: doc.file, model, chunk: doc.text });
1840
+ const cached = getCachedResult(db, cacheKey) ?? getCachedResult(db, legacyCacheKey);
1827
1841
  if (cached !== null) {
1828
- cachedResults.set(doc.file, parseFloat(cached));
1842
+ cachedResults.set(doc.text, parseFloat(cached));
1829
1843
  }
1830
1844
  else {
1831
- uncachedDocs.push({ file: doc.file, text: doc.text });
1845
+ uncachedDocsByChunk.set(doc.text, { file: doc.file, text: doc.text });
1832
1846
  }
1833
1847
  }
1834
1848
  // Rerank uncached documents using LlamaCpp
1835
- if (uncachedDocs.length > 0) {
1849
+ if (uncachedDocsByChunk.size > 0) {
1836
1850
  const llm = getDefaultLlamaCpp();
1851
+ const uncachedDocs = [...uncachedDocsByChunk.values()];
1837
1852
  const rerankResult = await llm.rerank(query, uncachedDocs, { model });
1838
- // Cache results use original doc.text for cache key (result.file lacks chunk text)
1839
- const textByFile = new Map(documents.map(d => [d.file, d.text]));
1853
+ // Cache results by chunk text so identical chunks across files are scored once.
1854
+ const textByFile = new Map(uncachedDocs.map(d => [d.file, d.text]));
1840
1855
  for (const result of rerankResult.results) {
1841
- const cacheKey = getCacheKey("rerank", { query, file: result.file, model, chunk: textByFile.get(result.file) || "" });
1856
+ const chunk = textByFile.get(result.file) || "";
1857
+ const cacheKey = getCacheKey("rerank", { query, model, chunk });
1842
1858
  setCachedResult(db, cacheKey, result.score.toString());
1843
- cachedResults.set(result.file, result.score);
1859
+ cachedResults.set(chunk, result.score);
1844
1860
  }
1845
1861
  }
1846
1862
  // Return all results sorted by score
1847
1863
  return documents
1848
- .map(doc => ({ file: doc.file, score: cachedResults.get(doc.file) || 0 }))
1864
+ .map(doc => ({ file: doc.file, score: cachedResults.get(doc.text) || 0 }))
1849
1865
  .sort((a, b) => b.score - a.score);
1850
1866
  }
1851
1867
  // =============================================================================
@@ -1890,6 +1906,65 @@ export function reciprocalRankFusion(resultLists, weights = [], k = 60) {
1890
1906
  .sort((a, b) => b.rrfScore - a.rrfScore)
1891
1907
  .map(e => ({ ...e.result, score: e.rrfScore }));
1892
1908
  }
1909
+ /**
1910
+ * Build per-document RRF contribution traces for explain/debug output.
1911
+ */
1912
+ export function buildRrfTrace(resultLists, weights = [], listMeta = [], k = 60) {
1913
+ const traces = new Map();
1914
+ for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
1915
+ const list = resultLists[listIdx];
1916
+ if (!list)
1917
+ continue;
1918
+ const weight = weights[listIdx] ?? 1.0;
1919
+ const meta = listMeta[listIdx] ?? {
1920
+ source: "fts",
1921
+ queryType: "original",
1922
+ query: "",
1923
+ };
1924
+ for (let rank0 = 0; rank0 < list.length; rank0++) {
1925
+ const result = list[rank0];
1926
+ if (!result)
1927
+ continue;
1928
+ const rank = rank0 + 1; // 1-indexed rank for explain output
1929
+ const contribution = weight / (k + rank);
1930
+ const existing = traces.get(result.file);
1931
+ const detail = {
1932
+ listIndex: listIdx,
1933
+ source: meta.source,
1934
+ queryType: meta.queryType,
1935
+ query: meta.query,
1936
+ rank,
1937
+ weight,
1938
+ backendScore: result.score,
1939
+ rrfContribution: contribution,
1940
+ };
1941
+ if (existing) {
1942
+ existing.baseScore += contribution;
1943
+ existing.topRank = Math.min(existing.topRank, rank);
1944
+ existing.contributions.push(detail);
1945
+ }
1946
+ else {
1947
+ traces.set(result.file, {
1948
+ contributions: [detail],
1949
+ baseScore: contribution,
1950
+ topRank: rank,
1951
+ topRankBonus: 0,
1952
+ totalScore: 0,
1953
+ });
1954
+ }
1955
+ }
1956
+ }
1957
+ for (const trace of traces.values()) {
1958
+ let bonus = 0;
1959
+ if (trace.topRank === 1)
1960
+ bonus = 0.05;
1961
+ else if (trace.topRank <= 3)
1962
+ bonus = 0.02;
1963
+ trace.topRankBonus = bonus;
1964
+ trace.totalScore = trace.baseScore + bonus;
1965
+ }
1966
+ return traces;
1967
+ }
1893
1968
  /**
1894
1969
  * Find a document by filename/path, docid (#hash), or with fuzzy matching.
1895
1970
  * Returns document metadata without body by default.
@@ -2264,8 +2339,10 @@ export async function hybridQuery(store, query, options) {
2264
2339
  const minScore = options?.minScore ?? 0;
2265
2340
  const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
2266
2341
  const collection = options?.collection;
2342
+ const explain = options?.explain ?? false;
2267
2343
  const hooks = options?.hooks;
2268
2344
  const rankedLists = [];
2345
+ const rankedListMeta = [];
2269
2346
  const docidMap = new Map(); // filepath -> docid
2270
2347
  const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2271
2348
  // Step 1: BM25 probe — strong signal skips expensive LLM expansion
@@ -2293,6 +2370,7 @@ export async function hybridQuery(store, query, options) {
2293
2370
  file: r.filepath, displayPath: r.displayPath,
2294
2371
  title: r.title, body: r.body || "", score: r.score,
2295
2372
  })));
2373
+ rankedListMeta.push({ source: "fts", queryType: "original", query });
2296
2374
  }
2297
2375
  // Step 3: Route searches by query type
2298
2376
  //
@@ -2310,17 +2388,18 @@ export async function hybridQuery(store, query, options) {
2310
2388
  file: r.filepath, displayPath: r.displayPath,
2311
2389
  title: r.title, body: r.body || "", score: r.score,
2312
2390
  })));
2391
+ rankedListMeta.push({ source: "fts", queryType: "lex", query: q.text });
2313
2392
  }
2314
2393
  }
2315
2394
  }
2316
2395
  // 3b: Collect all texts that need vector search (original query + vec/hyde expansions)
2317
2396
  if (hasVectors) {
2318
2397
  const vecQueries = [
2319
- { text: query, isOriginal: true },
2398
+ { text: query, queryType: "original" },
2320
2399
  ];
2321
2400
  for (const q of expanded) {
2322
2401
  if (q.type === 'vec' || q.type === 'hyde') {
2323
- vecQueries.push({ text: q.text, isOriginal: false });
2402
+ vecQueries.push({ text: q.text, queryType: q.type });
2324
2403
  }
2325
2404
  }
2326
2405
  // Batch embed all vector queries in a single call
@@ -2343,12 +2422,18 @@ export async function hybridQuery(store, query, options) {
2343
2422
  file: r.filepath, displayPath: r.displayPath,
2344
2423
  title: r.title, body: r.body || "", score: r.score,
2345
2424
  })));
2425
+ rankedListMeta.push({
2426
+ source: "vec",
2427
+ queryType: vecQueries[i].queryType,
2428
+ query: vecQueries[i].text,
2429
+ });
2346
2430
  }
2347
2431
  }
2348
2432
  }
2349
2433
  // Step 4: RRF fusion — first 2 lists (original FTS + first vec) get 2x weight
2350
2434
  const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
2351
2435
  const fused = reciprocalRankFusion(rankedLists, weights);
2436
+ const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
2352
2437
  const candidates = fused.slice(0, candidateLimit);
2353
2438
  if (candidates.length === 0)
2354
2439
  return [];
@@ -2402,6 +2487,22 @@ export async function hybridQuery(store, query, options) {
2402
2487
  const bestIdx = chunkInfo?.bestIdx ?? 0;
2403
2488
  const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
2404
2489
  const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
2490
+ const trace = rrfTraceByFile?.get(r.file);
2491
+ const explainData = explain ? {
2492
+ ftsScores: trace?.contributions.filter(c => c.source === "fts").map(c => c.backendScore) ?? [],
2493
+ vectorScores: trace?.contributions.filter(c => c.source === "vec").map(c => c.backendScore) ?? [],
2494
+ rrf: {
2495
+ rank: rrfRank,
2496
+ positionScore: rrfScore,
2497
+ weight: rrfWeight,
2498
+ baseScore: trace?.baseScore ?? 0,
2499
+ topRankBonus: trace?.topRankBonus ?? 0,
2500
+ totalScore: trace?.totalScore ?? 0,
2501
+ contributions: trace?.contributions ?? [],
2502
+ },
2503
+ rerankScore: r.score,
2504
+ blendedScore,
2505
+ } : undefined;
2405
2506
  return {
2406
2507
  file: r.file,
2407
2508
  displayPath: candidate?.displayPath || "",
@@ -2412,6 +2513,7 @@ export async function hybridQuery(store, query, options) {
2412
2513
  score: blendedScore,
2413
2514
  context: store.getContextForFile(r.file),
2414
2515
  docid: docidMap.get(r.file) || "",
2516
+ ...(explainData ? { explain: explainData } : {}),
2415
2517
  };
2416
2518
  }).sort((a, b) => b.score - a.score);
2417
2519
  // Step 8: Dedup by file (safety net — prevents duplicate output)
@@ -2494,6 +2596,7 @@ export async function structuredSearch(store, searches, options) {
2494
2596
  const limit = options?.limit ?? 10;
2495
2597
  const minScore = options?.minScore ?? 0;
2496
2598
  const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
2599
+ const explain = options?.explain ?? false;
2497
2600
  const hooks = options?.hooks;
2498
2601
  const collections = options?.collections;
2499
2602
  if (searches.length === 0)
@@ -2518,6 +2621,7 @@ export async function structuredSearch(store, searches, options) {
2518
2621
  }
2519
2622
  }
2520
2623
  const rankedLists = [];
2624
+ const rankedListMeta = [];
2521
2625
  const docidMap = new Map(); // filepath -> docid
2522
2626
  const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2523
2627
  // Helper to run search across collections (or all if undefined)
@@ -2534,13 +2638,18 @@ export async function structuredSearch(store, searches, options) {
2534
2638
  file: r.filepath, displayPath: r.displayPath,
2535
2639
  title: r.title, body: r.body || "", score: r.score,
2536
2640
  })));
2641
+ rankedListMeta.push({
2642
+ source: "fts",
2643
+ queryType: "lex",
2644
+ query: search.query,
2645
+ });
2537
2646
  }
2538
2647
  }
2539
2648
  }
2540
2649
  }
2541
2650
  // Step 2: Batch embed and run vector searches for vec/hyde
2542
2651
  if (hasVectors) {
2543
- const vecSearches = searches.filter(s => s.type === 'vec' || s.type === 'hyde');
2652
+ const vecSearches = searches.filter((s) => s.type === 'vec' || s.type === 'hyde');
2544
2653
  if (vecSearches.length > 0) {
2545
2654
  const llm = getDefaultLlamaCpp();
2546
2655
  const textsToEmbed = vecSearches.map(s => formatQueryForEmbedding(s.query));
@@ -2561,6 +2670,11 @@ export async function structuredSearch(store, searches, options) {
2561
2670
  file: r.filepath, displayPath: r.displayPath,
2562
2671
  title: r.title, body: r.body || "", score: r.score,
2563
2672
  })));
2673
+ rankedListMeta.push({
2674
+ source: "vec",
2675
+ queryType: vecSearches[i].type,
2676
+ query: vecSearches[i].query,
2677
+ });
2564
2678
  }
2565
2679
  }
2566
2680
  }
@@ -2571,6 +2685,7 @@ export async function structuredSearch(store, searches, options) {
2571
2685
  // Step 3: RRF fusion — first list gets 2x weight (assume caller ordered by importance)
2572
2686
  const weights = rankedLists.map((_, i) => i === 0 ? 2.0 : 1.0);
2573
2687
  const fused = reciprocalRankFusion(rankedLists, weights);
2688
+ const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
2574
2689
  const candidates = fused.slice(0, candidateLimit);
2575
2690
  if (candidates.length === 0)
2576
2691
  return [];
@@ -2627,6 +2742,22 @@ export async function structuredSearch(store, searches, options) {
2627
2742
  const bestIdx = chunkInfo?.bestIdx ?? 0;
2628
2743
  const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
2629
2744
  const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
2745
+ const trace = rrfTraceByFile?.get(r.file);
2746
+ const explainData = explain ? {
2747
+ ftsScores: trace?.contributions.filter(c => c.source === "fts").map(c => c.backendScore) ?? [],
2748
+ vectorScores: trace?.contributions.filter(c => c.source === "vec").map(c => c.backendScore) ?? [],
2749
+ rrf: {
2750
+ rank: rrfRank,
2751
+ positionScore: rrfScore,
2752
+ weight: rrfWeight,
2753
+ baseScore: trace?.baseScore ?? 0,
2754
+ topRankBonus: trace?.topRankBonus ?? 0,
2755
+ totalScore: trace?.totalScore ?? 0,
2756
+ contributions: trace?.contributions ?? [],
2757
+ },
2758
+ rerankScore: r.score,
2759
+ blendedScore,
2760
+ } : undefined;
2630
2761
  return {
2631
2762
  file: r.file,
2632
2763
  displayPath: candidate?.displayPath || "",
@@ -2637,6 +2768,7 @@ export async function structuredSearch(store, searches, options) {
2637
2768
  score: blendedScore,
2638
2769
  context: store.getContextForFile(r.file),
2639
2770
  docid: docidMap.get(r.file) || "",
2771
+ ...(explainData ? { explain: explainData } : {}),
2640
2772
  };
2641
2773
  }).sort((a, b) => b.score - a.score);
2642
2774
  // Step 7: Dedup by file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tobilu/qmd",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "@modelcontextprotocol/sdk": "^1.25.1",
40
40
  "better-sqlite3": "^11.0.0",
41
41
  "fast-glob": "^3.3.0",
42
- "node-llama-cpp": "^3.14.5",
42
+ "node-llama-cpp": "^3.17.1",
43
43
  "picomatch": "^4.0.0",
44
44
  "sqlite-vec": "^0.1.7-alpha.2",
45
45
  "yaml": "^2.8.2",
@@ -48,8 +48,9 @@
48
48
  "optionalDependencies": {
49
49
  "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2",
50
50
  "sqlite-vec-darwin-x64": "^0.1.7-alpha.2",
51
+ "sqlite-vec-linux-arm64": "^0.1.7-alpha.2",
51
52
  "sqlite-vec-linux-x64": "^0.1.7-alpha.2",
52
- "sqlite-vec-win32-x64": "^0.1.7-alpha.2"
53
+ "sqlite-vec-windows-x64": "^0.1.7-alpha.2"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@types/better-sqlite3": "^7.6.0",