What?
qmd (Query Markup Documents) is an on-device search engine for markdown. It indexes a folder of notes into SQLite and searches it three ways:
| Command | How | Needs models? | Good for |
|---|---|---|---|
qmd search | BM25 full-text (SQLite FTS5) | No | Exact words, names, ids — and every hit with --all |
qmd vsearch | Vector similarity (embeddings) | Embedding model | ”Notes about this idea” when you don’t know the words you used |
qmd query | Both, plus LLM query expansion and reranking | All three | Natural-language questions, best ranking |
Everything runs locally through node-llama-cpp with small GGUF models (an embedder, a reranker and a query-expansion model, ~2GB together). It also ships an MCP server, so an agent like Claude Code can search the vault directly.
Why?
Obsidian’s search finds strings. The questions I actually have for a vault of a thousand notes are about meaning and time:
- When did I first start writing about Rust?
- What’s my latest stated opinion on Rust?
- Which LeetCode problems did I solve in Rust?
Those need different tools — exhaustive keyword recall for the first and third, semantic ranking for the second — and qmd gives you all three behind one index, with JSON output an agent (or jq) can reason over.
How?
1. Install
Needs Node ≥ 22 (or Bun) and, on macOS, Homebrew’s SQLite for extension support.
brew install sqlite
npm install -g @tobilu/qmd # or: bun install -g @tobilu/qmd
qmd --version2. Add the vault as a collection
qmd collection add ~/Documents/Brain --name brain --mask "**/*.md"This indexes for keyword search immediately. Hidden folders (.obsidian, .trash) are skipped.
3. Teach it the vault’s shape — ~/.config/qmd/index.yml
Two things make a vault search well: ignoring files that aren’t knowledge (templates are full of placeholder text that matches everything), and context — a description per folder that’s returned with every result, so an agent knows a hit in Journal/ is a dated diary entry and a hit in Library/ is a reference note.
ignore is YAML-only (no CLI command sets it), so edit the config directly:
global_context: "Personal knowledge base (Obsidian vault, PARA layout). Notes carry YAML front matter with tags (e.g. languages/rust, leetcode, bytes) and dates."
collections:
brain:
path: /Users/me/Documents/Brain
pattern: "**/*.md"
ignore:
- "Templates/**"
- "**/attachments/**"
- "CLAUDE.md"
context:
"/Journal": "Daily journal notes. The file path encodes the date: Journal/01 Daily/YYYY/MM Month/WNN/YYYY-MM-DD.md"
"/Library/01 Projects": "Active projects and their Kanban boards"
"/Library/02 Areas": "Long-term areas of responsibility and interest"
"/Library/03 Resources": "Knowledge cards, research notes, how-to articles and coding challenge write-ups. LeetCode solutions are named 'LeetCode <id>, <title>.md'"
"/Library/04 Archive": "Archived, inactive notes"Don't put context on
/Context is matched by longest path prefix, and
/is a prefix of everything — so a collection-root context gets attached to every result. Useglobal_contextfor “what this whole vault is” and keep collection contexts to real subfolders.
Then re-scan and check:
qmd update # re-index after changing path / pattern / ignore
qmd context list4. Embed
qmd embedThe first run downloads the models to ~/.cache/qmd/models/ and chunks every note (~900 tokens, 15% overlap). For ~1,000 notes (8,000 chunks) on an M1 this took about 14 minutes; afterwards only changed notes are re-embedded. The first qmd query downloads the reranker and query-expansion models too (~2 minutes); after that a query takes ~25 seconds, since each call loads the models from scratch — run qmd mcp --http --daemon to keep them warm. qmd status shows Pending counting down.
5. Ask it things
Pipe --json through jq and keep the parts you need — .file, .score, .snippet. Note that .title is the note’s first heading, not its filename, so in a vault where every note opens with ## Summary you want .file.
”When did I first start writing about Rust?” — exhaustive keyword search, sorted by date
”First” is about completeness, not relevance, so use BM25 with --all and let the journal’s date-shaped paths do the sorting:
qmd search "rust" --all --json | jq -r '.[].file' \
| grep "Journal/01 Daily" | sed 's#.*/##' | sort | head -3
# 2024-04-01.md
# 2024-04-13.md
# 2024-04-16.md
qmd get "Journal/01 Daily/2024/04 April/W14/2024-04-01.md" | grep -i rust
# - Started looking into Rust lang - from which Gleam borrows some syntax …Stemming cuts both ways
BM25 stems words, so
rustalso matched “They sway and rustle, in the breeze” in a poem. Read the snippet before trusting the earliest hit.
”What’s my latest stated opinion on Rust?” — relevance and recency
Opinion is semantic — a note might say “like bowling with the guardrails up” without the word opinion — so query is the right tool for finding the essays:
qmd query "what do I think of Rust now, my latest view" \
-n 10 --min-score 0.3 --json | jq -r '.[] | "\(.score) \(.file)"'
# 1 …/Musings, Ferric Oxide.md (Dec 2024: "I *finally* get why it keeps topping the admired list")
# 0.53 …/Rust, Study Plan.md
# …But query ranks by relevance, not recency — the word latest means nothing to a reranker. The top hit was a December 2024 essay; a more pointed note a month later, “Rust, Did I make a mistake?”, never made the list. So pair it with the newest keyword hits from the journal:
qmd search "rust" --all --json | jq -r '.[].file' \
| grep "Journal/01 Daily" | sort -t/ -k6 | tail -4
# …/2025-01-23.md → "Using rust is like bowling with the guardrails up - can be quite boring / verbose"
# …/2026-10-04.md → GPUI, "like Flutter in Rust (which if true I would definitely use)"Read the candidates with qmd get <path> (or qmd get "#docid"). The newest dated view wins: warmer than January 2025’s verbosity complaint, conditional on the tooling.
”Which LeetCode problems did I solve using Rust?” — keyword search, ids from filenames
qmd search "leetcode rust" --all --json | jq -r '.[].file' \
| grep -io 'leetcode,\? [0-9]*' | grep -o '[0-9]*$' | sort -n | uniqSearch beats tags here
Comparing against a
grepfor thelanguages/rusttag, qmd found all 25 tagged notes plus five more that contain a```rustsolution but were never tagged. The index reads the content, not the metadata you remembered to add.Two gotchas: a query containing a slash, like
"languages/rust", returned nothing — search the words, not the tag. And qmd’s typed--filteronly reads a namespacedqmd: metadata:front-matter block, not ordinary Obsidiantags:, so tag filtering isn’t free.
6. Hand it to an agent
qmd’s MCP server exposes query, get, multi_get and status tools. For Claude Code:
claude plugin marketplace add tobi/qmd
claude plugin install qmd@qmdOr register it manually as an MCP server running qmd mcp. Then the questions above can be asked in plain English and the agent does the search → get → reason loop itself — the folder contexts from step 3 are what let it tell a diary entry from a reference note.
7. Keep it fresh
The index doesn’t watch the vault. Re-run after writing:
qmd update && qmd embed # re-scan changed files, embed only what's newWrap it in a just recipe (see Bytes, Just, Command Runner) or a launchd job if you’d rather not think about it.