@tobilu/qmd 1.0.6 → 1.1.1
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 +62 -1
- package/dist/collections.d.ts +16 -0
- package/dist/collections.js +55 -1
- package/dist/llm.d.ts +3 -0
- package/dist/llm.js +21 -2
- package/dist/mcp.js +143 -93
- package/dist/qmd.js +455 -146
- package/dist/store.d.ts +55 -3
- package/dist/store.js +289 -10
- package/package.json +3 -4
- package/qmd +0 -46
package/dist/store.d.ts
CHANGED
|
@@ -548,6 +548,12 @@ export declare function getCollectionsWithoutContext(db: Database): {
|
|
|
548
548
|
* Useful for suggesting where context might be needed.
|
|
549
549
|
*/
|
|
550
550
|
export declare function getTopLevelPathsWithoutContext(db: Database, collectionName: string): string[];
|
|
551
|
+
/**
|
|
552
|
+
* Validate that a vec/hyde query doesn't use lex-only syntax.
|
|
553
|
+
* Returns error message if invalid, null if valid.
|
|
554
|
+
*/
|
|
555
|
+
export declare function validateSemanticQuery(query: string): string | null;
|
|
556
|
+
export declare function validateLexQuery(query: string): string | null;
|
|
551
557
|
export declare function searchFTS(db: Database, query: string, limit?: number, collectionName?: string): SearchResult[];
|
|
552
558
|
export declare function searchVec(db: Database, query: string, model: string, limit?: number, collectionName?: string, session?: ILLMSession, precomputedEmbedding?: number[]): Promise<SearchResult[]>;
|
|
553
559
|
/**
|
|
@@ -630,12 +636,18 @@ export declare function addLineNumbers(text: string, startLine?: number): string
|
|
|
630
636
|
export interface SearchHooks {
|
|
631
637
|
/** BM25 probe found strong signal — expansion will be skipped */
|
|
632
638
|
onStrongSignal?: (topScore: number) => void;
|
|
633
|
-
/** Query expansion
|
|
634
|
-
|
|
639
|
+
/** Query expansion starting */
|
|
640
|
+
onExpandStart?: () => void;
|
|
641
|
+
/** Query expansion complete. Empty array = strong signal skip. elapsedMs = time taken. */
|
|
642
|
+
onExpand?: (original: string, expanded: ExpandedQuery[], elapsedMs: number) => void;
|
|
643
|
+
/** Embedding starting (vec/hyde queries) */
|
|
644
|
+
onEmbedStart?: (count: number) => void;
|
|
645
|
+
/** Embedding complete */
|
|
646
|
+
onEmbedDone?: (elapsedMs: number) => void;
|
|
635
647
|
/** Reranking is about to start */
|
|
636
648
|
onRerankStart?: (chunkCount: number) => void;
|
|
637
649
|
/** Reranking finished */
|
|
638
|
-
onRerankDone?: () => void;
|
|
650
|
+
onRerankDone?: (elapsedMs: number) => void;
|
|
639
651
|
}
|
|
640
652
|
export interface HybridQueryOptions {
|
|
641
653
|
collection?: string;
|
|
@@ -694,3 +706,43 @@ export interface VectorSearchResult {
|
|
|
694
706
|
* 4. Sort by score descending, filter by minScore, slice to limit
|
|
695
707
|
*/
|
|
696
708
|
export declare function vectorSearchQuery(store: Store, query: string, options?: VectorSearchOptions): Promise<VectorSearchResult[]>;
|
|
709
|
+
/**
|
|
710
|
+
* A single sub-search in a structured search request.
|
|
711
|
+
* Matches the format used in QMD training data.
|
|
712
|
+
*/
|
|
713
|
+
export interface StructuredSubSearch {
|
|
714
|
+
/** Search type: 'lex' for BM25, 'vec' for semantic, 'hyde' for hypothetical */
|
|
715
|
+
type: 'lex' | 'vec' | 'hyde';
|
|
716
|
+
/** The search query text */
|
|
717
|
+
query: string;
|
|
718
|
+
/** Optional line number for error reporting (CLI parser) */
|
|
719
|
+
line?: number;
|
|
720
|
+
}
|
|
721
|
+
export interface StructuredSearchOptions {
|
|
722
|
+
collections?: string[];
|
|
723
|
+
limit?: number;
|
|
724
|
+
minScore?: number;
|
|
725
|
+
candidateLimit?: number;
|
|
726
|
+
/** Future: domain intent hint for routing/boosting */
|
|
727
|
+
intent?: string;
|
|
728
|
+
hooks?: SearchHooks;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Structured search: execute pre-expanded queries without LLM query expansion.
|
|
732
|
+
*
|
|
733
|
+
* Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
|
|
734
|
+
* Skips the internal expandQuery() step — goes directly to:
|
|
735
|
+
*
|
|
736
|
+
* Pipeline:
|
|
737
|
+
* 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
|
|
738
|
+
* 2. RRF fusion across all result lists
|
|
739
|
+
* 3. Chunk documents + keyword-best-chunk selection
|
|
740
|
+
* 4. Rerank on chunks
|
|
741
|
+
* 5. Position-aware score blending
|
|
742
|
+
* 6. Dedup, filter, slice
|
|
743
|
+
*
|
|
744
|
+
* This is the recommended endpoint for capable LLMs — they can generate
|
|
745
|
+
* better query variations than our small local model, especially for
|
|
746
|
+
* domain-specific or nuanced queries.
|
|
747
|
+
*/
|
|
748
|
+
export declare function structuredSearch(store: Store, searches: StructuredSubSearch[], options?: StructuredSearchOptions): Promise<HybridQueryResult[]>;
|
package/dist/store.js
CHANGED
|
@@ -1510,15 +1510,108 @@ export function getTopLevelPathsWithoutContext(db, collectionName) {
|
|
|
1510
1510
|
function sanitizeFTS5Term(term) {
|
|
1511
1511
|
return term.replace(/[^\p{L}\p{N}']/gu, '').toLowerCase();
|
|
1512
1512
|
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Parse lex query syntax into FTS5 query.
|
|
1515
|
+
*
|
|
1516
|
+
* Supports:
|
|
1517
|
+
* - Quoted phrases: "exact phrase" → "exact phrase" (exact match)
|
|
1518
|
+
* - Negation: -term or -"phrase" → uses FTS5 NOT operator
|
|
1519
|
+
* - Plain terms: term → "term"* (prefix match)
|
|
1520
|
+
*
|
|
1521
|
+
* FTS5 NOT is a binary operator: `term1 NOT term2` means "match term1 but not term2".
|
|
1522
|
+
* So `-term` only works when there are also positive terms.
|
|
1523
|
+
*
|
|
1524
|
+
* Examples:
|
|
1525
|
+
* performance -sports → "performance"* NOT "sports"*
|
|
1526
|
+
* "machine learning" → "machine learning"
|
|
1527
|
+
*/
|
|
1513
1528
|
function buildFTS5Query(query) {
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1529
|
+
const positive = [];
|
|
1530
|
+
const negative = [];
|
|
1531
|
+
let i = 0;
|
|
1532
|
+
const s = query.trim();
|
|
1533
|
+
while (i < s.length) {
|
|
1534
|
+
// Skip whitespace
|
|
1535
|
+
while (i < s.length && /\s/.test(s[i]))
|
|
1536
|
+
i++;
|
|
1537
|
+
if (i >= s.length)
|
|
1538
|
+
break;
|
|
1539
|
+
// Check for negation prefix
|
|
1540
|
+
const negated = s[i] === '-';
|
|
1541
|
+
if (negated)
|
|
1542
|
+
i++;
|
|
1543
|
+
// Check for quoted phrase
|
|
1544
|
+
if (s[i] === '"') {
|
|
1545
|
+
const start = i + 1;
|
|
1546
|
+
i++;
|
|
1547
|
+
while (i < s.length && s[i] !== '"')
|
|
1548
|
+
i++;
|
|
1549
|
+
const phrase = s.slice(start, i).trim();
|
|
1550
|
+
i++; // skip closing quote
|
|
1551
|
+
if (phrase.length > 0) {
|
|
1552
|
+
const sanitized = phrase.split(/\s+/).map(t => sanitizeFTS5Term(t)).filter(t => t).join(' ');
|
|
1553
|
+
if (sanitized) {
|
|
1554
|
+
const ftsPhrase = `"${sanitized}"`; // Exact phrase, no prefix match
|
|
1555
|
+
if (negated) {
|
|
1556
|
+
negative.push(ftsPhrase);
|
|
1557
|
+
}
|
|
1558
|
+
else {
|
|
1559
|
+
positive.push(ftsPhrase);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
// Plain term (until whitespace or quote)
|
|
1566
|
+
const start = i;
|
|
1567
|
+
while (i < s.length && !/[\s"]/.test(s[i]))
|
|
1568
|
+
i++;
|
|
1569
|
+
const term = s.slice(start, i);
|
|
1570
|
+
const sanitized = sanitizeFTS5Term(term);
|
|
1571
|
+
if (sanitized) {
|
|
1572
|
+
const ftsTerm = `"${sanitized}"*`; // Prefix match
|
|
1573
|
+
if (negated) {
|
|
1574
|
+
negative.push(ftsTerm);
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
positive.push(ftsTerm);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (positive.length === 0 && negative.length === 0)
|
|
1583
|
+
return null;
|
|
1584
|
+
// If only negative terms, we can't search (FTS5 NOT is binary)
|
|
1585
|
+
if (positive.length === 0)
|
|
1518
1586
|
return null;
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1587
|
+
// Join positive terms with AND
|
|
1588
|
+
let result = positive.join(' AND ');
|
|
1589
|
+
// Add NOT clause for negative terms
|
|
1590
|
+
for (const neg of negative) {
|
|
1591
|
+
result = `${result} NOT ${neg}`;
|
|
1592
|
+
}
|
|
1593
|
+
return result;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Validate that a vec/hyde query doesn't use lex-only syntax.
|
|
1597
|
+
* Returns error message if invalid, null if valid.
|
|
1598
|
+
*/
|
|
1599
|
+
export function validateSemanticQuery(query) {
|
|
1600
|
+
// Check for negation syntax
|
|
1601
|
+
if (/-\w/.test(query) || /-"/.test(query)) {
|
|
1602
|
+
return 'Negation (-term) is not supported in vec/hyde queries. Use lex for exclusions.';
|
|
1603
|
+
}
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
export function validateLexQuery(query) {
|
|
1607
|
+
if (/[\r\n]/.test(query)) {
|
|
1608
|
+
return 'Lex queries must be a single line. Remove newline characters or split into separate lex: lines.';
|
|
1609
|
+
}
|
|
1610
|
+
const quoteCount = (query.match(/"/g) ?? []).length;
|
|
1611
|
+
if (quoteCount % 2 === 1) {
|
|
1612
|
+
return 'Lex query has an unmatched double quote ("). Add the closing quote or remove it.';
|
|
1613
|
+
}
|
|
1614
|
+
return null;
|
|
1522
1615
|
}
|
|
1523
1616
|
export function searchFTS(db, query, limit = 20, collectionName) {
|
|
1524
1617
|
const ftsQuery = buildFTS5Query(query);
|
|
@@ -2186,10 +2279,12 @@ export async function hybridQuery(store, query, options) {
|
|
|
2186
2279
|
if (hasStrongSignal)
|
|
2187
2280
|
hooks?.onStrongSignal?.(topScore);
|
|
2188
2281
|
// Step 2: Expand query (or skip if strong signal)
|
|
2282
|
+
hooks?.onExpandStart?.();
|
|
2283
|
+
const expandStart = Date.now();
|
|
2189
2284
|
const expanded = hasStrongSignal
|
|
2190
2285
|
? []
|
|
2191
2286
|
: await store.expandQuery(query);
|
|
2192
|
-
hooks?.onExpand?.(query, expanded);
|
|
2287
|
+
hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
|
|
2193
2288
|
// Seed with initial FTS results (avoid re-running original query FTS)
|
|
2194
2289
|
if (initialFts.length > 0) {
|
|
2195
2290
|
for (const r of initialFts)
|
|
@@ -2231,7 +2326,10 @@ export async function hybridQuery(store, query, options) {
|
|
|
2231
2326
|
// Batch embed all vector queries in a single call
|
|
2232
2327
|
const llm = getDefaultLlamaCpp();
|
|
2233
2328
|
const textsToEmbed = vecQueries.map(q => formatQueryForEmbedding(q.text));
|
|
2329
|
+
hooks?.onEmbedStart?.(textsToEmbed.length);
|
|
2330
|
+
const embedStart = Date.now();
|
|
2234
2331
|
const embeddings = await llm.embedBatch(textsToEmbed);
|
|
2332
|
+
hooks?.onEmbedDone?.(Date.now() - embedStart);
|
|
2235
2333
|
// Run sqlite-vec lookups with pre-computed embeddings
|
|
2236
2334
|
for (let i = 0; i < vecQueries.length; i++) {
|
|
2237
2335
|
const embedding = embeddings[i]?.embedding;
|
|
@@ -2279,8 +2377,9 @@ export async function hybridQuery(store, query, options) {
|
|
|
2279
2377
|
}
|
|
2280
2378
|
// Step 6: Rerank chunks (NOT full bodies)
|
|
2281
2379
|
hooks?.onRerankStart?.(chunksToRerank.length);
|
|
2380
|
+
const rerankStart = Date.now();
|
|
2282
2381
|
const reranked = await store.rerank(query, chunksToRerank);
|
|
2283
|
-
hooks?.onRerankDone?.();
|
|
2382
|
+
hooks?.onRerankDone?.(Date.now() - rerankStart);
|
|
2284
2383
|
// Step 7: Blend RRF position score with reranker score
|
|
2285
2384
|
// Position-aware weights: top retrieval results get more protection from reranker disagreement
|
|
2286
2385
|
const candidateMap = new Map(candidates.map(c => [c.file, {
|
|
@@ -2344,9 +2443,10 @@ export async function vectorSearchQuery(store, query, options) {
|
|
|
2344
2443
|
if (!hasVectors)
|
|
2345
2444
|
return [];
|
|
2346
2445
|
// Expand query — filter to vec/hyde only (lex queries target FTS, not vector)
|
|
2446
|
+
const expandStart = Date.now();
|
|
2347
2447
|
const allExpanded = await store.expandQuery(query);
|
|
2348
2448
|
const vecExpanded = allExpanded.filter(q => q.type !== 'lex');
|
|
2349
|
-
options?.hooks?.onExpand?.(query, vecExpanded);
|
|
2449
|
+
options?.hooks?.onExpand?.(query, vecExpanded, Date.now() - expandStart);
|
|
2350
2450
|
// Run original + vec/hyde expanded through vector, sequentially — concurrent embed() hangs
|
|
2351
2451
|
const queryTexts = [query, ...vecExpanded.map(q => q.text)];
|
|
2352
2452
|
const allResults = new Map();
|
|
@@ -2372,3 +2472,182 @@ export async function vectorSearchQuery(store, query, options) {
|
|
|
2372
2472
|
.filter(r => r.score >= minScore)
|
|
2373
2473
|
.slice(0, limit);
|
|
2374
2474
|
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Structured search: execute pre-expanded queries without LLM query expansion.
|
|
2477
|
+
*
|
|
2478
|
+
* Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
|
|
2479
|
+
* Skips the internal expandQuery() step — goes directly to:
|
|
2480
|
+
*
|
|
2481
|
+
* Pipeline:
|
|
2482
|
+
* 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
|
|
2483
|
+
* 2. RRF fusion across all result lists
|
|
2484
|
+
* 3. Chunk documents + keyword-best-chunk selection
|
|
2485
|
+
* 4. Rerank on chunks
|
|
2486
|
+
* 5. Position-aware score blending
|
|
2487
|
+
* 6. Dedup, filter, slice
|
|
2488
|
+
*
|
|
2489
|
+
* This is the recommended endpoint for capable LLMs — they can generate
|
|
2490
|
+
* better query variations than our small local model, especially for
|
|
2491
|
+
* domain-specific or nuanced queries.
|
|
2492
|
+
*/
|
|
2493
|
+
export async function structuredSearch(store, searches, options) {
|
|
2494
|
+
const limit = options?.limit ?? 10;
|
|
2495
|
+
const minScore = options?.minScore ?? 0;
|
|
2496
|
+
const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
|
|
2497
|
+
const hooks = options?.hooks;
|
|
2498
|
+
const collections = options?.collections;
|
|
2499
|
+
if (searches.length === 0)
|
|
2500
|
+
return [];
|
|
2501
|
+
// Validate queries before executing
|
|
2502
|
+
for (const search of searches) {
|
|
2503
|
+
const location = search.line ? `Line ${search.line}` : 'Structured search';
|
|
2504
|
+
if (/[\r\n]/.test(search.query)) {
|
|
2505
|
+
throw new Error(`${location} (${search.type}): queries must be single-line. Remove newline characters.`);
|
|
2506
|
+
}
|
|
2507
|
+
if (search.type === 'lex') {
|
|
2508
|
+
const error = validateLexQuery(search.query);
|
|
2509
|
+
if (error) {
|
|
2510
|
+
throw new Error(`${location} (lex): ${error}`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
else if (search.type === 'vec' || search.type === 'hyde') {
|
|
2514
|
+
const error = validateSemanticQuery(search.query);
|
|
2515
|
+
if (error) {
|
|
2516
|
+
throw new Error(`${location} (${search.type}): ${error}`);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
const rankedLists = [];
|
|
2521
|
+
const docidMap = new Map(); // filepath -> docid
|
|
2522
|
+
const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
2523
|
+
// Helper to run search across collections (or all if undefined)
|
|
2524
|
+
const collectionList = collections ?? [undefined]; // undefined = all collections
|
|
2525
|
+
// Step 1: Run FTS for all lex searches (sync, instant)
|
|
2526
|
+
for (const search of searches) {
|
|
2527
|
+
if (search.type === 'lex') {
|
|
2528
|
+
for (const coll of collectionList) {
|
|
2529
|
+
const ftsResults = store.searchFTS(search.query, 20, coll);
|
|
2530
|
+
if (ftsResults.length > 0) {
|
|
2531
|
+
for (const r of ftsResults)
|
|
2532
|
+
docidMap.set(r.filepath, r.docid);
|
|
2533
|
+
rankedLists.push(ftsResults.map(r => ({
|
|
2534
|
+
file: r.filepath, displayPath: r.displayPath,
|
|
2535
|
+
title: r.title, body: r.body || "", score: r.score,
|
|
2536
|
+
})));
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
// Step 2: Batch embed and run vector searches for vec/hyde
|
|
2542
|
+
if (hasVectors) {
|
|
2543
|
+
const vecSearches = searches.filter(s => s.type === 'vec' || s.type === 'hyde');
|
|
2544
|
+
if (vecSearches.length > 0) {
|
|
2545
|
+
const llm = getDefaultLlamaCpp();
|
|
2546
|
+
const textsToEmbed = vecSearches.map(s => formatQueryForEmbedding(s.query));
|
|
2547
|
+
hooks?.onEmbedStart?.(textsToEmbed.length);
|
|
2548
|
+
const embedStart = Date.now();
|
|
2549
|
+
const embeddings = await llm.embedBatch(textsToEmbed);
|
|
2550
|
+
hooks?.onEmbedDone?.(Date.now() - embedStart);
|
|
2551
|
+
for (let i = 0; i < vecSearches.length; i++) {
|
|
2552
|
+
const embedding = embeddings[i]?.embedding;
|
|
2553
|
+
if (!embedding)
|
|
2554
|
+
continue;
|
|
2555
|
+
for (const coll of collectionList) {
|
|
2556
|
+
const vecResults = await store.searchVec(vecSearches[i].query, DEFAULT_EMBED_MODEL, 20, coll, undefined, embedding);
|
|
2557
|
+
if (vecResults.length > 0) {
|
|
2558
|
+
for (const r of vecResults)
|
|
2559
|
+
docidMap.set(r.filepath, r.docid);
|
|
2560
|
+
rankedLists.push(vecResults.map(r => ({
|
|
2561
|
+
file: r.filepath, displayPath: r.displayPath,
|
|
2562
|
+
title: r.title, body: r.body || "", score: r.score,
|
|
2563
|
+
})));
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
if (rankedLists.length === 0)
|
|
2570
|
+
return [];
|
|
2571
|
+
// Step 3: RRF fusion — first list gets 2x weight (assume caller ordered by importance)
|
|
2572
|
+
const weights = rankedLists.map((_, i) => i === 0 ? 2.0 : 1.0);
|
|
2573
|
+
const fused = reciprocalRankFusion(rankedLists, weights);
|
|
2574
|
+
const candidates = fused.slice(0, candidateLimit);
|
|
2575
|
+
if (candidates.length === 0)
|
|
2576
|
+
return [];
|
|
2577
|
+
hooks?.onExpand?.("", [], 0); // Signal no expansion (pre-expanded)
|
|
2578
|
+
// Step 4: Chunk documents, pick best chunk per doc for reranking
|
|
2579
|
+
// Use first lex query as the "query" for keyword matching, or first vec if no lex
|
|
2580
|
+
const primaryQuery = searches.find(s => s.type === 'lex')?.query
|
|
2581
|
+
|| searches.find(s => s.type === 'vec')?.query
|
|
2582
|
+
|| searches[0]?.query || "";
|
|
2583
|
+
const queryTerms = primaryQuery.toLowerCase().split(/\s+/).filter(t => t.length > 2);
|
|
2584
|
+
const chunksToRerank = [];
|
|
2585
|
+
const docChunkMap = new Map();
|
|
2586
|
+
for (const cand of candidates) {
|
|
2587
|
+
const chunks = chunkDocument(cand.body);
|
|
2588
|
+
if (chunks.length === 0)
|
|
2589
|
+
continue;
|
|
2590
|
+
// Pick chunk with most keyword overlap
|
|
2591
|
+
let bestIdx = 0;
|
|
2592
|
+
let bestScore = -1;
|
|
2593
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2594
|
+
const chunkLower = chunks[i].text.toLowerCase();
|
|
2595
|
+
const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
|
|
2596
|
+
if (score > bestScore) {
|
|
2597
|
+
bestScore = score;
|
|
2598
|
+
bestIdx = i;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
chunksToRerank.push({ file: cand.file, text: chunks[bestIdx].text });
|
|
2602
|
+
docChunkMap.set(cand.file, { chunks, bestIdx });
|
|
2603
|
+
}
|
|
2604
|
+
// Step 5: Rerank chunks
|
|
2605
|
+
hooks?.onRerankStart?.(chunksToRerank.length);
|
|
2606
|
+
const rerankStart2 = Date.now();
|
|
2607
|
+
const reranked = await store.rerank(primaryQuery, chunksToRerank);
|
|
2608
|
+
hooks?.onRerankDone?.(Date.now() - rerankStart2);
|
|
2609
|
+
// Step 6: Blend RRF position score with reranker score
|
|
2610
|
+
const candidateMap = new Map(candidates.map(c => [c.file, {
|
|
2611
|
+
displayPath: c.displayPath, title: c.title, body: c.body,
|
|
2612
|
+
}]));
|
|
2613
|
+
const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
|
|
2614
|
+
const blended = reranked.map(r => {
|
|
2615
|
+
const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
|
|
2616
|
+
let rrfWeight;
|
|
2617
|
+
if (rrfRank <= 3)
|
|
2618
|
+
rrfWeight = 0.75;
|
|
2619
|
+
else if (rrfRank <= 10)
|
|
2620
|
+
rrfWeight = 0.60;
|
|
2621
|
+
else
|
|
2622
|
+
rrfWeight = 0.40;
|
|
2623
|
+
const rrfScore = 1 / rrfRank;
|
|
2624
|
+
const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
|
|
2625
|
+
const candidate = candidateMap.get(r.file);
|
|
2626
|
+
const chunkInfo = docChunkMap.get(r.file);
|
|
2627
|
+
const bestIdx = chunkInfo?.bestIdx ?? 0;
|
|
2628
|
+
const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
|
|
2629
|
+
const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
|
|
2630
|
+
return {
|
|
2631
|
+
file: r.file,
|
|
2632
|
+
displayPath: candidate?.displayPath || "",
|
|
2633
|
+
title: candidate?.title || "",
|
|
2634
|
+
body: candidate?.body || "",
|
|
2635
|
+
bestChunk,
|
|
2636
|
+
bestChunkPos,
|
|
2637
|
+
score: blendedScore,
|
|
2638
|
+
context: store.getContextForFile(r.file),
|
|
2639
|
+
docid: docidMap.get(r.file) || "",
|
|
2640
|
+
};
|
|
2641
|
+
}).sort((a, b) => b.score - a.score);
|
|
2642
|
+
// Step 7: Dedup by file
|
|
2643
|
+
const seenFiles = new Set();
|
|
2644
|
+
return blended
|
|
2645
|
+
.filter(r => {
|
|
2646
|
+
if (seenFiles.has(r.file))
|
|
2647
|
+
return false;
|
|
2648
|
+
seenFiles.add(r.file);
|
|
2649
|
+
return true;
|
|
2650
|
+
})
|
|
2651
|
+
.filter(r => r.score >= minScore)
|
|
2652
|
+
.slice(0, limit);
|
|
2653
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tobilu/qmd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
package/qmd
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# qmd - Quick Markdown Search
|
|
3
|
-
set -euo pipefail
|
|
4
|
-
|
|
5
|
-
# Find node - prefer PATH, fallback to known locations
|
|
6
|
-
find_node() {
|
|
7
|
-
if command -v node &>/dev/null; then
|
|
8
|
-
local ver=$(node --version 2>/dev/null | sed 's/^v//' || echo "0")
|
|
9
|
-
local major="${ver%%.*}"
|
|
10
|
-
if [[ "$major" -ge 22 ]]; then
|
|
11
|
-
command -v node
|
|
12
|
-
return 0
|
|
13
|
-
fi
|
|
14
|
-
fi
|
|
15
|
-
|
|
16
|
-
# Fallback: derive paths (need HOME)
|
|
17
|
-
: "${HOME:=$(eval echo ~)}"
|
|
18
|
-
|
|
19
|
-
# Check known locations
|
|
20
|
-
local candidates=(
|
|
21
|
-
"$HOME/.local/share/mise/installs/node/latest/bin/node"
|
|
22
|
-
"$HOME/.local/share/mise/shims/node"
|
|
23
|
-
"$HOME/.asdf/shims/node"
|
|
24
|
-
"/opt/homebrew/bin/node"
|
|
25
|
-
"/usr/local/bin/node"
|
|
26
|
-
"$HOME/.nvm/current/bin/node"
|
|
27
|
-
)
|
|
28
|
-
for c in "${candidates[@]}"; do
|
|
29
|
-
[[ -x "$c" ]] && { echo "$c"; return 0; }
|
|
30
|
-
done
|
|
31
|
-
|
|
32
|
-
return 1
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
NODE=$(find_node) || { echo "Error: node (>=22) not found. Install from https://nodejs.org" >&2; exit 1; }
|
|
36
|
-
|
|
37
|
-
# Resolve symlinks to find script location
|
|
38
|
-
SOURCE="${BASH_SOURCE[0]}"
|
|
39
|
-
while [[ -L "$SOURCE" ]]; do
|
|
40
|
-
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
41
|
-
SOURCE="$(readlink "$SOURCE")"
|
|
42
|
-
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
43
|
-
done
|
|
44
|
-
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
45
|
-
|
|
46
|
-
exec "$NODE" "$SCRIPT_DIR/dist/qmd.js" "$@"
|