Skip to main content

Search with QMD

Haft integrates with QMD to provide keyword and semantic search over your vault.

How it works

QMD maintains a separate index of your vault's text content:

  • Keyword search uses BM25 ranking over the full text
  • Semantic search uses local embeddings (llama.cpp) to find conceptually related passages

The private semantic-candidate provider returns bounded candidate identities, scores, and excerpts. Haft resolves those candidates against its current catalog and owns the public search response, visibility filtering, and hybrid merge.

Install QMD

npm install -g @tobilu/qmd

Requires Node.js 22+.

Configure a collection

Create ~/.config/qmd/index.yml:

collections:
- name: my-vault
path: /path/to/vault
glob: "**/*.md"
caution

Write this file directly. The qmd collection add CLI can misinterpret paths in some environments.

Build the index

qmd index /path/to/vault
qmd embed /path/to/vault

Performance note

Embedding runs a local LLM via llama.cpp. On a 2-vCPU machine, expect roughly 1 chunk/second. A 15,000-file vault (~24,000 chunks) takes about 16 hours.

Build locally, transfer the index

QMD stores paths relative to the collection root, so the index is portable. Build it on a faster machine, then transfer ~/.local/share/qmd/ to the server:

rsync -az ~/.local/share/qmd/ user@server:~/.local/share/qmd/

Ensure the collection name and path in index.yml match on both machines.

Session expiry

The embedded llama.cpp session expires after ~30 minutes. For large corpora, wrap the embed step in a restart loop:

until qmd embed /path/to/vault; do
echo "Session expired, restarting..."
sleep 2
done

Each run resumes where the previous one stopped.

Searching

# Keyword search
qmd search "your query"

# Bounded semantic vector search with QMD 2.5.3
qmd vsearch "your query" -c my-vault -n 10 --format json

Semantic search has a cold-start latency of 20–40 seconds on CPU (the embedding model must load into memory). Subsequent queries are fast.

The private provider service

The repository-owned service in apps/qmd-provider is subordinate to Haft's search engine. It:

  1. Accepts a strict versioned request at POST /v1/candidates.
  2. Runs only qmd vsearch with the requested QMD 2.5.3 -n bound.
  3. Returns a bounded typed response containing candidate identity, score, and excerpt.
  4. Applies a provider-owned deadline no longer than the caller's timeout, propagates cancellation, and terminates the QMD process group on timeout, abort, or output overflow.
  5. Bounds active requests and queue depth, returning a typed overload response instead of spawning unbounded child processes.
  6. Caps stdout and stderr while streaming, and reports malformed output or execution failures as typed failures rather than valid zero-hit responses.
  7. Emits structured request metadata without logging raw queries, excerpts, file paths, or backend stderr.
  8. Requires a deployed Git SHA, reports liveness through /health, and verifies executable/working-directory readiness through /ready.
  9. Refuses non-loopback listener configuration.

Both Haft semantic and hybrid requests use this same semantic-candidate operation. The provider never selects QMD query for public hybrid mode; Haft performs the lexical/semantic merge. Backend source keys remain private and are resolved through the current catalog's exact, case-sensitive source paths before results reach the public search contract. Stale, ambiguous, excluded, and filename-only candidates are rejected rather than guessed.

Prove semantic-index freshness

Haft does not use index age or one expected corpus size as proof of currentness. Operators reconcile the provider against a deterministic catalog-derived revision and explicit inclusion rule:

haft index rebuild --vault /path/to/vault
haft index semantic prepare-rebuild --vault /path/to/vault --json
# Build the provider index from the private rebuild manifest.
haft index semantic reconcile --vault /path/to/vault \
--index-revision sha256:... \
--indexed-count 123 \
--mapped-count 123 \
--unmapped-count 0 \
--pending-count 0
HAFT_QMD_SEARCH=true haft index semantic status --vault /path/to/vault --json

prepare-rebuild writes private, mode-0600 metadata under .haft/private/semantic-index/; it does not call or mutate a provider. Reconciliation reports current only when the catalog and index revisions match, pending work is zero, all eligible documents are indexed and mapped, and exclusion counts match the declared rule. Missing evidence, a later catalog mutation, an interrupted rebuild, or a recorded failure causes semantic requests to degrade truthfully to stale-index before provider execution.

Run provider counts/status through the exact executable and private environment used by the provider service, including explicit QMD configuration and index selectors. A bare interactive qmd status may inspect a different per-user cache and is not valid reconciliation evidence.

See the self-hosting guide for the repository-owned systemd setup.