@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/CHANGELOG.md +90 -1
- package/README.md +29 -3
- package/dist/collections.d.ts +17 -0
- package/dist/collections.js +40 -0
- package/dist/llm.d.ts +20 -5
- package/dist/llm.js +111 -49
- package/dist/mcp.js +222 -101
- package/dist/qmd.js +480 -147
- package/dist/store.d.ts +99 -12
- package/dist/store.js +436 -25
- package/package.json +6 -6
- package/qmd +0 -46
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
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
|
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,
|
|
1733
|
-
const
|
|
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.
|
|
1842
|
+
cachedResults.set(doc.text, parseFloat(cached));
|
|
1736
1843
|
}
|
|
1737
1844
|
else {
|
|
1738
|
-
|
|
1845
|
+
uncachedDocsByChunk.set(doc.text, { file: doc.file, text: doc.text });
|
|
1739
1846
|
}
|
|
1740
1847
|
}
|
|
1741
1848
|
// Rerank uncached documents using LlamaCpp
|
|
1742
|
-
if (
|
|
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
|
|
1746
|
-
const textByFile = new Map(
|
|
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
|
|
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(
|
|
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.
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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-
|
|
53
|
+
"sqlite-vec-windows-x64": "^0.1.7-alpha.2"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/better-sqlite3": "^7.6.0",
|