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"
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.
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:
- Accepts a strict versioned request at
POST /v1/candidates. - Runs only
qmd vsearchwith the requested QMD 2.5.3-nbound. - Returns a bounded typed response containing candidate identity, score, and excerpt.
- 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.
- Bounds active requests and queue depth, returning a typed overload response instead of spawning unbounded child processes.
- Caps stdout and stderr while streaming, and reports malformed output or execution failures as typed failures rather than valid zero-hit responses.
- Emits structured request metadata without logging raw queries, excerpts, file paths, or backend stderr.
- Requires a deployed Git SHA, reports liveness through
/health, and verifies executable/working-directory readiness through/ready. - 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.