@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 CHANGED
@@ -2,6 +2,96 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.2] - 2026-03-07
6
+
7
+ 13 community PRs merged. GPU initialization replaced with node-llama-cpp's
8
+ built-in `autoAttempt` — deleting ~220 lines of manual fallback code and
9
+ fixing GPU issues reported across 10+ PRs in one shot. Reranking is faster
10
+ through chunk deduplication and a parallelism cap that prevents VRAM
11
+ exhaustion.
12
+
13
+ ### Changes
14
+
15
+ - **GPU init**: use node-llama-cpp's `build: "autoAttempt"` instead of manual
16
+ GPU backend detection. Automatically tries Metal/CUDA/Vulkan and falls back
17
+ gracefully. #310 (thanks @giladgd — the node-llama-cpp author)
18
+ - **Query `--explain`**: `qmd query --explain` exposes retrieval score traces
19
+ — backend scores, per-list RRF contributions, top-rank bonus, reranker
20
+ score, and final blended score. Works in JSON and CLI output. #242
21
+ (thanks @vyalamar)
22
+ - **Collection ignore patterns**: `ignore: ["Sessions/**", "*.tmp"]` in
23
+ collection config to exclude files from indexing. #304 (thanks @sebkouba)
24
+ - **Multilingual embeddings**: `QMD_EMBED_MODEL` env var lets you swap in
25
+ models like Qwen3-Embedding for non-English collections. #273 (thanks
26
+ @daocoding)
27
+ - **Configurable expansion context**: `QMD_EXPAND_CONTEXT_SIZE` env var
28
+ (default 2048) — previously used the model's full 40960-token window,
29
+ wasting VRAM. #313 (thanks @0xble)
30
+ - **`candidateLimit` exposed**: `-C` / `--candidate-limit` flag and MCP
31
+ parameter to tune how many candidates reach the reranker. #255 (thanks
32
+ @pandysp)
33
+ - **MCP multi-session**: HTTP transport now supports multiple concurrent
34
+ client sessions, each with its own server instance. #286 (thanks @joelev)
35
+
36
+ ### Fixes
37
+
38
+ - **Reranking performance**: cap parallel rerank contexts at 4 to prevent
39
+ VRAM exhaustion on high-core machines. Deduplicate identical chunk texts
40
+ before reranking — same content from different files now shares a single
41
+ reranker call. Cache scores by content hash instead of file path.
42
+ - Deactivate stale docs when all files are removed from a collection and
43
+ `qmd update` is run. #312 (thanks @0xble)
44
+ - Handle emoji-only filenames (`🐘.md` → `1f418.md`) instead of crashing.
45
+ #308 (thanks @debugerman)
46
+ - Skip unreadable files during indexing (e.g. iCloud-evicted files returning
47
+ EAGAIN) instead of crashing. #253 (thanks @jimmynail)
48
+ - Suppress progress bar escape sequences when stderr is not a TTY. #230
49
+ (thanks @dgilperez)
50
+ - Emit format-appropriate empty output (`[]` for JSON, CSV header for CSV,
51
+ etc.) instead of plain text "No results." #228 (thanks @amsminn)
52
+ - Correct Windows sqlite-vec package name (`sqlite-vec-windows-x64`) and add
53
+ `sqlite-vec-linux-arm64`. #225 (thanks @ilepn)
54
+ - Fix claude plugin setup CLI commands in README. #311 (thanks @gi11es)
55
+
56
+ ## [1.1.1] - 2026-03-06
57
+
58
+ ### Fixes
59
+
60
+ - Reranker: truncate documents exceeding the 2048-token context window
61
+ instead of silently producing garbage scores. Long chunks (e.g. from
62
+ PDF ingestion) now get a fair ranking.
63
+ - Nix: add python3 and cctools to build dependencies. #214 (thanks
64
+ @pcasaretto)
65
+
66
+ ## [1.1.0] - 2026-02-20
67
+
68
+ QMD now speaks in **query documents** — structured multi-line queries where every line is typed (`lex:`, `vec:`, `hyde:`), combining keyword precision with semantic recall. A single plain query still works exactly as before (it's treated as an implicit `expand:` and auto-expanded by the LLM). Lex now supports quoted phrases and negation (`"C++ performance" -sports -athlete`), making intent-aware disambiguation practical. The formal query grammar is documented in `docs/SYNTAX.md`.
69
+
70
+ The npm package now uses the standard `#!/usr/bin/env node` bin convention, replacing the custom bash wrapper. This fixes native module ABI mismatches when installed via bun and works on any platform with node >= 22 on PATH.
71
+
72
+ ### Changes
73
+
74
+ - **Query document format**: multi-line queries with typed sub-queries (`lex:`, `vec:`, `hyde:`). Plain queries remain the default (`expand:` implicit, but not written inside the document). First sub-query gets 2× fusion weight — put your strongest signal first. Formal grammar in `docs/SYNTAX.md`.
75
+ - **Lex syntax**: full BM25 operator support. `"exact phrase"` for verbatim matching; `-term` and `-"phrase"` for exclusions. Essential for disambiguation when a term is overloaded across domains (e.g. `performance -sports -athlete`).
76
+ - **`expand:` shortcut**: send a single plain query (or start the document with `expand:` on its only line) to auto-expand via the local LLM. Query documents themselves are limited to `lex`, `vec`, and `hyde` lines.
77
+ - **MCP `query` tool** (renamed from `structured_search`): rewrote the tool description to fully teach AI agents the query document format, lex syntax, and combination strategy. Includes worked examples with intent-aware lex.
78
+ - **HTTP `/query` endpoint** (renamed from `/search`; `/search` kept as silent alias).
79
+ - **`collections` array filter**: filter by multiple collections in a single query (`collections: ["notes", "brain"]`). Removed the single `collection` string param — array only.
80
+ - **Collection `include`/`exclude`**: `includeByDefault: false` hides a collection from all queries unless explicitly named via `collections`. CLI: `qmd collection exclude <name>` / `qmd collection include <name>`.
81
+ - **Collection `update-cmd`**: attach a shell command that runs before every `qmd update` (e.g. `git stash && git pull --rebase --ff-only && git stash pop`). CLI: `qmd collection update-cmd <name> '<cmd>'`.
82
+ - **`qmd status` tips**: shows actionable tips when collections lack context descriptions or update commands.
83
+ - **`qmd collection` subcommands**: `show`, `update-cmd`, `include`, `exclude`. Bare `qmd collection` now prints help.
84
+ - **Packaging**: replaced custom bash wrapper with standard `#!/usr/bin/env node` shebang on `dist/qmd.js`. Fixes native module ABI mismatches when installed via bun, and works on any platform where node >= 22 is on PATH.
85
+ - **Removed MCP tools** `search`, `vector_search`, `deep_search` — all superseded by `query`.
86
+ - **Removed** `qmd context check` command.
87
+ - **CLI timing**: each LLM step (expand, embed, rerank) prints elapsed time inline (`Expanding query... (4.2s)`).
88
+
89
+ ### Fixes
90
+
91
+ - `qmd collection list` shows `[excluded]` tag for collections with `includeByDefault: false`.
92
+ - Default searches now respect `includeByDefault` — excluded collections are skipped unless explicitly named.
93
+ - Fix main module detection when installed globally via npm/bun (symlink resolution).
94
+
5
95
  ## [1.0.7] - 2026-02-18
6
96
 
7
97
  ### Changes
@@ -333,4 +423,3 @@ notes, journals, and meeting transcripts.
333
423
  [Unreleased]: https://github.com/tobi/qmd/compare/v1.0.0...HEAD
334
424
  [1.0.0]: https://github.com/tobi/qmd/releases/tag/v1.0.0
335
425
  [0.9.0]: https://github.com/tobi/qmd/compare/v0.8.0...v0.9.0
336
-
package/README.md CHANGED
@@ -97,8 +97,8 @@ Although the tool works perfectly fine when you just tell your agent to use it o
97
97
  **Claude Code** — Install the plugin (recommended):
98
98
 
99
99
  ```bash
100
- claude marketplace add tobi/qmd
101
- claude plugin add qmd@qmd
100
+ claude plugin marketplace add tobi/qmd
101
+ claude plugin install qmd@qmd
102
102
  ```
103
103
 
104
104
  Or configure MCP manually in `~/.claude/settings.json`:
@@ -252,12 +252,34 @@ QMD uses three local GGUF models (auto-downloaded on first use):
252
252
 
253
253
  | Model | Purpose | Size |
254
254
  |-------|---------|------|
255
- | `embeddinggemma-300M-Q8_0` | Vector embeddings | ~300MB |
255
+ | `embeddinggemma-300M-Q8_0` | Vector embeddings (default) | ~300MB |
256
256
  | `qwen3-reranker-0.6b-q8_0` | Re-ranking | ~640MB |
257
257
  | `qmd-query-expansion-1.7B-q4_k_m` | Query expansion (fine-tuned) | ~1.1GB |
258
258
 
259
259
  Models are downloaded from HuggingFace and cached in `~/.cache/qmd/models/`.
260
260
 
261
+ ### Custom Embedding Model
262
+
263
+ Override the default embedding model via the `QMD_EMBED_MODEL` environment variable.
264
+ This is useful for multilingual corpora (e.g. Chinese, Japanese, Korean) where
265
+ `embeddinggemma-300M` has limited coverage.
266
+
267
+ ```sh
268
+ # Use Qwen3-Embedding-0.6B for better multilingual (CJK) support
269
+ export QMD_EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/qwen3-embedding-0.6b-q8_0.gguf"
270
+
271
+ # After changing the model, re-embed all collections:
272
+ qmd embed -f
273
+ ```
274
+
275
+ Supported model families:
276
+ - **embeddinggemma** (default) — English-optimized, small footprint
277
+ - **Qwen3-Embedding** — Multilingual (119 languages including CJK), MTEB top-ranked
278
+
279
+ > **Note:** When switching embedding models, you must re-index with `qmd embed -f`
280
+ > since vectors are not cross-compatible between models. The prompt format is
281
+ > automatically adjusted for each model family.
282
+
261
283
  ## Installation
262
284
 
263
285
  ```sh
@@ -366,6 +388,7 @@ qmd query "user authentication"
366
388
  --min-score <num> # Minimum score threshold (default: 0)
367
389
  --full # Show full document content
368
390
  --line-numbers # Add line numbers to output
391
+ --explain # Include retrieval score traces (query, JSON/CLI output)
369
392
  --index <name> # Use named index
370
393
 
371
394
  # Output formats (for search and multi-get)
@@ -428,6 +451,9 @@ qmd search --md --full "error handling"
428
451
  # JSON output for scripting
429
452
  qmd query --json "quarterly reports"
430
453
 
454
+ # Inspect how each result was scored (RRF + rerank blend)
455
+ qmd query --json --explain "quarterly reports"
456
+
431
457
  # Use separate index for different knowledge base
432
458
  qmd --index work search "quarterly reports"
433
459
  ```
@@ -16,8 +16,10 @@ export type ContextMap = Record<string, string>;
16
16
  export interface Collection {
17
17
  path: string;
18
18
  pattern: string;
19
+ ignore?: string[];
19
20
  context?: ContextMap;
20
21
  update?: string;
22
+ includeByDefault?: boolean;
21
23
  }
22
24
  /**
23
25
  * The complete configuration file structure
@@ -55,6 +57,21 @@ export declare function getCollection(name: string): NamedCollection | null;
55
57
  * List all collections
56
58
  */
57
59
  export declare function listCollections(): NamedCollection[];
60
+ /**
61
+ * Get collections that are included by default in queries
62
+ */
63
+ export declare function getDefaultCollections(): NamedCollection[];
64
+ /**
65
+ * Get collection names that are included by default
66
+ */
67
+ export declare function getDefaultCollectionNames(): string[];
68
+ /**
69
+ * Update a collection's settings
70
+ */
71
+ export declare function updateCollectionSettings(name: string, settings: {
72
+ update?: string | null;
73
+ includeByDefault?: boolean;
74
+ }): boolean;
58
75
  /**
59
76
  * Add or update a collection
60
77
  */
@@ -117,6 +117,46 @@ export function listCollections() {
117
117
  ...collection,
118
118
  }));
