@wei840222/qmd 2026.8.23

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +1373 -0
  2. package/LICENSE +45 -0
  3. package/README.md +1439 -0
  4. package/THIRD_PARTY_NOTICES.md +31 -0
  5. package/bin/qmd +192 -0
  6. package/dist/ast.d.ts +65 -0
  7. package/dist/ast.js +334 -0
  8. package/dist/bench/bench.d.ts +35 -0
  9. package/dist/bench/bench.js +338 -0
  10. package/dist/bench/cjk-baseline.d.ts +36 -0
  11. package/dist/bench/cjk-baseline.js +111 -0
  12. package/dist/bench/fixture.d.ts +2 -0
  13. package/dist/bench/fixture.js +84 -0
  14. package/dist/bench/score.d.ts +38 -0
  15. package/dist/bench/score.js +107 -0
  16. package/dist/bench/types.d.ts +110 -0
  17. package/dist/bench/types.js +8 -0
  18. package/dist/cli/build-info.json +4 -0
  19. package/dist/cli/embed-lock.d.ts +24 -0
  20. package/dist/cli/embed-lock.js +94 -0
  21. package/dist/cli/embedding-owner.d.ts +10 -0
  22. package/dist/cli/embedding-owner.js +20 -0
  23. package/dist/cli/formatter.d.ts +120 -0
  24. package/dist/cli/formatter.js +355 -0
  25. package/dist/cli/mcp-pid.d.ts +25 -0
  26. package/dist/cli/mcp-pid.js +86 -0
  27. package/dist/cli/qmd.d.ts +72 -0
  28. package/dist/cli/qmd.js +4806 -0
  29. package/dist/cli/version.d.ts +42 -0
  30. package/dist/cli/version.js +80 -0
  31. package/dist/collections.d.ts +200 -0
  32. package/dist/collections.js +433 -0
  33. package/dist/db.d.ts +65 -0
  34. package/dist/db.js +143 -0
  35. package/dist/diagnostics.d.ts +62 -0
  36. package/dist/diagnostics.js +260 -0
  37. package/dist/embedding/config.d.ts +52 -0
  38. package/dist/embedding/config.js +229 -0
  39. package/dist/embedding/identity.d.ts +58 -0
  40. package/dist/embedding/identity.js +321 -0
  41. package/dist/embedding/local-identity.d.ts +1 -0
  42. package/dist/embedding/local-identity.js +15 -0
  43. package/dist/embedding/local.d.ts +34 -0
  44. package/dist/embedding/local.js +290 -0
  45. package/dist/embedding/openai.d.ts +79 -0
  46. package/dist/embedding/openai.js +477 -0
  47. package/dist/embedding/owner.d.ts +13 -0
  48. package/dist/embedding/owner.js +36 -0
  49. package/dist/embedding/provider.d.ts +68 -0
  50. package/dist/embedding/provider.js +16 -0
  51. package/dist/embedding/remote-chunking.d.ts +22 -0
  52. package/dist/embedding/remote-chunking.js +83 -0
  53. package/dist/embedding/remote-embedding.d.ts +15 -0
  54. package/dist/embedding/remote-embedding.js +77 -0
  55. package/dist/hybrid-llm.d.ts +18 -0
  56. package/dist/hybrid-llm.js +53 -0
  57. package/dist/index.d.ts +244 -0
  58. package/dist/index.js +418 -0
  59. package/dist/llm.d.ts +566 -0
  60. package/dist/llm.js +1847 -0
  61. package/dist/maintenance.d.ts +33 -0
  62. package/dist/maintenance.js +52 -0
  63. package/dist/mcp/origin-guard.d.ts +67 -0
  64. package/dist/mcp/origin-guard.js +137 -0
  65. package/dist/mcp/server.d.ts +116 -0
  66. package/dist/mcp/server.js +919 -0
  67. package/dist/paths.d.ts +1 -0
  68. package/dist/paths.js +4 -0
  69. package/dist/remote-llm.d.ts +52 -0
  70. package/dist/remote-llm.js +464 -0
  71. package/dist/search/cjk-analyzer.d.ts +33 -0
  72. package/dist/search/cjk-analyzer.js +158 -0
  73. package/dist/search/cjk-index.d.ts +104 -0
  74. package/dist/search/cjk-index.js +1031 -0
  75. package/dist/search/jieba-loader.d.ts +23 -0
  76. package/dist/search/jieba-loader.js +79 -0
  77. package/dist/search/query-expansion.d.ts +23 -0
  78. package/dist/search/query-expansion.js +43 -0
  79. package/dist/search/zh-dict.txt +624013 -0
  80. package/dist/store.d.ts +1218 -0
  81. package/dist/store.js +6076 -0
  82. package/dist/trust.d.ts +152 -0
  83. package/dist/trust.js +249 -0
  84. package/package.json +139 -0
  85. package/scripts/build.mjs +83 -0
  86. package/scripts/check-package-grammars.mjs +29 -0
  87. package/scripts/package-smoke.mjs +205 -0
  88. package/scripts/sync-zh-dict.mjs +187 -0
  89. package/scripts/test-all.mjs +45 -0
  90. package/skills/qmd/SKILL.md +324 -0
  91. package/skills/qmd/references/mcp-setup.md +119 -0
  92. package/skills/release/SKILL.md +141 -0
  93. package/skills/release/scripts/install-hooks.sh +38 -0
  94. package/skills/release/scripts/release-context.sh +129 -0
