Skip to content

Commit 44d7145

Browse files
committed
Merge pull request #242 from vyalamar/feat/query-explain-score-traces
feat(query): add --explain score traces for hybrid retrieval
2 parents 7904ab9 + b068ad0 commit 44d7145

5 files changed

Lines changed: 288 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
### Changes
6+
7+
- Query: add `--explain` for `qmd query` to expose retrieval score traces
8+
in JSON and CLI output. Includes backend scores (FTS/vector), per-list
9+
RRF contributions, top-rank bonus, reranker score, and final blended score.
10+
511
## [1.1.1] - 2026-03-06
612

713
### Fixes

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ qmd query "user authentication"
388388
--min-score <num> # Minimum score threshold (default: 0)
389389
--full # Show full document content
390390
--line-numbers # Add line numbers to output
391+
--explain # Include retrieval score traces (query, JSON/CLI output)
391392
--index <name> # Use named index
392393

393394
# Output formats (for search and multi-get)
@@ -450,6 +451,9 @@ qmd search --md --full "error handling"
450451
# JSON output for scripting
451452
qmd query --json "quarterly reports"
452453

454+
# Inspect how each result was scored (RRF + rerank blend)
455+
qmd query --json --explain "quarterly reports"
456+
453457
# Use separate index for different knowledge base
454458
qmd --index work search "quarterly reports"
455459
```

src/qmd.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
structuredSearch,
6363
addLineNumbers,
6464
type ExpandedQuery,
65+
type HybridQueryExplain,
6566
type StructuredSubSearch,
6667
DEFAULT_EMBED_MODEL,
6768
DEFAULT_RERANK_MODEL,
@@ -1767,6 +1768,7 @@ type OutputOptions = {
17671768
all?: boolean;
17681769
collection?: string | string[]; // Filter by collection name(s)
17691770
lineNumbers?: boolean; // Add line numbers to output
1771+
explain?: boolean; // Include retrieval score traces (query only)
17701772
context?: string; // Optional context for query expansion
17711773
candidateLimit?: number; // Max candidates to rerank (default: 40)
17721774
};
@@ -1792,6 +1794,10 @@ function formatScore(score: number): string {
17921794
return `${c.dim}${pct}%${c.reset}`;
17931795
}
17941796

1797+
function formatExplainNumber(value: number): string {
1798+
return value.toFixed(4);
1799+
}
1800+
17951801
// Shorten directory path for display - relative to $HOME (used for context paths, not documents)
17961802
function shortPath(dirpath: string): string {
17971803
const home = homedir();
@@ -1828,7 +1834,20 @@ function printEmptySearchResults(format: OutputFormat, reason: EmptySearchReason
18281834
console.log("No results found.");
18291835
}
18301836

1831-
function outputResults(results: { file: string; displayPath: string; title: string; body: string; score: number; context?: string | null; chunkPos?: number; hash?: string; docid?: string }[], query: string, opts: OutputOptions): void {
1837+
type OutputRow = {
1838+
file: string;
1839+
displayPath: string;
1840+
title: string;
1841+
body: string;
1842+
score: number;
1843+
context?: string | null;
1844+
chunkPos?: number;
1845+
hash?: string;
1846+
docid?: string;
1847+
explain?: HybridQueryExplain;
1848+
};
1849+
1850+
function outputResults(results: OutputRow[], query: string, opts: OutputOptions): void {
18321851
const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
18331852

18341853
if (filtered.length === 0) {
@@ -1857,6 +1876,7 @@ function outputResults(results: { file: string; displayPath: string; title: stri
18571876
...(row.context && { context: row.context }),
18581877
...(body && { body }),
18591878
...(snippet && { snippet }),
1879+
...(opts.explain && row.explain && { explain: row.explain }),
18601880
};
18611881
});
18621882
console.log(JSON.stringify(output, null, 2));
@@ -1896,6 +1916,28 @@ function outputResults(results: { file: string; displayPath: string; title: stri
18961916
// Line 4: Score
18971917
const score = formatScore(row.score);
18981918
console.log(`Score: ${c.bold}${score}${c.reset}`);
1919+
if (opts.explain && row.explain) {
1920+
const explain = row.explain;
1921+
const ftsScores = explain.ftsScores.length > 0
1922+
? explain.ftsScores.map(formatExplainNumber).join(", ")
1923+
: "none";
1924+
const vecScores = explain.vectorScores.length > 0
1925+
? explain.vectorScores.map(formatExplainNumber).join(", ")
1926+
: "none";
1927+
const contribSummary = explain.rrf.contributions
1928+
.slice()
1929+
.sort((a, b) => b.rrfContribution - a.rrfContribution)
1930+
.slice(0, 3)
1931+
.map(c => `${c.source}/${c.queryType}#${c.rank}:${formatExplainNumber(c.rrfContribution)}`)
1932+
.join(" | ");
1933+
1934+
console.log(`${c.dim}Explain: fts=[${ftsScores}] vec=[${vecScores}]${c.reset}`);
1935+
console.log(`${c.dim} RRF: total=${formatExplainNumber(explain.rrf.totalScore)} base=${formatExplainNumber(explain.rrf.baseScore)} bonus=${formatExplainNumber(explain.rrf.topRankBonus)} rank=${explain.rrf.rank}${c.reset}`);
1936+
console.log(`${c.dim} Blend: ${Math.round(explain.rrf.weight * 100)}%*${formatExplainNumber(explain.rrf.positionScore)} + ${Math.round((1 - explain.rrf.weight) * 100)}%*${formatExplainNumber(explain.rerankScore)} = ${formatExplainNumber(explain.blendedScore)}${c.reset}`);
1937+
if (contribSummary.length > 0) {
1938+
console.log(`${c.dim} Top RRF contributions: ${contribSummary}${c.reset}`);
1939+
}
1940+
}
18991941
console.log();
19001942

