@tobilu/qmd 2.1.0 → 2.5.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 CHANGED
@@ -2,6 +2,94 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.5.2] - 2026-05-22
6
+
7
+ ### Fixes
8
+
9
+ - Launcher: Rewrite `bin/qmd` as a Node-based shebang polyglot to fix global npm installation execution failures on Windows (#668 / #452), while supporting seamless fallback to Bun in Node-less environments.
10
+
11
+
12
+ ## [2.5.1] - 2026-05-20
13
+
14
+ ### Changes
15
+
16
+ - Release: publish from GitHub Actions via npm Trusted Publishing/OIDC instead of a long-lived `NPM_TOKEN` secret.
17
+
18
+ ## [2.5.0] - 2026-05-19
19
+
20
+ ### Changes
21
+
22
+ - Dependencies: update core SQLite/config/chunking packages (`better-sqlite3`, `yaml`, `web-tree-sitter`, `tree-sitter-go`, and `tree-sitter-python`) while keeping incompatible `zod`, `tsx`, and `vitest` majors pinned.
23
+ - Agent skills: add `qmd skills list|get|path` to serve version-matched runtime skill instructions from the installed CLI, and make `qmd skill install` write a stable discovery stub so installed agent skills do not go stale after QMD upgrades.
24
+ - CLI: add `qmd doctor` for index/runtime diagnostics, including SQLite/sqlite-vec versions, embedding fingerprint freshness, mixed-fingerprint detection, safe legacy fingerprint adoption, and content-hash sampling.
25
+
26
+ ### Fixes
27
+
28
+ - Launcher: prefer runnable TypeScript source in git checkouts even when ignored `dist/` artifacts exist, while packaged installs continue to run `dist/`.
29
+ - GPU: keep node-llama-cpp's documented `gpu: "auto"` initialization as the primary path, then perform no-build packaged CUDA/Vulkan/Metal probes only if auto falls back to CPU.
30
+ - CLI: move GPU/CPU runtime diagnostics out of `qmd status`; use `qmd doctor` for device probing and related environment guidance.
31
+ - CLI: point unexpected command/setup failures toward `qmd doctor` so diagnostics are the default next step when QMD behaves incorrectly.
32
+ - Doctor: explicitly warn when `content_vectors` contains multiple non-empty embedding fingerprint names, with the per-fingerprint document/chunk breakdown.
33
+ - Embed: make the TTY progress line label byte-based input progress explicitly, show embedded chunks as a count, and shorten the displayed model name.
34
+ - Embed: retain per-chunk failure details, retry failed chunks after later successful embeds and again when no other chunks remain, clear recovered errors, and cap retries to avoid endless loops.
35
+ - Tests: expand the container smoke harness to cover npm-global, npx-style, and Bun-global install scenarios, always checking auto and `QMD_FORCE_CPU=1` doctor modes, with opt-in tiny `qmd embed` and GPU probe runs for supported container runtimes.
36
+ - Embedding: fingerprint vector metadata using the active embedding model and formatting/chunking parameters so stale vectors are treated as pending after search semantics change. Legacy `content_vectors` columns are migrated lazily on first vector-health/write use to preserve fast QMD startup.
37
+
38
+ - Skill: expand the packaged QMD skill with retrieval-first workflows, structured query examples, wiki/source collection guidance, and safe fallbacks when model-backed search is unavailable.
39
+ - Tests: make `bun run test` execute the local unit suite under both Node/Vitest and Bun (`test:node` + `test:bun`) so runtime-specific regressions are caught before CI.
40
+ - Model config: centralize embedding/rerank/generation model resolution so `qmd embed`, `status`, `query`, `vsearch`, `pull`, SDK vector search, and `bench` use the same active `.qmd/index.yaml` model hints and environment fallbacks.
41
+ - GPU/status: `qmd status` now uses the same embedding model identity as `qmd embed` when computing pending embeddings, so URI-backed embeddings are not incorrectly reported as pending under the legacy `embeddinggemma` alias.
42
+ - GPU status: `qmd status` now always shows GPU mode/configuration without unsafe native probing, and CPU-fallback warnings point to `QMD_STATUS_DEVICE_PROBE=1 qmd status` for an actual backend probe. The no-GPU warning is emitted once per process instead of once per LLM instance during benchmarks.
43
+ - GPU: add `QMD_FORCE_CPU=1` / `--no-gpu` to bypass CUDA/Vulkan/Metal probing entirely, and route native llama.cpp stdout noise to stderr so JSON output stays parseable during search/query commands.
44
+ - Snippet line numbers: `qmd_query` (MCP), HTTP `/query`, and `qmd query`
45
+ (CLI JSON output and snippet headers) now return absolute source-file
46
+ line numbers instead of chunk-local ones, so the `line` field can be
47
+ passed back to `qmd_get` as `fromLine` without a separate lookup.
48
+ Snippet selection remains scoped to the best matching chunk
49
+ (preserves #149).
50
+ - CLI: `qmd query --full` now emits the full document body in all output
51
+ formats (json, csv, md, xml), restoring the documented behavior of the
52
+ flag. Previously it returned only the best matching chunk (~3.6KB max
53
+ per result). Output payload for `--full` queries is now proportional
54
+ to total document size.
55
+ - macOS Metal: `qmd query --json` now flushes successful JSON output and uses a safe immediate-exit path on Darwin to avoid ggml Metal finalizer aborts; other commands still dispose LLM contexts/models before the llama runtime. #368
56
+ - Embedding: require complete chunk coverage before treating a document as
57
+ embedded, remove partial vectors when chunk/session failures leave a
58
+ document incomplete, and keep `qmd status` pending counts honest after
59
+ interrupted long embed runs. #637 #378
60
+ - Embedding: `qmd embed -c <collection>` now scopes pending-doc selection
61
+ to the requested collection instead of embedding global pending work.
62
+ Scoped `--force` clears only collection-owned vectors, preserves shared
63
+ hashes referenced by sibling collections, and drops `vectors_vec` only
64
+ when the scoped clear empties all vectors.
65
+ - Hybrid search: weight RRF lists by query type so original FTS and original vector evidence get the intended 2x boost, instead of accidentally boosting the first lexical expansion. #591
66
+ - MCP: seed llama.cpp/GGML quiet env vars before launching `qmd mcp` so native logs cannot pollute stdio JSON-RPC framing. #593
67
+ - CLI: remove CommonJS `require()` calls from ESM index path normalization so `qmd --index <path>` no longer crashes with `ERR_AMBIGUOUS_MODULE_SYNTAX` on Node 22+. #634
68
+ - Windows CUDA: serialize llama.cpp embedding/reranking contexts by default to avoid intermittent `ggml-cuda.cu:98` crashes in `qmd query`; set `QMD_EMBED_PARALLELISM` to opt back into parallel contexts if your driver is stable. #519
69
+ - MCP: make `qmd mcp --index <name>` use the selected index for both foreground and daemon HTTP servers instead of falling back to the default store. #343
70
+ - Embedding: respect `QMD_EMBED_MODEL` consistently for vector indexing and vector-backed search, with default-model fallback when unset.
71
+ - Config: use one home-directory resolver for YAML config and the default SQLite cache path, avoiding Windows CLI/MCP split-brain when `HOME` is unset.
72
+ - GPU: respect explicit `QMD_LLAMA_GPU=metal|vulkan|cuda` backend overrides instead of always using auto GPU selection. #529
73
+ - Fix: preserve original filename case in `handelize()`. The previous
74
+ `.toLowerCase()` call made indexed paths unreachable on case-sensitive
75
+ filesystems (Linux). `qmd update` automatically migrates legacy
76
+ lowercase paths without re-embedding.
77
+ - CLI: make `qmd status` skip native `node-llama-cpp` device probing by
78
+ default so status stays safe on machines with broken or unsupported GPU
79
+ drivers. Set `QMD_STATUS_DEVICE_PROBE=1` to opt in.
80
+ - CLI: lazy-load `node-llama-cpp` so lightweight commands such as
81
+ `qmd status` do not import native ML dependencies or trigger llama.cpp
82
+ builds on ARM/no-GPU machines. #491
83
+ - Store: keep content rows referenced by inactive documents during orphan
84
+ cleanup so `qmd update` preserves soft-deleted tombstones for removed
85
+ files. #585
86
+ - Packaging: install AST grammar WASM packages as required dependencies so
87
+ Bun global installs include TypeScript/TSX/JavaScript grammars, and add a
88
+ `smoke:package-grammars` verification command. #595
89
+ - Launcher: add wrapper smoke coverage for scoped package, npm/npx,
90
+ Homebrew/Linuxbrew, Bun global symlink layouts, and `$BUN_INSTALL`
91
+ false-positive runtime selection regressions. #351 #353 #354 #356 #358 #359
92
+
5
93
  ## [2.1.0] - 2026-04-05
6
94
 
7
95
  Code files now chunk at function and class boundaries via tree-sitter,
package/README.md CHANGED
@@ -797,6 +797,9 @@ llm_cache -- Cached LLM responses (query expansion, rerank scores)
797
797
  | Variable | Default | Description |
798
798
  |----------|---------|-------------|
799
799
  | `XDG_CACHE_HOME` | `~/.cache` | Cache directory location |
800
+ | `QMD_LLAMA_GPU` | `auto` | Force llama.cpp GPU backend (`metal`, `vulkan`, `cuda`) or disable GPU with `false` |
801
+ | `QMD_FORCE_CPU` | unset | Set to `1`/`true` to force CPU mode before any CUDA/Vulkan/Metal probing. Equivalent CLI flag: `--no-gpu`. |
802
+ | `QMD_EMBED_PARALLELISM` | automatic | Override embedding/reranking context parallelism (1-8). Windows CUDA defaults to `1` because parallel CUDA contexts can crash with `ggml-cuda.cu:98`; use Vulkan or raise this only if your driver is stable. |
800
803
 
801
804
  ## How It Works
802
805
 
package/bin/qmd CHANGED
@@ -1,32 +1,111 @@
1
- #!/bin/sh
2
- # Resolve symlinks so global installs (npm link / npm install -g) can find the
3
- # actual package directory instead of the global bin directory.
4
- SOURCE="$0"
5
- while [ -L "$SOURCE" ]; do
6
- SOURCE_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
7
- TARGET="$(readlink "$SOURCE")"
8
- case "$TARGET" in
9
- /*) SOURCE="$TARGET" ;;
10
- *) SOURCE="$SOURCE_DIR/$TARGET" ;;
11
- esac
12
- done
13
-
14
- # Detect the runtime used to install this package and use the matching one
15
- # to avoid native module ABI mismatches (e.g., better-sqlite3 compiled for bun vs node)
16
- DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
17
-
18
- # Detect the package manager that installed dependencies by checking lockfiles.
19
- # $BUN_INSTALL is intentionally NOT checked — it only indicates that bun exists
20
- # on the system, not that it was used to install this package (see #361).
21
- #
22
- # package-lock.json takes priority: if it exists, npm installed the native
23
- # modules for Node. The repo ships bun.lock, so without this check, source
24
- # builds that use npm would be incorrectly routed to bun, causing ABI
25
- # mismatches with better-sqlite3 / sqlite-vec (see #381).
26
- if [ -f "$DIR/package-lock.json" ]; then
27
- exec node "$DIR/dist/cli/qmd.js" "$@"
28
- elif [ -f "$DIR/bun.lock" ] || [ -f "$DIR/bun.lockb" ]; then
29
- exec bun "$DIR/dist/cli/qmd.js" "$@"
30
- else
31
- exec node "$DIR/dist/cli/qmd.js" "$@"
32
- fi
1
+ #!/usr/bin/env node
2
+ // 2>/dev/null; if command -v node >/dev/null 2>&1; then exec node "$0" "$@"; else exec bun "$0" "$@"; fi
3
+ // Cross-platform launcher for qmd.
4
+ //
5
+ // Previously this was a POSIX shell script with `#!/bin/sh`, which meant npm
6
+ // on Windows generated shims that tried to route through `/bin/sh` — a path
7
+ // that doesn't exist on Windows, so `qmd` failed immediately after a global
8
+ // install. Rewriting the launcher in Node.js lets npm generate native
9
+ // cmd/ps1/sh shims that invoke `node` directly on every platform.
10
+
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { existsSync, realpathSync } from "node:fs";
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ // Resolve symlinks so global installs (npm link / npm install -g) can find
17
+ // the actual package directory instead of the global bin directory.
18
+ const self = realpathSync(fileURLToPath(import.meta.url));
19
+ const pkgDir = resolve(dirname(self), "..");
20
+ const jsEntry = resolve(pkgDir, "dist/cli/qmd.js");
21
+ const tsEntry = resolve(pkgDir, "src/cli/qmd.ts");
22
+
23
+ // MCP stdio reserves stdout exclusively for JSON-RPC frames. node-llama-cpp
24
+ // / llama.cpp / ggml can write native logs directly to stdout before JS-level
25
+ // log handlers are attached, so seed the native quiet env before Node/Bun imports
26
+ // the CLI and its LLM modules. Preserve explicit user values when provided.
27
+ if (process.argv[2] === "mcp") {
28
+ process.env.LLAMA_LOG_LEVEL = process.env.LLAMA_LOG_LEVEL || "error";
29
+ process.env.GGML_LOG_LEVEL = process.env.GGML_LOG_LEVEL || "error";
30
+ process.env.GGML_BACKEND_SILENT = process.env.GGML_BACKEND_SILENT || "1";
31
+ }
32
+
33
+ function hasBun() {
34
+ try {
35
+ const res = spawnSync("bun", ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
36
+ return res.status === 0;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // In published packages, bin/qmd must run dist/. In a git checkout, however,
43
+ // dist/ is often ignored and can be stale after git reset or branch switches.
44
+ // Prefer source mode only for checkouts so ./bin/qmd reflects the checked-out
45
+ // source without changing packaged/runtime behavior.
46
+ let useSourceMode = false;
47
+ let sourceRunner = null;
48
+ let sourceArgs = [];
49
+
50
+ if (existsSync(resolve(pkgDir, ".git")) && existsSync(tsEntry)) {
51
+ if (existsSync(resolve(pkgDir, "bun.lock")) || existsSync(resolve(pkgDir, "bun.lockb"))) {
52
+ if (hasBun()) {
53
+ useSourceMode = true;
54
+ sourceRunner = "bun";
55
+ sourceArgs = [tsEntry, ...process.argv.slice(2)];
56
+ }
57
+ }
58
+ if (!useSourceMode && existsSync(resolve(pkgDir, "node_modules/tsx/dist/cli.mjs"))) {
59
+ useSourceMode = true;
60
+ sourceRunner = "node";
61
+ sourceArgs = [resolve(pkgDir, "node_modules/tsx/dist/cli.mjs"), tsEntry, ...process.argv.slice(2)];
62
+ }
63
+ }
64
+
65
+ if (!useSourceMode && !existsSync(jsEntry)) {
66
+ console.error(`qmd is not built: missing ${jsEntry}`);
67
+ console.error("Run: bun install && bun run build");
68
+ console.error("Or: npm install && npm run build");
69
+ console.error("After building, run: qmd doctor");
70
+ process.exit(1);
71
+ }
72
+
73
+ // Detect the package manager that installed dependencies by checking lockfiles.
74
+ // $BUN_INSTALL is intentionally NOT checked — it only indicates that bun exists
75
+ // on the system, not that it was used to install this package (see #361).
76
+ //
77
+ // package-lock.json takes priority: if it exists, npm installed the native
78
+ // modules for Node. The repo ships bun.lock, so without this check, source
79
+ // builds that use npm would be incorrectly routed to bun, causing ABI
80
+ // mismatches with better-sqlite3 / sqlite-vec (see #381).
81
+ let runnerName = "node";
82
+ if (existsSync(resolve(pkgDir, "package-lock.json"))) {
83
+ runnerName = "node";
84
+ } else if (existsSync(resolve(pkgDir, "bun.lock")) || existsSync(resolve(pkgDir, "bun.lockb"))) {
85
+ runnerName = "bun";
86
+ } else {
87
+ runnerName = "node";
88
+ }
89
+
90
+ const runner = useSourceMode ? sourceRunner : (runnerName === "node" ? "node" : "bun");
91
+ const args = useSourceMode ? sourceArgs : [jsEntry, ...process.argv.slice(2)];
92
+ const needsShell = (runner === "bun") && process.platform === "win32";
93
+
94
+ const child = spawn(runner, args, {
95
+ stdio: "inherit",
96
+ shell: needsShell,
97
+ });
98
+
99
+ child.on("exit", (code, signal) => {
100
+ if (signal) {
101
+ process.kill(process.pid, signal);
102
+ } else {
103
+ process.exit(code ?? 0);
104
+ }
105
+ });
106
+
107
+ child.on("error", (err) => {
108
+ const name = useSourceMode ? sourceRunner : runnerName;
109
+ console.error(`qmd: failed to launch ${name}: ${err.message}`);
110
+ process.exit(1);
111
+ });
package/dist/ast.d.ts CHANGED
@@ -24,6 +24,7 @@ export type SupportedLanguage = "typescript" | "tsx" | "javascript" | "python" |
24
24
  * Returns null for unsupported or unknown extensions (including .md).
25
25
  */
26
26
  export declare function detectLanguage(filepath: string): SupportedLanguage | null;
27
+ export declare function formatGrammarLoadError(language: SupportedLanguage, err: unknown): string;
27
28
  /**
28
29
  * Parse a source file and return break points at AST node boundaries.
29
30
  *
package/dist/ast.js CHANGED
@@ -47,13 +47,19 @@ export function detectLanguage(filepath) {
47
47
  * Maps language to the npm package and wasm filename for the grammar.
48
48
  */
49
49
  const GRAMMAR_MAP = {
50
- typescript: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
51
- tsx: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-tsx.wasm" },
52
- javascript: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
53
- python: { pkg: "tree-sitter-python", wasm: "tree-sitter-python.wasm" },
54
- go: { pkg: "tree-sitter-go", wasm: "tree-sitter-go.wasm" },
55
- rust: { pkg: "tree-sitter-rust", wasm: "tree-sitter-rust.wasm" },
50
+ typescript: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm", version: "0.23.2" },
51
+ tsx: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-tsx.wasm", version: "0.23.2" },
52
+ javascript: { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm", version: "0.23.2" },
53
+ python: { pkg: "tree-sitter-python", wasm: "tree-sitter-python.wasm", version: "0.23.4" },
54
+ go: { pkg: "tree-sitter-go", wasm: "tree-sitter-go.wasm", version: "0.23.4" },
55
+ rust: { pkg: "tree-sitter-rust", wasm: "tree-sitter-rust.wasm", version: "0.24.0" },
56
56
  };
57
+ export function formatGrammarLoadError(language, err) {
58
+ const grammar = GRAMMAR_MAP[language];
59
+ const detail = err instanceof Error ? err.message : String(err);
60
+ return `${grammar.pkg}/${grammar.wasm} failed to load (${detail}); falling back to regex chunking. ` +
61
+ `Repair a broken global install with: bun add ${grammar.pkg}@${grammar.version}`;
62
+ }
57
63
  // =============================================================================
58
64
  // Per-Language Query Definitions
59
65
  // =============================================================================
@@ -152,6 +158,8 @@ let QueryClass = null;
152
158
  let initPromise = null;
153
159
  /** Languages that have already failed to load — warn only once per process. */
154
160
  const failedLanguages = new Set();
161
+ /** Last grammar load error by language, for status output. */
162
+ const grammarLoadErrors = new Map();
155
163
  /** Cached grammar load promises. */
156
164
  const grammarCache = new Map();
157
165
  /** Cached compiled queries per language. */
@@ -200,7 +208,9 @@ async function loadGrammar(language) {
200
208
  catch (err) {
201
209
  failedLanguages.add(language);
202
210
  grammarCache.delete(wasmKey);
203
- console.warn(`[qmd] Failed to load tree-sitter grammar for ${language}: ${err}`);
211
+ const message = formatGrammarLoadError(language, err);
212
+ grammarLoadErrors.set(language, message);
213
+ console.warn(`[qmd] AST grammar unavailable for ${language}: ${message}`);
204
214
  return null;
205
215
  }
206
216
  }
@@ -299,7 +309,7 @@ export async function getASTStatus() {
299
309
  languages.push({ language: lang, available: true });
300
310
  }
301
311
  else {
302
- languages.push({ language: lang, available: false, error: "grammar failed to load" });
312
+ languages.push({ language: lang, available: false, error: grammarLoadErrors.get(lang) ?? "grammar failed to load" });
303
313
  }
304
314
  }
305
315
  catch (err) {
@@ -18,4 +18,6 @@ export declare function runBenchmark(fixturePath: string, options?: {
18
18
  json?: boolean;
19
19
  collection?: string;
20
20
  backends?: string[];
21
+ dbPath?: string;
22
+ configPath?: string;
21
23
  }): Promise<BenchmarkResult>;
@@ -17,32 +17,113 @@ import { readFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { createStore, getDefaultDbPath, } from "../index.js";
19
19
  import { scoreResults } from "./score.js";
20
+ function parseStructuredQuery(query) {
21
+ const lines = query.split("\n").map((line, idx) => ({
22
+ trimmed: line.trim(),
23
+ number: idx + 1,
24
+ })).filter(line => line.trimmed.length > 0);
25
+ if (lines.length === 0)
26
+ return undefined;
27
+ const prefixRe = /^(lex|vec|hyde):\s*/i;
28
+ const intentRe = /^intent:\s*/i;
29
+ const searches = [];
30
+ let intent;
31
+ for (const line of lines) {
32
+ if (intentRe.test(line.trimmed)) {
33
+ if (intent !== undefined) {
34
+ throw new Error(`Line ${line.number}: only one intent: line is allowed per benchmark query.`);
35
+ }
36
+ intent = line.trimmed.replace(intentRe, "").trim();
37
+ if (!intent) {
38
+ throw new Error(`Line ${line.number}: intent: must include text.`);
39
+ }
40
+ continue;
41
+ }
42
+ const match = line.trimmed.match(prefixRe);
43
+ if (match) {
44
+ const type = match[1].toLowerCase();
45
+ const text = line.trimmed.slice(match[0].length).trim();
46
+ if (!text) {
47
+ throw new Error(`Line ${line.number} (${type}:) must include text.`);
48
+ }
49
+ searches.push({ type, query: text, line: line.number });
50
+ continue;
51
+ }
52
+ if (lines.length === 1) {
53
+ return undefined;
54
+ }
55
+ throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix.`);
56
+ }
57
+ if (intent && searches.length === 0) {
58
+ throw new Error("intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.");
59
+ }
60
+ return searches.length > 0 ? { searches, intent } : undefined;
61
+ }
62
+ function uniqueFiles(files, limit) {
63
+ const seen = new Set();
64
+ const out = [];
65
+ for (const file of files) {
66
+ if (seen.has(file))
67
+ continue;
68
+ seen.add(file);
69
+ out.push(file);
70
+ if (out.length >= limit)
71
+ break;
72
+ }
73
+ return out;
74
+ }
20
75
  const BACKENDS = [
21
76
  {
22
77
  name: "bm25",
23
78
  run: async (store, query, limit, collection) => {
24
- const results = await store.searchLex(query, { limit, collection });
79
+ const structured = parseStructuredQuery(query.query);
80
+ const lexQueries = structured?.searches.filter(q => q.type === "lex");
81
+ if (structured) {
82
+ const files = [];
83
+ for (const lex of lexQueries ?? []) {
84
+ const results = await store.searchLex(lex.query, { limit, collection });
85
+ files.push(...results.map((r) => r.filepath));
86
+ }
87
+ return uniqueFiles(files, limit);
88
+ }
89
+ const results = await store.searchLex(query.query, { limit, collection });
25
90
  return results.map((r) => r.filepath);
26
91
  },
27
92
  },
28
93
  {
29
94
  name: "vector",
30
95
  run: async (store, query, limit, collection) => {
31
- const results = await store.searchVector(query, { limit, collection });
96
+ const structured = parseStructuredQuery(query.query);
97
+ const vectorQueries = structured?.searches.filter(q => q.type === "vec" || q.type === "hyde");
98
+ if (structured) {
99
+ const files = [];
100
+ for (const vectorQuery of vectorQueries ?? []) {
101
+ const results = await store.searchVector(vectorQuery.query, { limit, collection });
102
+ files.push(...results.map((r) => r.filepath));
103
+ }
104
+ return uniqueFiles(files, limit);
105
+ }
106
+ const results = await store.searchVector(query.query, { limit, collection });
32
107
  return results.map((r) => r.filepath);
33
108
  },
34
109
  },
35
110
  {
36
111
  name: "hybrid",
37
112
  run: async (store, query, limit, collection) => {
38
- const results = await store.search({ query, limit, collection, rerank: false });
113
+ const structured = parseStructuredQuery(query.query);
114
+ const results = structured
115
+ ? await store.search({ queries: structured.searches, intent: structured.intent, limit, collection, rerank: false })
116
+ : await store.search({ query: query.query, limit, collection, rerank: false });
39
117
  return results.map((r) => r.file);
40
118
  },
41
119
  },
42
120
  {
43
121
  name: "full",
44
122
  run: async (store, query, limit, collection) => {
45
- const results = await store.search({ query, limit, collection, rerank: true });
123
+ const structured = parseStructuredQuery(query.query);
124
+ const results = structured
125
+ ? await store.search({ queries: structured.searches, intent: structured.intent, limit, collection, rerank: true })
126
+ : await store.search({ query: query.query, limit, collection, rerank: true });
46
127
  return results.map((r) => r.file);
47
128
  },
48
129
  },
@@ -52,19 +133,24 @@ async function runQuery(store, backend, query, collection) {
52
133
  const start = Date.now();
53
134
  let resultFiles;
54
135
  try {
55
- resultFiles = await backend.run(store, query.query, limit, collection);
136
+ resultFiles = await backend.run(store, query, limit, collection);
56
137
  }
57
- catch (err) {
138
+ catch {
58
139
  // Backend may not be available (e.g., no embeddings for vector search)
59
140
  return {
60
141
  precision_at_k: 0,
61
142
  recall: 0,
143
+ recall_at_1: 0,
144
+ recall_at_3: 0,
145
+ recall_at_5: 0,
62
146
  mrr: 0,
63
147
  f1: 0,
64
148
  hits_at_k: 0,
65
149
  total_expected: query.expected_files.length,
66
150
  latency_ms: Date.now() - start,
67
151
  top_files: [],
152
+ matched_files: [],
153
+ unmatched_expected_files: query.expected_files,
68
154
  };
69
155
  }
70
156
  const latency_ms = Date.now() - start;
@@ -80,11 +166,11 @@ function formatTable(results) {
80
166
  const lines = [];
81
167
  const pad = (s, n) => s.slice(0, n).padEnd(n);
82
168
  const num = (n) => n.toFixed(2).padStart(5);
83
- lines.push(`${pad("Query", 25)} ${pad("Backend", 8)} ${pad("P@k", 6)} ${pad("Recall", 7)} ${pad("MRR", 6)} ${pad("F1", 6)} ${pad("ms", 8)}`);
84
- lines.push("-".repeat(70));
169
+ lines.push(`${pad("Query", 25)} ${pad("Backend", 8)} ${pad("P@k", 6)} ${pad("R@1", 6)} ${pad("R@3", 6)} ${pad("R@5", 6)} ${pad("MRR", 6)} ${pad("F1", 6)} ${pad("ms", 8)}`);
170
+ lines.push("-".repeat(88));
85
171
  for (const r of results) {
86
172
  for (const [backend, br] of Object.entries(r.backends)) {
87
- lines.push(`${pad(r.id, 25)} ${pad(backend, 8)} ${num(br.precision_at_k)} ${num(br.recall)} ${num(br.mrr)} ${num(br.f1)} ${String(Math.round(br.latency_ms)).padStart(7)}ms`);
173
+ lines.push(`${pad(r.id, 25)} ${pad(backend, 8)} ${num(br.precision_at_k)} ${num(br.recall_at_1)} ${num(br.recall_at_3)} ${num(br.recall_at_5)} ${num(br.mrr)} ${num(br.f1)} ${String(Math.round(br.latency_ms)).padStart(7)}ms`);
88
174
  }
89
175
  lines.push("");
90
176
  }
@@ -99,14 +185,17 @@ function computeSummary(results) {
99
185
  backendNames.add(name);
100
186
  }
101
187
  }
102
- for (const name of backendNames) {
103
- let totalP = 0, totalR = 0, totalMrr = 0, totalF1 = 0, totalLat = 0, count = 0;
188
+ for (const name of Array.from(backendNames)) {
189
+ let totalP = 0, totalR = 0, totalR1 = 0, totalR3 = 0, totalR5 = 0, totalMrr = 0, totalF1 = 0, totalLat = 0, count = 0;
104
190
  for (const r of results) {
105
191
  const br = r.backends[name];
106
192
  if (!br)
107
193
  continue;
108
194
  totalP += br.precision_at_k;
109
195
  totalR += br.recall;
196
+ totalR1 += br.recall_at_1;
197
+ totalR3 += br.recall_at_3;
198
+ totalR5 += br.recall_at_5;
110
199
  totalMrr += br.mrr;
111
200
  totalF1 += br.f1;
112
201
  totalLat += br.latency_ms;
@@ -116,6 +205,9 @@ function computeSummary(results) {
116
205
  summary[name] = {
117
206
  avg_precision: totalP / count,
118
207
  avg_recall: totalR / count,
208
+ avg_recall_at_1: totalR1 / count,
209
+ avg_recall_at_3: totalR3 / count,
210
+ avg_recall_at_5: totalR5 / count,
119
211
  avg_mrr: totalMrr / count,
120
212
  avg_f1: totalF1 / count,
121
213
  avg_latency_ms: totalLat / count,
@@ -132,7 +224,10 @@ export async function runBenchmark(fixturePath, options = {}) {
132
224
  throw new Error("Invalid fixture: missing 'queries' array");
133
225
  }
134
226
  // Open store
135
- const store = await createStore({ dbPath: getDefaultDbPath() });
227
+ const store = await createStore({
228
+ dbPath: options.dbPath ?? getDefaultDbPath(),
229
+ ...(options.configPath ? { configPath: options.configPath } : {}),
230
+ });
136
231
  // Filter backends if requested
137
232
  const activeBackends = options.backends
138
233
  ? BACKENDS.filter(b => options.backends.includes(b.name))
@@ -178,7 +273,7 @@ export async function runBenchmark(fixturePath, options = {}) {
178
273
  const pad = (s, n) => s.slice(0, n).padEnd(n);
179
274
  const num = (n) => n.toFixed(3).padStart(6);
180
275
  for (const [name, s] of Object.entries(summary)) {
181
- console.log(` ${pad(name, 8)} P@k=${num(s.avg_precision)} Recall=${num(s.avg_recall)} MRR=${num(s.avg_mrr)} F1=${num(s.avg_f1)} Avg=${Math.round(s.avg_latency_ms)}ms`);
276
+ console.log(` ${pad(name, 8)} P@k=${num(s.avg_precision)} R@1=${num(s.avg_recall_at_1)} R@3=${num(s.avg_recall_at_3)} R@5=${num(s.avg_recall_at_5)} MRR=${num(s.avg_mrr)} F1=${num(s.avg_f1)} Avg=${Math.round(s.avg_latency_ms)}ms`);
182
277
  }
183
278
  }
184
279
  return benchResult;
@@ -14,13 +14,20 @@ export declare function normalizePath(p: string): string;
14
14
  * Handles different path formats by comparing normalized suffixes.
15
15
  */
16
16
  export declare function pathsMatch(result: string, expected: string): boolean;
17
- /**
18
- * Score a set of search results against expected files.
19
- */
20
- export declare function scoreResults(resultFiles: string[], expectedFiles: string[], topK: number): {
17
+ type ScoreMetrics = {
21
18
  precision_at_k: number;
22
19
  recall: number;
20
+ recall_at_1: number;
21
+ recall_at_3: number;
22
+ recall_at_5: number;
23
23
  mrr: number;
24
24
  f1: number;
25
25
  hits_at_k: number;
26
+ matched_files: string[];
27
+ unmatched_expected_files: string[];
26
28
  };
29
+ /**
30
+ * Score a set of search results against expected files.
31
+ */
32
+ export declare function scoreResults(resultFiles: string[], expectedFiles: string[], topK: number): ScoreMetrics;
33
+ export {};
@@ -10,7 +10,7 @@
10
10
  */
11
11
  export function normalizePath(p) {
12
12
  if (p.startsWith("qmd://")) {
13
- // qmd://collection/path/to/filepath/to/file
13
+ // qmd://collection/docs/readme.mddocs/readme.md
14
14
  const withoutScheme = p.slice("qmd://".length);
15
15
  const slashIdx = withoutScheme.indexOf("/");
16
16
  p = slashIdx >= 0 ? withoutScheme.slice(slashIdx + 1) : withoutScheme;
@@ -30,23 +30,30 @@ export function pathsMatch(result, expected) {
30
30
  return true;
31
31
  return false;
32
32
  }
33
+ function hitsWithin(resultFiles, expectedFiles, k) {
34
+ const topKResults = resultFiles.slice(0, k);
35
+ let hits = 0;
36
+ for (const expected of expectedFiles) {
37
+ if (topKResults.some(r => pathsMatch(r, expected))) {
38
+ hits++;
39
+ }
40
+ }
41
+ return hits;
42
+ }
33
43
  /**
34
44
  * Score a set of search results against expected files.
35
45
  */
36
46
  export function scoreResults(resultFiles, expectedFiles, topK) {
37
47
  // Count hits in top-k
38
- const topKResults = resultFiles.slice(0, topK);
39
- let hitsAtK = 0;
40
- for (const expected of expectedFiles) {
41
- if (topKResults.some(r => pathsMatch(r, expected))) {
42
- hitsAtK++;
43
- }
44
- }
45
- // Count total hits anywhere
46
- let totalHits = 0;
48
+ const hitsAtK = hitsWithin(resultFiles, expectedFiles, topK);
49
+ const matchedFiles = [];
50
+ const unmatchedExpectedFiles = [];
47
51
  for (const expected of expectedFiles) {
48
52
  if (resultFiles.some(r => pathsMatch(r, expected))) {
49
- totalHits++;
53
+ matchedFiles.push(expected);
54
+ }
55
+ else {
56
+ unmatchedExpectedFiles.push(expected);
50
57
  }
51
58
  }
52
59
  // MRR: reciprocal rank of first relevant result
@@ -59,9 +66,23 @@ export function scoreResults(resultFiles, expectedFiles, topK) {
59
66
  }
60
67
  const denominator = Math.min(topK, expectedFiles.length);
61
68
  const precision_at_k = denominator > 0 ? hitsAtK / denominator : 0;
62
- const recall = expectedFiles.length > 0 ? totalHits / expectedFiles.length : 0;
69
+ const recall = expectedFiles.length > 0 ? matchedFiles.length / expectedFiles.length : 0;
70
+ const recall_at_1 = expectedFiles.length > 0 ? hitsWithin(resultFiles, expectedFiles, 1) / expectedFiles.length : 0;
71
+ const recall_at_3 = expectedFiles.length > 0 ? hitsWithin(resultFiles, expectedFiles, 3) / expectedFiles.length : 0;
72
+ const recall_at_5 = expectedFiles.length > 0 ? hitsWithin(resultFiles, expectedFiles, 5) / expectedFiles.length : 0;
63
73
  const f1 = precision_at_k + recall > 0
64
74
  ? 2 * (precision_at_k * recall) / (precision_at_k + recall)
65
75
  : 0;
66
- return { precision_at_k, recall, mrr, f1, hits_at_k: hitsAtK };
76
+ return {
77
+ precision_at_k,
78
+ recall,
79
+ recall_at_1,
80
+ recall_at_3,
81
+ recall_at_5,
82
+ mrr,
83
+ f1,
84
+ hits_at_k: hitsAtK,
85
+ matched_files: matchedFiles,
86
+ unmatched_expected_files: unmatchedExpectedFiles,
87
+ };
67
88
  }