package/README.md ADDED
@@ -0,0 +1,1439 @@
1
+ # QMD - Query Markup Documents
2
+
3
+ An on-device search engine for everything you need to remember. Index your markdown notes, meeting transcripts, documentation, and knowledge bases. Search with keywords or natural language. Ideal for your agentic flows.
4
+
5
+ QMD combines BM25 full-text search, vector semantic search, and LLM re-ranking—all running locally via node-llama-cpp with GGUF models.
6
+
7
+ ```mermaid
8
+ flowchart LR
9
+ Q[User Query] --> X[Query Expansion]
10
+ Q --> FTS[BM25 Search]
11
+ Q --> VS[Vector Search]
12
+ X --> HYDE[HyDE]
13
+ X --> VEC[Vec dense sentences]
14
+ X --> LEX[Lex BM25 keywords]
15
+ HYDE --> VS
16
+ VEC --> VS
17
+ LEX --> FTS
18
+ VS --> RRF[Reciprocal Rank Fusion]
19
+ FTS --> RRF
20
+ RRF --> RR[LLM Reranker]
21
+ RR --> OUT[Final ranked results]
22
+ ```
23
+
24
+ Typed expansions are routed exclusively: `lex` → BM25/FTS, `vec` and `hyde` → vector search. The original query is sent to both backends, then fused with RRF and reranked.
25
+
26
+ You can read more about QMD's progress in the [CHANGELOG](CHANGELOG.md).
27
+
28
+ ## Quick Start (Local Embedding Default)
29
+
30
+ ```sh
31
+ # Install globally (Node or Bun)
32
+ npm install -g @wei840222/qmd
33
+ # or
34
+ bun install -g @wei840222/qmd
35
+
36
+ # Or run directly
37
+ npx @wei840222/qmd ...
38
+ bunx @wei840222/qmd ...
39
+
40
+ # Create collections for your notes, docs, and meeting transcripts
41
+ qmd collection add ~/notes --name notes
42
+ qmd collection add ~/Documents/meetings --name meetings
43
+ qmd collection add ~/work/docs --name docs
44
+
45
+ # Add context to help with search results, each piece of context will be returned when matching sub documents are returned. This works as a tree. This is the key feature of QMD as it allows LLMs to make much better contextual choices when selecting documents. Don't sleep on it!
46
+ qmd context add qmd://notes "Personal notes and ideas"
47
+ qmd context add qmd://meetings "Meeting transcripts and notes"
48
+ qmd context add qmd://docs "Work documentation"
49
+
50
+ # Generate embeddings for semantic search
51
+ qmd embed
52
+
53
+ # Search across everything
54
+ qmd search "project timeline" # Fast keyword search
55
+ qmd vsearch "how to deploy" # Semantic search
56
+ qmd query "quarterly planning process" # Hybrid + reranking (best quality)
57
+
58
+ # Get a specific document
59
+ qmd get "meetings/2024-01-15.md"
60
+
61
+ # Get a document by docid (shown in search results)
62
+ qmd get "#abc123"
63
+
64
+ # Get multiple documents by glob pattern
65
+ qmd multi-get "journals/2025-05*.md"
66
+
67
+ # Search within a specific collection
68
+ qmd search "API" -c notes
69
+
70
+ # Export all matches for an agent
71
+ qmd search "API" --all --files --min-score 0.3
72
+ ```
73
+
74
+ ### Using with AI Agents
75
+
76
+ QMD's `--json` and `--files` output formats are designed for agentic workflows:
77
+
78
+ ```sh
79
+ # Get structured results for an LLM
80
+ qmd search "authentication" --json -n 10
81
+
82
+ # List all relevant files above a threshold
83
+ qmd query "error handling" --all --files --min-score 0.4
84
+
85
+ # Retrieve full document content
86
+ qmd get "docs/api-reference.md" --full
87
+ ```
88
+
89
+ ### MCP Server
90
+
91
+ Although the tool works perfectly fine when you just tell your agent to use it on the command line, it also exposes an MCP (Model Context Protocol) server for tighter integration.
92
+
93
+ **Tools exposed:**
94
+ - `query` — Search with typed sub-queries (`lex`/`vec`/`hyde`), combined via RRF + reranking
95
+ - `get` — Retrieve a document by path or docid (with fuzzy matching suggestions)
96
+ - `multi_get` — Batch retrieve by glob pattern, comma-separated list, or docids
97
+ - `status` — Index health and collection info
98
+
99
+ **Claude Desktop configuration** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
100
+
101
+ ```json
102
+ {
103
+ "mcpServers": {
104
+ "qmd": {
105
+ "command": "qmd",
106
+ "args": ["mcp"]
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ **Claude Code** — Install the plugin (recommended):
113
+
114
+ ```bash
115
+ claude plugin marketplace add tobi/qmd
116
+ claude plugin install qmd@qmd
117
+ ```
118
+
119
+ Or configure MCP manually in `~/.claude/settings.json`:
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "qmd": {
125
+ "command": "qmd",
126
+ "args": ["mcp"]
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ #### HTTP Transport
133
+
134
+ By default, QMD's MCP server uses stdio (launched as a subprocess by each client). For a shared, long-lived server that avoids repeated model loading, use the HTTP transport:
135
+
136
+ ```sh
137
+ # Foreground (Ctrl-C to stop)
138
+ qmd mcp --http # localhost:8181
139
+ qmd mcp --http --port 8080 # custom port
140
+ qmd mcp --http --host 0.0.0.0 # bind all interfaces (e.g. container probes)
141
+
142
+ # Background daemon
143
+ qmd mcp --http --daemon # start, writes PID to ~/.cache/qmd/mcp.pid
144
+ qmd mcp stop # stop via PID file
145
+ qmd status # shows "MCP: running (PID ...)" when active
146
+ ```
147
+
148
+ The server binds to `localhost` by default. Pass `--host` (or set the `QMD_HOST`
149
+ environment variable) to override — `--host 0.0.0.0` is useful when the server
150
+ runs in a container and a liveness probe connects from a non-loopback address.
151
+
152
+ The HTTP server exposes two endpoints:
153
+ - `POST /mcp` — MCP Streamable HTTP (JSON responses, stateless)
154
+ - `POST /query` (alias `/search`) — structured search without the MCP protocol
155
+ - `GET /health` — liveness check with uptime
156
+
157
+
158
+ ##### Origin and Host validation
159
+
160
+ Every request is screened before routing: a request carrying an `Origin` header
161
+ that does not name a loopback address is rejected with `403`, as is a `Host`
162
+ header naming something other than the address the server is bound to. This is
163
+ what stops a web page you visit from reading your index through DNS rebinding —
164
+ loopback binding alone does not, since the browser makes the request from your
165
+ own machine.
166
+
167
+ Requests without an `Origin` header — curl, MCP clients, editors — are
168
+ unaffected, which covers every normal local client.
169
+
170
+ | Variable | Effect |
171
+ |----------|--------|
172
+ | `QMD_ALLOWED_ORIGINS` | Comma-separated origins to accept in addition to loopback, e.g. `https://notes.internal`. Set to `*` to disable the check entirely. |
173
+ | `QMD_ALLOWED_HOSTS` | Comma-separated `Host` values to accept in addition to loopback and the bind address. |
174
+
175
+ `--host 0.0.0.0` cannot know which `Host` values are legitimate, so it skips the
176
+ host check and warns at startup. Set `QMD_ALLOWED_HOSTS` to re-enable it, and
177
+ remember the endpoints are unauthenticated — put your own auth in front of a
178
+ server that is reachable off-host.
179
+
180
+ LLM models stay loaded in VRAM across requests. Embedding/reranking contexts are disposed after 5 min idle and transparently recreated on the next request (~1s penalty, models remain loaded).
181
+
182
+ Point any MCP client at `http://localhost:8181/mcp` to connect.
183
+
184
+ #### MCP Tool Parameters
185
+
186
+ | Tool | Parameter | Type | Notes |
187
+ |------|-----------|------|-------|
188
+ | `query` | `query` | string | Plain query evaluated by the shared expansion policy. Mutually exclusive with `searches`; exactly one is required. |
189
+ | `query` | `searches` | array | Typed sub-queries (`lex`/`vec`/`hyde`), 1–10. Mutually exclusive with `query`; exactly one is required. First gets 2x weight. |
190
+ | `query` | `expansion` | string | Plain-query policy: `auto` (default), `force`, or `skip`. Ignored when `searches` is used. |
191
+ | `query` | `collections` | string[] | Filter by collection names (OR). **Array only** — singular `collection` is silently ignored. |
192
+ | `query` | `expansionContext` | string | Additional context used only to generate `lex` / `vec` / `hyde` query expansions. |
193
+ | `query` | `rerankContext` | string | Additional context used only for reranking and snippet/chunk selection. |
194
+ | `query` | `limit` | number | Max results (default 10) |
195
+ | `query` | `minScore` | number | Minimum relevance 0–1 (default 0) |
196
+ | `query` | `candidateLimit` | number | Max candidates to rerank (default 40) |
197
+ | `query` | `rerank` | boolean | Run LLM reranking (default **true**); set false for RRF-only |
198
+ | `query` | `explain` | boolean | Include retrieval traces and the expansion decision or typed-query error (default **false**) |
199
+ | `get` | `file` | string | Path, docid (`#abc123`), or `path:from:count` (e.g. `#abc123:120:40`) |
200
+ | `get` | `fromLine` | number | Start line (1-indexed); overrides the `:from` suffix |
201
+ | `get` | `maxLines` | number | Limit returned lines |
202
+ | `get` | `lineNumbers` | boolean | Prefix lines with numbers (default **true**) |
203
+ | `multi_get` | `pattern` | string | Glob pattern or comma-separated list |
204
+ | `multi_get` | `maxBytes` | number | Skip files larger than N (default 10240) |
205
+ | `multi_get` | `maxLines` | number | Limit lines per file |
206
+ | `multi_get` | `lineNumbers` | boolean | Prefix lines with numbers (default **true**) |
207
+
208
+ Unknown parameters are silently ignored (not rejected) — double-check names if
209
+ results seem unscoped. The HTTP `/query` and `/search` endpoints return
210
+ `qmd://collection/path` URIs in the `file` field, matching the CLI and MCP output.
211
+
212
+ ### SDK / Library Usage
213
+
214
+ Use QMD as a library in your own Node.js or Bun applications.
215
+
216
+ #### Installation
217
+
218
+ ```sh
219
+ npm install @wei840222/qmd
220
+ ```
221
+
222
+ #### Quick Start
223
+
224
+ ```typescript
225
+ import { createStore } from '@wei840222/qmd'
226
+
227
+ const store = await createStore({
228
+ dbPath: './my-index.sqlite',
229
+ config: {
230
+ collections: {
231
+ docs: { path: '/path/to/docs', pattern: '**/*.md' },
232
+ },
233
+ },
234
+ })
235
+
236
+ const results = await store.search({ query: "authentication flow" })
237
+ console.log(results.map(r => `${r.title} (${Math.round(r.score * 100)}%)`))
238
+
239
+ await store.close()
240
+ ```
241
+
242
+ #### Store Creation
243
+
244
+ `createStore()` accepts three modes:
245
+
246
+ ```typescript
247
+ import { createStore } from '@wei840222/qmd'
248
+
249
+ // 1. Inline config — no files needed besides the DB
250
+ const store = await createStore({
251
+ dbPath: './index.sqlite',
252
+ config: {
253
+ collections: {
254
+ docs: { path: '/path/to/docs', pattern: '**/*.md' },
255
+ notes: { path: '/path/to/notes' },
256
+ },
257
+ },
258
+ })
259
+
260
+ // 2. YAML config file — collections defined in a file
261
+ const store2 = await createStore({
262
+ dbPath: './index.sqlite',
263
+ configPath: './qmd.yml',
264
+ })
265
+
266
+ // 3. DB-only — reopen a previously configured store
267
+ const store3 = await createStore({ dbPath: './index.sqlite' })
268
+ ```
269
+
270
+ #### Search
271
+
272
+ The unified `search()` method handles both simple queries and pre-expanded structured queries:
273
+
274
+ ```typescript
275
+ // Simple query — evaluated by the shared auto policy, then retrieved and reranked
276
+ const results = await store.search({ query: "authentication flow" })
277
+
278
+ // With options
279
+ const results2 = await store.search({
280
+ query: "rate limiting",
281
+ expansionContext: "API throttling and abuse prevention",
282
+ rerankContext: "API throttling and abuse prevention",
283
+ collection: "docs",
284
+ limit: 5,
285
+ minScore: 0.3,
286
+ explain: true,
287
+ expansion: "auto", // "auto" | "force" | "skip"
288
+ })
289
+
290
+ // Pre-expanded queries — bypass the policy and control each sub-query
291
+ const results3 = await store.search({
292
+ queries: [
293
+ { type: 'lex', query: '"connection pool" timeout -redis' },
294
+ { type: 'vec', query: 'why do database connections time out under load' },
295
+ ],
296
+ collections: ["docs", "notes"],
297
+ })
298
+
299
+ // Skip reranking for faster results
300
+ const fast = await store.search({ query: "auth", rerank: false })
301
+ ```
302
+
303
+ For simple queries, explicit `force` or `skip` overrides `auto`. Under `auto`, CJK
304
+ queries and strong lexical matches skip model expansion; other queries expand.
305
+ Supplying `queries` bypasses expansion policy evaluation entirely.
306
+
307
+ For direct backend access:
308
+
309
+ ```typescript
310
+ // BM25 keyword search (fast, no LLM)
311
+ const lexResults = await store.searchLex("auth middleware", { limit: 10 })
312
+
313
+ // Vector similarity search (embedding model, no reranking)
314
+ const vecResults = await store.searchVector("how users log in", { limit: 10 })
315
+
316
+ // Manual query expansion for full control
317
+ const expanded = await store.expandQuery("auth flow", { expansionContext: "user login" })
318
+ const results4 = await store.search({ queries: expanded })
319
+ ```
320
+
321
+ #### Retrieval
322
+
323
+ ```typescript
324
+ // Get a document by path or docid
325
+ const doc = await store.get("docs/readme.md")
326
+ const byId = await store.get("#abc123")
327
+
328
+ if (!("error" in doc)) {
329
+ console.log(doc.title, doc.displayPath, doc.context)
330
+ }
331
+
332
+ // Get document body with line range
333
+ const body = await store.getDocumentBody("docs/readme.md", {
334
+ fromLine: 50,
335
+ maxLines: 100,
336
+ })
337
+
338
+ // Batch retrieve by glob or comma-separated list
339
+ const { docs, errors } = await store.multiGet("docs/**/*.md", {
340
+ maxBytes: 20480,
341
+ })
342
+ ```
343
+
344
+ #### Collections
345
+
346
+ ```typescript
347
+ // Add a collection
348
+ await store.addCollection("myapp", {
349
+ path: "/src/myapp",
350
+ pattern: "**/*.ts",
351
+ ignore: ["node_modules/**", "*.test.ts"],
352
+ })
353
+
354
+ // List collections with document stats
355
+ const collections = await store.listCollections()
356
+ // => [{ name, pwd, glob_pattern, doc_count, active_count, last_modified, includeByDefault }]
357
+
358
+ // Get names of collections included in queries by default
359
+ const defaults = await store.getDefaultCollectionNames()
360
+
361
+ // Remove / rename
362
+ await store.removeCollection("myapp")
363
+ await store.renameCollection("old-name", "new-name")
364
+ ```
365
+
366
+ #### Context
367
+
368
+ Context adds descriptive metadata that improves search relevance and is returned alongside results:
369
+
370
+ ```typescript
371
+ // Add context for a path within a collection
372
+ await store.addContext("docs", "/api", "REST API reference documentation")
373
+
374
+ // Set global context (applies to all collections)
375
+ await store.setGlobalContext("Internal engineering documentation")
376
+
377
+ // List all contexts
378
+ const contexts = await store.listContexts()
379
+ // => [{ collection, path, context }]
380
+
381
+ // Remove context
382
+ await store.removeContext("docs", "/api")
383
+ await store.setGlobalContext(undefined) // clear global
384
+ ```
385
+
386
+ #### Indexing
387
+
388
+ ```typescript
389
+ // Re-index collections by scanning the filesystem
390
+ const result = await store.update({
391
+ collections: ["docs"], // optional — defaults to all
392
+ onProgress: ({ collection, file, current, total }) => {
393
+ console.log(`[${collection}] ${current}/${total} ${file}`)
394
+ },
395
+ })
396
+ // => { collections, indexed, updated, unchanged, removed, needsEmbedding }
397
+
398
+ // Generate vector embeddings
399
+ const embedResult = await store.embed({
400
+ force: false, // true to re-embed everything
401
+ chunkStrategy: "auto", // "regex" (default) or "auto" (AST for code files)
402
+ onProgress: ({ current, total, collection }) => {
403
+ console.log(`Embedding ${current}/${total}`)
404
+ },
405
+ })
406
+ ```
407
+
408
+ #### Types
409
+
410
+ Key types exported for SDK consumers:
411
+
412
+ ```typescript
413
+ import type {
414
+ QMDStore, // The store interface
415
+ SearchOptions, // Options for search()
416
+ LexSearchOptions, // Options for searchLex()
417
+ VectorSearchOptions, // Options for searchVector()
418
+ HybridQueryResult, // Search result with score, snippet, context
419
+ SearchResult, // Result from searchLex/searchVector
420
+ ExpandedQuery, // Typed sub-query { type: 'lex'|'vec'|'hyde', query }
421
+ DocumentResult, // Document metadata + body
422
+ DocumentNotFound, // Error with similarFiles suggestions
423
+ MultiGetResult, // Batch retrieval result
424
+ UpdateProgress, // Progress callback info for update()
425
+ UpdateResult, // Aggregated update result
426
+ EmbedProgress, // Progress callback info for embed()
427
+ EmbedResult, // Embedding result
428
+ StoreOptions, // createStore() options
429
+ CollectionConfig, // Inline config shape
430
+ IndexStatus, // From getStatus()
431
+ IndexHealthInfo, // From getIndexHealth()
432
+ } from '@wei840222/qmd'
433
+ ```
434
+
435
+ Utility exports:
436
+
437
+ ```typescript
438
+ import {
439
+ extractSnippet, // Extract a relevant snippet from text
440
+ addLineNumbers, // Add line numbers to text
441
+ DEFAULT_MULTI_GET_MAX_BYTES, // Default max file size for multiGet (64KB)
442
+ Maintenance, // Database maintenance operations
443
+ } from '@wei840222/qmd'
444
+ ```
445
+
446
+ #### Lifecycle
447
+
448
+ ```typescript
449
+ // Close the store — disposes LLM models and DB connection
450
+ await store.close()
451
+ ```
452
+
453
+ The SDK requires explicit `dbPath` — no defaults are assumed. This makes it safe to embed in any application without side effects.
454
+
455
+ ## Architecture
456
+
457
+ ```
458
+ ┌─────────────────────────────────────────────────────────────────────────────┐
459
+ │ QMD Hybrid Search Pipeline │
460
+ └─────────────────────────────────────────────────────────────────────────────┘
461
+
462
+ ┌─────────────────┐
463
+ │ User Query │
464
+ └────────┬────────┘
465
+
466
+ ┌────────▼────────┐
467
+ │ Expansion Policy│
468
+ │ auto|force|skip │
469
+ └────────┬────────┘
470
+
471
+ ┌─────────────┴─────────────┐
472
+ ▼ ▼
473
+ ┌─────────────────┐ ┌─────────────────┐
474
+ │ Original Query │ │ Typed Expansions│
475
+ │ (×2) │ │ lex / vec / HyDE│
476
+ └────────┬────────┘ └────────┬────────┘
477
+ │ │
478
+ ┌───────┴───────┐ ┌───────┴───────┐
479
+ ▼ ▼ ▼ ▼
480
+ ┌───────┐ ┌────────┐ ┌───────┐ ┌────────┐
481
+ │ BM25 │ │ Vector │ │ lex │ │vec/HyDE│
482
+ │(FTS5) │ │ Search │ └───┬───┘ └───┬────┘
483
+ └───┬───┘ └───┬────┘ ▼ ▼
484
+ │ │ ┌───────┐ ┌────────┐
485
+ │ │ │ BM25 │ │ Vector │
486
+ │ │ │(FTS5) │ │ Search │
487
+ └───────┬───────┘ └───┬───┘ └───┬────┘
488
+ └─────────────┬───────┴───────────────┘
489
+
490
+
491
+ ┌───────────────────────┐
492
+ │ RRF Fusion + Bonus │
493
+ │ Original paths ×2 │
494
+ │ Top-rank bonus: +0.05│
495
+ │ Top 30 Kept │
496
+ └───────────┬───────────┘
497
+
498
+
499
+ ┌───────────────────────┐
500
+ │ LLM Re-ranking │
501
+ │ (qwen3-reranker) │
502
+ │ Yes/No + logprobs │
503
+ └───────────┬───────────┘
504
+
505
+
506
+ ┌───────────────────────┐
507
+ │ Position-Aware Blend │
508
+ │ Top 1-3: 75% RRF │
509
+ │ Top 4-10: 60% RRF │
510
+ │ Top 11+: 40% RRF │
511
+ └───────────────────────┘
512
+ ```
513
+
514
+ ## Score Normalization & Fusion
515
+
516
+ ### Search Backends
517
+
518
+ | Backend | Raw Score | Conversion | Range |
519
+ |---------|-----------|------------|-------|
520
+ | **FTS (BM25)** | SQLite FTS5 BM25 | `Math.abs(score)` | 0 to ~25+ |
521
+ | **Vector** | Cosine distance | `1 / (1 + distance)` | 0.0 to 1.0 |
522
+ | **Reranker** | LLM 0-10 rating | `score / 10` | 0.0 to 1.0 |
523
+
524
+ ### Fusion Strategy
525
+
526
+ The `query` command uses **Reciprocal Rank Fusion (RRF)** with position-aware blending:
527
+
528
+ 1. **Query Expansion Policy**: `auto` expands eligible queries with a local generator but skips remote generation; `force` runs the configured generator; `skip` uses only the original query. Original FTS and vector retrieval paths have ×2 RRF weight in every mode. When expansion runs, QMD adds zero or more typed LLM variants at ×1: `lex` variants use FTS, while `vec` and `hyde` variants use vector search.
529
+ 2. **Parallel Retrieval**: The original query searches both indexes; `lex` variants search FTS, while `vec` and `hyde` variants search the vector index
530
+ 3. **RRF Fusion**: Combine all result lists using `score = Σ(1/(k+rank+1))` where k=60
531
+ 4. **Top-Rank Bonus**: Documents ranking #1 in any list get +0.05, #2-3 get +0.02
532
+ 5. **Top-K Selection**: Take top 30 candidates for reranking
533
+ 6. **Re-ranking**: LLM scores each document (yes/no with logprobs confidence)
534
+ 7. **Position-Aware Blending**:
535
+ - RRF rank 1-3: 75% retrieval, 25% reranker (preserves exact matches)
536
+ - RRF rank 4-10: 60% retrieval, 40% reranker
537
+ - RRF rank 11+: 40% retrieval, 60% reranker (trust reranker more)
538
+
539
+ **Why this approach**: Pure RRF can dilute exact matches when expanded queries don't match. The top-rank bonus preserves documents that score #1 for the original query. Position-aware blending prevents the reranker from destroying high-confidence retrieval results.
540
+
541
+ ### Score Interpretation
542
+
543
+ | Score | Meaning |
544
+ |-------|---------|
545
+ | 0.8 - 1.0 | Highly relevant |
546
+ | 0.5 - 0.8 | Moderately relevant |
547
+ | 0.2 - 0.5 | Somewhat relevant |
548
+ | 0.0 - 0.2 | Low relevance |
549
+
550
+ ## Requirements
551
+
552
+ ### System Requirements
553
+
554
+ - **Node.js** >= 22
555
+ - **Bun** >= 1.0.0
556
+ - **macOS**: Homebrew SQLite (for extension support)
557
+ ```sh
558
+ brew install sqlite
559
+ ```
560
+
561
+ ### GGUF Models (via node-llama-cpp)
562
+
563
+ QMD uses three local GGUF models (auto-downloaded on first use):
564
+
565
+ | Model | Purpose | Size |
566
+ |-------|---------|------|
567
+ | `embeddinggemma-300M-Q8_0` | Vector embeddings (default) | ~300MB |
568
+ | `qwen3-reranker-0.6b-q8_0` | Re-ranking | ~640MB |
569
+ | `qmd-query-expansion-1.7B-q4_k_m` | Query expansion (fine-tuned) | ~1.1GB |
570
+
571
+ Models are downloaded from HuggingFace and cached in `~/.cache/qmd/models/`.
572
+
573
+ ### Custom Embedding Model
574
+
575
+ Override the default local embedding model via the `QMD_EMBED_MODEL` environment variable.
576
+
577
+ ```sh
578
+ # Use another local GGUF embedding model
579
+ export QMD_EMBED_MODEL="hf:org/model/repo-file.gguf"
580
+
581
+ # After changing the model, re-embed all collections:
582
+ qmd embed -f
583
+ ```
584
+
585
+ Supported model families:
586
+ - **embeddinggemma** (default) — Smaller local model
587
+ - **Qwen3-Embedding** — Multilingual, including CJK
588
+
589
+ > **Note:** When switching embedding models, you must re-index with `qmd embed -f`
590
+ > since vectors are not cross-compatible between models. The prompt format is
591
+ > automatically adjusted for each model family.
592
+
593
+ ### Remote Embedding & Models Configuration (Explicit Opt-in)
594
+
595
+ QMD stays local by default. To use remote embeddings or remote LLM models, configure them in `index.yml` under the unified `models:` section.
596
+
597
+ ```yaml
598
+ models:
599
+ embed: hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf
600
+ embed_api_url: https://api.openai.com/v1 # Both embed_api_url and embed_api_model enable remote embeddings
601
+ embed_api_model: text-embedding-3-small # or text-embedding-3-large
602
+ embed_dimension: 1536 # Optional: expected vector dimension; validates local output
603
+
604
+ # Optional: Remote LLM Query Expansion (aliases: generate_url, generate_base_url, generate_api_url)
605
+ generate_api_url: https://generativelanguage.googleapis.com/v1beta/openai/v1 # Base URL (appends /chat/completions) or full endpoint
606
+ generate_api_model: gemini-3.5-flash-lite # or gpt-4o-mini
607
+
608
+ # Optional: Remote Reranking (aliases: rerank_url, rerank_base_url, rerank_api_url)
609
+ rerank_api_url: https://generativelanguage.googleapis.com/v1beta/openai/v1/chat/completions # Supports both /v1/rerank and /v1/chat/completions LLM endpoints
610
+ rerank_api_model: gemini-3.5-flash-lite # or gpt-4o-mini
611
+
612
+ # Optional: Custom User Dictionary for CJK segmentation
613
+ dictionary: ~/.config/qmd/dictionary.txt
614
+ ```
615
+
616
+ > **Smart URL Resolution & Aliases:** All remote endpoint URLs support `_url`, `_base_url`, and `_api_url` aliases (e.g. `generate_url`, `generate_base_url`, `generate_api_url`). When given a Base URL (e.g. `https://api.example.com/v1`), QMD automatically appends `/chat/completions` for LLM generate and `/rerank` for reranking. If a full endpoint URL is provided, it is used directly. For reranking, QMD supports both dedicated Cross-Encoder endpoints (`/v1/rerank`) and general LLM endpoints (`/v1/chat/completions`).
617
+
618
+ > **CLI, SDK & MCP Integration:** Remote LLM query expansion and LLM Chat Reranking are automatically wired into CLI (`qmd query`), SDK (`createStore`), and MCP. Query expansion automatically enforces query language & script consistency (e.g., Traditional Chinese queries generate Traditional Chinese `lex`, `vec`, and `hyde` variations). LLM Chat Reranking features prompt-tail Recency Enforcement and strict JSON sanitization to ensure safe execution with any remote LLM backend.
619
+
620
+ `qmd init` writes local defaults only; it does not prompt for or generate a remote embedding configuration. To use a remote endpoint, edit `index.yml` manually and set credentials when required.
621
+
622
+ > **API Key Note:** Remote embeddings require both an embedding endpoint (`embed_url`, `embed_base_url`, or `embed_api_url`) and `embed_api_model`. When using official OpenAI (`api.openai.com`), set `OPENAI_API_KEY="..."`. For self-hosted proxies or keyless local servers, `OPENAI_API_KEY` is optional. QMD never writes keys to SQLite, diagnostics, logs, or error messages.
623
+
624
+ ```sh
625
+ export OPENAI_API_KEY="..." # Required for api.openai.com; optional for self-hosted proxies
626
+
627
+ # Send pending document chunks and build the vector index.
628
+ qmd embed
629
+ ```
630
+
631
+ Configure `embed_url`, `embed_base_url`, or `embed_api_url` together with `embed_api_model` to select the OpenAI-compatible embedding provider; QMD sends `POST /embeddings` requests. Changing the endpoint or model changes the remote embedding identity and requires a vector rebuild.
632
+
633
+ `vectors_vec` can store only one dimension. A dimension or identity change requires a forced rebuild:
634
+
635
+ ```sh
636
+ qmd embed --force
637
+ ```
638
+
639
+ Remote document chunks use deterministic UTF-8 byte windows. Query embedding is allowed only when the same identity is `ready` and compatible vectors exist; a vector or hybrid query then sends the formatted query text to the configured remote provider. After a vector reset deletes the old vectors, there is no vector rollback; lexical search remains available while rebuilding.
640
+
641
+ #### Embedding identity, recovery, and data boundary
642
+
643
+ `vectors_vec` holds one vector dimension at a time. An embedding identity includes
644
+ the provider, model, dimension, local/remote mode, and prompt/chunking profiles;
645
+ changing it requires `qmd embed --force`. A build lease with an owner ID and
646
+ generation sequence prevents concurrent writers. If an embedding request fails or
647
+ the lease expires, the index enters `Partial`; rerunning `qmd embed` with the same
648
+ identity resumes committed work and requests only missing, vector-only,
649
+ metadata-only, or layout-incomplete chunks. Vector search reads only `ready`
650
+ vectors with a compatible fingerprint.
651
+
652
+ Remote document chunks and formatted query text are sent to the configured remote
653
+ embedding provider. Remote embedding failures **never fall back to a local
654
+ embedding provider**, because vectors from a different identity are incompatible.
655
+ API keys are read only from process environment variables and are never stored in
656
+ SQLite, diagnostics, logs, or errors. Errors redact response bodies, request
657
+ payloads, credentials, and sensitive URLs; identity and lease state are
658
+ revalidated inside SQLite write locks before vectors are cleared or published.
659
+
660
+ Remote LLM routing is independent per operation: expansion and reranking may each
661
+ use a remote endpoint while embeddings remain local. A remote LLM failure can fall
662
+ back to the corresponding local LlamaCpp operation without interrupting a search;
663
+ dedicated `/v1/rerank` endpoints that return HTTP 404 fall back to structured Chat
664
+ Completions reranking. This fallback does not apply to remote embeddings.
665
+
666
+ `qmd status` and `qmd doctor` report provider/model/dimension, short fingerprint,
667
+ key presence as a boolean, pending/inconsistent chunks, CJK channel readiness, and
668
+ actionable repair commands. Diagnostics are read-only: they do not create schemas,
669
+ modify SQLite databases, load local models, or issue remote HTTP requests.
670
+
671
+ ### CJK Lexical Search, Expansion, and Dictionary Maintenance
672
+
673
+ CJK lexical search combines independent character, word, and bigram channels by
674
+ rank. If Jieba or the published word/bigram index is unavailable, stale, or
675
+ incompatible, QMD omits both analyzed channels and falls back to character-only
676
+ search instead of failing the query or issuing an empty FTS match. `qmd status`
677
+ reports the reason and remediation.
678
+
679
+ Each lexical channel ranks candidates independently before channel-aware weighted
680
+ RRF combines them; raw channel scores are never summed. The original query runs
681
+ through every available lexical and vector path at ×2 weight. Typed expansion
682
+ variants are ×1: `lex` variants use the character, word, and bigram channels,
683
+ while `vec` and `hyde` variants use vector search. Use `--explain` to inspect
684
+ channel contributions and tie-breakers.
685
+
686
+ Query expansion uses one shared `auto | force | skip` policy across CLI, SDK, and
687
+ MCP. Precedence is: explicit skip or `lex:` skips expansion; explicit force,
688
+ `expand:`, or `--expand` forces it; under `auto`, CJK queries skip local model expansion (0ms bypass) but automatically expand when a remote LLM (`generate_api_url`) is configured; strong lexical
689
+ signals skip expansion, and remaining queries expand. Expanded queries automatically maintain language consistency with the user query.
690
+
691
+ #### CJK index publication and recovery
692
+
693
+ Published character, word, and bigram indexes are updated atomically with a
694
+ synchronous document mutation. Rebuilds populate shadow tables from a stable read
695
+ snapshot; mutations after that snapshot are recorded in a journal and replayed
696
+ before an atomic publication. The analyzer fingerprint includes normalization
697
+ rules, Jieba capability, built-in and optional user dictionary hashes, and token
698
+ stream parameters. A changed fingerprint triggers a rebuild rather than allowing
699
+ semantically incompatible analyzed indexes to remain active.
700
+
701
+ If a builder lease expires, cleanup restores `Ready`, `Empty`, or `Dirty` based on
702
+ the published index and dirty markers. `qmd status` and `qmd doctor` identify the
703
+ current cause and remediation. If Jieba is unavailable during a build, analyzed
704
+ channels are marked unavailable and search continues through the character channel.
705
+
706
+ QMD ships `zh-dict`, a single Jieba dictionary containing both Traditional and
707
+ Simplified Chinese entries. It deterministically combines the upstream
708
+ `@node-rs/jieba` dictionary, APCLab's `jieba-tw` dictionary, and selected
709
+ technical terminology from zhtw-mcp. A configured user dictionary is loaded
710
+ after `zh-dict`, so user entries retain the highest priority.
711
+
712
+ The runtime, build, and package installation never download dictionary sources.
713
+ Maintainers can regenerate the checked-in asset from the fixed source pins and
714
+ content hashes in `src/search/zh-dict.sources.json`:
715
+
716
+ ```sh
717
+ # Verify every pinned source and regenerate src/search/zh-dict.txt
718
+ pnpm dict:sync
719
+
720
+ # Intentionally advance the configured upstream branch pins, then regenerate
721
+ pnpm dict:sync -- --update-pins
722
+ ```
723
+
724
+ `dict:sync` records the generated asset's SHA-256 in the source configuration.
725
+ Review the resulting pin and dictionary diff before committing an update.
726
+
727
+ ## Installation
728
+
729
+ ```sh
730
+ npm install -g @wei840222/qmd
731
+ # or
732
+ bun install -g @wei840222/qmd
733
+ ```
734
+
735
+ ### Development
736
+
737
+ ```sh
738
+ git clone https://github.com/tobi/qmd
739
+ cd qmd
740
+ bun install
741
+ bun link
742
+ ```
743
+
744
+ ## Usage
745
+
746
+ ### Collection Management
747
+
748
+ ```sh
749
+ # Create a collection from current directory
750
+ qmd collection add . --name myproject
751
+
752
+ # Create a collection with explicit path and custom glob mask
753
+ qmd collection add ~/Documents/notes --name notes --mask "**/*.md"
754
+
755
+ # Comma-separated masks are a union (brace form `{a,b}` also works)
756
+ qmd collection add ~/notes --name notes --mask "sources/**/*.md,CO - *.md"
757
+
758
+ # List all collections
759
+ qmd collection list
760
+
761
+ # Remove a collection
762
+ qmd collection remove myproject
763
+
764
+ # Rename a collection
765
+ qmd collection rename myproject my-project
766
+
767
+ # List files in a collection
768
+ qmd ls notes
769
+ qmd ls notes/subfolder
770
+
771
+ # Show collection details (path, glob mask, include status, context count)
772
+ qmd collection show notes
773
+
774
+ # Include or exclude a collection from default (unscoped) queries
775
+ qmd collection include notes
776
+ qmd collection exclude notes
777
+
778
+ # Run a command before every `qmd update` (e.g. git pull); empty arg clears it
779
+ qmd collection update-cmd notes 'git pull --rebase'
780
+ qmd collection update-cmd notes
781
+ ```
782
+
783
+ ### Generate Vector Embeddings
784
+
785
+ ```sh
786
+ # Embed all indexed documents (900 tokens/chunk, 15% overlap)
787
+ qmd embed
788
+
789
+ # Force re-embed everything
790
+ qmd embed -f
791
+
792
+ # Enable AST-aware chunking for code files (TS, JS, Python, Go, Rust)
793
+ qmd embed --chunk-strategy auto
794
+
795
+ # Also works with query for consistent chunk selection
796
+ qmd query "auth flow" --chunk-strategy auto
797
+
798
+ # Memory control for large corpora / constrained systems
799
+ qmd embed --max-docs-per-batch 50 # cap docs per embedding batch
800
+ qmd embed --max-batch-mb 64 # cap batch size in MB
801
+ ```
802
+
803
+ **AST-aware chunking** (`--chunk-strategy auto`) uses tree-sitter to chunk code
804
+ files at function, class, and import boundaries instead of arbitrary text
805
+ positions. This produces higher-quality chunks and better search results for
806
+ codebases. Markdown and other file types always use regex-based chunking
807
+ regardless of strategy.
808
+
809
+ The default is `regex` (existing behavior). Use `--chunk-strategy auto` to
810
+ opt in. Run `qmd status` to verify which grammars are available.
811
+
812
+ > **Note:** Tree-sitter grammars are optional dependencies. If they are not
813
+ > installed, `--chunk-strategy auto` falls back to regex-only chunking
814
+ > automatically. Tested on both Node.js and Bun.
815
+
816
+ ### Context Management
817
+
818
+ Context adds descriptive metadata to collections and paths, helping search understand your content.
819
+
820
+ ```sh
821
+ # Add context to a collection (using qmd:// virtual paths)
822
+ qmd context add qmd://notes "Personal notes and ideas"
823
+ qmd context add qmd://docs/api "API documentation"
824
+
825
+ # Add context from within a collection directory
826
+ cd ~/notes && qmd context add "Personal notes and ideas"
827
+ cd ~/notes/work && qmd context add "Work-related notes"
828
+
829
+ # Add global context (applies to all collections)
830
+ qmd context add / "Knowledge base for my projects"
831
+
832
+ # List all contexts
833
+ qmd context list
834
+
835
+ # Remove context
836
+ qmd context rm qmd://notes/old
837
+ ```
838
+
839
+ ### Configuring `index.yml`
840
+
841
+ The `collection` and `context` commands above all read and write a single YAML
842
+ config file — you can also edit it directly. Everything QMD knows about your
843
+ collections (paths, masks, exclusions, per-collection update hooks, contexts, and
844
+ optional model overrides) lives here. A fully-commented starter template ships as
845
+ [`example-index.yml`](example-index.yml) in this repo.
846
+
847
+ **Location:** `~/.config/qmd/index.yml` by default. The directory honors
848
+ `XDG_CONFIG_HOME` (→ `$XDG_CONFIG_HOME/qmd/index.yml`) and `QMD_CONFIG_DIR`. A
849
+ named index uses `{name}.yml` — `qmd --index work …` reads/writes `work.yml`.
850
+ A **project-local** index created with `qmd init` lives at `.qmd/index.yml`
851
+ (`.qmd/index.yaml` is also accepted) alongside a project-local `index.sqlite`,
852
+ so config and index stay inside the project instead of `~/.config` / `~/.cache`.
853
+
854
+ ```yaml
855
+ # ~/.config/qmd/index.yml
856
+
857
+ # Context applied to every collection (system-message style). Optional.
858
+ global_context: "Knowledge base for my projects"
859
+
860
+ # Terminal hyperlink template for search results. Optional.
861
+ # Overridden by the QMD_EDITOR_URI env var. See "Editor Links" below.
862
+ editor_uri: "vscode://file{path}:{line}:{col}"
863
+
864
+ # Override the default GGUF models per role. Optional — omit to use the
865
+ # built-in defaults. `qmd init` writes this block pre-filled with the
866
+ # resolved defaults. See "Model Configuration" for the default URIs.
867
+ models:
868
+ embed: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
869
+ rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"
870
+ generate: "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf"
871
+
872
+ # One entry per collection. The key is the collection name.
873
+ collections:
874
+ notes:
875
+ path: /Users/me/notes # absolute path to index (required)
876
+ pattern: "**/*.md" # glob mask (default: **/*.md)
877
+ ignore: # glob patterns to exclude from indexing
878
+ - "Archive/**"
879
+ - "**/drafts/**"
880
+ update: "git pull --rebase" # bash command run before each `qmd update`
881
+ includeByDefault: true # include in unscoped queries (default: true)
882
+ context: # path prefix → description; longest match wins
883
+ "/": "Personal notes and ideas"
884
+ "/work": "Work-related notes"
885
+ ```
886
+
887
+ | Key | Scope | Purpose |
888
+ |-----|-------|---------|
889
+ | `global_context` | top-level | Context prepended for every collection. Set via `qmd context add /`. |
890
+ | `editor_uri` (alias `editor_uri_template`) | top-level | Hyperlink template for clickable result paths; `QMD_EDITOR_URI` overrides. |
891
+ | `models.embed` / `.rerank` / `.generate` | top-level | HuggingFace GGUF URIs (`hf:<user>/<repo>/<file>`) overriding the built-in defaults per role. |
892
+ | `collections.<name>.path` | per-collection | Absolute directory to index. |
893
+ | `collections.<name>.pattern` | per-collection | Glob mask. Set via `qmd collection add --mask`. Default `**/*.md`. Comma-separated lists and brace groups (`{a,b}`) are a union of patterns. |
894
+ | `collections.<name>.ignore` | per-collection | Glob patterns excluded from indexing — useful to stop nested collections double-indexing. **YAML-only — no CLI command sets this.** Additive with QMD's built-in exclusions (`node_modules`, `.git`, `.cache`, `vendor`, `dist`, `build`), which you cannot un-ignore. |
895
+ | `collections.<name>.update` | per-collection | Bash command run before `qmd update` re-indexes this collection. Set via `qmd collection update-cmd`. |
896
+ | `collections.<name>.includeByDefault` | per-collection | Whether unscoped queries search it. Toggle with `qmd collection include`/`exclude`. Default `true`. |
897
+ | `collections.<name>.context` | per-collection | Path-prefix → description map; the most specific (longest) matching prefix wins. Set via `qmd context add`. |
898
+
899
+ > **Note:** Editing `index.yml` changes which directories and models QMD *uses*,
900
+ > but does not re-index on its own. Run `qmd update` after changing `path`,
901
+ > `pattern`, or `ignore`, and `qmd embed` after changing `models.embed`.
902
+
903
+ #### Automatic update commands
904
+
905
+ A collection's `update` field is QMD's built-in refresh hook: when you run
906
+ `qmd update`, each collection's `update` command runs **first**, then the
907
+ collection is re-indexed. This keeps a collection in sync with an upstream source
908
+ (a git remote, a sync script) without wrapping `qmd` yourself.
909
+
910
+ ```yaml
911
+ collections:
912
+ wiki:
913
+ path: ~/reference/wiki
914
+ update: "git pull --ff-only"
915
+ ```
916
+
917
+ $ qmd update
918
+ [1/3] wiki (**/*.md)
919
+ Running update command: git pull --ff-only
920
+ Already up to date.
921
+ Collection: ~/reference/wiki (**/*.md)
922
+ Indexed: 0 new, 2 updated, 340 unchanged, 0 removed
923
+
924
+ The command runs via `bash -c` in the collection's own directory (its `path`), not
925
+ your current working directory. If it exits non-zero, `qmd update` prints the
926
+ failure and **aborts the entire run** — collections after the failing one are not
927
+ re-indexed. Set or clear it from the CLI instead of editing YAML by hand:
928
+
929
+ ```sh
930
+ qmd collection update-cmd wiki 'git pull --ff-only' # set
931
+ qmd collection update-cmd wiki # clear
932
+ ```
933
+
934
+ ##### Checked-in `.qmd` config is not trusted by default
935
+
936
+ A project-local `.qmd/index.yml` travels with a `git clone`, and QMD adopts it
937
+ automatically for any command run inside the tree. Three fields in that file can
938
+ reach outside the project, and QMD will not use them unattended:
939
+
940
+ - `update` commands — somebody else's shell script, run by `qmd update`
941
+ - `collections.*.path` pointing **outside** the project directory
942
+ - `models.embed` / `models.rerank` / `models.generate` other than the built-in
943
+ defaults (any `hf:` repo or local GGUF path)
944
+
945
+ In-project collection paths (for example `./docs`) still index. On a terminal
946
+ `qmd update` (and `qmd embed` / `qmd pull` / `qmd query`) lists the gated
947
+ fields and asks. Approving records the approval in `~/.config/qmd/trusted.json`.
948
+ With no terminal to ask — agents, CI, MCP — those fields are **skipped** and
949
+ in-project indexing continues.
950
+
951
+ Approvals cover the exact gated set you saw. Editing a command, pointing a
952
+ collection outside the project, or changing a custom model URI asks again.
953
+
954
+ ```sh
955
+ qmd trust # review and approve this project's gated fields
956
+ qmd trust list # show every approved project config
957
+ qmd trust revoke # drop the approval for this project
958
+ ```
959
+
960
+ Set `QMD_TRUST_LOCAL_CONFIG=1` (or `QMD_TRUST_UPDATE_HOOKS=1`) for CI that
961
+ should allow them unattended. Your own `~/.config/qmd/*.yml` — including
962
+ anything `qmd collection update-cmd` or `qmd collection add` writes — is
963
+ never gated.
964
+
965
+ ### Search Commands
966
+
967
+ ```
968
+ ┌──────────────────────────────────────────────────────────────────┐
969
+ │ Search Modes │
970
+ ├──────────┬───────────────────────────────────────────────────────┤
971
+ │ search │ BM25 full-text search only │
972
+ │ vsearch │ Vector semantic search only │
973
+ │ query │ Hybrid: FTS + Vector + Query Expansion + Re-ranking │
974
+ └──────────┴───────────────────────────────────────────────────────┘
975
+ ```
976
+
977
+ ```sh
978
+ # Full-text search (fast, keyword-based)
979
+ qmd search "authentication flow"
980
+
981
+ # Vector search (semantic similarity)
982
+ qmd vsearch "how to login"
983
+
984
+ # Hybrid search with re-ranking (best quality)
985
+ qmd query "user authentication"
986
+ ```
987
+
988
+ Two aliases exist for the semantic/hybrid modes: `vector-search` (→ `vsearch`)
989
+ and `deep-search` (→ `query`).
990
+
991
+ ### Options
992
+
993
+ ```sh
994
+ # Search options
995
+ -n <num> # Number of results (default: 5, or 20 for --files/--json)
996
+ -c, --collection # Restrict search to a specific collection
997
+ --all # Return all matches (use with --min-score to filter)
998
+ --min-score <num> # Minimum score threshold (default: 0)
999
+ --full # Show full document content
1000
+ --line-numbers # Add line numbers to output
1001
+ --explain # Include retrieval score traces (query, JSON/CLI output)
1002
+ --index <name> # Use named index
1003
+ --intent "<text>" # Legacy CLI alias for rerank context (e.g. "web page load times")
1004
+ --no-rerank # Skip LLM reranking (RRF scores only; faster on CPU)
1005
+ -C, --candidate-limit <n> # Max candidates to rerank (default: 40)
1006
+ --full-path # Emit on-disk filesystem paths instead of qmd:// URIs
1007
+ # (a result whose file has moved or been deleted since
1008
+ # indexing keeps its qmd:// URI + docid, and a notice is
1009
+ # printed to stderr — run `qmd update` to refresh)
1010
+
1011
+ # Output formats (for search and multi-get)
1012
+ --format <kind> # cli (default) | json | csv | md | xml | files
1013
+ # (--json, --csv, --md, --xml, --files are legacy aliases)
1014
+
1015
+ # Get options
1016
+ qmd get <file>[:from[:count]] # Get document; optional start line and count
1017
+ -l <num> # Maximum lines to return
1018
+ --from <num> # Start line (overrides the :from suffix)
1019
+ --no-line-numbers # Disable line numbering (on by default)
1020
+
1021
+ # Multi-get options
1022
+ -l <num> # Maximum lines per file
1023
+ --max-bytes <num> # Skip files larger than N bytes (default: 64KB)
1024
+ ```
1025
+
1026
+ ### Collection Filtering
1027
+
1028
+ The `-c`/`--collection` flag filters results by collection **name** (as shown by
1029
+ `qmd collection list`). Collections are a global registry — you can search any
1030
+ collection from any directory:
1031
+
1032
+ ```sh
1033
+ qmd search "auth" -c notes # single collection
1034
+ qmd search "auth" -c notes -c docs # multiple collections (OR)
1035
+ ```
1036
+
1037
+ With no `-c` flag, all default-included collections are searched. Collections
1038
+ marked excluded (`qmd collection exclude <name>`) are skipped unless named
1039
+ explicitly with `-c`.
1040
+
1041
+ > **Note:** Multiple `-c` flags are combined with OR and applied before each
1042
+ > lexical and vector candidate cutoff. Matching candidates from the selected
1043
+ > collections are then ranked together.
1044
+
1045
+ ### Output Format
1046
+
1047
+ Default output is colorized CLI format (respects `NO_COLOR` env).
1048
+
1049
+ When stdout is a TTY, result paths are emitted as clickable terminal hyperlinks (OSC 8). Clicking a path opens the file in your editor using an editor URI template.
1050
+
1051
+ When stdout is not a TTY (for example piped to another command or redirected to a file), QMD emits plain text paths with no escape sequences.
1052
+
1053
+ TTY example:
1054
+
1055
+ ```
1056
+ docs/guide.md:42 #a1b2c3
1057
+ Title: Software Craftsmanship
1058
+ Context: Work documentation
1059
+ Score: 93%
1060
+
1061
+ This section covers the **craftsmanship** of building
1062
+ quality software with attention to detail.
1063
+ See also: engineering principles
1064
+
1065
+
1066
+ notes/meeting.md:15 #d4e5f6
1067
+ Title: Q4 Planning
1068
+ Context: Personal notes and ideas
1069
+ Score: 67%
1070
+
1071
+ Discussion about code quality and craftsmanship
1072
+ in the development process.
1073
+ ```
1074
+
1075
+ Configure the editor link target with `QMD_EDITOR_URI` (or `editor_uri` in config):
1076
+
1077
+ ```sh
1078
+ # VS Code (default)
1079
+ export QMD_EDITOR_URI="vscode://file/{path}:{line}:{col}"
1080
+
1081
+ # Cursor
1082
+ export QMD_EDITOR_URI="cursor://file/{path}:{line}:{col}"
1083
+
1084
+ # Zed
1085
+ export QMD_EDITOR_URI="zed://file/{path}:{line}:{col}"
1086
+
1087
+ # Sublime Text
1088
+ export QMD_EDITOR_URI="subl://open?url=file://{path}&line={line}"
1089
+ ```
1090
+
1091
+ Template placeholders:
1092
+ - `{path}` absolute filesystem path (URI-encoded)
1093
+ - `{line}` 1-based line number
1094
+ - `{col}` or `{column}` 1-based column number
1095
+
1096
+ - **Path**: Collection-relative path (e.g., `docs/guide.md`)
1097
+ - **Docid**: Short hash identifier (e.g., `#a1b2c3`) - use with `qmd get #a1b2c3`
1098
+ - **Title**: Extracted from document (first heading or filename)
1099
+ - **Context**: Path context if configured via `qmd context add`
1100
+ - **Score**: Color-coded (green >70%, yellow >40%, dim otherwise)
1101
+ - **Snippet**: Context around match with query terms highlighted
1102
+
1103
+ ### Examples
1104
+
1105
+ ```sh
1106
+ # Get 10 results with minimum score 0.3
1107
+ qmd query -n 10 --min-score 0.3 "API design patterns"
1108
+
1109
+ # Output as markdown for LLM context
1110
+ qmd search --md --full "error handling"
1111
+
1112
+ # JSON output for scripting
1113
+ qmd query --json "quarterly reports"
1114
+
1115
+ # Inspect how each result was scored (RRF + rerank blend)
1116
+ qmd query --json --explain "quarterly reports"
1117
+
1118
+ # Use separate index for different knowledge base
1119
+ qmd --index work search "quarterly reports"
1120
+ ```
1121
+
1122
+ The `--explain` flag attaches a score breakdown to each result: the FTS/vector
1123
+ backend scores plus the RRF fusion math (rank, weight, top-rank bonus) and every
1124
+ sub-query's contribution. Abbreviated:
1125
+
1126
+ ```json
1127
+ {
1128
+ "docid": "#6c90f0",
1129
+ "score": 0.89,
1130
+ "file": "qmd://qmd/README.md",
1131
+ "explain": {
1132
+ "ftsScores": [0.892, 0.907],
1133
+ "vectorScores": [0.540, 0.484],
1134
+ "rrf": {
1135
+ "rank": 1,
1136
+ "weight": 0.75,
1137
+ "baseScore": 0.123,
1138
+ "topRankBonus": 0.05,
1139
+ "totalScore": 0.173,
1140
+ "contributions": [
1141
+ { "source": "fts", "queryType": "original", "query": "reranking",
1142
+ "rank": 1, "weight": 2, "backendScore": 0.892, "rrfContribution": 0.0328 }
1143
+ ]
1144
+ }
1145
+ }
1146
+ }
1147
+ ```
1148
+
1149
+ ### Index Maintenance
1150
+
1151
+ ```sh
1152
+ # Show index status and collections with contexts
1153
+ qmd status
1154
+
1155
+ # Re-index all collections. If a collection has a configured update command
1156
+ # (e.g. `git pull`), it runs first — set one with `qmd collection update-cmd`.
1157
+ qmd update
1158
+
1159
+ # Diagnose the install (runtime, sqlite-vec, embedding fingerprints, GPU probe)
1160
+ qmd doctor
1161
+
1162
+ # Initialize a project-local index in the current directory
1163
+ qmd init
1164
+
1165
+ # Get document by filepath (with fuzzy matching suggestions)
1166
+ qmd get notes/meeting.md
1167
+
1168
+ # Get document by docid (from search results)
1169
+ qmd get "#abc123"
1170
+
1171
+ # Get document starting at line 50, max 100 lines
1172
+ qmd get notes/meeting.md:50 -l 100
1173
+
1174
+ # Read 40 lines starting at line 120 via the :from:count suffix (works with docids)
1175
+ qmd get notes/meeting.md:120:40
1176
+ qmd get "#abc123:120:40"
1177
+
1178
+ # get / multi-get are line-numbered by default; disable with --no-line-numbers
1179
+ qmd get notes/meeting.md --no-line-numbers
1180
+
1181
+ # Get multiple documents by glob pattern
1182
+ qmd multi-get "journals/2025-05*.md"
1183
+
1184
+ # Get multiple documents by comma-separated list (supports docids)
1185
+ qmd multi-get "doc1.md, doc2.md, #abc123"
1186
+
1187
+ # Limit multi-get to files under 20KB
1188
+ qmd multi-get "docs/*.md" --max-bytes 20480
1189
+
1190
+ # Output multi-get as JSON for agent processing
1191
+ qmd multi-get "docs/*.md" --json
1192
+
1193
+ # Clean up cache and orphaned data
1194
+ qmd cleanup
1195
+ ```
1196
+
1197
+ ### Benchmarking
1198
+
1199
+ Measure search quality across all four backends with `qmd bench` and a fixture file
1200
+ of queries with known-relevant documents.
1201
+
1202
+ **From a git checkout**, an example fixture and its test corpus ship in the repo:
1203
+
1204
+ ```sh
1205
+ # One-time setup (indexes the repo's test corpus into its own collection)
1206
+ qmd collection add test/eval-docs --name eval-docs
1207
+ qmd embed -c eval-docs
1208
+
1209
+ # Run the benchmark (table output)
1210
+ qmd bench src/bench/fixtures/example.json
1211
+
1212
+ # JSON output for programmatic analysis
1213
+ qmd bench src/bench/fixtures/example.json --json
1214
+ ```
1215
+
1216
+ > The example fixture (`src/bench/fixtures/example.json`) and its test corpus
1217
+ > (`test/eval-docs/`) exist only in a git checkout — they are **not** part of the
1218
+ > published npm package. If you installed via `npm`/`npx`, write your own fixture
1219
+ > (see below) against a collection you have already indexed:
1220
+ >
1221
+ > ```sh
1222
+ > qmd bench my-fixture.json -c my-collection
1223
+ > ```
1224
+
1225
+ Each query runs against four backends, reporting precision@k, recall, MRR, and F1:
1226
+
1227
+ | Backend | What it tests | LLM required |
1228
+ |---------|---------------|--------------|
1229
+ | `bm25` | Keyword search only (FTS5) | No |
1230
+ | `vector` | Semantic similarity only | Embedding model |
1231
+ | `hybrid` | BM25 + vector fusion (no reranking) | Embedding model |
1232
+ | `full` | Full pipeline with LLM reranking | All three models |
1233
+
1234
+ **Score interpretation:** `1.00` = perfect (all expected docs in top results),
1235
+ `0.00` = complete miss. The example fixture typically shows bm25 ~0.50, vector
1236
+ ~0.70, and hybrid/full ~1.00 — a concrete demonstration of why hybrid search beats
1237
+ either backend alone.
1238
+
1239
+ **Custom fixtures** are JSON:
1240
+
1241
+ ```json
1242
+ {
1243
+ "description": "My benchmark",
1244
+ "version": 1,
1245
+ "collection": "my-collection",
1246
+ "queries": [
1247
+ {
1248
+ "id": "find-auth",
1249
+ "query": "authentication flow",
1250
+ "type": "semantic",
1251
+ "expected_files": ["docs/auth-design.md"],
1252
+ "expected_in_top_k": 3
1253
+ }
1254
+ ]
1255
+ }
1256
+ ```
1257
+
1258
+ `expected_files` are collection-relative paths as shown by `qmd ls`. The `type`
1259
+ field (`exact`, `semantic`, `topical`, `cross-domain`, `alias`) labels queries for
1260
+ grouping — it does not change search behavior.
1261
+
1262
+ > **Heads-up:** if the fixture's collection isn't indexed, bench currently runs to
1263
+ > completion and reports all zeros with no warning. Verify setup with
1264
+ > `qmd ls <collection>` first.
1265
+
1266
+ ## Data Storage
1267
+
1268
+ Index stored in: `~/.cache/qmd/index.sqlite`
1269
+
1270
+ ### Schema
1271
+
1272
+ ```sql
1273
+ collections -- Indexed directories with name and glob patterns
1274
+ path_contexts -- Context descriptions by virtual path (qmd://...)
1275
+ documents -- Markdown content with metadata and docid (6-char hash)
1276
+ documents_fts -- FTS5 full-text index
1277
+ content_vectors -- Embedding chunks (hash, seq, pos, 900 tokens each)
1278
+ vectors_vec -- sqlite-vec vector index (hash_seq key)
1279
+ llm_cache -- Cached LLM responses (query expansion, rerank scores)
1280
+ ```
1281
+
1282
+ ## Environment Variables
1283
+
1284
+ | Variable | Default | Description |
1285
+ |----------|---------|-------------|
1286
+ | `XDG_CACHE_HOME` | `~/.cache` | Cache directory location |
1287
+ | `XDG_CONFIG_HOME` | `~/.config` | Config directory location (where `index.yml` lives) |
1288
+ | `QMD_CONFIG_DIR` | unset | Override the config directory outright (takes precedence over `XDG_CONFIG_HOME`) |
1289
+ | `QMD_LLAMA_GPU` | `auto` | Force llama.cpp GPU backend (`metal`, `vulkan`, `cuda`) or disable GPU with `false` |
1290
+ | `QMD_FORCE_CPU` | unset | Set to `1`/`true` to force CPU mode before any CUDA/Vulkan/Metal probing. Equivalent CLI flag: `--no-gpu`. |
1291
+ | `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. |
1292
+
1293
+ ## How It Works
1294
+
1295
+ ### Indexing Flow
1296
+
1297
+ ```
1298
+ Collection ──► Glob Pattern ──► Markdown Files ──► Parse Title ──► Hash Content
1299
+ │ │ │
1300
+ │ │ ▼
1301
+ │ │ Generate docid
1302
+ │ │ (6-char hash)
1303
+ │ │ │
1304
+ └──────────────────────────────────────────────────►└──► Store in SQLite
1305
+
1306
+
1307
+ FTS5 Index
1308
+ ```
1309
+
1310
+ ### Embedding Flow
1311
+
1312
+ Documents are chunked into ~900-token pieces with 15% overlap using smart boundary detection:
1313
+
1314
+ ```
1315
+ Document ──► Smart Chunk (~900 tokens) ──► Format each chunk ──► node-llama-cpp ──► Store Vectors
1316
+ │ "title | text" embedBatch()
1317
+
1318
+ └─► Chunks stored with:
1319
+ - hash: document hash
1320
+ - seq: chunk sequence (0, 1, 2...)
1321
+ - pos: character position in original
1322
+ ```
1323
+
1324
+ ### Smart Chunking
1325
+
1326
+ Instead of cutting at hard token boundaries, QMD uses a scoring algorithm to find natural markdown break points. This keeps semantic units (sections, paragraphs, code blocks) together.
1327
+
1328
+ **Break Point Scores:**
1329
+
1330
+ | Pattern | Score | Description |
1331
+ |---------|-------|-------------|
1332
+ | `# Heading` | 100 | H1 - major section |
1333
+ | `## Heading` | 90 | H2 - subsection |
1334
+ | `### Heading` | 80 | H3 |
1335
+ | `#### Heading` | 70 | H4 |
1336
+ | `##### Heading` | 60 | H5 |
1337
+ | `###### Heading` | 50 | H6 |
1338
+ | ` ``` ` | 80 | Code block boundary |
1339
+ | `---` / `***` | 60 | Horizontal rule |
1340
+ | Blank line | 20 | Paragraph boundary |
1341
+ | `- item` / `1. item` | 5 | List item |
1342
+ | Line break | 1 | Minimal break |
1343
+
1344
+ **Algorithm:**
1345
+
1346
+ 1. Scan document for all break points with scores
1347
+ 2. When approaching the 900-token target, search a 200-token window before the cutoff
1348
+ 3. Score each break point: `finalScore = baseScore × (1 - (distance/window)² × 0.7)`
1349
+ 4. Cut at the highest-scoring break point
1350
+
1351
+ The squared distance decay means a heading 200 tokens back (score ~30) still beats a simple line break at the target (score 1), but a closer heading wins over a distant one.
1352
+
1353
+ **Code Fence Protection:** Break points inside code blocks are ignored—code stays together. If a code block exceeds the chunk size, it's kept whole when possible.
1354
+
1355
+ **AST-Aware Chunking (Code Files):**
1356
+
1357
+ For supported code files, QMD also parses the source with [tree-sitter](https://tree-sitter.github.io/) and adds AST-derived break points that are merged with the regex scores above:
1358
+
1359
+ | AST Node | Score | Languages |
1360
+ |----------|-------|-----------|
1361
+ | Class / interface / struct / impl / trait | 100 | All |
1362
+ | Function / method | 90 | All |
1363
+ | Type alias / enum | 80 | All |
1364
+ | Import / use declaration | 60 | All |
1365
+
1366
+ Supported for `.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, and `.rs` files. Enable with `--chunk-strategy auto`. Markdown and other file types always use regex chunking.
1367
+
1368
+ ### Query Flow (Hybrid)
1369
+
1370
+ ```
1371
+ Query ──► Expansion policy (`auto` | `force` | `skip`)
1372
+
1373
+
1374
+ ┌──────────────────────┴──────────────────────┐
1375
+ ▼ ▼
1376
+ Original query (×2) Zero or more typed variants (×1)
1377
+ │ │
1378
+ ├──► FTS (BM25) ──► Ranked List ├──► lex ──────► FTS ────► Ranked List
1379
+ └──► Vector Search ─► Ranked List └──► vec/hyde ─► Vector ─► Ranked List
1380
+ │ │
1381
+ └──────────────────┬──────────────────────────┘
1382
+
1383
+
1384
+ RRF Fusion (k=60)
1385
+ Original retrieval paths ×2
1386
+ Top-rank bonus: +0.05/#1, +0.02/#2-3
1387
+
1388
+
1389
+ Top 30 candidates
1390
+
1391
+
1392
+ LLM Re-ranking
1393
+ (yes/no + logprob confidence)
1394
+
1395
+
1396
+ Position-Aware Blend
1397
+ Rank 1-3: 75% RRF / 25% reranker
1398
+ Rank 4-10: 60% RRF / 40% reranker
1399
+ Rank 11+: 40% RRF / 60% reranker
1400
+
1401
+
1402
+ Final Results
1403
+ ```
1404
+
1405
+ ## Model Configuration
1406
+
1407
+ The default models are defined in `src/llm.ts` as HuggingFace URIs:
1408
+
1409
+ ```typescript
1410
+ const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
1411
+ const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
1412
+ const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
1413
+ ```
1414
+
1415
+ Override them per-role without touching source via the `models:` block in
1416
+ `index.yml` (see [Configuring `index.yml`](#configuring-indexyml)) or the
1417
+ `QMD_EMBED_MODEL` env var. Re-run `qmd embed` after changing the embedding model.
1418
+
1419
+ ### EmbeddingGemma Prompt Format
1420
+
1421
+ ```
1422
+ // For queries
1423
+ "task: search result | query: {query}"
1424
+
1425
+ // For documents
1426
+ "title: {title} | text: {content}"
1427
+ ```
1428
+
1429
+ ### Qwen3-Reranker
1430
+
1431
+ Uses node-llama-cpp's `createRankingContext()` and `rankAndSort()` API for cross-encoder reranking. Returns documents sorted by relevance score (0.0 - 1.0).
1432
+
1433
+ ### Qwen3 (Query Expansion)
1434
+
1435
+ Used for generating query variations via `LlamaChatSession`.
1436
+
1437
+ ## License
1438
+
1439
+ MIT