19011943
// Snippet with highlighting (diff-style header included)
@@ -2179,6 +2221,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
21792221
limit: opts.all ? 500 : (opts.limit || 10),
21802222
minScore: opts.minScore || 0,
21812223
candidateLimit: opts.candidateLimit,
2224+
explain: !!opts.explain,
21822225
hooks: {
21832226
onEmbedStart: (count) => {
21842227
process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
@@ -2203,6 +2246,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
22032246
limit: opts.all ? 500 : (opts.limit || 10),
22042247
minScore: opts.minScore || 0,
22052248
candidateLimit: opts.candidateLimit,
2249+
explain: !!opts.explain,
22062250
hooks: {
22072251
onStrongSignal: (score) => {
22082252
process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
@@ -2263,6 +2307,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
22632307
score: r.score,
22642308
context: r.context,
22652309
docid: r.docid,
2310+
explain: r.explain,
22662311
})), displayQuery, { ...opts, limit: results.length });
22672312
}, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
22682313
}
@@ -2292,6 +2337,7 @@ function parseCLI() {
22922337
xml: { type: "boolean" },
22932338
files: { type: "boolean" },
22942339
json: { type: "boolean" },
2340+
explain: { type: "boolean" },
22952341
collection: { type: "string", short: "c", multiple: true }, // Filter by collection(s)
22962342
// Collection options
22972343
name: { type: "string" }, // collection name
@@ -2346,6 +2392,7 @@ function parseCLI() {
23462392
collection: values.collection as string[] | undefined,
23472393
lineNumbers: !!values["line-numbers"],
23482394
candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
2395+
explain: !!values.explain,
23492396
};
23502397

23512398
return {
@@ -2449,6 +2496,7 @@ function showHelp(): void {
24492496
console.log(" --full - Output full document instead of snippet");
24502497
console.log(" -C, --candidate-limit <n> - Max candidates to rerank (default 40, lower = faster)");
24512498
console.log(" --line-numbers - Include line numbers in output");
2499+
console.log(" --explain - Include retrieval score traces (query --json/CLI)");
24522500
console.log(" --files | --json | --csv | --md | --xml - Output format");
24532501
console.log(" -c, --collection <name> - Filter by one or more collections");
24542502
console.log("");

0 commit comments

Comments
 (0)