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:

CommandHowNeeds models?Good for
qmd searchBM25 full-text (SQLite FTS5)NoExact words, names, ids — and every hit with --all
qmd vsearchVector similarity (embeddings)Embedding model”Notes about this idea” when you don’t know the words you used
qmd queryBoth, plus LLM query expansion and rerankingAll threeNatural-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 --version

2. 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. Use global_context for “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 list

4. Embed

qmd embed

The 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 rust also 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 | uniq

Search beats tags here

Comparing against a grep for the languages/rust tag, qmd found all 25 tagged notes plus five more that contain a ```rust solution 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 --filter only reads a namespaced qmd: metadata: front-matter block, not ordinary Obsidian tags:, 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@qmd

Or 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 new

Wrap it in a just recipe (see Bytes, Just, Command Runner) or a launchd job if you’d rather not think about it.

BLUESKY — START THE THREAD KO-FI / RSS