119
119
  }
120
+ /**
121
+ * Get collections that are included by default in queries
122
+ */
123
+ export function getDefaultCollections() {
124
+ return listCollections().filter(c => c.includeByDefault !== false);
125
+ }
126
+ /**
127
+ * Get collection names that are included by default
128
+ */
129
+ export function getDefaultCollectionNames() {
130
+ return getDefaultCollections().map(c => c.name);
131
+ }
132
+ /**
133
+ * Update a collection's settings
134
+ */
135
+ export function updateCollectionSettings(name, settings) {
136
+ const config = loadConfig();
137
+ const collection = config.collections[name];
138
+ if (!collection)
139
+ return false;
140
+ if (settings.update !== undefined) {
141
+ if (settings.update === null) {
142
+ delete collection.update;
143
+ }
144
+ else {
145
+ collection.update = settings.update;
146
+ }
147
+ }
148
+ if (settings.includeByDefault !== undefined) {
149
+ if (settings.includeByDefault === true) {
150
+ // true is default, remove the field
151
+ delete collection.includeByDefault;
152
+ }
153
+ else {
154
+ collection.includeByDefault = settings.includeByDefault;
155
+ }
156
+ }
157
+ saveConfig(config);
158
+ return true;
159
+ }
120
160
  /**
121
161
  * Add or update a collection
122
162
  */
