QMD - Query Markup Documents
QMD indexes Markdown notes, meeting transcripts, documentation, and source files. Search and retrieve them from the terminal, an MCP client, or an application.
Documents and keyword/CJK search stay local in SQLite. Semantic search, query expansion, and reranking use optional remote APIs; QMD does not download or run local inference models.
Start with Quick Start, Architecture, Configuration, or MCP Server. For an existing installation, read Upgrading before opening an old index with a new build. Changes are recorded in the changelog.
Quick Start
Install QMD, add a folder, and search it with keywords. This path needs no model configuration or API key.
pnpm add --global @wei840222/qmd
qmd collection add ~/notes --name notes
qmd context add qmd://notes "Personal notes and ideas"
qmd search "project timeline" -c notes
Search results include a file path and a short document ID. Retrieve the document or a section of it:
qmd get "notes/meeting.md"
qmd get "#abc123"
qmd get "notes/meeting.md:50:40"
For semantic or hybrid search, first configure your remote model APIs, then explicitly build embeddings:
qmd embed
qmd vsearch "how to deploy" -c notes
qmd query "quarterly planning process" -c notes
vsearch sends the query to the configured embedding provider. query can also
use remote expansion and reranking. Use typed lexical queries
with reranking disabled when an MCP client needs model-free search.
Architecture
QMD separates the local index from optional model services:
Choose a search mode according to the information you have:
The hybrid pipeline combines those stages:
flowchart LR
Q[User Query] --> X[Query Expansion]
Q --> FTS[BM25 Search]
Q --> VS[Vector Search]
JEV[Jev Intent Hints] -.-> X
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[Reranker]
API[rerank_api_*] --> RR
JR[Jev] -.->|none configured or failed| RR
RR --> OUT[Final ranked results]
Typed lex searches use BM25; vec and hyde use the vector index. Hybrid
results are combined with reciprocal rank fusion before optional reranking.
When configured, Jev supplies intent hints to the remote generator. A configured
rerank API is preferred; Jev can rerank when that API is absent or fails.
Vectors are partitioned by collection, so scoped searches scan that collection's vectors. Adding an embedded document to another collection reuses its stored vectors; a missing chunk is embedded again on its own. Explicit cleanup compacts sparse vector chunks without contacting a model. See Index Maintenance for commands and Score Normalization & Fusion for ranking details.
Requirements
System Requirements
- Node.js >= 26.0.0
- macOS: Homebrew SQLite (for extension support)
brew install sqlite
Remote-only runtime
QMD runs on Node.js with explicitly configured remote model APIs. There is no
bundled or automatically downloaded inference model and no GPU runtime to configure.
BM25 (qmd search), document reads, collection/context management, and maintenance
work without model configuration. Semantic operations require remote model APIs.
qmd pull is retired: it prints migration guidance and exits nonzero without
network access or downloads.
Existing local-model databases remain readable through BM25 and document tools.
QMD never re-embeds old vectors on its own or silently sends their documents to
a provider. The automatic layout upgrade described below can discard vectors
and chunk metadata in the cases listed there. To use remote vectors, configure
the desired remote model and run an explicit
qmd embed --force after reviewing the provider and data boundary. This clears
and rebuilds vectors as a user-requested operation; lexical content is retained.
Installation
pnpm add --global @wei840222/qmd
Development
git clone https://github.com/wei840222/qmd
cd qmd
pnpm install --frozen-lockfile
pnpm run build
pnpm qmd --version
pnpm qmd --help
For contributor workflows, source locations, and validation commands, see
AGENTS.md.
Configuration
Collections, contexts, and optional model settings live in index.yml.
Start with example-index.yml; its model section is
commented out so keyword search works without enabling remote requests.
Configuring index.yml
The collection and context commands 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}"
# Remote models are optional for BM25 and document reads.
models:
embed_api_url: https://api.openai.com/v1
embed_api_model: text-embedding-3-small
embed_dimension: 1536
generate_api_url: https://api.example.com/v1
generate_api_model: your-expansion-model
rerank_api_url: https://api.example.com/v1/rerank
rerank_api_model: your-reranker
# 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"
Note: Editing
index.ymlchanges which directories and models QMD uses, but does not re-index on its own. Runqmd updateafter changingpath,pattern, orignore. Changing remote embedding identity requires the explicit whole-index rebuild described above; legacymodels.embedno longer enables inference.
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 removed
The 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 # clear
Checked-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 (legacy model identity fields)
In-project collection paths (for example ./docs) still index. On a terminal
qmd update (and qmd embed / 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 project
Set 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.
Remote Embedding & Models Configuration (Explicit Opt-in)
Configure remote embeddings and remote LLM models in index.yml under the unified models: section. Configuration alone sends no requests; embedding builds and model-backed searches are explicit operations.
models:
# Remote embeddings: set both embed_api_url (or embed_url / embed_base_url) and embed_api_model.
# Provider is auto-detected from the model name / URL (voyage* → voyageai, otherwise openai-compatible).
# Optional override: embed_provider: voyageai | openai
embed_api_url: https://api.openai.com/v1
embed_api_model: text-embedding-3-small # or text-embedding-3-large / voyage-4 / ...
embed_dimension: 1536 # Optional; required for unknown custom OpenAI models
# Optional: Remote LLM Query Expansion (aliases: generate_url, generate_base_url, generate_api_url)
generate_api_url: https://your-model-gateway.example/v1/chat/completions
generate_api_model: your-expansion-model
# generate_api_key: <key> # required if this endpoint authenticates requests
# Optional: Remote Reranking (aliases: rerank_url, rerank_base_url, rerank_api_url)
rerank_api_url: https://your-reranker.example/v1/rerank # Cohere-shaped `results`
rerank_api_model: your-reranker
# rerank_api_key: <key> # required if this endpoint authenticates requests
# For LLM scoring, use a /v1/chat/completions endpoint instead.
# Optional: TypeSafe Jev. Guides query expansion; reranks only when no rerank_api_* is configured, or as a fallback.
# jev_api_key: <key> # or TYPESAFE_API_KEY
# jev_base_url: https://jev.example.com # or TYPESAFE_BASE_URL
# jev_api_model: jev-1.13 # or TYPESAFE_DEFAULT_MODEL
# Optional: Custom User Dictionary for CJK segmentation
dictionary: ~/.config/qmd/dictionary.txt
OpenAI-compatible embeddings
models:
embed_api_url: https://api.openai.com/v1
embed_api_model: text-embedding-3-small # or text-embedding-3-large
# embed_dimension: 1536 # optional for known models; required for unknown custom models
export OPENAI_API_KEY="..." # or models.embed_api_key in a private config
qmd embed
Voyage AI embeddings
Voyage is a first-class provider (voyageai). Model names starting with voyage (or an explicit embed_provider: voyageai) select it. Known models resolve default dimensions automatically (voyage-4 / voyage-4-large / voyage-3 → 1024, voyage-3-lite / voyage-3.5-lite → 512, etc.). Voyage requests use native output_dimension and input_type (query vs document); they do not send OpenAI encoding_format.
Official Voyage endpoint:
models:
embed_api_url: https://api.voyageai.com/v1 # optional when embed_provider: voyageai (default base URL)
embed_api_model: voyage-4 # or voyage-4-large, voyage-3, voyage-code-3, ...
# embed_provider: voyageai # optional; inferred from voyage* model names
# embed_dimension: 1024 # optional override / Matryoshka truncation when supported
export VOYAGE_API_KEY="..." # or models.embed_api_key in a private config
qmd embed
OpenAI-compatible proxy hosting Voyage (Bifrost, LiteLLM, etc.):
models:
embed_api_url: https://your-proxy.example/v1
embed_api_model: voyage-4
# embed_provider: voyageai # set if the model name alone is not enough to infer Voyage
# Prefer VOYAGE_API_KEY. OPENAI_API_KEY is accepted only for non-official Voyage base URLs
# (custom proxies). Official api.voyageai.com never falls back to OPENAI_API_KEY.
export VOYAGE_API_KEY="..."
# export OPENAI_API_KEY="..." # proxy-only fallback when VOYAGE_API_KEY is unset
qmd embed
URL fields: choose one alias per operation: *_url takes precedence over
*_base_url, then *_api_url. Embedding URLs are base URLs: the provider appends
/embeddings. Expansion and reranking also accept complete endpoints ending in
/chat/completions or /rerank; a base URL gets /chat/completions for expansion
or /rerank for reranking. Configure the full /chat/completions URL explicitly
for chat scoring; QMD does not discover a sibling chat endpoint automatically.
Dedicated rerank endpoints must return results[].{index,relevance_score}.
Voyage's rerank API returns
data, which needs a gateway that translates it to this shape.
Credentials: OpenAI-compatible embeddings use models.embed_api_key, then
OPENAI_API_KEY. Voyage uses models.embed_api_key, then VOYAGE_API_KEY, with
OPENAI_API_KEY accepted only for custom Voyage proxies. Expansion and reranking
use their own models.generate_api_key and models.rerank_api_key; they do not
inherit OPENAI_API_KEY. An unset key omits the Authorization header, for keyless
gateways. YAML does not interpolate ${ENV_VAR} placeholders. Keep actual keys
out of checked-in files; use the supported embedding/Jev environment variables
or an explicitly managed private config.
CLI, SDK & MCP Integration: Remote LLM query expansion and LLM Chat Reranking are automatically wired into CLI (
qmd query), SDK (createStore), and MCP. Query expansion automatically enforces query language & script consistency (e.g., Traditional Chinese queries generate Traditional Chineselex,vec, andhydevariations). LLM Chat Reranking features prompt-tail Recency Enforcement and strict JSON sanitization to ensure safe execution with any remote LLM backend.
qmd init writes index and collection defaults only; it does not prompt for or generate model configuration. To use a remote endpoint, edit index.yml manually and set credentials when required.
API Key Note: Remote embeddings require both an embedding endpoint (
embed_url,embed_base_url, orembed_api_url) andembed_api_model(unlessembed_provider: voyageaisupplies the Voyage default model/URL). Official OpenAI needsmodels.embed_api_keyorOPENAI_API_KEY; official Voyage needsmodels.embed_api_keyorVOYAGE_API_KEY. Custom OpenAI-compatible proxies may omit a key when the upstream is keyless. For Voyage-shaped traffic through a custom proxy only,OPENAI_API_KEYmay fill in whenVOYAGE_API_KEYis unset — never forhttps://api.voyageai.com/v1. QMD never writes keys to SQLite, diagnostics, logs, or error messages.
Configure embed_url, embed_base_url, or embed_api_url together with embed_api_model to enable remote embeddings. OpenAI-compatible providers receive POST /embeddings; Voyage AI uses its native embeddings payload (input_type, optional output_dimension). Changing the endpoint, provider, or model changes the remote embedding identity and requires a vector rebuild.
Remote document embeddings use a bounded memory cache by default, shared by stores using the same loaded QMD module instance. To share results across processes, plugin reloads, and process restarts, add an optional cache directory to the existing models configuration:
models:
embed_api_url: https://api.openai.com/v1
embed_api_model: text-embedding-3-small
embed_cache_dir: ~/.cache/qmd/embeddings
# Linux tmpfs alternative: /dev/shm/qmd-embeddings
The SDK accepts the same field in createStore({ config: { collections: ..., models: ... } }); callers such as plugins must forward it into QMD's config. Paths support ~. Relative paths resolve from the YAML directory, except project-local .qmd/index.yml, where they resolve from the project root; inline SDK paths resolve from the working directory. Project-local cache paths outside the project follow the existing qmd trust policy. Omitting the field keeps memory-only caching; a blank value is invalid.
With a directory configured, OpenAI-compatible and Voyage providers use document-embeddings-v1.sqlite as the authoritative document cache. Processes pointing to the same local directory share atomic writes and a crash-released SQLite lock, including separate OpenClaw plugin generations. Cache-miss batches in one directory are serialized; use separate directories for unrelated workloads if independent throughput matters. A normal disk retains the cache across restarts; tmpfs works too, but its contents disappear on reboot and consume RAM. QMD creates the directory as needed and does not mount tmpfs.
Both modes reuse results for up to two hours, with at most 2,000 entries and a 64 MiB payload budget (SQLite files and journals add overhead). Reuse requires matching provider configuration, credentials, build identity, and exact formatted input. Memory mode also separates different fetch transports; disk mode deliberately shares across transports and processes using that configured directory. Only vectors and hashed keys are persisted, never input text or raw API keys. Each store retains its own authorization, build lease, and index. Forced rebuilds bypass reuse and refresh the shared vectors. Query embeddings keep their separate cache, and read-only SDK stores do not open the disk document cache.
Corrupt individual entries are recomputed. An unavailable or corrupt cache database emits a warning and falls back to normal computation without deleting the file or blocking indexing. Expiration, eviction, forced rebuilds, failed requests, and crash recovery can require new API calls; this is deduplication, not an exactly-once billing guarantee.
The vector index can store only one dimension. A dimension or identity change requires a forced rebuild:
qmd embed --force
Remote document chunks use deterministic UTF-8 byte windows. Query embedding is allowed only when the same identity is ready and compatible vectors exist; a vector or hybrid query then sends the formatted query text to the configured remote provider. After a vector reset deletes the old vectors, there is no vector rollback; lexical search remains available while rebuilding.
Embedding identity, recovery, and data boundary
The vector index holds one vector dimension at a time. An embedding identity includes
the provider, model, dimension, local/remote mode, and prompt/chunking profiles;
changing it requires qmd embed --force. A build lease with an owner ID and
generation sequence prevents concurrent writers. If an embedding request fails or
the lease expires, the index enters Partial; rerunning qmd embed with the same
identity resumes committed work and requests only missing, vector-only,
metadata-only, or layout-incomplete chunks. Vector search reads only ready
vectors with a compatible fingerprint.
Remote document chunks and formatted query text are sent to the configured remote embedding provider. Remote embedding failures never fall back to a local embedding provider, because vectors from a different identity are incompatible. API keys follow the per-operation config/environment rules above and are never stored in SQLite, diagnostics, logs, or errors. Errors redact response bodies, request payloads, credentials, and sensitive URLs; identity and lease state are revalidated inside SQLite write locks before vectors are cleared or published.
Remote LLM routing is independent per operation: expansion and reranking may each
use separately configured remote endpoints. Remote failures do not fall back to local inference;
a dedicated rerank URL returning HTTP 404 retries with a chat-scoring payload at
the same URL. It does not rewrite the URL to /chat/completions; configure that
endpoint explicitly when the backend expects chat scoring. This behavior does
not apply to remote embeddings.
qmd status and qmd doctor report provider/model/dimension, short fingerprint,
key presence as a boolean, pending/inconsistent chunks, CJK channel readiness, and
actionable repair commands without loading local models or issuing remote HTTP
requests. qmd status reads the existing index without migration. qmd doctor
reconciles a valid YAML config into an existing database and can initialize its
schema, including vector layout conversion; follow the upgrade backup procedure
before running it on an older index.
Expansion and reranking need their own URL/model settings. Jev supplies intent hints, not a replacement generator. A failed API request does not permanently disable that endpoint; later requests can retry. When Jev is available as the rerank fallback, QMD waits 60 seconds by default before trying a failed rerank API again. Cancellation does not start a fallback request.
Usage
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 notes
Generate 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 MB
AST-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.
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/old
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).
Query Syntax
Use a plain query when you want QMD to choose search variants, or supply typed queries when you want to control keyword and semantic retrieval yourself.
qmd search '"rate limiter" -sports' # model-free keyword search
qmd query "how does auth work" # automatic expansion policy
qmd query --expand "how does auth work" # force remote expansion
qmd query "lex: authentication" # skip expansion; other remote stages can still run
For plain qmd query input, the expansion policy is:
Skipping expansion is not an offline guarantee: vector retrieval can still call
the embedding API, and reranking is enabled by default. --no-rerank disables
reranking only. Use qmd search for model-free CLI search.
Keyword operators work in qmd search and typed lex queries:
Include at least one positive term; a query containing only exclusions returns no
matches. Separator-containing terms such as multi-agent and src/lib/i18n.ts
match adjacent tokens rather than a word prefix. CJK uses character-phrase
matching instead of ordinary word-prefix matching.
Typed query documents bypass automatic expansion. Each non-empty line names the retrieval method and the text to search:
# Bash/Zsh: $'...' turns \n into actual newlines
qmd query $'lex: rate limiter\nvec: how does the API limit burst traffic'
qmd query 'hyde: The API uses a token bucket to limit bursts and returns HTTP 429 when the quota is exhausted.'
# Optional intent provides context for reranking
qmd query $'intent: web page load times\nlex: performance\nvec: how to improve performance'
qmd query --intent "web page load times" "performance"
Put your strongest search first: it receives 2x weight in structured rank fusion.
vec and hyde need remote embedding configuration and a compatible vector index;
reranking still defaults to enabled. A single vec: or hyde: line is also a
typed document; a single lex: line uses the plain-query skip policy above.
- Every non-empty document line must use
lex:,vec:,hyde:, orintent:with non-empty text. Blank lines are ignored and surrounding whitespace is trimmed; a final newline is optional. - At most one
intent:line is allowed, in any position, alongside at least one search line. CLI--intentoverrides inline intent and supplies reranking context; intent is not a search term. - Do not mix plain text or
expand:with typed document lines.--expandwith a standalonelex:policy is an error. - Keep each search on one line. Lexical quotes must be balanced;
-termand-"phrase"exclusions belong in lexical queries, notvecorhydetext.
Use -c docs -c notes to scope collections; see Collection Filtering.
For MCP, send typed lines as a searches array rather than a multiline query
string. The MCP parameter reference covers the tool schema;
the bundled agent query reference covers
validation, context precedence, and request examples.
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>" # Background context for ambiguous queries, same as an `intent:` line
# (CLI query reranking context; e.g. "web page load times")
--no-rerank # Skip remote reranking (RRF scores only)
-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: Multiple
-cflags are combined with OR and applied before each lexical and vector candidate cutoff. Matching candidates from the selected collections are then ranked together.
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 here
Supported 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" } }
]
}'
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.
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 notes
topics 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 419
With --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 5
filter: 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-values
Two 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 documents
Omitting 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) work
To 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 number -
Path: Collection-relative path (e.g.,
docs/guide.md) -
Docid: Short hash identifier (e.g.,
#a1b2c3) - use withqmd get #a1b2c3 -
Title: Extracted from document (first heading or filename)
-
Context: Path context if configured via
qmd context add -
Score: 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; can reconcile config and migrate an older index
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
# Preview cleanup actions; writable initialization can still migrate an old index
qmd cleanup --dry-run
# Drop caches and orphans; repack sparse vector chunks within each partition
qmd cleanup
--dry-run skips cleanup deletion, repacking, FTS optimization, and vacuum;
it does not make the CLI's store initialization read-only. Use the upgrade
backup procedure before any writable open of an older index.
Cleanup repacks when the table's useful-chunk occupancy is below 90% and an
older sparse chunk can move. Each chunk moves in its own transaction; rowids,
partitions, vector bytes, chunk metadata, and embedding identity are preserved
by the repack itself. Normal orphan cleanup still removes rows no active
document needs. Live chunks with missing vectors stay pending for an explicit
qmd embed; cleanup does not call a model or re-embed them.
SDK callers can use Maintenance.vectorLayout(), preview(), repackVectors(),
and run() for the same layout and maintenance operations.
Benchmarking
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 --json
The 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 the package, 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:
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.
Upgrading
Upgrading the vector layout
Vectors are stored in one index partitioned by collection, so a scoped query scans
only that collection's vectors. An index written by an older build keeps its vectors
in a single vectors_vec table; the first writable store open converts it in
place. Read-only SDK/MCP stores and qmd status do not run this conversion.
qmd doctor can open an existing index writable when its config is parsed, and
qmd cleanup --dry-run opens a writable store before previewing cleanup; both
can therefore trigger initialization and migration. Back up before using either
on an older index.
For a hash_seq-keyed legacy table with parsed dimensions, the conversion
copies, byte for byte, every vector whose chunk metadata belongs to an active
document. It sends no request to any provider, never re-embeds, and leaves
embedding_index_state alone. Vectors with no chunk metadata or no active
document are not copied. Chunk metadata with no vector is deleted so the next
qmd embed recreates it; this differs from qmd update and qmd cleanup, which
retain a live document's vectorless metadata and leave it pending. A readable
legacy table that is not keyed by hash_seq, or whose dimensions cannot be
parsed, is dropped without copying its vectors. For a supported table, the
conversion prints its progress, resumes if interrupted, and drops the old table
in the transaction that stamps the new schema version, after a verification
pass that copies rows the walk missed. It then runs a VACUUM that can take
minutes on a large index. Do not run an older build against the index while it
converts. Without sqlite-vec loaded the conversion is deferred and keyword
search keeps working.
Before the first writable open, stop all CLI writers and MCP clients that hold the index. For a systemd user service, stop its unit first. Keep those processes stopped while copying the main database and any WAL/SHM sidecars; copying a live WAL database file by file is not a consistent backup.
# Use your actual unit name, if managed by systemd
systemctl --user stop qmd-mcp-http.service
qmd_backup_dir="$HOME/.local/state/qmd/backups/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$qmd_backup_dir"
chmod 700 "$qmd_backup_dir"
cp -p "$HOME"/.cache/qmd/index.sqlite* "$qmd_backup_dir/"
chmod 600 "$qmd_backup_dir"/index.sqlite*
Adjust paths and index selection for named or project-local indexes. Verify the
backup before continuing. Update the installed code, check qmd --version, then
run qmd ls against that same index to trigger initialization without embedding
or update hooks. Restart MCP after the conversion finishes.
An older build cannot read the converted layout. To roll back, stop all index
holders, move aside all converted database components, and restore the complete
pre-upgrade backup before starting the older build. Never pair an old main file
with a new WAL/SHM. Without a pre-upgrade backup, returning to the old vector
layout requires a full paid qmd embed --force.
Breaking SDK changes
The supported package entry remains createStore() with the existing remote
config.models fields. Local-model execution through embed, vector search,
expansion, and reranking is retired: missing remote settings now fail explicitly.
Model-free callers should use searchLex() or explicit lexical queries with
rerank: false and expansion skipped; model-backed queries never silently skip
required expansion or reranking.
Code importing implementation modules must migrate away from LlamaCpp,
LlamaCppConfig, LocalEmbeddingProvider, LocalEmbeddingProviderOwner,
withLLMSession, the default-Llama singleton helpers, pullModels, GGUF/cache
inspection, GPU helpers, and local tokenization APIs. They are removed, as is
store.internal.localLlm. Shared model types and pure formatting helpers remain
separate from inference; scoped sessions require an explicit remote owner.
Existing local identity labels remain diagnostic data, not executable providers.
MCP 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
Stdio Transport (Default)
Add QMD to your MCP client configuration (e.g. Cursor, Claude Desktop, Zed, or other MCP-compatible clients):
{
"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 used by multiple clients, 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 active
The 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 these 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.
--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.
MCP uses remote API clients for model operations. It does not load models into local RAM or VRAM.
Point any MCP client at http://localhost:8181/mcp to connect.
MCP opens the index read-only: starting it does not migrate an older vector
layout or rebuild embeddings. Complete the layout upgrade
with a writable CLI command before starting MCP on an older index. Restart the
server after changing its installed code, model settings, or collection config.
After editing collection/context YAML manually, first run qmd ls to reconcile
those settings into SQLite; read-only MCP does not synchronize the config itself.
Path/pattern changes still require an explicit qmd update to re-index files.
HTTP request bodies for /mcp, /query, /search, and /metadata are limited
to 1 MiB, including chunked uploads. Larger requests receive HTTP 413. MCP tool
arguments allow at most 10,000 combined array elements and object members;
calls over that limit return a tool error. Split larger requests into smaller calls.
For a model-free smoke test, call the query tool with these arguments:
{"searches":[{"type":"lex","query":"project"}],"rerank":false,"limit":1}
Typed lex skips expansion and vector search; rerank: false also disables
reranking, which otherwise defaults to true. /health checks process liveness,
not retrieval or remote-model availability.
Running with systemd (Linux)
An existing user service, for example qmd-mcp-http.service, should run
qmd mcp --http --host 127.0.0.1 --port 8181 in the foreground, without
--daemon. Set its ExecStart to the absolute CLI path returned by
command -v qmd, and make Node.js available in the service's PATH.
The service manager owns restarts; qmd mcp stop only manages QMD's own daemon.
# After installing or editing your user unit
systemctl --user daemon-reload
systemctl --user enable --now qmd-mcp-http.service
systemctl --user status qmd-mcp-http.service
curl --fail http://127.0.0.1:8181/health
When updating a linked CLI, stop the service, follow the backup procedure,
update and build the checkout that qmd resolves to, and check
qmd --version for its commit. Run qmd ls once to complete any writable
layout upgrade, then use systemctl --user start qmd-mcp-http.service.
For an already upgraded index, use systemctl --user restart qmd-mcp-http.service
after code or config changes.
MCP Tool Parameters
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.
Using 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" --full
QMD also ships a runtime skill that tells an agent how to use it:
qmd skills list # List the bundled skills (name and description)
qmd skills get qmd # Print the QMD skill; add --full to include its references
qmd skills path # Print the directory the bundled skills live in
qmd skill show # Print the QMD skill
qmd skill install # Install it into ./.agents/skills/qmd
qmd skill install --global # ...or into ~/.agents/skills/qmd
# --yes also creates the .claude/skills/qmd symlink
# -f, --force replaces an existing install or symlink
SDK / Library Usage
Use QMD as a library in your own Node.js applications.
SDK Installation
pnpm add @wei840222/qmd
SDK Quick Start
import { createStore } from '@wei840222/qmd'
const store = await createStore({
dbPath: './my-index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '**/*.md' },
},
},
})
const results = await store.searchLex("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 '@wei840222/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 — evaluated by the shared auto policy, then retrieved and reranked
const results = await store.search({ query: "authentication flow" })
// With options
const results2 = await store.search({
query: "rate limiting",
expansionContext: "API throttling and abuse prevention",
rerankContext: "API throttling and abuse prevention",
collection: "docs",
limit: 5,
minScore: 0.3,
explain: true,
expansion: "auto", // "auto" | "force" | "skip"
})
// Pre-expanded queries — bypass the policy and 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 simple queries, explicit force or skip overrides auto. The automatic
shortcuts and remote requirements are described under Query Syntax.
Supplying queries bypasses expansion policy evaluation entirely.
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", { expansionContext: "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 global
Indexing
// 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 '@wei840222/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 '@wei840222/qmd'
Lifecycle
// Close the store — releases remote clients and the 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.
CJK Lexical Search, Expansion, and Dictionary Maintenance
CJK lexical search combines independent character, word, and bigram channels by
rank. If Jieba or the published word/bigram index is unavailable, stale, or
incompatible, QMD omits both analyzed channels and falls back to character-only
search instead of failing the query or issuing an empty FTS match. qmd status
reports the reason and remediation.
Each lexical channel ranks candidates independently before channel-aware weighted
RRF combines them; raw channel scores are never summed. The original query runs
through every available lexical and vector path at ×2 weight. Typed expansion
variants are ×1: lex variants use the character, word, and bigram channels,
while vec and hyde variants use vector search. Use --explain to inspect
channel contributions and tie-breakers.
Query expansion uses one shared auto | force | skip policy across CLI, SDK, and
MCP. Precedence is: explicit skip or lex: skips expansion; explicit force,
expand:, or --expand forces it; under auto, eligible queries expand when a r