@tobilu/qmd 1.0.7 → 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);
@@ -1510,15 +1521,108 @@ export function getTopLevelPathsWithoutContext(db, collectionName) {
1510
1521
  function sanitizeFTS5Term(term) {
1511
1522
  return term.replace(/[^\p{L}\p{N}']/gu, '').toLowerCase();
1512
1523
  }
1524
+ /**
1525
+ * Parse lex query syntax into FTS5 query.
1526
+ *
1527
+ * Supports:
1528
+ * - Quoted phrases: "exact phrase" → "exact phrase" (exact match)
1529
+ * - Negation: -term or -"phrase" → uses FTS5 NOT operator
1530
+ * - Plain terms: term → "term"* (prefix match)
1531
+ *
1532
+ * FTS5 NOT is a binary operator: `term1 NOT term2` means "match term1 but not term2".
1533
+ * So `-term` only works when there are also positive terms.
1534
+ *
1535
+ * Examples:
1536
+ * performance -sports → "performance"* NOT "sports"*
1537
+ * "machine learning" → "machine learning"
1538
+ */
1513
1539
  function buildFTS5Query(query) {
1514
- const terms = query.split(/\s+/)
1515
- .map(t => sanitizeFTS5Term(t))
1516
- .filter(t => t.length > 0);
1517
- if (terms.length === 0)
1540
+ const positive = [];
1541
+ const negative = [];
1542
+ let i = 0;
1543
+ const s = query.trim();
1544
+ while (i < s.length) {
1545
+ // Skip whitespace
1546
+ while (i < s.length && /\s/.test(s[i]))
1547
+ i++;
1548
+ if (i >= s.length)
1549
+ break;
1550
+ // Check for negation prefix
1551
+ const negated = s[i] === '-';
1552
+ if (negated)
1553
+ i++;
1554
+ // Check for quoted phrase
1555
+ if (s[i] === '"') {
1556
+ const start = i + 1;
1557
+ i++;
1558
+ while (i < s.length && s[i] !== '"')
1559
+ i++;
1560
+ const phrase = s.slice(start, i).trim();
1561
+ i++; // skip closing quote
1562
+ if (phrase.length > 0) {
1563
+ const sanitized = phrase.split(/\s+/).map(t => sanitizeFTS5Term(t)).filter(t => t).join(' ');
1564
+ if (sanitized) {
1565
+ const ftsPhrase = `"${sanitized}"`; // Exact phrase, no prefix match
1566
+ if (negated) {
1567
+ negative.push(ftsPhrase);
1568
+ }
1569
+ else {
1570
+ positive.push(ftsPhrase);
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+ else {
1576
+ // Plain term (until whitespace or quote)
1577
+ const start = i;
1578
+ while (i < s.length && !/[\s"]/.test(s[i]))
1579
+ i++;
1580
+ const term = s.slice(start, i);
1581
+ const sanitized = sanitizeFTS5Term(term);
1582
+ if (sanitized) {
1583
+ const ftsTerm = `"${sanitized}"*`; // Prefix match
1584
+ if (negated) {
1585
+ negative.push(ftsTerm);
1586
+ }
1587
+ else {
1588
+ positive.push(ftsTerm);
1589
+ }
1590
+ }
1591
+ }
1592
+ }
1593
+ if (positive.length === 0 && negative.length === 0)
1594
+ return null;
1595
+ // If only negative terms, we can't search (FTS5 NOT is binary)
1596
+ if (positive.length === 0)
1518
1597
  return null;
1519
- if (terms.length === 1)
1520
- return `"${terms[0]}"*`;
1521
- return terms.map(t => `"${t}"*`).join(' AND ');
1598
+ // Join positive terms with AND
1599
+ let result = positive.join(' AND ');
1600
+ // Add NOT clause for negative terms
1601
+ for (const neg of negative) {
1602
+ result = `${result} NOT ${neg}`;
1603
+ }
1604
+ return result;
1605
+ }
1606
+ /**
1607
+ * Validate that a vec/hyde query doesn't use lex-only syntax.
1608
+ * Returns error message if invalid, null if valid.
1609
+ */
1610
+ export function validateSemanticQuery(query) {
1611
+ // Check for negation syntax
1612
+ if (/-\w/.test(query) || /-"/.test(query)) {
1613
+ return 'Negation (-term) is not supported in vec/hyde queries. Use lex for exclusions.';
1614
+ }
1615
+ return null;
1616
+ }
1617
+ export function validateLexQuery(query) {
1618
+ if (/[\r\n]/.test(query)) {
1619
+ return 'Lex queries must be a single line. Remove newline characters or split into separate lex: lines.';
1620
+ }
1621
+ const quoteCount = (query.match(/"/g) ?? []).length;
1622
+ if (quoteCount % 2 === 1) {
1623
+ return 'Lex query has an unmatched double quote ("). Add the closing quote or remove it.';
1624
+ }
1625
+ return null;
1522
1626
  }
1523
1627
  export function searchFTS(db, query, limit = 20, collectionName) {
1524
1628
  const ftsQuery = buildFTS5Query(query);
@@ -1652,7 +1756,7 @@ export async function searchVec(db, query, model, limit = 20, collectionName, se
1652
1756
  // =============================================================================
1653
1757
  async function getEmbedding(text, model, isQuery, session) {
1654
1758
  // Format text using the appropriate prompt template
1655
- const formattedText = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text);
1759
+ const formattedText = isQuery ? formatQueryForEmbedding(text, model) : formatDocForEmbedding(text, undefined, model);
1656
1760
  const result = session
1657
1761
  ? await session.embed(formattedText, { model, isQuery })
1658
1762
  : await getDefaultLlamaCpp().embed(formattedText, { model, isQuery });
@@ -1724,35 +1828,40 @@ export async function expandQuery(query, model = DEFAULT_QUERY_MODEL, db) {
1724
1828
  // =============================================================================
1725
1829
  export async function rerank(query, documents, model = DEFAULT_RERANK_MODEL, db) {
1726
1830
  const cachedResults = new Map();
1727
- const uncachedDocs = [];
1831
+ const uncachedDocsByChunk = new Map();
1728
1832
  // Check cache for each document
1729
1833
  // Cache key includes chunk text — different queries can select different chunks
1730
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.
1731
1837
  for (const doc of documents) {
1732
- const cacheKey = getCacheKey("rerank", { query, file: doc.file, model, chunk: doc.text });
1733
- 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);
1734
1841
  if (cached !== null) {
1735
- cachedResults.set(doc.file, parseFloat(cached));
1842
+ cachedResults.set(doc.text, parseFloat(cached));
1736
1843
  }
1737
1844
  else {
1738
- uncachedDocs.push({ file: doc.file, text: doc.text });
1845
+ uncachedDocsByChunk.set(doc.text, { file: doc.file, text: doc.text });
1739
1846
  }
1740
1847
  }
1741
1848
  // Rerank uncached documents using LlamaCpp
1742
- if (uncachedDocs.length > 0) {
1849
+ if (uncachedDocsByChunk.size > 0) {
1743
1850
  const llm = getDefaultLlamaCpp();
1851
+ const uncachedDocs = [...uncachedDocsByChunk.values()];
1744
1852
  const rerankResult = await llm.rerank(query, uncachedDocs, { model });
1745
- // Cache results use original doc.text for cache key (result.file lacks chunk text)
1746
- 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]));
1747
1855
  for (const result of rerankResult.results) {
1748
- 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 });
1749
1858
  setCachedResult(db, cacheKey, result.score.toString());
1750
- cachedResults.set(result.file, result.score);
1859
+ cachedResults.set(chunk, result.score);
1751
1860
  }
1752
1861
  }
1753
1862
  // Return all results sorted by score
1754
1863
  return documents
1755
- .map(doc => ({ file: doc.file, score: cachedResults.get(doc.file) || 0 }))
1864
+ .map(doc => ({ file: doc.file, score: cachedResults.get(doc.text) || 0 }))
1756
1865
  .sort((a, b) => b.score - a.score);
1757
1866
  }
1758
1867
  // =============================================================================
@@ -1797,6 +1906,65 @@ export function reciprocalRankFusion(resultLists, weights = [], k = 60) {
1797
1906
  .sort((a, b) => b.rrfScore - a.rrfScore)
1798
1907
  .map(e => ({ ...e.result, score: e.rrfScore }));
1799
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
+ }
1800
1968
  /**
1801
1969
  * Find a document by filename/path, docid (#hash), or with fuzzy matching.
1802
1970
  * Returns document metadata without body by default.
@@ -2171,8 +2339,10 @@ export async function hybridQuery(store, query, options) {
2171
2339
  const minScore = options?.minScore ?? 0;
2172
2340
  const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
2173
2341
  const collection = options?.collection;
2342
+ const explain = options?.explain ?? false;
2174
2343
  const hooks = options?.hooks;
2175
2344
  const rankedLists = [];
2345
+ const rankedListMeta = [];
2176
2346
  const docidMap = new Map(); // filepath -> docid
2177
2347
  const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2178
2348
  // Step 1: BM25 probe — strong signal skips expensive LLM expansion
@@ -2186,10 +2356,12 @@ export async function hybridQuery(store, query, options) {
2186
2356
  if (hasStrongSignal)
2187
2357
  hooks?.onStrongSignal?.(topScore);
2188
2358
  // Step 2: Expand query (or skip if strong signal)
2359
+ hooks?.onExpandStart?.();
2360
+ const expandStart = Date.now();
2189
2361
  const expanded = hasStrongSignal
2190
2362
  ? []
2191
2363
  : await store.expandQuery(query);
2192
- hooks?.onExpand?.(query, expanded);
2364
+ hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
2193
2365
  // Seed with initial FTS results (avoid re-running original query FTS)
2194
2366
  if (initialFts.length > 0) {
2195
2367
  for (const r of initialFts)
@@ -2198,6 +2370,7 @@ export async function hybridQuery(store, query, options) {
2198
2370
  file: r.filepath, displayPath: r.displayPath,
2199
2371
  title: r.title, body: r.body || "", score: r.score,
2200
2372
  })));
2373
+ rankedListMeta.push({ source: "fts", queryType: "original", query });
2201
2374
  }
2202
2375
  // Step 3: Route searches by query type
2203
2376
  //
@@ -2215,23 +2388,27 @@ export async function hybridQuery(store, query, options) {
2215
2388
  file: r.filepath, displayPath: r.displayPath,
2216
2389
  title: r.title, body: r.body || "", score: r.score,
2217
2390
  })));
2391
+ rankedListMeta.push({ source: "fts", queryType: "lex", query: q.text });
2218
2392
  }
2219
2393
  }
2220
2394
  }
2221
2395
  // 3b: Collect all texts that need vector search (original query + vec/hyde expansions)
2222
2396
  if (hasVectors) {
2223
2397
  const vecQueries = [
2224
- { text: query, isOriginal: true },
2398
+ { text: query, queryType: "original" },
2225
2399
  ];
2226
2400
  for (const q of expanded) {
2227
2401
  if (q.type === 'vec' || q.type === 'hyde') {
2228
- vecQueries.push({ text: q.text, isOriginal: false });
2402
+ vecQueries.push({ text: q.text, queryType: q.type });
2229
2403
  }
2230
2404
  }
2231
2405
  // Batch embed all vector queries in a single call
2232
2406
  const llm = getDefaultLlamaCpp();
2233
2407
  const textsToEmbed = vecQueries.map(q => formatQueryForEmbedding(q.text));
2408
+ hooks?.onEmbedStart?.(textsToEmbed.length);
2409
+ const embedStart = Date.now();
2234
2410
  const embeddings = await llm.embedBatch(textsToEmbed);
2411
+ hooks?.onEmbedDone?.(Date.now() - embedStart);
2235
2412
  // Run sqlite-vec lookups with pre-computed embeddings
2236
2413
  for (let i = 0; i < vecQueries.length; i++) {
2237
2414
  const embedding = embeddings[i]?.embedding;
@@ -2245,12 +2422,18 @@ export async function hybridQuery(store, query, options) {
2245
2422
  file: r.filepath, displayPath: r.displayPath,
2246
2423
  title: r.title, body: r.body || "", score: r.score,
2247
2424
  })));
2425
+ rankedListMeta.push({
2426
+ source: "vec",
2427
+ queryType: vecQueries[i].queryType,
2428
+ query: vecQueries[i].text,
2429
+ });
2248
2430
  }
2249
2431
  }
2250
2432
  }
2251
2433
  // Step 4: RRF fusion — first 2 lists (original FTS + first vec) get 2x weight
2252
2434
  const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
2253
2435
  const fused = reciprocalRankFusion(rankedLists, weights);
2436
+ const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
2254
2437
  const candidates = fused.slice(0, candidateLimit);
2255
2438
  if (candidates.length === 0)
2256
2439
  return [];
@@ -2279,8 +2462,9 @@ export async function hybridQuery(store, query, options) {
2279
2462
  }
2280
2463
  // Step 6: Rerank chunks (NOT full bodies)
2281
2464
  hooks?.onRerankStart?.(chunksToRerank.length);
2465
+ const rerankStart = Date.now();
2282
2466
  const reranked = await store.rerank(query, chunksToRerank);
2283
- hooks?.onRerankDone?.();
2467
+ hooks?.onRerankDone?.(Date.now() - rerankStart);
2284
2468
  // Step 7: Blend RRF position score with reranker score
2285
2469
  // Position-aware weights: top retrieval results get more protection from reranker disagreement
2286
2470
  const candidateMap = new Map(candidates.map(c => [c.file, {
@@ -2303,6 +2487,22 @@ export async function hybridQuery(store, query, options) {
2303
2487
  const bestIdx = chunkInfo?.bestIdx ?? 0;
2304
2488
  const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
2305
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;
2306
2506
  return {
2307
2507
  file: r.file,
2308
2508
  displayPath: candidate?.displayPath || "",
@@ -2313,6 +2513,7 @@ export async function hybridQuery(store, query, options) {
2313
2513
  score: blendedScore,
2314
2514
  context: store.getContextForFile(r.file),
2315
2515
  docid: docidMap.get(r.file) || "",
2516
+ ...(explainData ? { explain: explainData } : {}),
2316
2517
  };
2317
2518
  }).sort((a, b) => b.score - a.score);
2318
2519
  // Step 8: Dedup by file (safety net — prevents duplicate output)
@@ -2344,9 +2545,10 @@ export async function vectorSearchQuery(store, query, options) {
2344
2545
  if (!hasVectors)
2345
2546
  return [];
2346
2547
  // Expand query — filter to vec/hyde only (lex queries target FTS, not vector)
2548
+ const expandStart = Date.now();
2347
2549
  const allExpanded = await store.expandQuery(query);
2348
2550
  const vecExpanded = allExpanded.filter(q => q.type !== 'lex');
2349
- options?.hooks?.onExpand?.(query, vecExpanded);
2551
+ options?.hooks?.onExpand?.(query, vecExpanded, Date.now() - expandStart);
2350
2552
  // Run original + vec/hyde expanded through vector, sequentially — concurrent embed() hangs
2351
2553
  const queryTexts = [query, ...vecExpanded.map(q => q.text)];
2352
2554
  const allResults = new Map();
@@ -2372,3 +2574,212 @@ export async function vectorSearchQuery(store, query, options) {
2372
2574
  .filter(r => r.score >= minScore)
2373
2575
  .slice(0, limit);
2374
2576
  }
2577
+ /**
2578
+ * Structured search: execute pre-expanded queries without LLM query expansion.
2579
+ *
2580
+ * Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
2581
+ * Skips the internal expandQuery() step — goes directly to:
2582
+ *
2583
+ * Pipeline:
2584
+ * 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
2585
+ * 2. RRF fusion across all result lists
2586
+ * 3. Chunk documents + keyword-best-chunk selection
2587
+ * 4. Rerank on chunks
2588
+ * 5. Position-aware score blending
2589
+ * 6. Dedup, filter, slice
2590
+ *
2591
+ * This is the recommended endpoint for capable LLMs — they can generate
2592
+ * better query variations than our small local model, especially for
2593
+ * domain-specific or nuanced queries.
2594
+ */
2595
+ export async function structuredSearch(store, searches, options) {
2596
+ const limit = options?.limit ?? 10;
2597
+ const minScore = options?.minScore ?? 0;
2598
+ const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
2599
+ const explain = options?.explain ?? false;
2600
+ const hooks = options?.hooks;
2601
+ const collections = options?.collections;
2602
+ if (searches.length === 0)
2603
+ return [];
2604
+ // Validate queries before executing
2605
+ for (const search of searches) {
2606
+ const location = search.line ? `Line ${search.line}` : 'Structured search';
2607
+ if (/[\r\n]/.test(search.query)) {
2608
+ throw new Error(`${location} (${search.type}): queries must be single-line. Remove newline characters.`);
2609
+ }
2610
+ if (search.type === 'lex') {
2611
+ const error = validateLexQuery(search.query);
2612
+ if (error) {
2613
+ throw new Error(`${location} (lex): ${error}`);
2614
+ }
2615
+ }
2616
+ else if (search.type === 'vec' || search.type === 'hyde') {
2617
+ const error = validateSemanticQuery(search.query);
2618
+ if (error) {
2619
+ throw new Error(`${location} (${search.type}): ${error}`);
2620
+ }
2621
+ }
2622
+ }
2623
+ const rankedLists = [];
2624
+ const rankedListMeta = [];
2625
+ const docidMap = new Map(); // filepath -> docid
2626
+ const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2627
+ // Helper to run search across collections (or all if undefined)
2628
+ const collectionList = collections ?? [undefined]; // undefined = all collections
2629
+ // Step 1: Run FTS for all lex searches (sync, instant)
2630
+ for (const search of searches) {
2631
+ if (search.type === 'lex') {
2632
+ for (const coll of collectionList) {
2633
+ const ftsResults = store.searchFTS(search.query, 20, coll);
2634
+ if (ftsResults.length > 0) {
2635
+ for (const r of ftsResults)
2636
+ docidMap.set(r.filepath, r.docid);
2637
+ rankedLists.push(ftsResults.map(r => ({
2638
+ file: r.filepath, displayPath: r.displayPath,
2639
+ title: r.title, body: r.body || "", score: r.score,
2640
+ })));
2641
+ rankedListMeta.push({
2642
+ source: "fts",
2643
+ queryType: "lex",
2644
+ query: search.query,
2645
+ });
2646
+ }
2647
+ }
2648
+ }
2649
+ }
2650
+ // Step 2: Batch embed and run vector searches for vec/hyde
2651
+ if (hasVectors) {
2652
+ const vecSearches = searches.filter((s) => s.type === 'vec' || s.type === 'hyde');
2653
+ if (vecSearches.length > 0) {
2654
+ const llm = getDefaultLlamaCpp();
2655
+ const textsToEmbed = vecSearches.map(s => formatQueryForEmbedding(s.query));
2656
+ hooks?.onEmbedStart?.(textsToEmbed.length);
2657
+ const embedStart = Date.now();
2658
+ const embeddings = await llm.embedBatch(textsToEmbed);
2659
+ hooks?.onEmbedDone?.(Date.now() - embedStart);
2660
+ for (let i = 0; i < vecSearches.length; i++) {
2661
+ const embedding = embeddings[i]?.embedding;
2662
+ if (!embedding)
2663
+ continue;
2664
+ for (const coll of collectionList) {
2665
+ const vecResults = await store.searchVec(vecSearches[i].query, DEFAULT_EMBED_MODEL, 20, coll, undefined, embedding);
2666
+ if (vecResults.length > 0) {
2667
+ for (const r of vecResults)
2668
+ docidMap.set(r.filepath, r.docid);
2669
+ rankedLists.push(vecResults.map(r => ({
2670
+ file: r.filepath, displayPath: r.displayPath,
2671
+ title: r.title, body: r.body || "", score: r.score,
2672
+ })));
2673
+ rankedListMeta.push({
2674
+ source: "vec",
2675
+ queryType: vecSearches[i].type,
2676
+ query: vecSearches[i].query,
2677
+ });
2678
+ }
2679
+ }
2680
+ }
2681
+ }
2682
+ }
2683
+ if (rankedLists.length === 0)
2684
+ return [];
2685
+ // Step 3: RRF fusion — first list gets 2x weight (assume caller ordered by importance)
2686
+ const weights = rankedLists.map((_, i) => i === 0 ? 2.0 : 1.0);
2687
+ const fused = reciprocalRankFusion(rankedLists, weights);
2688
+ const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
2689
+ const candidates = fused.slice(0, candidateLimit);
2690
+ if (candidates.length === 0)
2691
+ return [];
2692
+ hooks?.onExpand?.("", [], 0); // Signal no expansion (pre-expanded)
2693
+ // Step 4: Chunk documents, pick best chunk per doc for reranking
2694
+ // Use first lex query as the "query" for keyword matching, or first vec if no lex
2695
+ const primaryQuery = searches.find(s => s.type === 'lex')?.query
2696
+ || searches.find(s => s.type === 'vec')?.query
2697
+ || searches[0]?.query || "";
2698
+ const queryTerms = primaryQuery.toLowerCase().split(/\s+/).filter(t => t.length > 2);
2699
+ const chunksToRerank = [];
2700
+ const docChunkMap = new Map();
2701
+ for (const cand of candidates) {
2702
+ const chunks = chunkDocument(cand.body);
2703
+ if (chunks.length === 0)
2704
+ continue;
2705
+ // Pick chunk with most keyword overlap
2706
+ let bestIdx = 0;
2707
+ let bestScore = -1;
2708
+ for (let i = 0; i < chunks.length; i++) {
2709
+ const chunkLower = chunks[i].text.toLowerCase();
2710
+ const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
2711
+ if (score > bestScore) {
2712
+ bestScore = score;
2713
+ bestIdx = i;
2714
+ }
2715
+ }
2716
+ chunksToRerank.push({ file: cand.file, text: chunks[bestIdx].text });
2717
+ docChunkMap.set(cand.file, { chunks, bestIdx });
2718
+ }
2719
+ // Step 5: Rerank chunks
2720
+ hooks?.onRerankStart?.(chunksToRerank.length);
2721
+ const rerankStart2 = Date.now();
2722
+ const reranked = await store.rerank(primaryQuery, chunksToRerank);
2723
+ hooks?.onRerankDone?.(Date.now() - rerankStart2);
2724
+ // Step 6: Blend RRF position score with reranker score
2725
+ const candidateMap = new Map(candidates.map(c => [c.file, {
2726
+ displayPath: c.displayPath, title: c.title, body: c.body,
2727
+ }]));
2728
+ const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
2729
+ const blended = reranked.map(r => {
2730
+ const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
2731
+ let rrfWeight;
2732
+ if (rrfRank <= 3)
2733
+ rrfWeight = 0.75;
2734
+ else if (rrfRank <= 10)
2735
+ rrfWeight = 0.60;
2736
+ else
2737
+ rrfWeight = 0.40;
2738
+ const rrfScore = 1 / rrfRank;
2739
+ const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
2740
+ const candidate = candidateMap.get(r.file);
2741
+ const chunkInfo = docChunkMap.get(r.file);
2742
+ const bestIdx = chunkInfo?.bestIdx ?? 0;
2743
+ const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
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;
2761
+ return {
2762
+ file: r.file,
2763
+ displayPath: candidate?.displayPath || "",
2764
+ title: candidate?.title || "",
2765
+ body: candidate?.body || "",
2766
+ bestChunk,
2767
+ bestChunkPos,
2768
+ score: blendedScore,
2769
+ context: store.getContextForFile(r.file),
2770
+ docid: docidMap.get(r.file) || "",
2771
+ ...(explainData ? { explain: explainData } : {}),
2772
+ };
2773
+ }).sort((a, b) => b.score - a.score);
2774
+ // Step 7: Dedup by file
2775
+ const seenFiles = new Set();
2776
+ return blended
2777
+ .filter(r => {
2778
+ if (seenFiles.has(r.file))
2779
+ return false;
2780
+ seenFiles.add(r.file);
2781
+ return true;
2782
+ })
2783
+ .filter(r => r.score >= minScore)
2784
+ .slice(0, limit);
2785
+ }
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "@tobilu/qmd",
3
- "version": "1.0.7",
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": {
7
- "qmd": "qmd"
7
+ "qmd": "dist/qmd.js"
8
8
  },
9
9
  "files": [
10
10
  "dist/",
11
- "qmd",
12
11
  "LICENSE",
13
12
  "CHANGELOG.md"
14
13
  ],
15
14
  "scripts": {
16
15
  "prepare": "[ -d .git ] && ./scripts/install-hooks.sh || true",
17
- "build": "tsc -p tsconfig.build.json",
16
+ "build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/qmd.js > dist/qmd.tmp && mv dist/qmd.tmp dist/qmd.js && chmod +x dist/qmd.js",
18
17
  "test": "vitest run --reporter=verbose test/",
19
18
  "qmd": "tsx src/qmd.ts",
20
19
  "index": "tsx src/qmd.ts index",
@@ -40,7 +39,7 @@
40
39
  "@modelcontextprotocol/sdk": "^1.25.1",
41
40
  "better-sqlite3": "^11.0.0",
42
41
  "fast-glob": "^3.3.0",
43
- "node-llama-cpp": "^3.14.5",
42
+ "node-llama-cpp": "^3.17.1",
44
43
  "picomatch": "^4.0.0",
45
44
  "sqlite-vec": "^0.1.7-alpha.2",
46
45
  "yaml": "^2.8.2",
@@ -49,8 +48,9 @@
49
48
  "optionalDependencies": {
50
49
  "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2",
51
50
  "sqlite-vec-darwin-x64": "^0.1.7-alpha.2",
51
+ "sqlite-vec-linux-arm64": "^0.1.7-alpha.2",
52
52
  "sqlite-vec-linux-x64": "^0.1.7-alpha.2",
53
- "sqlite-vec-win32-x64": "^0.1.7-alpha.2"
53
+ "sqlite-vec-windows-x64": "^0.1.7-alpha.2"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/better-sqlite3": "^7.6.0",