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