QMD - Query Markup Documents
Memory server
First committed by tobi
Published in tobi/qmd. Publisher page
31,041 GitHub stars
What it is
Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking
AutoGineer overview, from the brain’s own files
QMD (Query Markup Documents) is an on-device search engine designed to index markdown notes, documentation, and transcripts. It provides a hybrid search architecture that combines BM25 full-text search with vector semantic search, utilizing Reciprocal Rank Fusion (RRF) and LLM-based reranking to deliver search results. It is built to serve as a memory layer for AI agents, offering both a CLI and an MCP server for integration with tools like Claude Desktop and Claude Code. QMD supports metadata filtering, document context management, and AST-aware chunking for code files.
How it works
Memory model: Hybrid
AutoGineer overview, from the brain’s own files
QMD stores data in a local SQLite database using FTS5 for keyword search and sqlite-vec for vector similarity. It processes documents by chunking them—using regex for general text or tree-sitter for code—and generating embeddings via local GGUF models. When a user queries, the system expands the query, performs both lexical and vector searches, fuses the results, and reranks them using an LLM. Users can manage collections of files, add descriptive context to paths, and retrieve documents via paths or unique content-based IDs.
| Folder | Role | Files |
|---|---|---|
| .github | agent-config | 3 |
| bin | scripts | 1 |
| docs | docs | 1 |
| scripts | scripts | 9 |
| skills | agent-instructions | 5 |
| src | source-code | 29 |
| test | tests | 76 |
| tools | scripts | 36 |
| .claude-plugin | folder | 1 |
| .pi | folder | 1 |
| finetune | folder | 62 |
Requirements
Runtimes: Node.js >=22.0.0, Bun
Databases: Redis, SQLite
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)
Set it up
This brain runs code automatically: 1 script. Review package.json before opening the folder in Claude Code. No step below turns a hook on for you.
package.json:node scripts/install-hooks.mjs && node scripts/build.mjs
- Not scanned by AutoGineerThe author’s README also describes installing it from a clone of its repository. Read its install section: AutoGineer did not scan that path. https://github.com/tobi/qmd/blob/86d2365d17fc682e491c2c53d1e4a884aee8682a/README.md#installation-1
- Not scanned by AutoGineerThe author’s README also describes installing it as a Claude Code plugin marketplace. Read its install section: AutoGineer did not scan that path. https://github.com/tobi/qmd/blob/86d2365d17fc682e491c2c53d1e4a884aee8682a/README.md#quick-start
- Not scanned by AutoGineerIts dependencies are installed by npm when you run it and were not scanned by AutoGineer (19 declared). https://www.npmjs.com/package/@tobilu/qmd
First committed by tobi. Published in tobi/qmd. Licence: MIT License.
Usage
AutoGineer overview, from the brain’s own files
- Index local markdown collections for agent access
- Perform hybrid semantic and keyword searches via CLI
- Expose local knowledge bases to AI agents via MCP
- Filter search results using structured metadata queries
- Retrieve specific document content or chunks for agent context
- Manage document context to improve LLM relevance
- “qmd query "quarterly planning process"”
- Performs a hybrid search with reranking to return the most relevant documents related to the planning process.
- “qmd context add ~/notes "Personal notes and ideas"”
- Adds descriptive context to the notes collection, which helps the LLM make better choices when selecting documents.
- “qmd search "authentication" --json -n 10”
- Returns the top 10 search results in a structured JSON format suitable for programmatic use by an AI agent.
- “qmd get "meetings/2024-01-15.md"”
- Retrieves the full content of the specified markdown file.
Documentation by tobi
QMD - Query Markup Documents
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.
QMD combines BM25 full-text search, vector semantic search, and LLM re-ranking—all running locally via node-llama-cpp with GGUF models.
flowchart LR
Q[User Query] --> X[Query Expansion]
Q --> FTS[BM25 Search]
Q --> VS[Vector Search]
X --> HYDE[HyDE]
X --> VEC[Vec dense sentences]
X --> LEX[Lex BM25 keywords]
HYDE --> VS
VEC --> VS
LEX --> FTS
VS --> RRF[Reciprocal Rank Fusion]
FTS --> RRF
RRF --> RR[LLM Reranker]
RR --> OUT[Final ranked results]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.
You can read more about QMD's progress in the CHANGELOG.
Quick Start
# Install globally (Node or Bun)
npm install -g @tobilu/qmd
# or
bun install -g @tobilu/qmd
# Or run directly
npx @tobilu/qmd ...
bunx @tobilu/qmd ...
# Create collections for your notes, docs, and meeting transcripts
qmd collection add ~/notes --name notes
qmd collection add ~/Documents/meetings --name meetings
qmd collection add ~/work/docs --name docs
# 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!
qmd context add qmd://notes "Personal notes and ideas"
qmd context add qmd://meetings "Meeting transcripts and notes"
qmd context add qmd://docs "Work documentation"
# Generate embeddings for semantic search
qmd embed
# Search across everything
qmd search "project timeline" # Fast keyword search
qmd vsearch "how to deploy" # Semantic search
qmd query "quarterly planning process" # Hybrid + reranking (best quality)
# Get a specific document
qmd get "meetings/2024-01-15.md"
# Get a document by docid (shown in search results)
qmd get "#abc123"
# Get multiple documents by glob pattern
qmd multi-get "journals/2025-05*.md"
# Search within a specific collection
qmd search "API" -c notes
# Export all matches for an agent
qmd search "API" --all --files --min-score 0.3Using with AI Agents
QMD's --json and --files output formats are designed for agentic workflows:
# Get structured results for an LLM
qmd search "authentication" --json -n 10
# List all relevant files above a threshold
qmd query "error handling" --all --files --min-score 0.4
# Retrieve full document content
qmd get "docs/api-reference.md" --fullMCP Server
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.
Tools exposed:
query— Search with typed sub-queries (lex/vec/hyde), combined via RRF + rerankingget— Retrieve a document by path or docid (with fuzzy matching suggestions)multi_get— Batch retrieve by glob pattern, comma-separated list, or docidsstatus— Index health and collection info, including each collection's top metadata keysmetadata— Discover metadata keys, types, and value counts to filter on
Claude Desktop configuration (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": ["mcp"]
}
}
}Claude Code — Install the plugin (recommended):
claude plugin marketplace add tobi/qmd
claude plugin install qmd@qmdOr configure MCP manually in ~/.claude/settings.json:
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": ["mcp"]
}
}
}HTTP Transport
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:
# Foreground (Ctrl-C to stop)
qmd mcp --http # localhost:8181
qmd mcp --http --port 8080 # custom port
qmd mcp --http --host 0.0.0.0 # bind all interfaces (e.g. container probes)
# Background daemon
qmd mcp --http --daemon # start, writes PID to ~/.cache/qmd/mcp.pid
qmd mcp stop # stop via PID file
qmd status # shows "MCP: running (PID ...)" when activeThe server binds to localhost by default. Pass --host (or set the QMD_HOST
environment variable) to override — --host 0.0.0.0 is useful when the server
runs in a container and a liveness probe connects from a non-loopback address.
The HTTP server exposes two endpoints:
POST /mcp— MCP Streamable HTTP (JSON responses, stateless)POST /query(alias/search) — structured search without the MCP protocol. Accepts the same optionalfilterobject as thequerytool (invalid filters return400); see Metadata FilteringPOST /metadata— metadata discovery without the MCP protocol. Same body as themetadatatool (invalid filters return400); see Metadata DiscoveryGET /health— liveness check with uptime
Origin and Host validation
Every request is screened before routing: a request carrying an Origin header
that does not name a loopback address is rejected with 403, as is a Host
header naming something other than the address the server is bound to. This is
what stops a web page you visit from reading your index through DNS rebinding —
loopback binding alone does not, since the browser makes the request from your
own machine.
Requests without an Origin header — curl, MCP clients, editors — are
unaffected, which covers every normal local client.
| Variable | Effect |
|---|---|
QMD_ALLOWED_ORIGINS | Comma-separated origins to accept in addition to loopback, e.g. https://notes.internal. Set to * to disable the check entirely. |
QMD_ALLOWED_HOSTS | Comma-separated Host values to accept in addition to loopback and the bind address. |
--host 0.0.0.0 cannot know which Host values are legitimate, so it skips the
host check and warns at startup. Set QMD_ALLOWED_HOSTS to re-enable it, and
remember the endpoints are unauthenticated — put your own auth in front of a
server that is reachable off-host.
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).
Point any MCP client at http://localhost:8181/mcp to connect.
MCP Tool Parameters
| Tool | Parameter | Type | Notes |
|---|---|---|---|
query | searches | array | Typed sub-queries (lex/vec/hyde), 1–10. Required. First gets 2x weight. |
query | collections | string[] | Filter by collection names (OR). Array only — singular collection is silently ignored. |
query | filter | object | Metadata filter (recursive operator-discriminated JSON AST; see Metadata Filtering) |
query | intent | string | Disambiguation context (does not search on its own) |
query | limit | number | Max results (default 10) |
query | minScore | number | Minimum relevance 0–1 (default 0) |
query | candidateLimit | number | Max candidates to rerank (default 40) |
query | rerank | boolean | Run LLM reranking (default true); set false for RRF-only |
get | file | string | Path, docid (#abc123), or path:from:count (e.g. #abc123:120:40) |
get | fromLine | number | Start line (1-indexed); overrides the :from suffix |
get | maxLines | number | Limit returned lines |
get | lineNumbers | boolean | Prefix lines with numbers (default true) |
multi_get | pattern | string | Glob pattern or comma-separated list |
multi_get | maxBytes | number | Skip files larger than N (default 10240) |
multi_get | maxLines | number | Limit lines per file |
multi_get | lineNumbers | boolean | Prefix lines with numbers (default true) |
metadata | collections | string[] | Restrict discovery to collection names (default: the collections query searches) |
metadata | match | object | Report only metadata entries matching this condition (same AST as filter, with field naming the entry's key or value) |
metadata | filter | object | Count only documents matching this filter (same AST as query) |
metadata | keyLimit | number | Keys reported (default 50). totalKeys and remainingKeys describe the rest |
metadata | keyOffset | number | Keys skipped before the window, in report order (default 0) |
metadata | valueLimit | number | Values reported per key and type (default 10). remainingValues reports the rest |
metadata | valueOffset | number | Values skipped per key and type before the window, in sort order (default 0) |
metadata | sort | string | count (default) or value |
metadata | minCount | number | Hide values held by fewer documents (default 1) |
Unknown parameters are silently ignored (not rejected) — double-check names if
results seem unscoped. The HTTP /query and /search endpoints return
qmd://collection/path URIs in the file field, matching the CLI and MCP output.
SDK / Library Usage
Use QMD as a library in your own Node.js or Bun applications.
Installation
npm install @tobilu/qmdQuick Start
import { createStore } from '@tobilu/qmd'
const store = await createStore({
dbPath: './my-index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '**/*.md' },
},
},
})
const results = await store.search({ query: "authentication flow" })
console.log(results.map(r => `${r.title} (${Math.round(r.score * 100)}%)`))
await store.close()Store Creation
createStore() accepts three modes:
import { createStore } from '@tobilu/qmd'
// 1. Inline config — no files needed besides the DB
const store = await createStore({
dbPath: './index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '**/*.md' },
notes: { path: '/path/to/notes' },
},
},
})
// 2. YAML config file — collections defined in a file
const store2 = await createStore({
dbPath: './index.sqlite',
configPath: './qmd.yml',
})
// 3. DB-only — reopen a previously configured store
const store3 = await createStore({ dbPath: './index.sqlite' })Search
The unified search() method handles both simple queries and pre-expanded structured queries:
// Simple query — auto-expanded via LLM, then BM25 + vector + reranking
const results = await store.search({ query: "authentication flow" })
// With options
const results2 = await store.search({
query: "rate limiting",
intent: "API throttling and abuse prevention",
collection: "docs",
limit: 5,
minScore: 0.3,
explain: true,
})
// Pre-expanded queries — skip auto-expansion, control each sub-query
const results3 = await store.search({
queries: [
{ type: 'lex', query: '"connection pool" timeout -redis' },
{ type: 'vec', query: 'why do database connections time out under load' },
],
collections: ["docs", "notes"],
})
// Skip reranking for faster results
const fast = await store.search({ query: "auth", rerank: false })
// Metadata filter — every returned result satisfies it (also available on
// searchLex() and searchVector()); results expose indexed metadata via
// r.metadata. See "Metadata Filtering" for the full grammar.
const published = await store.search({
query: "authentication flow",
filter: {
operator: "and",
operands: [
{ field: "topics", operator: "all", value: ["typescript"] },
{ field: "status", operator: "ne", value: "draft" },
],
},
})For direct backend access:
// BM25 keyword search (fast, no LLM)
const lexResults = await store.searchLex("auth middleware", { limit: 10 })
// Vector similarity search (embedding model, no reranking)
const vecResults = await store.searchVector("how users log in", { limit: 10 })
// Manual query expansion for full control
const expanded = await store.expandQuery("auth flow", { intent: "user login" })
const results4 = await store.search({ queries: expanded })Retrieval
// Get a document by path or docid
const doc = await store.get("docs/readme.md")
const byId = await store.get("#abc123")
if (!("error" in doc)) {
console.log(doc.title, doc.displayPath, doc.context)
}
// Get document body with line range
const body = await store.getDocumentBody("docs/readme.md", {
fromLine: 50,
maxLines: 100,
})
// Batch retrieve by glob or comma-separated list
const { docs, errors } = await store.multiGet("docs/**/*.md", {
maxBytes: 20480,
})Collections
// Add a collection
await store.addCollection("myapp", {
path: "/src/myapp",
pattern: "**/*.ts",
ignore: ["node_modules/**", "*.test.ts"],
})
// List collections with document stats
const collections = await store.listCollections()
// => [{ name, pwd, glob_pattern, doc_count, active_count, last_modified, includeByDefault }]
// Get names of collections included in queries by default
const defaults = await store.getDefaultCollectionNames()
// Remove / rename
await store.removeCollection("myapp")
await store.renameCollection("old-name", "new-name")Context
Context adds descriptive metadata that improves search relevance and is returned alongside results:
// Add context for a path within a collection
await store.addContext("docs", "/api", "REST API reference documentation")
// Set global context (applies to all collections)
await store.setGlobalContext("Internal engineering documentation")
// List all contexts
const contexts = await store.listContexts()
// => [{ collection, path, context }]
// Remove context
await store.removeContext("docs", "/api")
await store.setGlobalContext(undefined) // clear globalIndexing
// Re-index collections by scanning the filesystem
const result = await store.update({
collections: ["docs"], // optional — defaults to all
onProgress: ({ collection, file, current, total }) => {
console.log(`[${collection}] ${current}/${total} ${file}`)
},
})
// => { collections, indexed, updated, unchanged, removed, needsEmbedding }
// Generate vector embeddings
const embedResult = await store.embed({
force: false, // true to re-embed everything
chunkStrategy: "auto", // "regex" (default) or "auto" (AST for code files)
onProgress: ({ current, total, collection }) => {
console.log(`Embedding ${current}/${total}`)
},
})Types
Key types exported for SDK consumers:
import type {
QMDStore, // The store interface
SearchOptions, // Options for search()
LexSearchOptions, // Options for searchLex()
VectorSearchOptions, // Options for searchVector()
HybridQueryResult, // Search result with score, snippet, context
SearchResult, // Result from searchLex/searchVector
ExpandedQuery, // Typed sub-query { type: 'lex'|'vec'|'hyde', query }
DocumentResult, // Document metadata + body
DocumentNotFound, // Error with similarFiles suggestions
MultiGetResult, // Batch retrieval result
UpdateProgress, // Progress callback info for update()
UpdateResult, // Aggregated update result
EmbedProgress, // Progress callback info for embed()
EmbedResult, // Embedding result
StoreOptions, // createStore() options
CollectionConfig, // Inline config shape
IndexStatus, // From getStatus()
IndexHealthInfo, // From getIndexHealth()
} from '@tobilu/qmd'Utility exports:
import {
extractSnippet, // Extract a relevant snippet from text
addLineNumbers, // Add line numbers to text
DEFAULT_MULTI_GET_MAX_BYTES, // Default max file size for multiGet (64KB)
Maintenance, // Database maintenance operations
} from '@tobilu/qmd'Lifecycle
// Close the store — disposes LLM models and DB connection
await store.close()The SDK requires explicit dbPath — no defaults are assumed. This makes it safe to embed in any application without side effects.
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ QMD Hybrid Search Pipeline │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ User Query │
└────────┬────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Query Expansion│ │ Original Query│
│ (fine-tuned) │ │ (×2 weight) │
└───────┬────────┘ └───────┬────────┘
│ │
│ 2 alternative queries │
└──────────────┬──────────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Original Query │ │ Expanded Query 1│ │ Expanded Query 2│
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
▼ ▼ ▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ BM25 │ │Vector │ │ BM25 │ │Vector │ │ BM25 │ │Vector │
│(FTS5) │ │Search │ │(FTS5) │ │Search │ │(FTS5) │ │Search │
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘
│ │ │ │ │ │
└───────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────────────┼───────────────────────┘
│
▼
┌───────────────────────┐
│ RRF Fusion + Bonus │
│ Original query: ×2 │
│ Top-rank bonus: +0.05│
│ Top 30 Kept │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ LLM Re-ranking │
│ (qwen3-reranker) │
│ Yes/No + logprobs │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ Position-Aware Blend │
│ Top 1-3: 75% RRF │
│ Top 4-10: 60% RRF │
│ Top 11+: 40% RRF │
└───────────────────────┘Score Normalization & Fusion
Search Backends
| Backend | Raw Score | Conversion | Range |
|---|---|---|---|
| FTS (BM25) | SQLite FTS5 BM25 | Math.abs(score) | 0 to ~25+ |
| Vector | Cosine distance | 1 / (1 + distance) | 0.0 to 1.0 |
| Reranker | LLM 0-10 rating | score / 10 | 0.0 to 1.0 |
Fusion Strategy
The query command uses Reciprocal Rank Fusion (RRF) with position-aware blending:
Query Expansion: Original query (×2 for weighting) + 1 LLM variation
Parallel Retrieval: Each query searches both FTS and vector indexes
RRF Fusion: Combine all result lists using
score = Σ(1/(k+rank+1))where k=60Top-Rank Bonus: Documents ranking #1 in any list get +0.05, #2-3 get +0.02
Top-K Selection: Take top 30 candidates for reranking
Re-ranking: LLM scores each document (yes/no with logprobs confidence)
Position-Aware Blending:
RRF rank 1-3: 75% retrieval, 25% reranker (preserves exact matches)
RRF rank 4-10: 60% retrieval, 40% reranker
RRF rank 11+: 40% retrieval, 60% reranker (trust reranker more)
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.
Score Interpretation
| Score | Meaning |
|---|---|
| 0.8 - 1.0 | Highly relevant |
| 0.5 - 0.8 | Moderately relevant |
| 0.2 - 0.5 | Somewhat relevant |
| 0.0 - 0.2 | Low relevance |
Requirements
System Requirements
Node.js >= 22
Bun >= 1.0.0
macOS: Homebrew SQLite (for extension support)
brew install sqlite
GGUF Models (via node-llama-cpp)
QMD uses three local GGUF models (auto-downloaded on first use):
| Model | Purpose | Size |
|---|---|---|
embeddinggemma-300M-Q8_0 | Vector embeddings (default) | ~300MB |
qwen3-reranker-0.6b-q8_0 | Re-ranking | ~640MB |
qmd-query-expansion-1.7B-q4_k_m | Query expansion (fine-tuned) | ~1.1GB |
Models are downloaded from HuggingFace and cached in ~/.cache/qmd/models/.
Custom Embedding Model
Override the default embedding model via the QMD_EMBED_MODEL environment variable.
This is useful for multilingual corpora (e.g. Chinese, Japanese, Korean) where
embeddinggemma-300M has limited coverage.
# Use Qwen3-Embedding-0.6B for better multilingual (CJK) support
export QMD_EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"
# After changing the model, re-embed all collections:
qmd embed -fSupported model families:
embeddinggemma (default) — English-optimized, small footprint
Qwen3-Embedding — Multilingual (119 languages including CJK), MTEB top-ranked
Note: When switching embedding models, you must re-index with
qmd embed -fsince vectors are not cross-compatible between models. The prompt format is automatically adjusted for each model family.
Installation
npm install -g @tobilu/qmd
# or
bun install -g @tobilu/qmdDevelopment
git clone https://github.com/tobi/qmd
cd qmd
npm install
npm linkUsage
Collection Management
# Create a collection from current directory
qmd collection add . --name myproject
# Create a collection with explicit path and custom glob mask
qmd collection add ~/Documents/notes --name notes --mask "**/*.md"
# Comma-separated masks are a union (brace form `{a,b}` also works)
qmd collection add ~/notes --name notes --mask "sources/**/*.md,CO - *.md"
# List all collections
qmd collection list
# Remove a collection
qmd collection remove myproject
# Rename a collection
qmd collection rename myproject my-project
# List files in a collection
qmd ls notes
qmd ls notes/subfolder
# Show collection details (path, glob mask, include status, context count, top metadata keys)
qmd collection show notes
# Discover metadata keys, types, and value counts (see Metadata Discovery)
qmd collection metadata notes
qmd collection metadata notes --match '{"field":"key","operator":"eq","value":"topics"}'
qmd collection metadata notes --match '{"field":"value","operator":"eq","value":"docs-team"}'
# Include or exclude a collection from default (unscoped) queries
qmd collection include notes
qmd collection exclude notes
# Run a command before every `qmd update` (e.g. git pull); empty arg clears it
qmd collection update-cmd notes 'git pull --rebase'
qmd collection update-cmd notesGenerate Vector Embeddings
# Embed all indexed documents (900 tokens/chunk, 15% overlap)
qmd embed
# Force re-embed everything
qmd embed -f
# Enable AST-aware chunking for code files (TS, JS, Python, Go, Rust)
qmd embed --chunk-strategy auto
# Also works with query for consistent chunk selection
qmd query "auth flow" --chunk-strategy auto
# Memory control for large corpora / constrained systems
qmd embed --max-docs-per-batch 50 # cap docs per embedding batch
qmd embed --max-batch-mb 64 # cap batch size in MBAST-aware chunking (--chunk-strategy auto) uses tree-sitter to chunk code
files at function, class, and import boundaries instead of arbitrary text
positions. This produces higher-quality chunks and better search results for
codebases. Markdown and other file types always use regex-based chunking
regardless of strategy.
The default is regex (existing behavior). Use --chunk-strategy auto to
opt in. Run qmd status to verify which grammars are available.
Note: Tree-sitter grammars are optional dependencies. If they are not installed,
--chunk-strategy autofalls back to regex-only chunking automatically. Tested on both Node.js and Bun.
Context Management
Context adds descriptive metadata to collections and paths, helping search understand your content.
# Add context to a collection (using qmd:// virtual paths)
qmd context add qmd://notes "Personal notes and ideas"
qmd context add qmd://docs/api "API documentation"
# Add context from within a collection directory
cd ~/notes && qmd context add "Personal notes and ideas"
cd ~/notes/work && qmd context add "Work-related notes"
# Add global context (applies to all collections)
qmd context add / "Knowledge base for my projects"
# List all contexts
qmd context list
# Remove context
qmd context rm qmd://notes/oldConfiguring index.yml
The collection and context commands above all read and write a single YAML
config file — you can also edit it directly. Everything QMD knows about your
collections (paths, masks, exclusions, per-collection update hooks, contexts, and
optional model overrides) lives here. A fully-commented starter template ships as
example-index.yml in this repo.
Location: ~/.config/qmd/index.yml by default. The directory honors
XDG_CONFIG_HOME (→ $XDG_CONFIG_HOME/qmd/index.yml) and QMD_CONFIG_DIR. A
named index uses {name}.yml — qmd --index work … reads/writes work.yml.
A project-local index created with qmd init lives at .qmd/index.yml
(.qmd/index.yaml is also accepted) alongside a project-local index.sqlite,
so config and index stay inside the project instead of ~/.config / ~/.cache.
# ~/.config/qmd/index.yml
# Context applied to every collection (system-message style). Optional.
global_context: "Knowledge base for my projects"
# Terminal hyperlink template for search results. Optional.
# Overridden by the QMD_EDITOR_URI env var. See "Editor Links" below.
editor_uri: "vscode://file{path}:{line}:{col}"
# Override the default GGUF models per role. Optional — omit to use the
# built-in defaults. `qmd init` writes this block pre-filled with the
# resolved defaults. See "Model Configuration" for the default URIs.
models:
embed: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"
generate: "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf"
# One entry per collection. The key is the collection name.
collections:
notes:
path: /Users/me/notes # absolute path to index (required)
pattern: "**/*.md" # glob mask (default: **/*.md)
ignore: # glob patterns to exclude from indexing
- "Archive/**"
- "**/drafts/**"
update: "git pull --rebase" # bash command run before each `qmd update`
includeByDefault: true # include in unscoped queries (default: true)
context: # path prefix → description; longest match wins
"/": "Personal notes and ideas"
"/work": "Work-related notes"| Key | Scope | Purpose |
|---|---|---|
global_context | top-level | Context prepended for every collection. Set via qmd context add /. |
editor_uri (alias editor_uri_template) | top-level | Hyperlink template for clickable result paths; QMD_EDITOR_URI overrides. |
models.embed / .rerank / .generate | top-level | HuggingFace GGUF URIs (hf:<user>/<repo>/<file>) overriding the built-in defaults per role. |
collections.<name>.path | per-collection | Absolute directory to index. |
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. |
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. |
collections.<name>.update | per-collection | Bash command run before qmd update re-indexes this collection. Set via qmd collection update-cmd. |
collections.<name>.includeByDefault | per-collection | Whether unscoped queries search it. Toggle with qmd collection include/exclude. Default true. |
collections.<name>.context | per-collection | Path-prefix → description map; the most specific (longest) matching prefix wins. Set via qmd context add. |
Note: Editing
index.ymlchanges which directories and models QMD uses, but does not re-index on its own. Runqmd updateafter changingpath,pattern, orignore, andqmd embedafter changingmodels.embed.
Automatic update commands
A collection's update field is QMD's built-in refresh hook: when you run
qmd update, each collection's update command runs first, then the
collection is re-indexed. This keeps a collection in sync with an upstream source
(a git remote, a sync script) without wrapping qmd yourself.
collections:
wiki:
path: ~/reference/wiki
update: "git pull --ff-only"$ qmd update
[1/3] wiki (**/*.md)
Running update command: git pull --ff-only
Already up to date.
Collection: ~/reference/wiki (**/*.md)
Indexed: 0 new, 2 updated, 340 unchanged, 0 removedThe command runs via bash -c in the collection's own directory (its path), not
your current working directory. If it exits non-zero, qmd update prints the
failure and aborts the entire run — collections after the failing one are not
re-indexed. Set or clear it from the CLI instead of editing YAML by hand:
qmd collection update-cmd wiki 'git pull --ff-only' # set
qmd collection update-cmd wiki # clearChecked-in .qmd config is not trusted by default
A project-local .qmd/index.yml travels with a git clone, and QMD adopts it
automatically for any command run inside the tree. Three fields in that file can
reach outside the project, and QMD will not use them unattended:
updatecommands — somebody else's shell script, run byqmd updatecollections.*.pathpointing outside the project directorymodels.embed/models.rerank/models.generateother than the built-in defaults (anyhf:repo or local GGUF path)
In-project collection paths (for example ./docs) still index. On a terminal
qmd update (and qmd embed / qmd pull / qmd query) lists the gated
fields and asks. Approving records the approval in ~/.config/qmd/trusted.json.
With no terminal to ask — agents, CI, MCP — those fields are skipped and
in-project indexing continues.
Approvals cover the exact gated set you saw. Editing a command, pointing a collection outside the project, or changing a custom model URI asks again.
qmd trust # review and approve this project's gated fields
qmd trust list # show every approved project config
qmd trust revoke # drop the approval for this projectSet QMD_TRUST_LOCAL_CONFIG=1 (or QMD_TRUST_UPDATE_HOOKS=1) for CI that
should allow them unattended. Your own ~/.config/qmd/*.yml — including
anything qmd collection update-cmd or qmd collection add writes — is
never gated.
Search Commands
┌──────────────────────────────────────────────────────────────────┐
│ Search Modes │
├──────────┬───────────────────────────────────────────────────────┤
│ search │ BM25 full-text search only │
│ vsearch │ Vector semantic search only │
│ query │ Hybrid: FTS + Vector + Query Expansion + Re-ranking │
└──────────┴───────────────────────────────────────────────────────┘# Full-text search (fast, keyword-based)
qmd search "authentication flow"
# Vector search (semantic similarity)
qmd vsearch "how to login"
# Hybrid search with re-ranking (best quality)
qmd query "user authentication"Two aliases exist for the semantic/hybrid modes: vector-search (→ vsearch)
and deep-search (→ query).
Options
# Search options
-n <num> # Number of results (default: 5, or 20 for --files/--json)
-c, --collection # Restrict search to a specific collection
--all # Return all matches (use with --min-score to filter)
--min-score <num> # Minimum score threshold (default: 0)
--full # Show full document content
--line-numbers # Add line numbers to output
--explain # Include retrieval score traces (query, JSON/CLI output)
--filter <json> # Metadata filter (recursive JSON AST; see Metadata Filtering)
--index <name> # Use named index
--intent "<text>" # Disambiguation context (e.g. "web page load times")
--no-rerank # Skip LLM reranking (RRF scores only; faster on CPU)
-C, --candidate-limit <n> # Max candidates to rerank (default: 40)
--full-path # Emit on-disk filesystem paths instead of qmd:// URIs
# (a result whose file has moved or been deleted since
# indexing keeps its qmd:// URI + docid, and a notice is
# printed to stderr — run `qmd update` to refresh)
# Output formats (for search and multi-get)
--format <kind> # cli (default) | json | csv | md | xml | files
# (--json, --csv, --md, --xml, --files are legacy aliases)
# Get options
qmd get <file>[:from[:count]] # Get document; optional start line and count
-l <num> # Maximum lines to return
--from <num> # Start line (overrides the :from suffix)
--no-line-numbers # Disable line numbering (on by default)
# Multi-get options
-l <num> # Maximum lines per file
--max-bytes <num> # Skip files larger than N bytes (default: 64KB)Collection Filtering
The -c/--collection flag filters results by collection name (as shown by
qmd collection list). Collections are a global registry — you can search any
collection from any directory:
qmd search "auth" -c notes # single collection
qmd search "auth" -c notes -c docs # multiple collections (OR)With no -c flag, all default-included collections are searched. Collections
marked excluded (qmd collection exclude <name>) are skipped unless named
explicitly with -c.
Note: With multiple
-cflags, results come from a global top-K pool and are then filtered. If one collection dominates the rankings, matches from smaller collections may not appear at the default limit — raise-nor use--all.
Metadata Filtering
Documents can opt into typed metadata through a namespaced frontmatter block. A document without qmd.metadata behaves exactly as before, and the frontmatter stays ordinary searchable content (no chunking, embedding, or line-number changes):
---
qmd:
metadata:
topics:
- typescript
- programming
status: published
priority: 3
reviewed: true
---
# Document body starts hereSupported values are strings, numbers, booleans, and flat homogeneous arrays of one of those. Nested objects, nulls, empty arrays, and mixed-type arrays are rejected (the document still indexes; it is excluded from filtered search until corrected). Metadata keys are user-defined data — tags, topics, and labels are all ordinary keys with no special semantics.
Every search surface (CLI, SDK, MCP, HTTP) accepts the same recursive filter, a JSON AST discriminated by operator. A condition is a predicate over one field of the document's metadata: field names the metadata key, operator says how to compare, and value is what to compare against:
# One condition
qmd search "authentication" \
--filter '{"field":"status","operator":"eq","value":"published"}'
# Composed conditions — works with search, vsearch, and query
qmd query "dependency injection" --filter '{
"operator": "and",
"operands": [
{ "field": "topics", "operator": "all", "value": ["typescript", "programming"] },
{ "field": "status", "operator": "nin", "value": ["draft", "archived"] },
{ "operator": "or", "operands": [
{ "field": "priority", "operator": "gte", "value": 3 },
{ "field": "reviewed", "operator": "eq", "value": true }
] },
{ "field": "topics", "operator": "prefix", "value": "sql", "caseInsensitive": true },
{ "operator": "not", "operand": { "field": "audience", "operator": "eq", "value": "internal" } }
]
}'| Node | Shape |
|---|---|
| Logical group | { "operator": "and" | "or", "operands": […] } |
| Negation | { "operator": "not", "operand": {…} } |
| Comparison | { "field", "operator": "eq" | "ne" | "gt" | "gte" | "lt" | "lte", "value" } |
| Membership | { "field", "operator": "in" | "nin" | "all", "value": […] } |
| Text | { "field", "operator": "contains" | "prefix" | "suffix", "value": "…" } |
| Type | { "field", "operator": "type", "value": "string" | "number" | "boolean" } |
| Presence | { "field", "operator": "exists", "value": true | false } |
Any condition whose value is a string or an array of strings may add "caseInsensitive": true.
Semantics:
Matching is typed and exact — no string/number/boolean coercion, and a type mismatch never matches (including
neandnin). Text operators match string values only.typematches the stored type of a key's values, which is how a filter reaches one side of a key whose documents disagree on type.Array-valued metadata is a set: a condition matches when any element satisfies it,
allrequires every filter value to be present, andne/ninrequire that no element equals the operand (with at least one element of the operand's type present).Missing keys do not match
ne/nin; combine with{ "operator": "exists", "value": false }in anorgroup to include them.Matching is case-sensitive unless a condition sets
caseInsensitive, which folds ASCII letters on both sides. Non-ASCII letters compare exactly.Multiple conditions require an explicit
andgroup — there is no implicit AND, and no$-prefixed shorthand.
Guarantees and limits:
Every returned result satisfies the filter, before RRF fusion and reranking.
Highly selective filters can return fewer than
limitresults. Lexical search filters a bounded over-fetch window; vector search scans the filter-eligible set exactly when it holds at most 20,000 chunk vectors, and above that over-fetches and post-filters.Filtered search only considers documents whose metadata has been extracted (run
qmd updateafter upgrading;qmd statusshows the pending count).
JSON output (--format json), the SDK, MCP structured results, and the HTTP endpoints include each result's indexed metadata.
Metadata Discovery
Filtering is only useful if you know what to filter on. Discovery reports the metadata keys, types, and value counts already in the index, so a filter can be written from what is indexed instead of guessed. It reads the same tables filtering reads: no re-indexing, and every value it reports is one an eq filter can match.
The command is one sentence: show metadata matching X for documents filtered by Y. --filter narrows documents by their metadata (same AST as search) and decides which are counted. --match narrows the metadata itself and decides which entries are reported. It takes the same AST, evaluated against each metadata entry instead of each document: a condition's field is "key" (the entry's key name) or "value" (its value). Every operator applies, including type, the text operators, caseInsensitive, and and/or/not. Only exists and all are rejected, as they have no meaning for a single entry.
--match | Question answered |
|---|---|
| Which keys exist, with a window of values each | |
{"field":"key","operator":"eq","value":"topics"} | Everything about one key |
{"field":"key","operator":"prefix","value":"mem-"} | A family of keys |
{"field":"key","operator":"in","value":["tags","topics","labels"]} | Which of these key names exist |
{"field":"value","operator":"eq","value":"docs-team"} | Which keys hold this value |
{"field":"value","operator":"prefix","value":"2025-"} | Which keys hold values shaped like this |
{"field":"value","operator":"type","value":"boolean"} | Which keys hold booleans |
and of key eq priority and value gte 3 | Values of one key above a threshold |
and of key eq priority and value type number | The numeric side of a key whose documents disagree on type |
Start wide and narrow:
# Which keys does this collection use? (`qmd collection show notes` previews the top five)
qmd collection metadata notes
# Everything about one key: coverage, distinct count, top values
qmd collection metadata notes --match '{"field":"key","operator":"eq","value":"topics"}'
# Reverse lookup: which keys hold this value
qmd collection metadata notes --match '{"field":"value","operator":"eq","value":"docs-team"}'
# Values of one key matching a pattern
qmd collection metadata notes --match '{
"operator": "and",
"operands": [
{ "field": "key", "operator": "eq", "value": "topics" },
{ "field": "value", "operator": "prefix", "value": "sql" }
]
}'
# What remains after a filter, before committing to it in a query
qmd collection metadata notes \
--match '{"field":"key","operator":"eq","value":"topics"}' \
--filter '{"field":"status","operator":"eq","value":"published"}'
# Then search with the filter you just validated
qmd query "dependency injection" -c notes --filter '{
"operator": "and",
"operands": [
{ "field": "status", "operator": "eq", "value": "published" },
{ "field": "topics", "operator": "all", "value": ["typescript"] }
]
}'The drill-down prints one block per key, in coverage order:
qmd collection metadata notestopics string[] 388 of 480 documents 1,204 distinct
typescript 140
sqlite 92
search 77
architecture 61
sqlite-vec 44
mcp 39
embeddings 35
agents 31
cli 28
testing 26
1,194 more values, use --value-limit <n>, --value-offset <n>, or --all-values
priority number 205 of 480 documents 5 distinct
min 1 median 3 max 5
1 (12) 2 (40) 3 (88) 4 (50) 5 (15)
reviewed boolean 480 of 480 documents
true 61 false 419With --filter, the output opens with a filter: line stating how many documents pass, and every coverage count is measured against that population rather than the whole collection — the numbers a filtered search would see:
qmd collection metadata notes \
--match '{"field":"key","operator":"eq","value":"topics"}' \
--filter '{"field":"status","operator":"eq","value":"published"}' \
--value-limit 5filter: 312 of 480 documents
topics string[] 260 of 312 documents 811 distinct
typescript 104
sqlite 70
search 58
architecture 40
mcp 31
806 more values, use --value-limit <n>, --value-offset <n>, or --all-valuesTwo windows page the key and value lists. Each windowed list ends with the exact remainder and the flags that reach it:
--key-limit <n> # Keys reported, in coverage order (default 50)
--key-offset <n> # Keys skipped before the window
--all-keys # Remove the key window
--value-limit <n> # Values reported per key and type (default 10)
--value-offset <n> # Values skipped per key and type, in --sort order
--all-values # Remove the value window
--sort count|value # Order values by document count (default) or by value
--min-count <n> # Drop values held by fewer documentsOmitting the collection name covers the default collections, exactly as an unscoped search does.
Reading the output:
Counts are documents, not values. A document with
topics: [a, b]contributes one to each. Coverage is "documents declaring this key", out of the documents the filter admits when there is one.A bare string is exactly the value. A string whose bare form could be read as something else (
"42","","a, b") prints as a JSON string, so paste it into a filter as the JSON string it is.Numbers report min, median, and max, plus the enumerated values when they fit, which is enough to write a
gt/ltthreshold in one call.Discovery sees exactly what filtering sees. Same extraction gate, same active-document rule, same collection scope, and every count in a result comes from one database snapshot. Documents still pending extraction are reported on stderr and excluded until
qmd updateruns.Type conflicts are reported, not resolved. Metadata is validated one document at a time, so
priority: 3in one file andpriority: highin another both index, within one collection or across several. Discovery splits such a key by type, each with its own document count and (across collections) its contributing collections:
qmd collection metadata --match '{"field":"key","operator":"eq","value":"priority"}'priority number | string 1,427 of 2,100 documents
number 223 docs min 1 median 3 max 5 notes, work
string 1,204 docs high (700), medium (380), low (124) workTo report one side only, add a type condition on the value field. The filter that reaches exactly those documents is the same condition with the metadata key in field:
qmd collection metadata --match '{
"operator": "and",
"operands": [
{ "field": "key", "operator": "eq", "value": "priority" },
{ "field": "value", "operator": "type", "value": "number" }
]
}'
qmd query "release checklist" --filter '{"field":"priority","operator":"type","value":"number"}'qmd collection list names each collection's top keys, qmd collection show <name> details the top five with a value preview, and qmd status summarizes how many keys and files carry metadata.
The CLI prints text and rejects unsupported format flags. Structured discovery is available through the SDK, MCP, and HTTP with the same options (collection, match, filter, keyLimit, keyOffset, valueLimit, valueOffset, sort, minCount) and the same result shape:
// SDK: one flat result, keys in coverage order, each split per type
const discovery = await store.listMetadata({
collection: "notes",
match: { field: "key", operator: "eq", value: "topics" },
valueLimit: 5,
})
discovery.documents // active documents in scope
discovery.totalKeys // keys with a matching entry, before the key window
discovery.remainingKeys // keys after the window
discovery.keys[0].types[0] // { type, multiValued, documents, distinctValues, values, remainingValues, range?, collections }
// Narrowed by a filter: how many documents pass, and what is left to filter on
const published = await store.listMetadata({
collection: "notes",
filter: { field: "status", operator: "eq", value: "published" },
})
published.filteredDocuments // the denominator for every coverage count in this result
// Page through a wide vocabulary
const nextPage = await store.listMetadata({ keyLimit: 50, keyOffset: 50 })filter is a MetadataFilter and match is a MetadataMatch. Both are MetadataPredicate<Condition>, the one recursive grammar over the conditions each record admits, so a document-only condition (exists, all, or a metadata key as the field) is a type error in a match as well as a runtime one. An option outside its domain throws MetadataOptionError, and a filter and match that together bind more SQL parameters than one statement allows throw MetadataBindingBudgetError.
The MCP metadata tool takes the same options with collections spelled as on query, returns the CLI text plus the result as structuredContent, and the MCP status tool lists each collection's most covered keys so an agent's first call reveals that metadata exists. POST /metadata accepts the same body as the tool and returns the same result (400 on an invalid match, filter, or option).
Output Format
Default output is colorized CLI format (respects NO_COLOR env).
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.
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.
TTY example:
docs/guide.md:42 #a1b2c3
Title: Software Craftsmanship
Context: Work documentation
Score: 93%
This section covers the **craftsmanship** of building
quality software with attention to detail.
See also: engineering principles
notes/meeting.md:15 #d4e5f6
Title: Q4 Planning
Context: Personal notes and ideas
Score: 67%
Discussion about code quality and craftsmanship
in the development process.Configure the editor link target with QMD_EDITOR_URI (or editor_uri in config):
# VS Code (default)
export QMD_EDITOR_URI="vscode://file/{path}:{line}:{col}"
# Cursor
export QMD_EDITOR_URI="cursor://file/{path}:{line}:{col}"
# Zed
export QMD_EDITOR_URI="zed://file/{path}:{line}:{col}"
# Sublime Text
export QMD_EDITOR_URI="subl://open?url=file://{path}&line={line}"Template placeholders:
{path}absolute filesystem path (URI-encoded){line}1-based line number{col}or{column}1-based column numberPath: Collection-relative path (e.g.,
docs/guide.md)Docid: Short hash identifier (e.g.,
#a1b2c3) - use withqmd get #a1b2c3Title: Extracted from document (first heading or filename)
Context: Path context if configured via
qmd context addScore: Color-coded (green >70%, yellow >40%, dim otherwise)
Snippet: Context around match with query terms highlighted
Examples
# Get 10 results with minimum score 0.3
qmd query -n 10 --min-score 0.3 "API design patterns"
# Output as markdown for LLM context
qmd search --md --full "error handling"
# JSON output for scripting
qmd query --json "quarterly reports"
# Inspect how each result was scored (RRF + rerank blend)
qmd query --json --explain "quarterly reports"
# Use separate index for different knowledge base
qmd --index work search "quarterly reports"The --explain flag attaches a score breakdown to each result: the FTS/vector
backend scores plus the RRF fusion math (rank, weight, top-rank bonus) and every
sub-query's contribution. Abbreviated:
{
"docid": "#6c90f0",
"score": 0.89,
"file": "qmd://qmd/README.md",
"explain": {
"ftsScores": [0.892, 0.907],
"vectorScores": [0.540, 0.484],
"rrf": {
"rank": 1,
"weight": 0.75,
"baseScore": 0.123,
"topRankBonus": 0.05,
"totalScore": 0.173,
"contributions": [
{ "source": "fts", "queryType": "original", "query": "reranking",
"rank": 1, "weight": 2, "backendScore": 0.892, "rrfContribution": 0.0328 }
]
}
}
}Index Maintenance
# Show index status and collections with contexts
qmd status
# Re-index all collections. If a collection has a configured update command
# (e.g. `git pull`), it runs first — set one with `qmd collection update-cmd`.
qmd update
# Diagnose the install (runtime, sqlite-vec, embedding fingerprints, GPU probe)
qmd doctor
# Initialize a project-local index in the current directory
qmd init
# Get document by filepath (with fuzzy matching suggestions)
qmd get notes/meeting.md
# Get document by docid (from search results)
qmd get "#abc123"
# Get document starting at line 50, max 100 lines
qmd get notes/meeting.md:50 -l 100
# Read 40 lines starting at line 120 via the :from:count suffix (works with docids)
qmd get notes/meeting.md:120:40
qmd get "#abc123:120:40"
# get / multi-get are line-numbered by default; disable with --no-line-numbers
qmd get notes/meeting.md --no-line-numbers
# Get multiple documents by glob pattern
qmd multi-get "journals/2025-05*.md"
# Get multiple documents by comma-separated list (supports docids)
qmd multi-get "doc1.md, doc2.md, #abc123"
# Limit multi-get to files under 20KB
qmd multi-get "docs/*.md" --max-bytes 20480
# Output multi-get as JSON for agent processing
qmd multi-get "docs/*.md" --json
# Drop caches and orphans; repack the vector table when it has holes
qmd cleanupBenchmarking
Measure search quality across all four backends with qmd bench and a fixture file
of queries with known-relevant documents.
From a git checkout, an example fixture and its test corpus ship in the repo:
# One-time setup (indexes the repo's test corpus into its own collection)
qmd collection add test/eval-docs --name eval-docs
qmd embed -c eval-docs
# Run the benchmark (table output)
qmd bench src/bench/fixtures/example.json
# JSON output for programmatic analysis
qmd bench src/bench/fixtures/example.json --jsonThe example fixture (
src/bench/fixtures/example.json) and its test corpus (test/eval-docs/) exist only in a git checkout — they are not part of the published npm package. If you installed vianpm/npx, write your own fixture (see below) against a collection you have already indexed:qmd bench my-fixture.json -c my-collection
Each query runs against four backends, reporting precision@k, recall, MRR, and F1:
| Backend | What it tests | LLM required |
|---|---|---|
bm25 | Keyword search only (FTS5) | No |
vector | Semantic similarity only | Embedding model |
hybrid | BM25 + vector fusion (no reranking) | Embedding model |
full | Full pipeline with LLM reranking | All three models |
Score interpretation: 1.00 = perfect (all expected docs in top results),
0.00 = complete miss. The example fixture typically shows bm25 ~0.50, vector
~0.70, and hybrid/full ~1.00 — a concrete demonstration of why hybrid search beats
either backend alone.
Custom fixtures are JSON:
{
"description": "My benchmark",
"version": 1,
"collection": "my-collection",
"queries": [
{
"id": "find-auth",
"query": "authentication flow",
"type": "semantic",
"expected_files": ["docs/auth-design.md"],
"expected_in_top_k": 3
}
]
}expected_files are collection-relative paths as shown by qmd ls. The type
field (exact, semantic, topical, cross-domain, alias) labels queries for
grouping — it does not change search behavior.
Heads-up: if the fixture's collection isn't indexed, bench currently runs to completion and reports all zeros with no warning. Verify setup with
qmd ls <collection>first.
Data Storage
Index stored in: ~/.cache/qmd/index.sqlite
Schema
collections -- Indexed directories with name and glob patterns
path_contexts -- Context descriptions by virtual path (qmd://...)
documents -- Markdown content with metadata and docid (6-char hash)
documents_fts -- FTS5 full-text index
content_vectors -- Embedding chunks (hash, seq, pos, 900 tokens each)
vector_collection_ids -- Integer id per collection name (the vector partition key)
vector_rows -- Vector rowid to (hash, seq, collection_id)
vectors_by_collection -- sqlite-vec vector index, one row per chunk and collection
llm_cache -- Cached LLM responses (query expansion, rerank scores)Environment Variables
| Variable | Default | Description |
|---|---|---|
XDG_CACHE_HOME | ~/.cache | Cache directory location |
XDG_CONFIG_HOME | ~/.config | Config directory location (where index.yml lives) |
QMD_CONFIG_DIR | unset | Override the config directory outright (takes precedence over XDG_CONFIG_HOME) |
QMD_LLAMA_GPU | auto | Force llama.cpp GPU backend (metal, vulkan, cuda) or disable GPU with false |
QMD_FORCE_CPU | unset | Set to 1/true to force CPU mode before any CUDA/Vulkan/Metal probing. Equivalent CLI flag: --no-gpu. |
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. |
How It Works
Indexing Flow
Collection ──► Glob Pattern ──► Markdown Files ──► Parse Title ──► Hash Content
│ │ │
│ │ ▼
│ │ Generate docid
│ │ (6-char hash)
│ │ │
└──────────────────────────────────────────────────►└──► Store in SQLite
│
▼
FTS5 IndexEmbedding Flow
Documents are chunked into ~900-token pieces with 15% overlap using smart boundary detection:
Document ──► Smart Chunk (~900 tokens) ──► Format each chunk ──► node-llama-cpp ──► Store Vectors
│ "title | text" embedBatch()
│
└─► Chunks stored with:
- hash: document hash
- seq: chunk sequence (0, 1, 2...)
- pos: character position in originalSmart Chunking
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.
Break Point Scores:
| Pattern | Score | Description |
|---|---|---|
# Heading | 100 | H1 - major section |
## Heading | 90 | H2 - subsection |
### Heading | 80 | H3 |
#### Heading | 70 | H4 |
##### Heading | 60 | H5 |
###### Heading | 50 | H6 |
``` | 80 | Code block boundary |
--- / *** | 60 | Horizontal rule |
| Blank line | 20 | Paragraph boundary |
- item / 1. item | 5 | List item |
| Line break | 1 | Minimal break |
Algorithm:
Scan document for all break points with scores
When approaching the 900-token target, search a 200-token window before the cutoff
Score each break point:
finalScore = baseScore × (1 - (distance/window)² × 0.7)Cut at the highest-scoring break point
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.
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.
AST-Aware Chunking (Code Files):
For supported code files, QMD also parses the source with tree-sitter and adds AST-derived break points that are merged with the regex scores above:
| AST Node | Score | Languages |
|---|---|---|
| Class / interface / struct / impl / trait | 100 | All |
| Function / method | 90 | All |
| Type alias / enum | 80 | All |
| Import / use declaration | 60 | All |
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.
Query Flow (Hybrid)
Query ──► LLM Expansion ──► [Original, Variant 1, Variant 2]
│
┌─────────┴─────────┐
▼ ▼
For each query: FTS (BM25)
│ │
▼ ▼
Vector Search Ranked List
│
▼
Ranked List
│
└─────────┬─────────┘
▼
RRF Fusion (k=60)
Original query ×2 weight
Top-rank bonus: +0.05/#1, +0.02/#2-3
│
▼
Top 30 candidates
│
▼
LLM Re-ranking
(yes/no + logprob confidence)
│
▼
Position-Aware Blend
Rank 1-3: 75% RRF / 25% reranker
Rank 4-10: 60% RRF / 40% reranker
Rank 11+: 40% RRF / 60% reranker
│
▼
Final ResultsModel Configuration
The default models are defined in src/llm.ts as HuggingFace URIs:
const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";Override them per-role without touching source via the models: block in
index.yml (see Configuring index.yml) or the
QMD_EMBED_MODEL env var. Re-run qmd embed after changing the embedding model.
EmbeddingGemma Prompt Format
// For queries
"task: search result | query: {query}"
// For documents
"title: {title} | text: {content}"Qwen3-Reranker
Uses node-llama-cpp's createRankingContext() and rankAndSort() API for cross-encoder reranking. Returns documents sorted by relevance score (0.0 - 1.0).
Qwen3 (Query Expansion)
Used for generating query variations via LlamaChatSession.
License
MIT
CHANGELOG.md
Changelog
Unreleased
Fixed
qmd doctorselects vector sample identities before loading document bodies, avoiding excessive SQLite memory use on large indexes with duplicate paths. #978 (thanks @naveenspark)Vector diagnostics match passages by their saved character position, avoiding false mismatches when earlier chunks change the sequence numbering. #978 (thanks @naveenspark)
Added
Added Oxlint lint fence.
Document metadata and metadata filtering. Markdown documents can opt into typed metadata through a namespaced frontmatter block (
qmd.metadatawith strings, numbers, booleans, or flat homogeneous arrays), and every search surface — CLIsearch/vsearch/queryvia--filter <json>, the SDK'sfilteroption onsearch()/searchLex()/searchVector(), the MCPquerytool, and HTTPPOST /queryand/search— accepts one shared recursive filter AST discriminated byoperator:and/or/notlogical groups,eq/ne/gt/gte/lt/ltecomparisons,in/nin/allmembership,contains/prefix/suffixtext matching,typefor the stored type of a key's values, andexistspresence, with an optionalcaseInsensitiveflag on any condition whose value is a string (ASCII folding). Every returned result satisfies the filter (applied before RRF fusion and reranking); like collection filtering, highly selective filters remain best-effort for top-K completeness. Frontmatter stays ordinary searchable content — no chunking, embedding, snippet, or line-number changes — and documents withoutqmd.metadatabehave exactly as before. JSON/SDK/MCP/HTTP results now include each document's indexed metadata, andqmd statusreports how many documents still need metadata extraction (a normalqmd updatebackfills existing indexes).Metadata discovery. Filtering is only useful when the caller knows what to filter on, so every surface now reports the metadata keys, types, and value counts already in the index — read from the existing metadata tables, with no re-indexing. The CLI adds
qmd collection metadata [name...]with--filter <json>to count only documents matching a filter (same AST as search) and--match <json>to select which metadata entries are reported (the same AST evaluated per entry, where a condition'sfieldis the entry'skeyorvalue, so one grammar covers a single key, a family of keys, reverse lookup of a value, typed thresholds, and anyand/or/notcomposition), plus--key-limit/--key-offset/--all-keysand--value-limit/--value-offset/--all-valuesto page both windows, and--sort count|valueand--min-count <n>to order and trim values. Every windowed list reports its exact remainder, counts are documents rather than values, numbers report min/median/max, keys whose documents disagree on type are split per type with their own counts, and with--filterevery coverage count is measured against the documents the filter admits.qmd collection listnames each collection's top keys,qmd collection showdetails them with a value preview, andqmd statussummarizes coverage. The SDK addslistMetadata(options)(the same options, spelledkeyLimit,valueLimit, and so on, withMetadataOptionErrorfor a value outside its domain andMetadataBindingBudgetErrorfor a filter and match that together bind too many SQL parameters), the MCP server adds ametadatatool and lists each collection's most covered keys instatus, and the HTTP server addsPOST /metadata.MetadataFilterandMetadataMatchare bothMetadataPredicate<Condition>, one recursive grammar over the conditions a document or a metadata entry admits.
Fixed
Filtered vector search binds candidate IDs as one JSON list, so a parser-valid metadata filter cannot exhaust Node's SQL variable limit during document lookup. Applies to both exact scans and the capped global fallback.
Collection- and metadata-scoped BM25 search (
qmd search -c, the lex leg ofqmd query, MCP, SDKsearchLex) no longer returns false-empty or incomplete results when stronger matches outside the scope fill the oldlimit * 10candidate window (#922). The scope is now applied to the full FTS5 match set, materialized once, sosearch -c <collection>is exact for common terms; unscoped search keeps its early-terminating plan. A search over several collections, such as the default ones, runs one keyword query instead of one per collection, which made keyword search about 12x faster over 13 collections (thanks @brettdavies). Builds on the approach in #918 (thanks @fxstein). #953 (thanks @Mr-Beasley)qmd updateno longer deactivates every document in a collection whose root folder is missing, for example an unmounted drive or an offline network share. The folder globbed to an empty list, which read as "every file was deleted", so search went empty and aqmd cleanupbefore the drive came back deleted the rows for good. The collection is now reported as not found and its index is left unchanged. Permission and I/O errors still fail with their original cause (#989). #990 (thanks @ParkerRex)Embedding generation and legacy fingerprint adoption now tokenize documents with the store-selected embedding model instead of the global default. This keeps chunk boundaries aligned with the model that creates and verifies the stored vectors without initializing an unrelated provider.
qmd doctorno longer stalls or fills the temp directory on large indexes when checking legacy (empty-fingerprint) embeddings. The adoption sample query joinedcontentand grouped by the document body, so SQLite materialized the full body once per legacy chunk and per active path beforeLIMIT 1discarded it, the same pattern as the doctor vector-sample check (#978). It now picks the sample row through indexes and loads only that row's body; the sampled chunk is unchanged (#994). #995 (thanks @mjaverto)
Changed
The MCP server caches its instructions per store for up to 60 seconds instead of rebuilding them, with a full index-status scan, for every HTTP request. Concurrent requests share one build and a failed build is not cached; index changes from other processes show up in the instructions within a minute. #815 (thanks @fxstein)
Avoid a redundant runtime process on packaged CLI calls where the launcher is already running under its selected Node or Bun runtime. #923 (thanks @ilepn)
qmd updateno longer reads files whose modification time and size match the last pass (tracked in a newfile_sync_statetable), so re-indexing an unchanged collection takes seconds. A cached entry counts only while its document is still active at that path with that content, so a collection removed and added back is re-indexed, while a renamed one keeps its entries; files with missing or outdated metadata are still re-extracted.qmd updateandqmd collection addskip files over 10 MB withFILE_TOO_LARGE, andqmd updatedeactivates a previously indexed file that becomes empty or is over 10 MB, including one indexed by an earlier release. #962 (thanks @rikvanriel)Search results carry at most the first 262,144 characters of each document body (
qmd search --full, MCP results, keyword and vector hits), which bounds memory on indexes with very large documents. A match past that point is still found, but its snippet, and the passage the reranker scores, come from the start of the document. #962 (thanks @rikvanriel)qmd updateandqmd collection addwrite a collection scan in short transactions instead of committing every row on its own, so a first index of 20,000 files takes about 5 s instead of about 3 minutes. #1021 (thanks @brettdavies)qmd queryandqmd vsearchnow drop repeated query expansions before searching. The expansion model can repeat a line, and the cache kept every copy: one reported query ran 23 expansions where 9 were distinct, and each copy ran its own search. Cached expansions are deduplicated when read, so existing caches need no rebuild. Repeated lex lines used to count more than once in the rank fusion, so result order can shift slightly. (#921) #1000 (thanks @ParkerRex)Structured searches over several collections (
qmd querywithlex:/vec:/hyde:lines, the MCPquerytool, SDKqueries) run one search per line over the whole collection list, a keyword search for alex:line and a vector search for avec:orhyde:line, and fuse one ranked list per search. Rankings no longer depend on the order the collections are named. #946 (thanks @shalom-t), #1009 (thanks @xidus90)
Changed
The vector index is partitioned by collection. Each embedded chunk is stored once per collection that holds it, under an integer collection id, so a collection-scoped
qmd queryorqmd vsearchscans only that collection's vectors instead of pre-filtering the whole index, and a small collection is never crowded out of the results by a large one. The first command after upgrading converts the index in place and prints its progress; the conversion resumes from where it stopped if interrupted, two commands started at once share it, and a VACUUM at the end reclaims the space of the old table.qmd collection renameno longer touches vectors,qmd updateremoves, as it finishes, the vectors of content that no indexed document holds any more (content that comes back later is embedded again), andqmd updateandqmd embedcopy an already-embedded document into a collection that gains it instead of embedding it again. A metadata-filtered vector search returns the nearest documents the filter admits however many chunks it admits, and a vector search fills its limit even when one long document holds all of the nearest chunks. Downgrading to an older qmd afterwards needsqmd embed -f, and aqmd mcpserver started before the upgrade must be restarted. #983 (thanks @brettdavies)
Changed
qmd cleanuprepacks the vector table when fewer than 90% of its chunk reads hold live rows. vec0 fills the first free slot of its newest chunk and reclaims a chunk only once it is empty, so re-embeds and orphan removal leave holes in older chunks that every brute-force scan still reads; a 794k-vector index at 36% occupancy scanned twice as slowly as a packed copy of the same rows. The repack moves the live rows of each mostly-empty chunk to the tail, one chunk per short transaction, so it never holds the write lock for long and an interrupted run leaves a consistent table. The cleanup output andqmd cleanup --dry-runreport the chunk counts. #937 (thanks @brettdavies)
[2.8.3] - 2026-08-16
Security
qmd updateno longer runs a project-local.qmd/index.yml'supdate:commands without approval (#886). That file arrives with agit cloneand is adopted automatically for any command run inside the tree, so cloning a repository and runningqmd updateexecuted shell commands chosen by whoever wrote it. On a terminal QMD now lists the commands and asks; with nobody to ask it skips them and keeps indexing. Approvals are recorded per config file and per command set in<config dir>/trusted.json, so editing a command — or agit pullthat rewrites one — asks again. Newqmd trust,qmd trust listandqmd trust revokemanage approvals, andQMD_TRUST_UPDATE_HOOKS=1opts unattended runs back in. Commands in your own~/.config/qmd/*.yml, including anythingqmd collection update-cmdwrites, are unaffected.The same project-local trust gate now covers collection
pathvalues that resolve outside the project and non-defaultmodels.embed/models.rerank/models.generateURIs (#889). In-project paths still index unattended; out-of-project directories are skipped untilqmd trust, and custom model URIs are not loaded or downloaded.QMD_TRUST_LOCAL_CONFIG=1opts unattended runs back in (andQMD_TRUST_UPDATE_HOOKS=1still does).qmd collection addrecords trust as it writes, the same wayupdate-cmddoes.Indexing no longer follows file symlinks or glob
..// absolute patterns out of the collection directory.fast-globalready skipped symlinked directories, but a file symlink (or a mask like../**/*.md) still resolved viarealpathand ingested the target.qmd://filesystem resolution uses the same containment check, soqmd://collection/../../../etc/passwdno longer produces a path outside the collection.qmd mcp --httpnow validates theOriginandHostheaders on every request and answers403when they name anything but a loopback address (#881). Binding to localhost is no defence against the user's own browser: a page can re-point its hostname at127.0.0.1(DNS rebinding) and read the indexed corpus throughPOST /queryorPOST /mcp. Requests with noOrigin(curl, MCP clients, editors) are unaffected. Extend the allowlists withQMD_ALLOWED_ORIGINS/QMD_ALLOWED_HOSTS, or setQMD_ALLOWED_ORIGINS=*behind your own authenticating proxy. A wildcard bind (--host 0.0.0.0) skips the host check and warns at startup.
Changed
Dependencies:
node-llama-cpp3.18.1 → 3.20.0 (llama.cpp b8390 → b10361, 2026-08-11). Also safe patch/minors:picomatch4.0.4 → 4.0.5,web-tree-sitter0.26.8 → 0.26.12,tsx4.21.0 → 4.23.12,vitest3.2.4 → 3.2.7. No zod/vitest major;@modelcontextprotocol/serverstays 2.0.0 (no 2.x patch).flake.nixFOD hashes are not updated here.generateand query expansion now awaitLlamaContextSequence.dispose()before disposing the parent context. node-llama-cpp 3.20 made sequence dispose async; the library's context-onDispose path does not wait.MCP server now speaks protocol revision 2026-07-28 via the official TypeScript SDK 2.x (
@modelcontextprotocol/server). HTTP is sessionless (noMcp-Session-Id, no initialize handshake, no idle-session TTL / #816 reaper). Clients send version and capabilities in_meta;server/discoveris implemented; Streamable HTTP POST requiresMcp-Method/Mcp-Name(mismatch →-32020);tools/listis deterministic and carriesttlMs/cacheScope. 2025-era stdio clients still work (serveStdiodual-speak); 2025-era HTTPinitializeis answered per-request without minting a session. Existing tools (query/get/multi_get/status), stdio EOF shutdown, and named-index daemon PIDs are unchanged. No release.qmd pull(and implicit model downloads inembed/query) no longer print node-llama-cpp's download progress bar. The bar redraws every few kilobytes and flooded agent transcripts with thousands of tokens (#776). Passqmd pull --progressto show it on an interactive terminal.--full-pathno longer degrades silently when a result cannot be resolved on disk (#785). A fallback there means the file moved or was deleted since the last index, sosearch,query,getandmulti-getnow print a notice to stderr naming how many results fell back and suggestingqmd update; stdout stays machine-readable.search/querynow decide per result whether to show the docid under--full-path, matchingmulti-getandget: a result that resolved shows its on-disk path and no docid, one that did not keeps itsqmd://URI and its docid, so it is still addressable. Previously the docid was dropped for every row whenever the flag was set, leaving unresolved rows with neither a usable path nor an identifier.search --format csvalways emits thedocidcolumn, empty for rows that resolved to an on-disk path. Under--full-paththe header previously dropped the column entirely — which also disagreed with the empty-result header, always printed withdocid. Column positions are now stable across runs and formats.
Fixed
Concurrent first-open of a cold index no longer fails with
table documents_fts already existson Bun/macOS. FTS5CREATE VIRTUAL TABLE IF NOT EXISTSis not atomic across WAL connections: two processes can both see a missing table on their schema snapshot and the loser throws. Table create and legacy-schema repair now use the sameBEGIN IMMEDIATE+ double-check as the FTS sync triggers, and treat a concurrent "already exists" as success when the table is present.Nix flake
qmd-node-modulesFOD hashes updated for x86_64-linux and aarch64-darwin after the MCP SDK 2.0 bump.nix build/ Nix GHA was failing with a fixed-output hash mismatch.CJK FTS rebuild no longer skips leftover
fts5(name, body, content='documents')tables whenfts_cjk_normalized_versionis already stamped, and schema repair now checks live FTS columns (PRAGMA table_info) as well assqlite_master.sql. The MCP HTTP test helper still seeds that legacy table;startMcpHttpServer/createStoreon it must not throwno such column: T.name(#792 regression).qmd collection add --globis no longer silently ignored. parseArgs ran withstrict: false, so OpenClaw's--glob memory.md(and any other--glob) fell through, the default**/*.mdwas used, and a second collection on the same path collided as a duplicate instead of indexing the requested mask (#536).--globis now an alias for--mask.The
bin/qmdtrampoline now execsprocess.execPathinstead of re-resolvingnodefrom PATH. Native addons (better-sqlite3) are compiled for the Node that installed qmd; a version manager (nvm, fnm, mise) selecting a different major in the working directory used to spawn that other binary and fail withNODE_MODULE_VERSION/ERR_DLOPEN_FAILED(#577 leftover; #319).bun bin/qmdstill resolvesnodefrom PATH so Node-ABI addons are not loaded into bun.qmd update/qmd collection addno longer swallow unreadable files silently (#460).readFileSyncfailures (ETIMEDOUT on APFS compressed files, EAGAIN, EACCES, …) still skip the file so the rest of the collection indexes, but the CLI now warns with the path and error code and reports the skip count. The SDKupdate()result includesskipped.The architecture diagram no longer draws Vec expansions into BM25 search (#680).
lexexpansions are FTS-only;vecandhydeexpansions are vector-only. The original query still goes to both backends.qmd benchno longer runs to a wall of 0.00 when the fixture collection is missing or empty (#716). It errors up front with the same "Collection not found" / index hint asqmd search -c, and if every backend still scores zero it warns on stderr to checkqmd ls.qmd cleanupnow reclaims the content and FTS space left behind after a wrong-directoryqmd update. Deactivating files (the next update in the right directory) only tombstoned thedocumentsrows; cleanup deleted those rows and vacuumed, but never dropped the unreferencedcontenthashes and never ran FTS5optimize, sodocuments_fts_datakept the old bodies (#550). Cleanup now deletes inactive docs, then orphaned content, then compact FTS, then vacuum.--dry-runreports the content hashes too.Concurrent
querycalls withrerank: trueon a cold MCP server no longer raceensureRerankContexts(). Embed already serialized context creation; rerank did not, so two overlapping first queries both saw an empty pool, both created ranking contexts, and the inactivity timer disposed the loser (Object is disposed, #682). Callers now await the in-flight create.Embedding-context pool size no longer assumes every GGUF costs 150 MB of VRAM (the nomic-embed figure). Larger models such as Qwen3-Embedding-0.6B are ~1190 MB per 2048-token context; opening 8 of those exhausted an 8 GB card so
qmd queryfailed withFailed to create any rerank contexteven though the reranker itself was fine. The pool is now sized from the weight file, and 1 GB is reserved for the reranker (#799). Default embeddinggemma/nomic throughput is unchanged.QMD_EMBED_PARALLELISMstill overrides.Multi-collection
-c A -c B(and SDK/MCPcollections: [A, B]) no longer searches globally then post-filters. A large unrelated collection could fill the FTS/ANN top-k so the requested collections vanished, yielding false-empty results even though each collection matched on its own.searchFTS/searchVecnow search each requested collection, then merge by score (#775). Single-collection exact-scan (#791, #803) is unchanged.Query expansion no longer consumes caller
intent, and a cached expansion whose sub-queries all miss is dropped instead of replaying forever (#818). Intent still steers reranking and snippet/chunk selection; it just no longer enters the expansion prompt or cache key, where the model copied meta-language ("so I can compare spend settings") into lex/vec terms that matched nothing.Files whose names differ only in the characters the legacy slug collapsed to
-(spaces, underscores) no longer evict each other from the index (#717). The handalized-path migration now skips any row whose path is still owned by a file in the current scan, so it only adopts genuinely stale pre-2.6 rows.qmd getandqmd ls <prefix>also match_and%in paths literally instead of as SQLLIKEwildcards, soqmd get 2026_06_16.mdno longer returns a sibling2026-06-16.md.CLI
multi-getand SDK/MCPmulti_getnow share one comma-list resolver. Collection-prefixed paths (qmd/docs/SYNTAX.md) work in both transports, unanchoredLIKE '%name'no longer silently fetches a different document for a filename fragment (NTAX.md≠SYNTAX.md), and ambiguous names across collections error with the candidate list instead ofLIMIT 1(#759).The Nix flake wrapper now seeds the same pre-import env as
bin/qmd. Nix installs execbun src/cli/qmd.tsdirectly, so they previously skipped the launcher:qmd mcpcould leak llama/ggml native logs onto JSON-RPC stdio, and Darwin CLI exits dumped a ggml Metal residency-set stack trace after an otherwise successful query. The wrapper now quiets those logs formcpand setsGGML_METAL_NO_RESIDENCY=1on Darwin unlessQMD_METAL_KEEP_RESIDENCY=1(#723).Rerank context creation no longer swallows the real failure. A VRAM OOM or corrupt model previously produced only
Reranker unavailable — skipping reranking(and a dead identical retry whose comment claimed it disabled flash attention, which ranking contexts never supported). The warning now includes the underlying message so the two cases are distinguishable (#782).The Nix flake package now ships
skills/next tosrc/in$out/lib/qmd/.findPackageRoot()walks up from the wrappedsrc/cli/qmd.tslooking for a siblingskills/directory; without it,qmd skill showandqmd skills listalways failed with "QMD skill not found" on Nix-installed binaries (#722)./releasestep 1 no longer points at a missing script.skills/release/scripts/release-context.shnow exists: it silently installs git hooks and prints version info, working-tree status, commits and files since the last tag,[Unreleased], and the previous changelog entry. The skill's process list also drops the duplicate step 7 and checks dependency updates before cutting the release (#796).store.searchVec()(and SDKsearchVector()) now embed the query with the store's pinned embed model instead of the globalQMD_EMBED_MODEL. A store created with a non-defaultmodels.embedpreviously failed withDimension mismatch ... Expected N ... received Mand loaded the wrong (often much larger) model at query time. Hybrid/precomputed/session search paths were unaffected and stay unchanged (#690).qmd collection add --mask "a.md,*.txt"now indexes the union of each pattern. The comma-separated form was documented and commonly guessed, but the joined string was passed to fast-glob as one literal glob, so it matched zero files with no error. Brace form{a.md,*.txt}is unchanged. The same split applies onqmd updatefor stored comma-list masks (#557).NixOS / immutable-root installs no longer crash
qmd embedwith EACCES when node-llama-cpp tries to compile llama.cpp into a read-onlynode_modules. The flake wrapper puts Nix's glibc and libstdc++ onLD_LIBRARY_PATHso prebuilt binaries candlopenthem, andgetLlama()usesbuild: "never"when the llama directory is not writable (#574).CJK FTS rebuild no longer raises "database is busy" when flushing insert batches. The streaming
.iterate()cursor stayed open acrossBEGINon the same connection; the scan now uses keyset-paginatedLIMITbatches so each SELECT finalizes before the insert transaction starts (#797).Quoted FTS phrases containing dotted tokens (e.g.
"1.0.21") now match the indexed document. The porter unicode61 tokenizer stores dotted strings as adjacent parts, but phrase sanitization stripped the dots into a single token (1021) that could never hit. Dotted tokens inside quotes are split into adjacent phrase terms, matching the bare-term rewrite from #563 (#757).scripts/build.mjsno longer passesshell: truetospawnSyncon Windows. With the default Node install path (C:\Program Files\nodejs\node.exe),cmd.exesplit the unquotedprocess.execPathat the space, thetscspawn failed, andpreparecould still report success with nodist/— leavingbin/qmdat "not built". The helper always receives a real binary path plus an args array, so no shell is needed. A spawn error now prints the missing binary path instead of failing silently. (#681)Case-sensitive collections no longer collapse distinct document identities that differ only by path casing. The implicit
COLLATE NOCASElegacy migration was unsafe for filesystems that contain bothREADME.mdandreadme.md; case-only legacy migrations must now be explicit and operator-reviewed (#801).cleanupOrphanedVectorsnow runs its orphan count and both DELETEs in a single immediate transaction. An interruption between the two DELETEs (crash,SQLITE_BUSY) could desyncvectors_vecfromcontent_vectors, leaving stale metadata rows that make a later reactivation of the same content hash look already-embedded — soqmd embedskips it and the document becomes silently unsearchable by vector, with no orphan left to clean up (#766).qmd embedno longer splits a UTF-16 surrogate pair (emoji, etc.) across a chunk boundary. A chunk ending or starting mid-pair produced an unpaired surrogate in the chunk text, which some remote embedding APIs reject as invalid JSON — permanently failing that chunk on every retry, since the boundary calculation is deterministic. This covers both the character-based chunker andchunkDocumentByTokens's recursive re-splitting for astral-plane-dense content, which could previously drive the char budget low enough to reproduce the same split (#777).insertContextlooks upstore_collectionsby name. #754 retargeted the query from the droppedcollectionstable but leftWHERE id = ?, andstore_collectionshasname TEXT PRIMARY KEYwith noidcolumn — the call threwno such column: id. MatchesdeleteContext/updateStoreContext(#853).qmd collection addwith no path argument now errors with usage instead of silently indexing the current working directory (#684). Pass.to index CWD, matching the documented examples.qmd statusreports orphaned embedding chunks,qmd updatehints when they exceed 10% of vectors, andqmd cleanup --dry-runpreviews what would be removed. Incremental update still does not auto-prune vectors (a transient empty mount would otherwise force a full re-embed) (#768).qmd --index <name> mcp --http --daemonnow scopes PID/log files per index (mcp-<name>.pid) and passes the resolved database path to the child, so a named-index daemon no longer collides with the defaultmcp.pidor opens the default store (#772).Opening a store no longer throws
SQLiteError: no such column: T.namewhendocuments_ftsis still the legacyfts5(name, body, content='documents')schema.CREATE VIRTUAL TABLE IF NOT EXISTSleft that table in place, and the CJK FTS rebuild'sDELETE FROM documents_ftscompiled againstdocuments.name, which does not exist (#792).Rerank cache keys now include the resolved
models.rerankURI, so swapping the configured reranker no longer serves the previous model's cached scores (#764).vsearch -c <collection>no longer returns empty results for small collections crowded out of the global ANN candidate pool.searchVecnow exact-scans the collection's vectors withvec_distance_cosinewhen the set is within 20k rows (ANN + post-filter cannot see collections that never enter global top-k, and sqlite-vec capskat 4096 so a larger multiplier alone is not enough). Larger collections still use capped ANN over-fetch (#791, #803).multi-get --format filesnow emits the docid as its own CSV field (#docid,path,...) instead of prepending it into the path field with a space (#docid path,...), matchingsearch --format filesand keeping naive comma-splitting usable (#760).qmd embednow takes an exclusive process lock (.qmd-embed.locknext to the index DB) so concurrent invocations no longer race onvectors_vecand fail withUNIQUE constraint failed: vectors_vec.hash_seq. A second embed exits early withAnother embed process is already running. Skipping.Stale locks from crashed processes are recovered via PID identity checks (#825).windows prepare fix #778 keeps dist build #824
qmd mcp(stdio) now shuts down gracefully when stdin reaches EOF instead of orphaning to PID 1 when the parent MCP client dies (#751): the server closes its transport, gives in-flight request handlers a bounded window to settle, closes the store (which disposes its llama.cpp instance), and lets the process drain viaprocess.exitCode(no forcedprocess.exit(), which has caused exit-time native crashes before).qmd --versionno longer reports an unrelated repository's commit (#787). The commit was discovered at runtime withgit -C <installDir> rev-parse, andgit -Cwalks up, so any install nested inside another checkout reported that checkout's HEAD — a global npm install under a git-managed prefix such as Homebrew's/opt/homebrewclaimed Homebrew's commit as qmd's. Identical tarballs reported different "commits" depending only on where they were installed, which is why one issue can collect three distinct hashes for the same published build.scripts/build.mjsnow stamps the commit it built from intodist/cli/build-info.json(suffixed-dirtywhen built from a modified tree), and the runtime prefers that. Source checkouts still resolve their own HEAD, but only after confirming the enclosing repository is qmd's; anything else reports no commit rather than a misleading one. The lookup also no longer interpolates the install path into a shell string, so a path containing a space stops silently dropping the commit, and--versionfrom a build runs no subprocess at all.qmd doctorno longer false-positives.etagHTTP sidecars (written byqmd pullnext to each download) as invalid GGUF models. The model-cache check now only inspects real.gguffiles, so a sidecar that happens to sort before the blob no longer poisons the report. #812qmd mcp stopandqmd mcp --http --daemonnow verify that a pidfile PID still belongs to a qmd process before signalling it or refusing to start. Recycled PIDs (common after reboot) are treated as stale: the pidfile is unlinked instead of SIGTERM'ing an unrelated process or blocking daemon start with a false "Already running" error (#806).qmd collection addnow rejects missing paths and regular files before creating collection configuration or index state. The error reports both the received and resolved path so malformed shell arguments can be corrected.Claude Code plugin: scope the plugin
sourceto./skillsso installs copy just the skills (~50 KB) instead of the entire repository. Previously a canonical install materialized ~230 MB / 9,000+ items into~/.claude/plugins/cache/— including a full npm dependency install triggered by the repo-rootpackage.json. Both skills (qmdandrelease) still ship, unchanged. (#790)Claude Code plugin: releases now bump the plugin version in
.claude-plugin/marketplace.jsonin lockstep withpackage.json. The plugin cache is keyed on this version, and it had been stuck at0.1.0since February — so installed plugins never received skill updates (users who installed in February are still being served that snapshot today, despite the qmd skill nearly tripling in size since). Also bumps the plugin to2.6.3as a one-time catch-up so existing installs pick up the current skill on their nextclaude plugin update. (#789)
Changed
--full-pathno longer degrades silently when a result cannot be resolved on disk (#785). A fallback there means the file moved or was deleted since the last index, sosearch,query,getandmulti-getnow print a notice to stderr naming how many results fell back and suggestingqmd update; stdout stays machine-readable.search/querynow decide per result whether to show the docid under--full-path, matchingmulti-getandget: a result that resolved shows its on-disk path and no docid, one that did not keeps itsqmd://URI and its docid, so it is still addressable. Previously the docid was dropped for every row whenever the flag was set, leaving unresolved rows with neither a usable path nor an identifier.search --format csvalways emits thedocidcolumn, empty for rows that resolved to an on-disk path. Under--full-paththe header previously dropped the column entirely — which also disagreed with the empty-result header, always printed withdocid. Column positions are now stable across runs and formats.
[2.6.3] - 2026-06-24
Added
qmd embed --timeout <minutes>overrides the embed session's max duration (previously hardcoded to 30 minutes). Use a larger value to let a big index finish in one run, or--timeout 0to remove the cap entirely. When the cap is reached, remaining document batches are skipped as before, so re-runningqmd embedcontinues where it left off.
Documentation
README: added a "Configuring
index.yml" section documenting the full config schema —global_context,editor_uri, themodels.embed/rerank/generateoverrides, and per-collectionpath/pattern/ignore/update/includeByDefault/context— with file-location rules (XDG_CONFIG_HOME,QMD_CONFIG_DIR, named{name}.yml, project-local.qmd/index.yml). Every key is verified againstsrc/collections.tsand its consumers. Documents behavior that previously existed only in code, where the absence of docs led contributors to repeatedly re-submit already-shipped model-resolution fixes (#502, #559, #564) and to request config that already works (#645, #678). AddedXDG_CONFIG_HOME/QMD_CONFIG_DIRto the environment-variable table and noted theindex.ymlmodels:/QMD_EMBED_MODELoverride path in the Model Configuration section.README: expanded the per-collection
updatefield into an "Automatic update commands" subsection — the feature the maintainer publicly called under-documented — covering execution viabash -cin the collection's directory, the run-then- reindex order, the non-zero-exit abort behavior, and theqmd collection update-cmdset/clear shortcut. Theignorekey now states it is YAML-only (no CLI command) and additive with the un-overridable built-in exclusions.example-index.yml: overhauled from three near-identical collections into a fully-commented starter template where each collection demonstrates a distinct feature (hierarchical context, auto-update,ignorepatterns, non-markdown globs,includeByDefault: false, and an all-fields example), plus commentededitor_uri/modelsstubs. README now links to it. Model URIs are intentionally left as placeholders so the template can't drift from the defaults.README: documented collection filtering (
-csemantics), thecollection show/include/exclude/update-cmdsubcommands, the--intent/--no-rerank/-C/--full-pathsearch flags, the--format <kind>output selector (with the legacy--json/--csv/--md/--xml/--filesbooleans noted as aliases),vector-search/deep-searchaliases, embed memory flags (--max-docs-per-batch/--max-batch-mb), a sample--explainscore trace, theqmd doctor/qmd initcommands, theget:from:countsuffix and--no-line-numbers, an MCP tool parameter reference, and a Benchmarking section forqmd bench.docs/SYNTAX.md: removed the non-existent
qMCP parameter example (thequerytool and REST endpoint accept only thesearchesarray) and added a Scoping section.README: removed the misleading
qmd update --pullexample. The--pullflag is parsed but never consumed (updateCollections()ignores it); the real mechanism for runninggit pullbefore re-indexing is a per-collectionupdatecommand, set viaqmd collection update-cmd.
Fixed
MCP server instructions now tell agents to scope with the plural
collectionsparameter (matching the schema). The previous singularcollectionhint led agents to pass a parameter that Zod silently strips, producing unscoped results. Thegetinstruction line also now documents the fullfile.md:from:countrange suffix instead of only the single-linefile.md:100offset.Filesystem paths with special characters (
#,&, spaces,[],(), etc.) now round-trip correctly through index → search → get. PreviouslyreindexCollectioncalledhandelize()on relative paths before storing them, turning# Meeting - 234232 3432 __ 5.mdintoMeeting-234232-3432-5.mdand makingqmd get <actual-path>,qmd get --full-path, andqmd lsreturn dead or garbled paths. Paths are now stored verbatim. Existing indexes auto-migrate on the nextqmd update.FTS5 search now correctly matches dotted version strings like
2026.4.10. Theporter unicode61tokenizer splits on dots (storing2026,4,10as separate tokens), but the query sanitizer was stripping dots and producing2026410which never matched. Dotted terms are now split and ANDed together so version-string searches work as expected (#563).HTTP REST endpoints
/queryand/searchnow returnqmd://collection/pathURIs in thefilefield, matching the output format used by the CLI and MCP resource URIs. Previously the rawdisplayPath(collection/path) was returned without the scheme prefix (#576).The embed session
maxDurationis now env-configurable viaQMD_EMBED_MAX_DURATION_MS(default: 30 min). This prevents large-corpus embeddings from being aborted by the hardcoded 30-minute ceiling (#673).qmd query,qmd update, and other commands no longer fail withSQLiteError: database is lockedwhen multiple processes run against the same index in parallel (e.g. anupdateracing a longembed, the first-open schema migration racing a routine command, or an agent fanning out searches).openDatabasenow setsPRAGMA busy_timeout = 120000on every connection, so a writer that loses the race queues at batch boundaries instead of throwing immediately. WAL handles read/write concurrency but does not serialise concurrent writers, andbun:sqliteandbetter-sqlite3both default the timeout to 0, so the loser previously failed on the first DDL statement ininitializeDatabase. Override the default withQMD_SQLITE_BUSY_TIMEOUT(milliseconds;0restores fail-fast). Two more crashes on the same concurrent-open path are fixed:trigger documents_ai already exists(the FTS sync triggers were dropped and recreated as separate statements on every open, so two processes interleaved between theDROPand theCREATE;busy_timeoutserialises individual statements but not the pair) anddatabase is lockedwhile migrating a cold database to WAL (thejournal_modeswitch needs a brief exclusive lock and does not invoke the busy handler). FTS trigger setup is now gated behindPRAGMA user_versioninside oneIMMEDIATEtransaction, and the WAL migration retries within the busy-timeout budget.
[2.5.3] - 2026-05-28
Features
qmd getnow accepts a:from:countsuffix on a path or docid (e.g.qmd get "#abc123:120:40"reads 40 lines starting at line 120). Explicit--from/-lflags still override the suffix. The MCPgettool accepts the same suffix.qmd getandqmd multi-getare now line-numbered by default and print the document's#docidandqmd://path in the output header. Disable line numbers with--no-line-numbers. The MCPget/multi_gettools defaultlineNumberstotrueto match.qmd multi-getnow includes the#docidin every output format (--md,--json,--csv,--xml,--files, and the default CLI view), consistent withqmd search.qmd getandqmd multi-getaccept--full-path, which replaces theqmd://path +#docidwith the document's on-disk filesystem path (handy for piping intoRead/Edit/an editor). Falls back to the canonicalqmd://+ docid header when the file no longer exists on disk.qmd search/qmd querynow show a clearer hit identifier: the default CLI view (and the new**file:**line in--mdoutput) always prints the fullqmd://collection/pathURI so you can pipe it straight back intoqmd get.qmd search/qmd queryaccept--full-pathwith the same semantics asqmd get: the result label becomes the file's on-disk path —./-prefixed relative path when the file lives in a subfolder of$PWD, absolute realpath otherwise — and the per-result#docidis dropped because the path is the identifier. The leading./is intentional so the output is unambiguously a filesystem path. Applies to all output formats.qmd getandqmd multi-getnow also use the./-prefixed convention when--full-pathrenders a path under$PWD, matchingsearch/query.New
--format <kind>flag selects the output format (cli|json|csv|md|xml|files) forsearch,query, andmulti-get. The legacy boolean aliases (--json/--csv/--md/--xml/--files) still work but are no longer in--help; prefer--format.
Fixes
Launcher: source-mode runner selection now prefers Node + tsx over Bun when both
package-lock.jsonandbun.lockare present in the package root, mirroring the dist-mode "npm priority" rule. Fixes pnpm-global installs that copy the entire working tree (including.gitandbun.lock) into the install dir and previously routed through Bun, causing ABI mismatches with the Node-builtbetter-sqlite3/sqlite-vecnative modules.Darwin Metal: llama-using commands (
query,vsearch,embed) no longer dump a multi-kB GGML/Metal backtrace at process exit even when output succeeded. The libggml-metal staticggml_metal_devicedestructor asserts[rsets->data count] == 0during__cxa_finalize_ranges, but the buffer-free path never calls the symmetricggml_metal_device_rsets_rmto remove released rsets from the device collection (upstream ggml-org/llama.cpp#22593, one-line fix open as PR #22595). The assertion only fires whenprocess.exit()skips Node'sbeforeExithook, which is what node-llama-cpp uses to auto-dispose Metal contexts. Primary fix:finishSuccessfulCliCommandnow setsprocess.exitCode = 0and returns instead of callingprocess.exit(0), sobeforeExitfires and the native binding cleans up before libc's static destructor runs. Defense-in-depth: the launcher (bin/qmd) and the npm test driver (scripts/test-all.mjsthe
test:bun/test:unitpackage.json scripts) also setGGML_METAL_NO_RESIDENCY=1on darwin before spawning node/bun, covering error paths and tests that still terminate viaprocess.exit(). The env var must be set before node/bun start — libggml-metal reads it via libcgetenvat module-load time, and Bun does not propagateprocess.envmutations to libcsetenv— so it lives in the launcher rather than in test-preload. Residency sets give no measurable speedup for QMD's short-lived CLI workflow (benchmarked on M3 Pro). Opt back in withQMD_METAL_KEEP_RESIDENCY=1for long-lived qmd processes (e.g. the MCP daemon may benefit on hot reload) or to triage the upstream fix.qmd doctorreports the mitigation state. Minimal reproduction:scripts/repro-metal-rsets-crash.mjs.
Docs
qmd skill: emphasize reading line ranges with
get's built-in:from:countsuffix /--from/-lflags instead of piping throughsed/head/tail; cite the docid and line numbers now present in retrieval output; and author structuredintent:/lex:/vec:/hyde:queries yourself rather than relying on built-in query expansion.
[2.5.2] - 2026-05-22
Fixes
Launcher: Rewrite
bin/qmdas a Node-based shebang polyglot to fix global npm installation execution failures on Windows (#668 / #452), while supporting seamless fallback to Bun in Node-less environments.
[2.5.1] - 2026-05-20
Changes
Release: publish from GitHub Actions via npm Trusted Publishing/OIDC instead of a long-lived
NPM_TOKENsecret.
[2.5.0] - 2026-05-19
Changes
Dependencies: update core SQLite/config/chunking packages (
better-sqlite3,yaml,web-tree-sitter,tree-sitter-go, andtree-sitter-python) while keeping incompatiblezod,tsx, andvitestmajors pinned.Agent skills: add
qmd skills list|get|pathto serve version-matched runtime skill instructions from the installed CLI, and makeqmd skill installwrite a stable discovery stub so installed agent skills do not go stale after QMD upgrades.CLI: add
qmd doctorfor index/runtime diagnostics, including SQLite/sqlite-vec versions, embedding fingerprint freshness, mixed-fingerprint detection, safe legacy fingerprint adoption, and content-hash sampling.
Fixes
Launcher: prefer runnable TypeScript source in git checkouts even when ignored
dist/artifacts exist, while packaged installs continue to rundist/.GPU: keep node-llama-cpp's documented
gpu: "auto"initialization as the primary path, then perform no-build packaged CUDA/Vulkan/Metal probes only if auto falls back to CPU.CLI: move GPU/CPU runtime diagnostics out of
qmd status; useqmd doctorfor device probing and related environment guidance.CLI: point unexpected command/setup failures toward
qmd doctorso diagnostics are the default next step when QMD behaves incorrectly.Doctor: explicitly warn when
content_vectorscontains multiple non-empty embedding fingerprint names, with the per-fingerprint document/chunk breakdown.Embed: make the TTY progress line label byte-based input progress explicitly, show embedded chunks as a count, and shorten the displayed model name.
Embed: retain per-chunk failure details, retry failed chunks after later successful embeds and again when no other chunks remain, clear recovered errors, and cap retries to avoid endless loops.
Tests: expand the container smoke harness to cover npm-global, npx-style, and Bun-global install scenarios, always checking auto and
QMD_FORCE_CPU=1doctor modes, with opt-in tinyqmd embedand GPU probe runs for supported container runtimes.Embedding: fingerprint vector metadata using the active embedding model and formatting/chunking parameters so stale vectors are treated as pending after search semantics change. Legacy
content_vectorscolumns are migrated lazily on first vector-health/write use to preserve fast QMD startup.Skill: expand the packaged QMD skill with retrieval-first workflows, structured query examples, wiki/source collection guidance, and safe fallbacks when model-backed search is unavailable.
Tests: make
bun run testexecute the local unit suite under both Node/Vitest and Bun (test:node+test:bun) so runtime-specific regressions are caught before CI.Model config: centralize embedding/rerank/generation model resolution so
qmd embed,status,query,vsearch,pull, SDK vector search, andbenchuse the same active.qmd/index.yamlmodel hints and environment fallbacks.GPU/status:
qmd statusnow uses the same embedding model identity asqmd embedwhen computing pending embeddings, so URI-backed embeddings are not incorrectly reported as pending under the legacyembeddinggemmaalias.GPU status:
qmd statusnow always shows GPU mode/configuration without unsafe native probing, and CPU-fallback warnings point toQMD_STATUS_DEVICE_PROBE=1 qmd statusfor an actual backend probe. The no-GPU warning is emitted once per process instead of once per LLM instance during benchmarks.GPU: add
QMD_FORCE_CPU=1/--no-gputo bypass CUDA/Vulkan/Metal probing entirely, and route native llama.cpp stdout noise to stderr so JSON output stays parseable during search/query commands.Snippet line numbers:
qmd_query(MCP), HTTP/query, andqmd query(CLI JSON output and snippet headers) now return absolute source-file line numbers instead of chunk-local ones, so thelinefield can be passed back toqmd_getasfromLinewithout a separate lookup. Snippet selection remains scoped to the best matching chunk (preserves #149).CLI:
qmd query --fullnow emits the full document body in all output formats (json, csv, md, xml), restoring the documented behavior of the flag. Previously it returned only the best matching chunk (~3.6KB max per result). Output payload for--fullqueries is now proportional to total document size.macOS Metal:
qmd query --jsonnow flushes successful JSON output and uses a safe immediate-exit path on Darwin to avoid ggml Metal finalizer aborts; other commands still dispose LLM contexts/models before the llama runtime. #368Embedding: require complete chunk coverage before treating a document as embedded, remove partial vectors when chunk/session failures leave a document incomplete, and keep
qmd statuspending counts honest after interrupted long embed runs. #637 #378Embedding:
qmd embed -c <collection>now scopes pending-doc selection to the requested collection instead of embedding global pending work. Scoped--forceclears only collection-owned vectors, preserves shared hashes referenced by sibling collections, and dropsvectors_veconly when the scoped clear empties all vectors.Hybrid search: weight RRF lists by query type so original FTS and original vector evidence get the intended 2x boost, instead of accidentally boosting the first lexical expansion. #591
MCP: seed llama.cpp/GGML quiet env vars before launching
qmd mcpso native logs cannot pollute stdio JSON-RPC framing. #593CLI: remove CommonJS
require()calls from ESM index path normalization soqmd --index <path>no longer crashes withERR_AMBIGUOUS_MODULE_SYNTAXon Node 22+. #634Windows CUDA: serialize llama.cpp embedding/reranking contexts by default to avoid intermittent
ggml-cuda.cu:98crashes inqmd query; setQMD_EMBED_PARALLELISMto opt back into parallel contexts if your driver is stable. #519MCP: make
qmd mcp --index <name>use the selected index for both foreground and daemon HTTP servers instead of falling back to the default store. #343Embedding: respect
QMD_EMBED_MODELconsistently for vector indexing and vector-backed search, with default-model fallback when unset.Config: use one home-directory resolver for YAML config and the default SQLite cache path, avoiding Windows CLI/MCP split-brain when
HOMEis unset.GPU: respect explicit
QMD_LLAMA_GPU=metal|vulkan|cudabackend overrides instead of always using auto GPU selection. #529Fix: preserve original filename case in
handelize(). The previous.toLowerCase()call made indexed paths unreachable on case-sensitive filesystems (Linux).qmd updateautomatically migrates legacy lowercase paths without re-embedding.CLI: make
qmd statusskip nativenode-llama-cppdevice probing by default so status stays safe on machines with broken or unsupported GPU drivers. SetQMD_STATUS_DEVICE_PROBE=1to opt in.CLI: lazy-load
node-llama-cppso lightweight commands such asqmd statusdo not import native ML dependencies or trigger llama.cpp builds on ARM/no-GPU machines. #491Store: keep content rows referenced by inactive documents during orphan cleanup so
qmd updatepreserves soft-deleted tombstones for removed files. #585Packaging: install AST grammar WASM packages as required dependencies so Bun global installs include TypeScript/TSX/JavaScript grammars, and add a
smoke:package-grammarsverification command. #595Launcher: add wrapper smoke coverage for scoped package, npm/npx, Homebrew/Linuxbrew, Bun global symlink layouts, and
$BUN_INSTALLfalse-positive runtime selection regressions. #351 #353 #354 #356 #358 #359
[2.1.0] - 2026-04-05
Code files now chunk at function and class boundaries via tree-sitter, clickable editor links land you at the right line from search results, and per-collection model configuration means you can point different collections at different embedding models. 25+ community PRs fix embedding stability, BM25 accuracy, and cross-platform launcher issues.
Changes
AST-aware chunking for code files via
web-tree-sitter. Supported languages: TypeScript/JavaScript, Python, Go, and Rust. Code files are chunked at function, class, and import boundaries instead of arbitrary text positions. Markdown and unknown file types are unchanged.--chunk-strategy <auto|regex>flag onqmd embedandqmd query(defaultregex). SDK:chunkStrategyoption onembed()andsearch().qmd statusshows grammar availability.qmd bench <fixture.json>command for search quality benchmarks. Measures precision@k, recall, MRR, and F1 across BM25, vector, hybrid, and full pipeline backends. Ships with an example fixture against the eval-docs test collection. #470 (thanks @jmilinovich)models:section inindex.ymllets you configureembed,rerank, andgeneratemodel URIs per collection. Resolution order is config > env var (QMD_EMBED_MODEL,QMD_RERANK_MODEL,QMD_GENERATE_MODEL) > built-in default. #502 (thanks @JohnRichardEnders)CLI search output now emits clickable OSC 8 terminal hyperlinks when stdout is a TTY. Links resolve
qmd://paths to absolute filesystem paths and open in editors via URI templates (default:vscode://file/{path}:{line}:{col}). Configure withQMD_EDITOR_URIoreditor_uriin the YAML config. #508 (thanks @danmackinlay)--no-rerankflag skips the reranking step inqmd query— useful when you want fast results or don't have a GPU. Also exposed asrerank: falseon the MCPquerytool. #370 (thanks @mvanhorn), #478 (thanks @zestyboy)ONNX conversion script for deploying embedding models via Transformers.js. #399 (thanks @shreyaskarnik)
GitHub Actions workflow to build the Nix flake on Linux and macOS.
Fixes
Embedding: prevent
qmd embedfrom running indefinitely when the embedding loop stalls. #458 (thanks @ccc-fff)Embedding: truncate oversized text before embedding to prevent GGML crash, and bound memory usage during batch embedding. #393 (thanks @lskun), #395 (thanks @ProgramCaiCai)
Embedding: set explicit embed context size (default 2048, configurable via
QMD_EMBED_CONTEXT_SIZE) instead of using the model's full window. #500Embedding: error on dimension mismatch instead of silently rebuilding the vec0 table. #501
Embedding: handle vec0
OR REPLACElimitation ininsertEmbedding. #456 (thanks @antonio-mello-ai)Embedding: fix model selection when multiple models are configured. #494
BM25: correct field weights to include all 3 FTS columns — title, body, and path were not weighted correctly. #462 (thanks @goldsr09)
BM25: handle hyphenated tokens in FTS5 lex queries so terms like "real-time" match correctly. #463 (thanks @goldsr09)
BM25: preserve underscores in search terms instead of stripping them. #404
BM25: use CTE in
searchFTSto prevent query planner regression with collection filter.Reranker: increase default context size 2048→4096 and make configurable via
QMD_RERANK_CONTEXT_SIZE. Fix template overhead underestimate 200→512. #453 (thanks @builderjarvis)GPU: catch initialization failures and fall back to CPU instead of crashing.
MCP: read version from
package.jsoninstead of hardcoding. #431MCP: include collection name in status output. #416
Multi-get: support brace expansion patterns in glob matching. #424
Launcher: prioritize
package-lock.jsonto prevent Bun false positive. #385 (thanks @rymalia)Launcher: remove
$BUN_INSTALLcheck that caused false Bun detection. #362 (thanks @syedair)Launcher: skip Git Bash path detection on WSL. #371 (thanks @oysteinkrog)
Model cache: respect
XDG_CACHE_HOMEfor model cache directory. #457 (thanks @antonio-mello-ai)SQLite: add macOS Homebrew SQLite support for Bun and restore actionable errors. #377 (thanks @serhii12)
Pin zod to exact 4.2.1 to fix
tscbuild failure. #382 (thanks @rymalia)Preserve dots and original case in
handelize()— filenames likeMEMORY.mdno longer becomememory-md. #475 (thanks @alexei-led)Include
linein--jsonsearch output so editor integrations can jump directly tofile:line. #506 (thanks @danmackinlay)Nix: fix paths in flake and make Bun dependency a fixed-output derivation so sandboxed Linux builds work offline. #479 (thanks @surma-dump)
Sync stale
bun.lock(better-sqlite311.x → 12.x). CI and release script now use--frozen-lockfileto prevent recurrence. #386 (thanks @Mic92)Approve native build scripts in pnpm so
better-sqlite3and tree-sitter modules compile correctly. Update vitest ^3.0.0 → ^3.2.4.
[2.0.1] - 2026-03-10
Changes
qmd skill installcopies the packaged QMD skill into~/.claude/commands/for one-command setup. #355 (thanks @nibzard)
Fixes
Fix Qwen3-Embedding GGUF filename case — HuggingFace filenames are case-sensitive, the lowercase variant returned 404. #349 (thanks @byheaven)
Resolve symlinked global launcher path so
qmdworks correctly when installed vianpm i -g. #352 (thanks @nibzard)
[2.0.0] - 2026-03-10
QMD 2.0 declares a stable library API. The SDK is now the primary interface —
the MCP server is a clean consumer of it, and the source is organized into
src/cli/ and src/mcp/. Also: Node 25 support and a runtime-aware bin wrapper
for bun installs.
Changes
Stable SDK API with
QMDStoreinterface — search, retrieval, collection/context management, indexing, lifecycleUnified
search(): passqueryfor auto-expansion orqueriesfor pre-expanded lex/vec/hyde — replaces the old query/search/structuredSearch splitNew
getDocumentBody(),getDefaultCollectionNames(),MaintenanceclassMCP server rewritten as a clean SDK consumer — zero internal store access
CLI and MCP organized into
src/cli/andsrc/mcp/subdirectoriesRuntime-aware
bin/qmdwrapper detects bun vs node to avoid ABI mismatches. Closes #319better-sqlite3bumped to ^12.4.5 for Node 25 support. Closes #257Utility exports:
extractSnippet,addLineNumbers,DEFAULT_MULTI_GET_MAX_BYTES
Fixes
Remove unused
import { resolve }in store.ts that shadowed local export
[1.1.6] - 2026-03-09
QMD can now be used as a library. import { createStore } from '@tobilu/qmd'
gives you the full search and indexing API — hybrid query, BM25, structured
search, collection/context management — without shelling out to the CLI.
Changes
SDK / library mode:
createStore({ dbPath, config })returns aQMDStorewithquery(),search(),structuredSearch(),get(),multiGet(), and collection/context management methods. Supports inline config (no files needed) or a YAML config path.Package exports:
package.jsonnow declaresmain,types, andexportsso bundlers and TypeScript resolve@tobilu/qmdcorrectly.
[1.1.5] - 2026-03-07
Ambiguous queries like "performance" now produce dramatically better results
when the caller knows what they mean. The new intent parameter steers all
five pipeline stages — expansion, strong-signal bypass, chunk selection,
reranking, and snippet extraction — without searching on its own. Design and
original implementation by Ilya Grigorik (@vyalamar) in #180.
Changes
Intent parameter: optional
intentstring disambiguates queries across the entire search pipeline. Available via CLI (--intentflag orintent:line in query documents), MCP (intentfield on the query tool), and programmatic API. Adapted from PR #180 (thanks @vyalamar).Query expansion: when intent is provided, the expansion LLM prompt includes
Query intent: {intent}, matching the finetune training data format for better-aligned expansions.Reranking: intent is prepended to the rerank query so Qwen3-Reranker scores with domain context.
Chunk selection: intent terms scored at 0.5× weight alongside query terms (1.0×) when selecting the best chunk per document for reranking.
Snippet extraction: intent terms scored at 0.3× weight to nudge snippets toward intent-relevant lines without overriding query anchoring.
Strong-signal bypass disabled with intent: when intent is provided, the BM25 strong-signal shortcut is skipped — the obvious keyword match may not be what the caller wants.
MCP instructions: callers are now guided to provide
intenton every search call for disambiguation.Query document syntax:
intent:recognized as a line type. At most one per document, cannot appear alone. Grammar updated indocs/SYNTAX.md.
[1.1.2] - 2026-03-07
13 community PRs merged. GPU initialization replaced with node-llama-cpp's
built-in autoAttempt — deleting ~220 lines of manual fallback code and
fixing GPU issues reported across 10+ PRs in one shot. Reranking is faster
through chunk deduplication and a parallelism cap that prevents VRAM
exhaustion.
Changes
GPU init: use node-llama-cpp's
build: "autoAttempt"instead of manual GPU backend detection. Automatically tries Metal/CUDA/Vulkan and falls back gracefully. #310 (thanks @giladgd — the node-llama-cpp author)Query
--explain:qmd query --explainexposes retrieval score traces — backend scores, per-list RRF contributions, top-rank bonus, reranker score, and final blended score. Works in JSON and CLI output. #242 (thanks @vyalamar)Collection ignore patterns:
ignore: ["Sessions/**", "*.tmp"]in collection config to exclude files from indexing. #304 (thanks @sebkouba)Multilingual embeddings:
QMD_EMBED_MODELenv var lets you swap in models like Qwen3-Embedding for non-English collections. #273 (thanks @daocoding)Configurable expansion context:
QMD_EXPAND_CONTEXT_SIZEenv var (default 2048) — previously used the model's full 40960-token window, wasting VRAM. #313 (thanks @0xble)candidateLimitexposed:-C/--candidate-limitflag and MCP parameter to tune how many candidates reach the reranker. #255 (thanks @pandysp)MCP multi-session: HTTP transport now supports multiple concurrent client sessions, each with its own server instance. #286 (thanks @joelev)
Fixes
Reranking performance: cap parallel rerank contexts at 4 to prevent VRAM exhaustion on high-core machines. Deduplicate identical chunk texts before reranking — same content from different files now shares a single reranker call. Cache scores by content hash instead of file path.
Deactivate stale docs when all files are removed from a collection and
qmd updateis run. #312 (thanks @0xble)Handle emoji-only filenames (
🐘.md→1f418.md) instead of crashing. #308 (thanks @debugerman)Skip unreadable files during indexing (e.g. iCloud-evicted files returning EAGAIN) instead of crashing. #253 (thanks @jimmynail)
Suppress progress bar escape sequences when stderr is not a TTY. #230 (thanks @dgilperez)
Emit format-appropriate empty output (
[]for JSON, CSV header for CSV, etc.) instead of plain text "No results." #228 (thanks @amsminn)Correct Windows sqlite-vec package name (
sqlite-vec-windows-x64) and addsqlite-vec-linux-arm64. #225 (thanks @ilepn)Fix claude plugin setup CLI commands in README. #311 (thanks @gi11es)
[1.1.1] - 2026-03-06
Fixes
Reranker: truncate documents exceeding the 2048-token context window instead of silently producing garbage scores. Long chunks (e.g. from PDF ingestion) now get a fair ranking.
Nix: add python3 and cctools to build dependencies. #214 (thanks @pcasaretto)
[1.1.0] - 2026-02-20
QMD now speaks in query documents — structured multi-line queries where every line is typed (lex:, vec:, hyde:), combining keyword precision with semantic recall. A single plain query still works exactly as before (it's treated as an implicit expand: and auto-expanded by the LLM). Lex now supports quoted phrases and negation ("C++ performance" -sports -athlete), making intent-aware disambiguation practical. The formal query grammar is documented in docs/SYNTAX.md.
The npm package now uses the standard #!/usr/bin/env node bin convention, replacing the custom bash wrapper. This fixes native module ABI mismatches when installed via bun and works on any platform with node >= 22 on PATH.
Changes
Query document format: multi-line queries with typed sub-queries (
lex:,vec:,hyde:). Plain queries remain the default (expand:implicit, but not written inside the document). First sub-query gets 2× fusion weight — put your strongest signal first. Formal grammar indocs/SYNTAX.md.Lex syntax: full BM25 operator support.
"exact phrase"for verbatim matching;-termand-"phrase"for exclusions. Essential for disambiguation when a term is overloaded across domains (e.g.performance -sports -athlete).expand:shortcut: send a single plain query (or start the document withexpand:on its only line) to auto-expand via the local LLM. Query documents themselves are limited tolex,vec, andhydelines.MCP
querytool (renamed fromstructured_search): rewrote the tool description to fully teach AI agents the query document format, lex syntax, and combination strategy. Includes worked examples with intent-aware lex.HTTP
/queryendpoint (renamed from/search;/searchkept as silent alias).collectionsarray filter: filter by multiple collections in a single query (collections: ["notes", "brain"]). Removed the singlecollectionstring param — array only.Collection
include/exclude:includeByDefault: falsehides a collection from all queries unless explicitly named viacollections. CLI:qmd collection exclude <name>/qmd collection include <name>.Collection
update-cmd: attach a shell command that runs before everyqmd update(e.g.git stash && git pull --rebase --ff-only && git stash pop). CLI:qmd collection update-cmd <name> '<cmd>'.qmd statustips: shows actionable tips when collections lack context descriptions or update commands.qmd collectionsubcommands:show,update-cmd,include,exclude. Bareqmd collectionnow prints help.Packaging: replaced custom bash wrapper with standard
#!/usr/bin/env nodeshebang ondist/qmd.js. Fixes native module ABI mismatches when installed via bun, and works on any platform where node >= 22 is on PATH.Removed MCP tools
search,vector_search,deep_search— all superseded byquery.Removed
qmd context checkcommand.CLI timing: each LLM step (expand, embed, rerank) prints elapsed time inline (
Expanding query... (4.2s)).
Fixes
qmd collection listshows[excluded]tag for collections withincludeByDefault: false.Default searches now respect
includeByDefault— excluded collections are skipped unless explicitly named.Fix main module detection when installed globally via npm/bun (symlink resolution).
[1.0.7] - 2026-02-18
Changes
LLM: add LiquidAI LFM2-1.2B as an alternative base model for query expansion fine-tuning. LFM2's hybrid architecture (convolutions + attention) is 2x faster at decode/prefill vs standard transformers — good fit for on-device inference.
CLI: support multiple
-cflags to search across several collections at once (e.g.qmd search -c notes -c journals "query"). #191 (thanks @openclaw)
Fixes
Return empty JSON array
[]instead of no output when--jsonsearch finds no results.Resolve relative paths passed to
--indexso they don't produce malformed config entries.Respect
XDG_CONFIG_HOMEfor collection config path instead of always using~/.config. #190 (thanks @openclaw)CLI: empty-collection hint now shows the correct
collection addcommand. #200 (thanks @vincentkoc)
[1.0.6] - 2026-02-16
Changes
CLI:
qmd statusnow shows models with full HuggingFace links instead of static names in--help. Model info is derived from the actual configured URIs so it stays accurate if models change.Release tooling: pre-push hook handles non-interactive shells (CI, editors) gracefully — warnings auto-proceed instead of hanging on a tty prompt. Annotated tags now resolve correctly for CI checks.
[1.0.5] - 2026-02-16
The npm package now ships compiled JavaScript instead of raw TypeScript,
removing the tsx runtime dependency. A new /release skill automates the
full release workflow with changelog validation and git hook enforcement.
Changes
Build: compile TypeScript to
dist/viatscso the npm package no longer requirestsxat runtime. Theqmdshell wrapper now runsdist/qmd.jsdirectly.Release tooling: new
/releaseskill that manages the full release lifecycle — validates changelog, installs git hooks, previews release notes, and cuts the release. Auto-populates[Unreleased]from git history when empty.Release tooling:
scripts/extract-changelog.shextracts cumulative notes for the full minor series (e.g. 1.0.0 through 1.0.5) for GitHub releases. Includes[Unreleased]content in previews.Release tooling:
scripts/release.shrenames[Unreleased]to a versioned heading and inserts a fresh empty[Unreleased]section automatically.Release tooling: pre-push git hook blocks
v*tag pushes unlesspackage.jsonversion matches the tag, a changelog entry exists, and CI passed on GitHub.Publish workflow: GitHub Actions now builds TypeScript, creates a GitHub release with cumulative notes extracted from the changelog, and publishes to npm with provenance.
1.0.0 - 2026-02-15
QMD now runs on both Node.js and Bun, with up to 2.7x faster reranking
through parallel GPU contexts. GPU auto-detection replaces the unreliable
gpu: "auto" with explicit CUDA/Metal/Vulkan probing.
Changes
Runtime: support Node.js (>=22) alongside Bun via a cross-runtime SQLite abstraction layer (
src/db.ts).bun:sqliteon Bun,better-sqlite3on Node. Theqmdwrapper auto-detects a suitable Node.js install via PATH, then falls back to mise, asdf, nvm, and Homebrew locations.Performance: parallel embedding & reranking via multiple LlamaContext instances — up to 2.7x faster on multi-core machines.
Performance: flash attention for ~20% less VRAM per reranking context, enabling more parallel contexts on GPU.
Performance: right-sized reranker context (40960 → 2048 tokens, 17x less memory) since chunks are capped at ~900 tokens.
Performance: adaptive parallelism — context count computed from available VRAM (GPU) or CPU math cores rather than hardcoded.
GPU: probe for CUDA, Metal, Vulkan explicitly at startup instead of relying on node-llama-cpp's
gpu: "auto".qmd statusshows device info.Tests: reorganized into flat
test/directory with vitest for Node.js and bun test for Bun. Neweval-bm25andstore.helpers.unitsuites.
Fixes
Prevent VRAM waste from duplicate context creation during concurrent
embedBatchcalls — initialization lock now covers the full path.Collection-aware FTS filtering so scoped keyword search actually restricts results to the requested collection.
0.9.0 - 2026-02-15
First published release on npm as @tobilu/qmd. MCP HTTP transport with
daemon mode cuts warm query latency from ~16s to ~10s by keeping models
loaded between requests.
Changes
MCP: HTTP transport with daemon lifecycle —
qmd mcp --http --daemonstarts a background server,qmd mcp stopshuts it down. Models stay warm in VRAM between queries. #149 (thanks @igrigorik)Search: type-routed query expansion preserves lex/vec/hyde type info and routes to the appropriate backend. Eliminates ~4 wasted backend calls per query (10.0 → 6.0 calls, 1278ms → 549ms). #149 (thanks @igrigorik)
Search: unified pipeline — extracted
hybridQuery()andvectorSearchQuery()tostore.tsso CLI and MCP share identical logic. Fixes a class of bugs where results differed between the two. #149 (thanks @igrigorik)MCP: dynamic instructions generated at startup from actual index state — LLMs see collection names, doc counts, and content descriptions. #149 (thanks @igrigorik)
MCP: tool renames (vsearch → vector_search, query → deep_search) with rewritten descriptions for better tool selection. #149 (thanks @igrigorik)
Integration: Claude Code plugin with inline status checks and MCP integration. #99 (thanks @galligan)
Fixes
BM25 score normalization — formula was inverted (
1/(1+|x|)instead of|x|/(1+|x|)), so strong matches scored lowest. Broke--min-scorefiltering and made the "strong signal" short-circuit dead code. #76 (thanks @dgilperez)Normalize Unicode paths to NFC for macOS compatibility. #82 (thanks @c-stoeckl)
Handle dense content (code) that tokenizes beyond expected chunk size.
Proper cleanup of Metal GPU resources on process exit.
SQLite-vec readiness verification after extension load.
Reactivate deactivated documents on re-index instead of creating duplicates.
Bun UTF-8 path corruption workaround for non-ASCII filenames.
Disable following symlinks in glob.scan to avoid infinite loops.
[0.8.0] - 2026-01-28
Fine-tuned query expansion model trained with GRPO replaces the stock Qwen3 0.6B. The training pipeline scores expansions on named entity preservation, format compliance, and diversity — producing noticeably better lexical variations and HyDE documents.
Changes
LLM: deploy GRPO-trained (Group Relative Policy Optimization) query expansion model, hosted on HuggingFace and auto-downloaded on first use. Better preservation of proper nouns and technical terms in expansions.
LLM:
/only:lexmode for single-type expansions — useful when you know which search backend will help.LLM: HyDE output moved to first position so vector search can start embedding while other expansions generate.
LLM: session lifecycle management via
withLLMSession()pattern — ensures cleanup even on failure, similar to database transactions.Integration: org-mode title extraction support. #50 (thanks @sh54)
Integration: SQLite extension loading in Nix devshell. #48 (thanks @sh54)
Integration: AI agent discovery via skills.sh. #64 (thanks @Algiras)
Fixes
Use sequential embedding on CPU-only systems — parallel contexts caused a race condition where contexts competed for CPU cores, making things slower. #54 (thanks @freeman-jiang)
Fix
collectionNamecolumn in vector search SQL (was still using oldcollectionIdfrom before YAML migration). #61 (thanks @jdvmi00)Fix Qwen3 sampling params to prevent repetition loops — stock temperature/top-p caused occasional infinite repeat patterns.
Add
--indexoption to CLI argument parser (was documented but not wired up). #84 (thanks @Tritlo)Fix DisposedError during slow batch embedding. #41 (thanks @wuhup)
[0.7.0] - 2026-01-09
First community contributions. The project gained external contributors, surfacing bugs that only appear in diverse environments — Homebrew sqlite-vec paths, case-sensitive model filenames, and sqlite-vec JOIN incompatibilities.
Changes
Indexing: native
realpathSync()replacesreadlink -fsubprocess spawn per file. On a 5000-file collection this eliminates 5000 shell spawns, ~15% faster. #8 (thanks @burke)Indexing: single-pass tokenization — chunking algorithm tokenized each document twice (count then split); now tokenizes once and reuses. #9 (thanks @burke)
Fixes
Fix
vsearchandqueryhanging — sqlite-vec's virtual table doesn't support the JOIN pattern used; rewrote to subquery. #23 (thanks @mbrendan)Fix MCP server exiting immediately after startup — process had no active handles keeping the event loop alive. #29 (thanks @mostlydev)
Fix collection filter SQL to properly restrict vector search results.
Support non-ASCII filenames in collection filter.
Skip empty files during indexing instead of crashing on zero-length content.
Fix case sensitivity in Qwen3 model filename resolution. #15 (thanks @gavrix)
Fix sqlite-vec loading on macOS with Homebrew (
BREW_PREFIXdetection). #42 (thanks @komsit37)Fix Nix flake to use correct
src/qmd.tspath. #7 (thanks @burke)Fix docid lookup with quotes support in get command. #36 (thanks @JoshuaLelon)
Fix query expansion model size in documentation. #38 (thanks @odysseus0)
[0.6.0] - 2025-12-28
Replaced Ollama HTTP API with node-llama-cpp for all LLM operations. Ollama adds convenience but also a running server dependency. node-llama-cpp loads GGUF models directly in-process — zero external dependencies. Models auto-download from HuggingFace on first use.
Changes
LLM: structured query expansion via JSON schema grammar constraints. Model produces typed expansions — lexical (BM25 keywords), vector (semantic rephrasings), HyDE (hypothetical document excerpts) — so each routes to the right backend instead of sending everything everywhere.
LLM: lazy model loading with 2-minute inactivity auto-unload. Keeps memory low when idle while avoiding ~3s model load on every query.
Search: conditional query expansion — when BM25 returns strong results, the expensive LLM expansion is skipped entirely.
Search: multi-chunk reranking — documents with multiple relevant chunks scored by aggregating across all chunks rather than best single chunk.
Search: cosine distance for vector search (was L2).
Search: embeddinggemma nomic-style prompt formatting.
Testing: evaluation harness with synthetic test documents and Hit@K metrics for BM25, vector, and hybrid RRF.
[0.5.0] - 2025-12-13
Collections and contexts moved from SQLite tables to YAML at
~/.config/qmd/index.yml. SQLite was overkill for config — you can't share
it, and it's opaque. YAML is human-readable and version-controllable. The
migration was extensive (35+ commits) because every part of the system that
touched collections or contexts had to be updated.
Changes
Config: YAML-based collections and contexts replace SQLite tables.
collectionsandpath_contextstables dropped from schema. Collections support an optionalupdate:command (e.g.,git pull) before re-index.CLI:
qmd collection add/list/remove/renamecommands with--nameand--maskglob pattern support.CLI:
qmd lsvirtual file tree — list collections, files in a collection, or files under a path prefix.CLI:
qmd context add/list/check/rmwith hierarchical context inheritance. A query toqmd://notes/2024/jan/inherits context fromnotes/,notes/2024/, andnotes/2024/jan/.CLI:
qmd context add / "text"for global context across all collections.CLI:
qmd context checkaudit command to find paths without context.Paths:
qmd://virtual URI scheme for portable document references.qmd://notes/ideas.mdworks regardless of where the collection lives on disk. Works inget,multi-get,ls, and context commands.CLI: document IDs (docid) — first 6 chars of content hash for stable references. Shown as
#abc123in search results, usable withgetandmulti-get.CLI:
--line-numbersflag for get command output.
[0.4.0] - 2025-12-10
MCP server for AI agent integration. Without it, agents had to shell out to
qmd search and parse CLI output. The monolithic qmd.ts (1840 lines) was
split into focused modules with the project's first test suite (215 tests).
Changes
MCP: stdio server with tools for search, vector search, hybrid query, document retrieval, and status. Runs over stdio transport for Claude Desktop and MCP clients.
MCP: spec-compliant with June 2025 MCP specification — removed non-spec
mimeType, addedisError: trueto errors,structuredContentfor machine-readable results, proper URI encoding.MCP: simplified tool naming (
qmd_search→search) since MCP already namespaces by server.Architecture: extract
store.ts(1221 LOC),llm.ts(539 LOC),formatter.ts(359 LOC),mcp.ts(503 LOC) from monolithicqmd.ts.Testing: 215 tests (store: 96, llm: 60, mcp: 59) with mocked Ollama for fast, deterministic runs. Before this: zero tests.
[0.3.0] - 2025-12-08
Document chunking for vector search. A 5000-word document about many topics gets a single embedding that averages everything together, matching poorly for specific queries. Chunking produces one embedding per ~900-token section with focused semantic signal.
Changes
Search: markdown-aware chunking — prefers heading boundaries, then paragraph breaks, then sentence boundaries. 15% overlap between chunks ensures cross-boundary queries still match.
Search: multi-chunk scoring bonus (+0.02 per additional chunk, capped at +0.1 for 5+ chunks). Documents relevant in multiple sections rank higher.
CLI: display paths show collection-relative paths and extracted titles (from H1 headings or YAML frontmatter) instead of raw filesystem paths.
CLI:
--allflag returns all matches (use with--min-scoreto filter).CLI: byte-based progress bar with ETA for
embedcommand.CLI: human-readable time formatting ("15m 4s" instead of "904.2s").
CLI: documents >64KB truncated with warning during embedding.
[0.2.0] - 2025-12-08
Changes
CLI:
--json,--csv,--files,--md,--xmloutput format flags.--jsonfor programmatic access,--filesfor piping,--md/--xmlfor LLM consumption,--csvfor spreadsheets.CLI:
qmd statusshows index health — document count, size, embedding coverage, time since last update.Search: weighted RRF — original query gets 2x weight relative to expanded queries since the user's actual words are a more reliable signal.
[0.1.0] - 2025-12-07
Initial implementation. Built in a single day for searching personal markdown notes, journals, and meeting transcripts.
Changes
Search: SQLite FTS5 with BM25 ranking. Chose SQLite over Elasticsearch because QMD is a personal tool — single binary, no server dependencies.
Search: sqlite-vec for vector similarity. Same rationale: in-process, no external vector database.
Search: Reciprocal Rank Fusion to combine BM25 and vector results. RRF is parameter-free and handles missing signals gracefully.
LLM: Ollama for embeddings, reranking, and query expansion. Later replaced with node-llama-cpp in 0.6.0.
CLI:
qmd add,qmd embed,qmd search,qmd vsearch,qmd query,qmd get. ~1800 lines of TypeScript in a singleqmd.tsfile.
244 files · 235 not served here
- .claude-plugin/marketplace.json (not served here)
- .gitattributes (not served here)
- .github/workflows/ci.yml (not served here)
- .github/workflows/nix.yml (not served here)
- .github/workflows/publish.yml (not served here)
- .gitignore (not served here)
- .pi/settings.json (not served here)
- ATTRIBUTION.md
- AUTOGINEER-RUNS.md
- CHANGELOG.md
- CLAUDE.md
- DESIGN.md
- LICENSE
- README.md
- bin/qmd (not served here)
- bun.lock (not served here)
- docs/SYNTAX.md
- example-index.yml (not served here)
- finetune/.gitignore (not served here)
- finetune/CLAUDE.md (not served here)
- finetune/Justfile (not served here)
- finetune/Modelfile (not served here)
- finetune/README.md (not served here)
- finetune/SCORING.md (not served here)
- finetune/benchmark.py (not served here)
- finetune/configs/accelerate_multi_gpu.yaml (not served here)
- finetune/configs/sft-lfm2.yaml (not served here)
- finetune/configs/sft.yaml (not served here)
- finetune/configs/sft_local.yaml (not served here)
- finetune/convert_gguf.py (not served here)
- finetune/convert_onnx.py (not served here)
- finetune/data/fix_hyde_checkpoint.json (not served here)
- finetune/data/qmd_expansion_balanced_deduped.jsonl (not served here)
- finetune/data/qmd_expansion_diverse_addon.jsonl (not served here)
- finetune/data/qmd_expansion_handcrafted.jsonl (not served here)
- finetune/data/qmd_expansion_handcrafted_only.jsonl (not served here)
- finetune/data/qmd_expansion_lex_phrases_negation.jsonl (not served here)
- finetune/data/qmd_expansion_locations.jsonl (not served here)
- finetune/data/qmd_expansion_people.jsonl (not served here)
- finetune/data/qmd_expansion_personal_entities.jsonl (not served here)
- finetune/data/qmd_expansion_short_nontech.jsonl (not served here)
- finetune/data/qmd_expansion_sports.jsonl (not served here)
- finetune/data/qmd_expansion_v3_structured.jsonl (not served here)
- finetune/data/qmd_only_sampled.jsonl (not served here)
- finetune/data/train/dataset_info.json (not served here)
- finetune/dataset/analyze_data.py (not served here)
- finetune/dataset/prepare_data.py (not served here)
- finetune/dataset/prepare_data_lfm2.py (not served here)
- finetune/dataset/schema.py (not served here)
- finetune/dataset/score_data.py (not served here)
- finetune/dataset/validate_schema.py (not served here)
- finetune/eval.py (not served here)
- finetune/eval_retrieval.py (not served here)
- finetune/evals/.gitignore (not served here)
- finetune/evals/queries.txt (not served here)
- finetune/experiments/gepa/__init__.py (not served here)
- finetune/experiments/gepa/best_prompt.txt (not served here)
- finetune/experiments/gepa/best_prompt_glm.txt (not served here)
- finetune/experiments/gepa/dspy_gepa.py (not served here)
- finetune/experiments/gepa/example.py (not served here)
- finetune/experiments/gepa/generate.py (not served here)
- finetune/experiments/gepa/gepa_outputs.jsonl (not served here)
- finetune/experiments/gepa/gepa_outputs_glm.jsonl (not served here)
- finetune/experiments/gepa/model.json (not served here)
- finetune/experiments/gepa/optimizer.py (not served here)
- finetune/experiments/gepa/score.py (not served here)
- finetune/experiments/grpo/README.md (not served here)
- finetune/experiments/grpo/grpo.py (not served here)
- finetune/experiments/grpo/grpo.yaml (not served here)
- finetune/experiments/lfm2/sft_lfm2.py (not served here)
- finetune/experiments/lfm2/sft_lfm2.yaml (not served here)
- finetune/jobs/eval.py (not served here)
- finetune/jobs/eval_common.py (not served here)
- finetune/jobs/sft.py (not served here)
- finetune/pyproject.toml (not served here)
- finetune/reward.py (not served here)
- finetune/train-0.8B.log (not served here)
- finetune/train.py (not served here)
- finetune/train_unsloth.py (not served here)
- finetune/uv.lock (not served here)
- flake.lock (not served here)
- flake.nix (not served here)
- migrate-schema.ts (not served here)
- oxlint.config.ts (not served here)
- package.json (not served here)
- pnpm-lock.yaml (not served here)
- scripts/build.mjs (not served here)
- scripts/check-package-grammars.mjs (not served here)
- scripts/extract-changelog.sh (not served here)
- scripts/install-hooks.mjs (not served here)
- scripts/package-smoke.mjs (not served here)
- scripts/pre-push (not served here)
- scripts/release.sh (not served here)
- scripts/repro-metal-rsets-crash.mjs (not served here)
- scripts/test-all.mjs (not served here)
- skills/qmd/SKILL.md (not served here)
- skills/qmd/references/mcp-setup.md (not served here)
- skills/release/SKILL.md (not served here)
- skills/release/scripts/install-hooks.sh (not served here)
- skills/release/scripts/release-context.sh (not served here)
- src/ast.ts (not served here)
- src/bench-rerank.ts (not served here)
- src/bench/bench.ts (not served here)
- src/bench/fixtures/example.json (not served here)
- src/bench/score.ts (not served here)
- src/bench/types.ts (not served here)
- src/cli/embed-lock.ts (not served here)
- src/cli/formatter.ts (not served here)
- src/cli/mcp-pid.ts (not served here)
- src/cli/qmd.ts (not served here)
- src/cli/version.ts (not served here)
- src/collections.ts (not served here)
- src/db.ts (not served here)
- src/index.ts (not served here)
- src/llm.ts (not served here)
- src/maintenance.ts (not served here)
- src/mcp/origin-guard.ts (not served here)
- src/mcp/server.ts (not served here)
- src/metadata-filter.ts (not served here)
- src/metadata-format.ts (not served here)
- src/metadata-store.ts (not served here)
- src/metadata.ts (not served here)
- src/paths.ts (not served here)
- src/store-migrations.ts (not served here)
- src/store.ts (not served here)
- src/test-preload.ts (not served here)
- src/trust.ts (not served here)
- src/types/picomatch.d.ts (not served here)
- src/vec-layout.ts (not served here)
- test/Containerfile (not served here)
- test/_helpers/doctor-vector-check-worker.ts (not served here)
- test/_helpers/doctor-vector-sample-worker.ts (not served here)
- test/_helpers/embed-lock-holder.ts (not served here)
- test/_helpers/heap-limit.ts (not served here)
- test/_helpers/legacy-adoption-sample-worker.ts (not served here)
- test/_helpers/legacy-adoption-shared-body-worker.ts (not served here)
- test/_helpers/store-init-worker.ts (not served here)
- test/ast-chunking.test.ts (not served here)
- test/ast.test.ts (not served here)
- test/bench-guard.test.ts (not served here)
- test/bench-score.test.ts (not served here)
- test/bin-wrapper.test.ts (not served here)
- test/build-script.test.ts (not served here)
- test/cleanup-repack-partitions.test.ts (not served here)
- test/cleanup.test.ts (not served here)
- test/cli-exit-lifecycle.test.ts (not served here)
- test/cli-lazy-llm-import.test.ts (not served here)
- test/cli.test.ts (not served here)
- test/collections-config.test.ts (not served here)
- test/db.test.ts (not served here)
- test/doctor-vector-sampling.test.ts (not served here)
- test/embed-lock.test.ts (not served here)
- test/esm-ambiguous-module.test.ts (not served here)
- test/eval-bm25.test.ts (not served here)
- test/eval-deep-research.jsonl (not served here)
- test/eval-deep-research.ts (not served here)
- test/eval-docs/api-design-principles.md (not served here)
- test/eval-docs/distributed-systems-overview.md (not served here)
- test/eval-docs/machine-learning-primer.md (not served here)
- test/eval-docs/product-launch-retrospective.md (not served here)
- test/eval-docs/remote-work-policy.md (not served here)
- test/eval-docs/startup-fundraising-memo.md (not served here)
- test/eval-harness.ts (not served here)
- test/eval.test.ts (not served here)
- test/formatter.test.ts (not served here)
- test/full-path-fallback.test.ts (not served here)
- test/install-hooks.test.ts (not served here)
- test/intent.test.ts (not served here)
- test/launcher-detection.test.sh (not served here)
- test/llm.test.ts (not served here)
- test/local-config-trust-cli.test.ts (not served here)
- test/local-config.test.ts (not served here)
- test/mcp-origin-guard.test.ts (not served here)
- test/mcp-pid.test.ts (not served here)
- test/mcp-stdio-lifecycle.test.ts (not served here)
- test/mcp.test.ts (not served here)
- test/metadata-cli.test.ts (not served here)
- test/metadata-discovery.test.ts (not served here)
- test/metadata-filter.test-d.ts (not served here)
- test/metadata-filter.test.ts (not served here)
- test/metadata-format.test.ts (not served here)
- test/metadata-search.test.ts (not served here)
- test/metadata-store.test.ts (not served here)
- test/metadata-surfaces.test.ts (not served here)
- test/metadata.test.ts (not served here)
- test/multi-collection-filter.test.ts (not served here)
- test/package.test.ts (not served here)
- test/path-fidelity.test.ts (not served here)
- test/release-context.test.ts (not served here)
- test/rrf-trace.test.ts (not served here)
- test/sdk.test.ts (not served here)
- test/smoke-install.sh (not served here)
- test/store-cjk-fts.test.ts (not served here)
- test/store-concurrency.test.ts (not served here)
- test/store-fts-separator-queries.test.ts (not served here)
- test/store-insert-context.test.ts (not served here)
- test/store-migrations.test.ts (not served here)
- test/store-paths.test.ts (not served here)
- test/store.helpers.unit.test.ts (not served here)
- test/store.test.ts (not served here)
- test/structured-search.test.ts (not served here)
- test/update-hook-trust-cli.test.ts (not served here)
- test/update-hook-trust.test.ts (not served here)
- test/vec-layout.test.ts (not served here)
- test/version.test.ts (not served here)
- tools/oxlint/anti-slop/LICENSE
- tools/oxlint/anti-slop/VENDOR.txt (not served here)
- tools/oxlint/anti-slop/effect/index.ts (not served here)
- tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.test.ts (not served here)
- tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts (not served here)
- tools/oxlint/anti-slop/index.ts (not served here)
- tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts (not served here)
- tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts (not served here)
- tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-known-value-widening.ts (not served here)
- tools/oxlint/anti-slop/rules/no-module-mocking.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-module-mocking.ts (not served here)
- tools/oxlint/anti-slop/rules/no-object-parameters.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-object-parameters.ts (not served here)
- tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-reflect-apply.ts (not served here)
- tools/oxlint/anti-slop/rules/no-reflect-get.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-reflect-get.ts (not served here)
- tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-runtime-typeof.ts (not served here)
- tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unknown-parameters.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unknown-returns.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts (not served here)
- tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts (not served here)
- tools/oxlint/anti-slop/rules/no-widen-then-assert.ts (not served here)
- tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts (not served here)
- tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts (not served here)
- tools/oxlint/anti-slop/shared/dictionary-types.ts (not served here)
- tools/oxlint/anti-slop/shared/lexical-type-parameters.ts (not served here)
- tools/oxlint/anti-slop/shared/reflect-method.ts (not served here)
- tsconfig.build.json (not served here)
- tsconfig.json (not served here)
- vitest.config.ts (not served here)
Advantages
- Combines BM25, vector search, and LLM reranking for hybrid retrieval. evidence: README.md
- Supports AST-aware chunking for code files to improve retrieval boundaries. evidence: CLAUDE.md
- Provides an MCP server for integration with Claude Desktop and other agents. evidence: README.md
Limitations
- Requires manual execution of indexing commands like collection add and update. evidence: CLAUDE.md
- Embedding and reranking models are resource-intensive and run locally. evidence: README.md
- Does not support automatic indexing; requires user-triggered updates. evidence: CLAUDE.md
Security
Risk level: Medium risk
This brain runs
Agent instructions that run or fetch something (27)
CLAUDE.md:Use Bun instead of Node.js (`bun` not `node`, `bun install` not `npm install`).CLAUDE.md:qmd update # Re-index collections; configured update hooks run firstCLAUDE.md:qmd bench <fixture.json> # Run search-quality benchmarksCLAUDE.md:Run everything:CLAUDE.md:npx vitest run --reporter=verbose test/CLAUDE.md:bun test --preload ./src/test-preload.ts test/CLAUDE.md:node-llama-cpp for embeddings (embeddinggemma), reranking (qwen3-reranker), and query expansion (Qwen3)CLAUDE.md:Write out example commands for the user to run manuallyCLAUDE.md:`npm run build` compiles TypeScript to `dist/` via `tsc -p tsconfig.build.json`finetune/CLAUDE.md:uv run dataset/prepare_data.pyfinetune/CLAUDE.md:uv run train.py sft --config configs/sft.yamlfinetune/CLAUDE.md:hf jobs uv run --flavor a10g-large --secrets HF_TOKEN --timeout 2h jobs/sft.pyfinetune/CLAUDE.md:cd finetune && HF_TOKEN=${HF_TOKEN} uv run python experiments/grpo/grpo.pyfinetune/CLAUDE.md:uv run eval.py ./outputs/sftfinetune/CLAUDE.md:uv run eval.py tobil/qmd-query-expansion-1.7Bfinetune/CLAUDE.md:uv run eval.py ./outputs/sft -o eval_results.jsonfinetune/CLAUDE.md:uv run reward.py # Self-testskills/qmd/SKILL.md:If a model-backed command fails, run it before changing configuration.skills/release/SKILL.md:**Gather context** — run `skills/release/scripts/release-context.sh <version>`.skills/release/SKILL.md:Re-run the context script after committing if needed.skills/release/SKILL.md:Run `pnpm outdated` and report any available updates for these packages.skills/release/SKILL.md:If updates exist, bump them (pinned, no `^` ranges) and re-run tests before proceeding.skills/release/SKILL.md:**Cut the release** — run `scripts/release.sh <version>`.skills/release/SKILL.md:**Push** — after explicit confirmation, run `git push origin main --tags`.· uses the networkskills/release/SKILL.md:gh run watch $(gh run list --workflow=publish.yml --limit=1 --json databaseId --jq '.[0].databaseId') --exit-statusskills/release/SKILL.md:auto-detects a suitable install via PATH. #149 (thanks @igrigorik)skills/release/SKILL.md:They can also be installed manually via `skills/release/scripts/install-hooks.sh` or automatically via `bun install` (prepare script).
MCP servers (2)
README.md(qmd):qmd mcpREADME.md(qmd):qmd mcp
Package scripts (1)
package.json(prepare):node scripts/install-hooks.mjs && node scripts/build.mjs
- Medium risk: runs-instruction-instruction-network (skills/release/SKILL.md:48)
- Low risk: scripts-present (finetune/dataset/prepare_data.py)
- Low risk: scripts-present (finetune/eval.py)
- Low risk: scripts-present (finetune/experiments/grpo/grpo.py)
- Low risk: scripts-present (finetune/reward.py)
- Low risk: scripts-present (finetune/train.py)
- Low risk: scripts-present (scripts/build.mjs)
- Low risk: scripts-present (scripts/install-hooks.mjs)
- Low risk: network-instructions (skills/qmd/SKILL.md:5)
- Low risk: scripts-present (src/cli/qmd.ts)
Attribution and licence
- First committed by
tobi
- Declared authors
- Tobi Lutke (declared in package.json)
- Publisher
- Tobias Lütke (Community)
- Source
- tobi/qmd · commit 86d2365
- Licence
- MIT License (files served here)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Contributors
- tobi, brettdavies, aaronccasanova, naveenspark, rikvanriel
AutoGineer is not affiliated with or endorsed by tobi.
Install from source
235 of its files are not served here, so there is no ZIP. tobi/qmd at 86d2365