package/dist/llm.d.ts CHANGED
@@ -4,16 +4,23 @@
4
4
  * Provides embeddings, text generation, and reranking using local GGUF models.
5
5
  */
6
6
  import { type Token as LlamaToken } from "node-llama-cpp";
7
+ /**
8
+ * Detect if a model URI uses the Qwen3-Embedding format.
9
+ * Qwen3-Embedding uses a different prompting style than nomic/embeddinggemma.
10
+ */
11
+ export declare function isQwen3EmbeddingModel(modelUri: string): boolean;
7
12
  /**
8
13
  * Format a query for embedding.
9
- * Uses nomic-style task prefix format for embeddinggemma.
14
+ * Uses nomic-style task prefix format for embeddinggemma (default).
15
+ * Uses Qwen3-Embedding instruct format when a Qwen embedding model is active.
10
16
  */
11
- export declare function formatQueryForEmbedding(query: string): string;
17
+ export declare function formatQueryForEmbedding(query: string, modelUri?: string): string;
12
18
  /**
13
19
  * Format a document for embedding.
14
- * Uses nomic-style format with title and text fields.
20
+ * Uses nomic-style format with title and text fields (default).
21
+ * Qwen3-Embedding encodes documents as raw text without special prefixes.
15
22
  */
16
- export declare function formatDocForEmbedding(text: string, title?: string): string;
23
+ export declare function formatDocForEmbedding(text: string, title?: string, modelUri?: string): string;
17
24
  /**
18
25
  * Token with log probability
19
26
  */
@@ -130,7 +137,7 @@ export type RerankDocument = {
130
137
  };
131
138
  export declare const LFM2_GENERATE_MODEL = "hf:LiquidAI/LFM2-1.2B-GGUF/LFM2-1.2B-Q4_K_M.gguf";
132
139
  export declare const LFM2_INSTRUCT_MODEL = "hf:LiquidAI/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf";
133
- export declare const DEFAULT_EMBED_MODEL_URI = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
140
+ export declare const DEFAULT_EMBED_MODEL_URI: string;
134
141
  export declare const DEFAULT_RERANK_MODEL_URI = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
135
142
  export declare const DEFAULT_GENERATE_MODEL_URI = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
136
143
  export declare const DEFAULT_MODEL_CACHE_DIR: string;
@@ -183,6 +190,11 @@ export type LlamaCppConfig = {
183
190
  generateModel?: string;
184
191
  rerankModel?: string;
185
192
  modelCacheDir?: string;
193
+ /**
194
+ * Context size used for query expansion generation contexts.
195
+ * Default: 2048. Can also be set via QMD_EXPAND_CONTEXT_SIZE.
196
+ */
197
+ expandContextSize?: number;
186
198
  /**
187
199
  * Inactivity timeout in ms before unloading contexts (default: 2 minutes, 0 to disable).
188
200
  *
@@ -210,6 +222,7 @@ export declare class LlamaCpp implements LLM {
210
222
  private generateModelUri;
211
223
  private rerankModelUri;
212
224
  private modelCacheDir;
225
+ private expandContextSize;
213
226
  private embedModelLoadPromise;
214
227
  private generateModelLoadPromise;
215
228
  private rerankModelLoadPromise;
@@ -318,6 +331,8 @@ export declare class LlamaCpp implements LLM {
318
331
  context?: string;
319
332
  includeLexical?: boolean;
320
333
  }): Promise<Queryable[]>;
334
+ private static readonly RERANK_TEMPLATE_OVERHEAD;
335
+ private static readonly RERANK_TARGET_DOCS_PER_CONTEXT;
321
336
  rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise<RerankResult>;
322
337
  /**
323
338
  * Get device/GPU info for status display.
package/dist/llm.js CHANGED
@@ -3,25 +3,43 @@
3
3
  *
4
4
  * Provides embeddings, text generation, and reranking using local GGUF models.
5
5
  */
6
- import { getLlama, getLlamaGpuTypes, resolveModelFile, LlamaChatSession, LlamaLogLevel, } from "node-llama-cpp";
6
+ import { getLlama, resolveModelFile, LlamaChatSession, LlamaLogLevel, } from "node-llama-cpp";
7
7
  import { homedir } from "os";
8
8
  import { join } from "path";
9
9
  import { existsSync, mkdirSync, statSync, unlinkSync, readdirSync, readFileSync, writeFileSync } from "fs";
10
10
  // =============================================================================
11
11
  // Embedding Formatting Functions
12
12
  // =============================================================================
13
+ /**
14
+ * Detect if a model URI uses the Qwen3-Embedding format.
15
+ * Qwen3-Embedding uses a different prompting style than nomic/embeddinggemma.
16
+ */
17
+ export function isQwen3EmbeddingModel(modelUri) {
18
+ return /qwen.*embed/i.test(modelUri) || /embed.*qwen/i.test(modelUri);
19
+ }
13
20
  /**
14
21
  * Format a query for embedding.
15
- * Uses nomic-style task prefix format for embeddinggemma.
22
+ * Uses nomic-style task prefix format for embeddinggemma (default).
23
+ * Uses Qwen3-Embedding instruct format when a Qwen embedding model is active.
16
24
  */
17
- export function formatQueryForEmbedding(query) {
25
+ export function formatQueryForEmbedding(query, modelUri) {
26
+ const uri = modelUri ?? process.env.QMD_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
27
+ if (isQwen3EmbeddingModel(uri)) {
28
+ return `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`;
29
+ }
18
30
  return `task: search result | query: ${query}`;
19
31
  }
20
32
  /**
21
33
  * Format a document for embedding.
22
- * Uses nomic-style format with title and text fields.
34
+ * Uses nomic-style format with title and text fields (default).
35
+ * Qwen3-Embedding encodes documents as raw text without special prefixes.
23
36
  */
24
- export function formatDocForEmbedding(text, title) {
37
+ export function formatDocForEmbedding(text, title, modelUri) {
38
+ const uri = modelUri ?? process.env.QMD_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
39
+ if (isQwen3EmbeddingModel(uri)) {
40
+ // Qwen3-Embedding: documents are raw text, no task prefix
41
+ return title ? `${title}\n${text}` : text;
42
+ }
25
43
  return `title: ${title || "none"} | text: ${text}`;
26
44
  }
27
45
  // =============================================================================
@@ -29,7 +47,8 @@ export function formatDocForEmbedding(text, title) {
29
47
  // =============================================================================
30
48
  // HuggingFace model URIs for node-llama-cpp
31
49
  // Format: hf:<user>/<repo>/<file>
32
- const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
50
+ // Override via QMD_EMBED_MODEL env var (e.g. hf:Qwen/Qwen3-Embedding-0.6B-GGUF/qwen3-embedding-0.6b-q8_0.gguf)
51
+ const DEFAULT_EMBED_MODEL = process.env.QMD_EMBED_MODEL ?? "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
33
52
  const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
34
53
  // const DEFAULT_GENERATE_MODEL = "hf:ggml-org/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf";
35
54
  const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
@@ -126,6 +145,24 @@ export async function pullModels(models, options = {}) {
126
145
  */
127
146
  // Default inactivity timeout: 5 minutes (keep models warm during typical search sessions)
128
147
  const DEFAULT_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
148
+ const DEFAULT_EXPAND_CONTEXT_SIZE = 2048;
149
+ function resolveExpandContextSize(configValue) {
150
+ if (configValue !== undefined) {
151
+ if (!Number.isInteger(configValue) || configValue <= 0) {
152
+ throw new Error(`Invalid expandContextSize: ${configValue}. Must be a positive integer.`);
153
+ }
154
+ return configValue;
155
+ }
156
+ const envValue = process.env.QMD_EXPAND_CONTEXT_SIZE?.trim();
157
+ if (!envValue)
158
+ return DEFAULT_EXPAND_CONTEXT_SIZE;
159
+ const parsed = Number.parseInt(envValue, 10);
160
+ if (!Number.isInteger(parsed) || parsed <= 0) {
161
+ process.stderr.write(`QMD Warning: invalid QMD_EXPAND_CONTEXT_SIZE="${envValue}", using default ${DEFAULT_EXPAND_CONTEXT_SIZE}.\n`);
162
+ return DEFAULT_EXPAND_CONTEXT_SIZE;
163
+ }
164
+ return parsed;
165
+ }
129
166
  export class LlamaCpp {
130
167
  llama = null;
131
168
  embedModel = null;
@@ -137,6 +174,7 @@ export class LlamaCpp {
137
174
  generateModelUri;
138
175
  rerankModelUri;
139
176
  modelCacheDir;
177
+ expandContextSize;
140
178
  // Ensure we don't load the same model/context concurrently (which can allocate duplicate VRAM).
141
179
  embedModelLoadPromise = null;
142
180
  generateModelLoadPromise = null;
@@ -152,6 +190,7 @@ export class LlamaCpp {
152
190
  this.generateModelUri = config.generateModel || DEFAULT_GENERATE_MODEL;
153
191
  this.rerankModelUri = config.rerankModel || DEFAULT_RERANK_MODEL;
154
192
  this.modelCacheDir = config.modelCacheDir || MODEL_CACHE_DIR;
193
+ this.expandContextSize = resolveExpandContextSize(config.expandContextSize);
155
194
  this.inactivityTimeoutMs = config.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
156
195
  this.disposeModelsOnInactivity = config.disposeModelsOnInactivity ?? false;
157
196
  }
@@ -249,27 +288,12 @@ export class LlamaCpp {
249
288
  */
250
289
  async ensureLlama() {
251
290
  if (!this.llama) {
252
- // Detect available GPU types and use the best one.
253
- // We can't rely on gpu:"auto" — it returns false even when CUDA is available
254
- // (likely a binary/build config issue in node-llama-cpp).
255
- // @ts-expect-error node-llama-cpp API compat
256
- const gpuTypes = await getLlamaGpuTypes();
257
- // Prefer CUDA > Metal > Vulkan > CPU
258
- const preferred = ["cuda", "metal", "vulkan"].find(g => gpuTypes.includes(g));
259
- let llama;
260
- if (preferred) {
261
- try {
262
- llama = await getLlama({ gpu: preferred, logLevel: LlamaLogLevel.error });
263
- }
264
- catch {
265
- llama = await getLlama({ gpu: false, logLevel: LlamaLogLevel.error });
266
- process.stderr.write(`QMD Warning: ${preferred} reported available but failed to initialize. Falling back to CPU.\n`);
267
- }
268
- }
269
- else {
270
- llama = await getLlama({ gpu: false, logLevel: LlamaLogLevel.error });
271
- }
272
- if (!llama.gpu) {
291
+ const llama = await getLlama({
292
+ // attempt to build
293
+ build: "autoAttempt",
294
+ logLevel: LlamaLogLevel.error
295
+ });
296
+ if (llama.gpu === false) {
273
297
  process.stderr.write("QMD Warning: no GPU acceleration, running on CPU (slow). Run 'qmd status' for details.\n");
274
298
  }
275
299
  this.llama = llama;
@@ -466,7 +490,7 @@ export class LlamaCpp {
466
490
  if (this.rerankContexts.length === 0) {
467
491
  const model = await this.ensureRerankModel();
468
492
  // ~960 MB per context with flash attention at contextSize 2048
469
- const n = await this.computeParallelism(1000);
493
+ const n = Math.min(await this.computeParallelism(1000), 4);
470
494
  const threads = await this.threadsPerContext(n);
471
495
  for (let i = 0; i < n; i++) {
472
496
  try {
@@ -668,8 +692,10 @@ export class LlamaCpp {
668
692
  `
669
693
  });
670
694
  const prompt = `/no_think Expand this search query: ${query}`;
671
- // Create fresh context for each call
672
- const genContext = await this.generateModel.createContext();
695
+ // Create a bounded context for expansion to prevent large default VRAM allocations.
696
+ const genContext = await this.generateModel.createContext({
697
+ contextSize: this.expandContextSize,
698
+ });
673
699
  const sequence = genContext.getSequence();
674
700
  const session = new LlamaChatSession({ contextSequence: sequence });
675
701
  try {
@@ -731,38 +757,73 @@ export class LlamaCpp {
731
757
  await genContext.dispose();
732
758
  }
733
759
  }
760
+ // Qwen3 reranker chat template overhead (system prompt, tags, separators)
761
+ static RERANK_TEMPLATE_OVERHEAD = 200;
762
+ static RERANK_TARGET_DOCS_PER_CONTEXT = 10;
734
763
  async rerank(query, documents, options = {}) {
735
764
  // Ping activity at start to keep models alive during this operation
736
765
  this.touchActivity();
737
766
  const contexts = await this.ensureRerankContexts();
738
- // Build a map from document text to original indices (for lookup after sorting)
739
- const textToDoc = new Map();
740
- documents.forEach((doc, index) => {
741
- textToDoc.set(doc.text, { file: doc.file, index });
767
+ const model = await this.ensureRerankModel();
768
+ // Truncate documents that would exceed the rerank context size.
769
+ // Budget = contextSize - template overhead - query tokens
770
+ const queryTokens = model.tokenize(query).length;
771
+ const maxDocTokens = LlamaCpp.RERANK_CONTEXT_SIZE - LlamaCpp.RERANK_TEMPLATE_OVERHEAD - queryTokens;
772
+ const truncationCache = new Map();
773
+ const truncatedDocs = documents.map((doc) => {
774
+ const cached = truncationCache.get(doc.text);
775
+ if (cached !== undefined) {
776
+ return cached === doc.text ? doc : { ...doc, text: cached };
777
+ }
778
+ const tokens = model.tokenize(doc.text);
779
+ const truncatedText = tokens.length <= maxDocTokens
780
+ ? doc.text
781
+ : model.detokenize(tokens.slice(0, maxDocTokens));
782
+ truncationCache.set(doc.text, truncatedText);
783
+ if (truncatedText === doc.text)
784
+ return doc;
785
+ return { ...doc, text: truncatedText };
786
+ });
787
+ // Deduplicate identical effective texts before scoring.
788
+ // This avoids redundant work for repeated chunks and fixes collisions where
789
+ // multiple docs map to the same chunk text.
790
+ const textToDocs = new Map();
791
+ truncatedDocs.forEach((doc, index) => {
792
+ const existing = textToDocs.get(doc.text);
793
+ if (existing) {
794
+ existing.push({ file: doc.file, index });
795
+ }
796
+ else {
797
+ textToDocs.set(doc.text, [{ file: doc.file, index }]);
798
+ }
742
799
  });
743
800
  // Extract just the text for ranking
744
- const texts = documents.map((doc) => doc.text);
801
+ const texts = Array.from(textToDocs.keys());
745
802
  // Split documents across contexts for parallel evaluation.
746
803
  // Each context has its own sequence with a lock, so parallelism comes
747
804
  // from multiple contexts evaluating different chunks simultaneously.
748
- const n = contexts.length;
749
- const chunkSize = Math.ceil(texts.length / n);
750
- const chunks = Array.from({ length: n }, (_, i) => texts.slice(i * chunkSize, (i + 1) * chunkSize)).filter(chunk => chunk.length > 0);
751
- const allScores = await Promise.all(chunks.map((chunk, i) => contexts[i].rankAll(query, chunk)));
805
+ const activeContextCount = Math.max(1, Math.min(contexts.length, Math.ceil(texts.length / LlamaCpp.RERANK_TARGET_DOCS_PER_CONTEXT)));
806
+ const activeContexts = contexts.slice(0, activeContextCount);
807
+ const chunkSize = Math.ceil(texts.length / activeContexts.length);
808
+ const chunks = Array.from({ length: activeContexts.length }, (_, i) => texts.slice(i * chunkSize, (i + 1) * chunkSize)).filter(chunk => chunk.length > 0);
809
+ const allScores = await Promise.all(chunks.map((chunk, i) => activeContexts[i].rankAll(query, chunk)));
752
810
  // Reassemble scores in original order and sort
753
811
  const flatScores = allScores.flat();
754
812
  const ranked = texts
755
813
  .map((text, i) => ({ document: text, score: flatScores[i] }))
756
814
  .sort((a, b) => b.score - a.score);
757
- // Map back to our result format using the text-to-doc map
758
- const results = ranked.map((item) => {
759
- const docInfo = textToDoc.get(item.document);
760
- return {
761
- file: docInfo.file,
762
- score: item.score,
763
- index: docInfo.index,
764
- };
765
- });
815
+ // Map back to our result format.
816
+ const results = [];
817
+ for (const item of ranked) {
818
+ const docInfos = textToDocs.get(item.document) ?? [];
819
+ for (const docInfo of docInfos) {
820
+ results.push({
821
+ file: docInfo.file,
822
+ score: item.score,
823
+ index: docInfo.index,
824
+ });
825
+ }
826
+ }
766
827
  return {
767
828
  results,
768
829
  model: this.rerankModelUri,
@@ -1019,7 +1080,8 @@ let defaultLlamaCpp = null;
1019
1080
  */
1020
1081
  export function getDefaultLlamaCpp() {
1021
1082
  if (!defaultLlamaCpp) {
1022
- defaultLlamaCpp = new LlamaCpp();
1083
+ const embedModel = process.env.QMD_EMBED_MODEL;
1084
+ defaultLlamaCpp = new LlamaCpp(embedModel ? { embedModel } : {});
1023
1085
  }
1024
1086
  return defaultLlamaCpp;
1025
1087
  }