
Qmd Search
- 63 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Search a local Obsidian/markdown vault by meaning using the on-device qmd engine with BM25, vector, hybrid rerank, literal grep, and fused modes.
About
Wraps the qmd engine to semantically search a markdown knowledge base on-device, matching meaning across languages and offering keyword, vector, hybrid, and literal grep modes. A developer uses it to find notes by concept or cross-lingual query without anything leaving the machine.
- Five search modes including cross-lingual semantic retrieval
- Bilingual proper-noun rule with native-script grep for absence checks
Qmd Search by the numbers
- 63 all-time installs (skills.sh)
- Ranked #1,537 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill qmd-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 63 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Search a local Obsidian/markdown vault by meaning using the on-device qmd engine with BM25, vector, hybrid rerank, literal grep, and fused modes.
Files
qmd Search
Search a local markdown knowledge base semantically with `qmd`. Five modes — BM25 keywords, vector similarity, hybrid (expansion + rerank), literal native-script grep, and a fused find — all running on-device. The key advantage over Obsidian's built-in search: it matches meaning, finds notes that share no words with the query, and works across languages (e.g. a Russian query retrieves English notes).
When to use which mode
- hybrid (`query`) — default. A real question or fuzzy intent ("how do I stop overengineering").
Best quality; first run downloads reranker/expansion models (~one-time slow).
- vector (`vsearch`) — fast concept lookup ("notes about embodied computing").
- BM25 (`search`) — an exact keyword, name, or filename. Instant, no model.
- grep (`-m grep`) — literal fixed-string ripgrep over the .md files. The audit path for
proper nouns, transliterations, exact phrases, Russian stems/inflections, and absence checks. Bypasses the index; matches only the exact script/spelling you type.
Bilingual / proper-name rule (do not skip)
This vault is bilingual (English/Russian). The embedding model is decent for concepts but weak for proper nouns / specific entities, and BM25 only matches the script you type. So:
Never conclude "it's not in the vault" after one English semantic query. For names, people, pets, places, foreign terms, or bilingual topics: 1. Search semantically first (query / vsearch). 2. Generate likely native-script spellings/stems and try them, e.g. Ziggy → Зигги/Зиги, dog/pet → собак, пёс, щенок, питомц, животн. Use stems (собак catches собака/собаку/собаки), not just the nominative. 3. Run a literal pass before concluding absence: qmd-search.sh -m grep -n 20 "Зигги". 4. Use literal hits to disambiguate close names (e.g. Зигги the pet vs. Зигмунд Freud). 5. If everything fails, say "I didn't find it with these queries: …" and list the terms tried — not "it's not in the vault." Raise -n to ~20 for absence checks.
Primary usage — the wrapper
Use the bundled wrapper; it suppresses qmd's stderr spinner, formats results as score path (parsing qmd's JSON, so commas in filenames are safe), and makes a best-effort refusal to run during an active qmd embed (which would return empty results — override with --force):
~/.claude/skills/qmd-search/scripts/qmd-search.sh [-m query|search|vsearch|grep|find] [-n N] [-c COLLECTION] [--snippet] [--min-score X] [--json] [--full] <query...>Examples:
qmd-search.sh "what helps with anxiety" # hybrid (default)
qmd-search.sh -m vsearch -n 8 "behavioral health from photos"
qmd-search.sh -m search sensorium # BM25 keyword
qmd-search.sh -m grep -n 20 "Зигги" # literal native-spelling / absence check
qmd-search.sh -m find "Зигги собака" # fused: semantic + literal in one call
qmd-search.sh --snippet "agent orchestration" # rows + matching snippets
qmd-search.sh --min-score 0.5 "quarterly planning" # drop low-relevance hits
qmd-search.sh --json "agent orchestration" # structured output for further processingAfter getting hits, read the top files directly (they are normal vault paths) or fetch slices with qmd get "<path>:<line>" -l <N>.
Setup / indexing (only if qmd status shows the vault is not indexed)
qmd collection add ~/Brains/brain --name brain # index the vault
qmd context add qmd://brain "short description of the vault"
qmd embed # build vectors; re-run until status shows 0 pending
qmd cleanup # compact the indexRefresh after large edits: qmd update && qmd embed. Check health any time with qmd status.
Operational rules (do not skip)
- One embed at a time, and never search while embedding — both cause empty/garbage results.
The wrapper guards searches; for manual qmd calls, check qmd status first.
- If embedding never reaches 0 pending, check disk space (
df -h) — a full disk fails writes
silently. See references/cli-reference.md → "Operational gotchas".
- Vector scores are modest (~0.4–0.6); judge by ranking, not the absolute number.
MCP (native tools) vs. the CLI wrapper
qmd ships an MCP server (qmd mcp, stdio) exposing tools query, get, multi_get, status. If it's registered in the host (e.g. .mcp.json), prefer the native `query` tool for hybrid search — it returns structured results with no spinner/JSON-parsing/exit-code quirks. Register with:
{ "mcpServers": { "qmd": { "command": "qmd", "args": ["mcp"] } } }Use the wrapper (scripts/qmd-search.sh) when you need what MCP doesn't cover: BM25-only (search), vector-only (vsearch), the literal/native-script `grep` pass, the fused `find` mode, --snippet, or --min-score. The bilingual/proper-name rule above applies to both paths.
Quality / evals
evals/fixture.example.json + scripts/run-evals.sh run qmd bench to score search quality (precision/recall/MRR per backend). Baseline and interpretation: evals/BASELINE.md. Re-run after changing the wrapper, the index, or the embedding model; a drop vs. baseline is a regression.
Reference
Full command surface, query grammar (lex:/vec:/hyde:), output formats, models, and recovery steps are in references/cli-reference.md.
qmd-search eval baseline
Regression set: evals/fixture.example.json (6 queries across exact / semantic / topical / cross-domain / alias types). Run: scripts/run-evals.sh. Metrics are per backend, averaged.
Baseline (vault brain, embeddinggemma-300M, 2026-05-29)
| Backend | P@k | R@1 | R@3 | R@5 | MRR | F1 | Avg latency |
|---|---|---|---|---|---|---|---|
| bm25 | 0.50 | 0.31 | 0.75 | 0.75 | 0.64 | 0.50 | ~230ms |
| hybrid | 0.33 | 0.14 | 0.69 | 0.875 | 0.59 | 0.33 | ~2.1s |
| vector | 0.17 | 0.06 | 0.19 | 0.53 | 0.33 | 0.17 | ~2.3s |
| full | 0.33 | 0.14 | 0.57 | 0.75 | 0.58 | 0.33 | ~1.8s |
What this tells us
- BM25 is the best default on this vault: top precision/MRR and ~10× faster than hybrid.
(The wrapper defaults to hybrid for recall on fuzzy questions; for keyword/name lookups, -m search is both better and faster.)
- Hybrid wins on R@5 (0.875) — use it when recall matters more than latency.
- Vector-only is weakest — prefer hybrid or BM25 over raw
vsearchunless you specifically
want pure semantic neighbors.
- Cross-lingual (RU→EN) is the genuine weak spot: the
cross-lingual-anxiety-rucase scores
0 on bm25/vector and only ~0.25–0.50 R@5 on hybrid/full, and the specific top file is unstable across re-embeds. Cross-lingual recall is topical, not exact-file-stable. This is exactly why the skill mandates a native-script literal -m grep pass for names/entities before concluding absence (see SKILL.md → "Bilingual / proper-name rule").
How to use
Re-run after any change to the wrapper, the index, or the embedding model. A drop in R@5/MRR vs. this table is a regression. Latencies are machine-dependent (Apple Silicon, Metal); compare relative backend ordering, not absolute ms.
{
"description": "qmd-search quality regression set. Paths are examples from one bilingual Obsidian vault — copy this file and replace `expected_files` with paths from your own indexed collection (the canonical, qmd-normalized form: spaces become hyphens). Run with: qmd bench evals/fixture.example.json -c <collection>",
"version": 1,
"collection": "brain",
"queries": [
{
"id": "exact-sensorium",
"query": "sensorium",
"type": "exact",
"description": "Exact keyword should hit the project trail note.",
"expected_files": ["Trails/Trail-Sensorium.md"],
"expected_in_top_k": 1
},
{
"id": "semantic-anxiety-coping",
"query": "what helps with anxiety",
"type": "semantic",
"description": "Intent query should surface coping/CBT notes, not just notes titled 'anxiety'.",
"expected_files": ["Cognitive-Behavioral-Techniques.md", "anxiety.md"],
"expected_in_top_k": 1
},
{
"id": "topical-animal-communication",
"query": "animal communication",
"type": "topical",
"description": "Topical query should cluster the cross-species / animal-communication notes.",
"expected_files": ["Cross-species-communication.md", "Animal-Communication.md", "Sources/Using-AI-to-Decode-Animal-Communication-with-Aza-Raskin.md"],
"expected_in_top_k": 2
},
{
"id": "cross-domain-photo-health",
"query": "behavioral health signals from photographs",
"type": "cross-domain",
"description": "No shared words with the target PRD; tests semantic bridging.",
"expected_files": ["Claude-Drafts/20260331-product-prds.md"],
"expected_in_top_k": 1
},
{
"id": "alias-ziggy-cyrillic",
"query": "Зигги",
"type": "alias",
"description": "Proper noun in native (Cyrillic) script — must reach the therapy/daily notes about the dog. Searching the Latin 'Ziggy' instead returns nothing relevant (the bilingual gotcha).",
"expected_files": ["Meetings/20241023-my-therapy.md", "Daily/20241009.md"],
"expected_in_top_k": 1
},
{
"id": "cross-lingual-anxiety-ru",
"query": "как справляться с тревогой",
"type": "cross-domain",
"description": "Russian query should bridge to relevant coping notes (EN or RU). Cross-lingual recall is topical, not exact-file-stable, so several valid targets are listed and only one need appear.",
"expected_files": ["Worry-time.md", "Sources/The-Worry-Cure-Seven-Steps-to-Stop-Worry-from-Stopping-You.md", "Claude-Drafts/20260313-руминации-и-тревога-стратегии-совладания-и-самоподдержки-summary.md", "Cognitive-Behavioral-Techniques.md"],
"expected_in_top_k": 1
}
]
}
qmd CLI reference
Full command surface for `qmd` (Quick Markdown Search) v2.5.x — a local-first search engine combining BM25 full-text, vector similarity, and LLM reranking. All models run on-device (node-llama-cpp); nothing leaves the machine.
Search commands
| Command | Mode | Notes |
|---|---|---|
qmd query <q> | Hybrid | Query expansion → lex+vec+hyde → LLM rerank. Best quality, slowest. |
qmd search <q> | BM25 | Tokenized full-text. Instant, no model. Best for exact keywords/filenames. |
qmd vsearch <q> | Vector | Cosine similarity over embeddings. Fast semantic/concept lookup. |
qmd get <file>[:line] [-l N] | — | Show one document, optional line slice. |
qmd multi-get <pattern> | — | Batch fetch via glob or comma list. |
Common search flags
-n N— number of results (default 5)-c <collection>— restrict to one collection--min-score <0..1>— drop low-scoring hits- Output:
--json,--files(docid,score,path,context),--md,--csv,--xml --full— include full document content--line-numbers,--explain(scoring trace),--all(export all matches)
Output stream note (important for scripting)
The animated spinner and the query-expansion trace (lex:/vec:/hyde: lines) are written to stderr. Redirect 2>/dev/null to get clean stdout. --json and --files then parse reliably.
Query syntax (qmd query)
A query is either a single implicit-expand line, or a multi-line typed document:
qmd query "how does auth work" # implicit expand
qmd query $'lex: CAP theorem\nvec: consistency' # typed: lexical + vector lines
qmd query $'lex: "exact phrase" sports -baseball' # phrase + negation in lex
qmd query $'hyde: Hypothetical ideal answer text' # hyde-only (embeds a fake answer)lex:BM25 term(s); supports"phrases"and-negation.vec:text to embed for vector search.hyde:hypothetical-answer text (HyDE) to embed.intent:optional first line describing intent.- Standalone expand queries cannot mix with typed lines.
Collections & context
qmd collection add <dir> --name <name> # index a folder (**/*.md)
qmd collection list|show|rename|remove
qmd context add qmd://<name> "summary" # human summary; improves relevance
qmd context list|rm
qmd ls [collection[/path]] # inspect indexed filesMaintenance
qmd status # index health: files, vectors, pending, models
qmd update [--pull] # re-index collections (optionally git pull first)
qmd embed [-f] [-c name] # generate/refresh vectors; -f forces re-embed
--max-docs-per-batch N # cap docs per batch (lower if memory-constrained)
--max-batch-mb N # cap UTF-8 MB per batch
qmd cleanup # clear caches + VACUUM the SQLite index
qmd init # create a project-local .qmd index
qmd mcp # MCP server (stdio) for AI agentsModels (auto-downloaded to ~/.cache/qmd/models, ~318 MB total)
- Embedding:
embeddinggemma-300M-Q8_0(multilingual — enables cross-lingual search) - Reranker:
Qwen3-Reranker-0.6B-Q8_0(hybrid mode) - Query expansion:
qmd-query-expansion-1.7B(hybrid mode)
Operational gotchas (learned the hard way)
1. Never run two `qmd embed` at once — concurrent processes contend on the SQLite index and corrupt progress counts. Run embeds sequentially. 2. Don't search while embedding — qmd vsearch/query during an active qmd embed competes for the GPU/model and returns EMPTY results. Wait for embed to finish. The wrapper script refuses to run in this case unless --force is passed. 3. Disk space — a near-full disk makes embed writes and qmd cleanup's VACUUM fail silently with SQLITE_FULL (looks like a stall). If embedding won't converge, check df -h. Recover a bloated write-ahead log with: sqlite3 ~/.cache/qmd/index.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" 4. Embedding is incremental — large vaults may need qmd embed to be re-run until qmd status shows no "Pending". Re-run after big edits, then qmd cleanup to compact. 5. Vector scores are modest (~0.4–0.6) with the 300M model — ranking matters, not the absolute number. 6. `qmd query --json` may exit 134 (SIGABRT) during model teardown after writing complete, valid JSON. Treat the output as authoritative, not the exit code. The wrapper script judges success by output validity for this reason; raw qmd query --json in scripts should do the same (or use --files, which exits cleanly). 7. Cross-lingual embeddings are good for concepts, weak for proper nouns. On a bilingual vault, a specific entity (a pet/person/place named in one language) often won't surface from a query in the other language. BM25 (search) only matches the script you type. The reliable audit path is a literal pass on the native spelling — the wrapper's -m grep mode (ripgrep --fixed-strings), which also disambiguates close names (e.g. Зигги the dog vs. Зигмунд Freud).
Bilingual search examples
qmd-search.sh -m grep -n 20 "Зигги" # literal native-spelling pass / absence check
qmd-search.sh -m grep -n 20 "собак" # Russian STEM — catches собака/собаку/собаки
qmd-search.sh $'lex: Зигги собак\nvec: animals pets dog' # typed hybrid: native lex + EN concept vecPrerequisites
- Node ≥ 22 or Bun ≥ 1.0; macOS:
brew install sqlite. - Install:
bun install -g @tobilu/qmd(ornpm install -g @tobilu/qmd), then trust postinstalls
(node-llama-cpp builds the local inference binary).
#!/usr/bin/env bash
# qmd-search.sh — thin, agent-friendly wrapper around `qmd` (https://github.com/tobi/qmd)
#
# Why this exists:
# - qmd writes its spinner + query-expansion trace to STDERR; this wrapper suppresses
# it so captured output is clean.
# - Running a search while `qmd embed` is active causes GPU/DB contention and returns
# EMPTY results. This wrapper makes a best-effort check and refuses (override: --force).
# - Normalizes result rows to "score path" by parsing qmd's --json (comma-safe), or
# passes through --json / --full unchanged. Real qmd failures are surfaced, not hidden.
#
# Usage:
# qmd-search.sh [-m query|search|vsearch|grep] [-n N] [-c COLLECTION]
# [--min-score X] [--snippet] [--json] [--full] [--force] <query...>
#
# Output flags:
# --snippet append a one-line matching snippet under each row (default modes)
# --min-score X drop hits below score X (0..1); cuts low-relevance noise
# --json/--full structured / full-content passthrough
#
# Modes:
# query (default) hybrid: query expansion + vector + BM25 + LLM rerank. Best quality.
# search BM25 full-text. Instant, no model. Use for exact keywords/filenames.
# vsearch pure vector/semantic similarity. Fast concept lookup.
# grep literal fixed-string ripgrep over the vault's .md files. The audit path for
# proper nouns, transliterations (e.g. "Зигги"), exact phrases, and absence checks.
# Does not use qmd's index. Root = -c <collection|path>, else $QMD_SEARCH_ROOT,
# else the sole qmd collection's path (auto-detected). Output: file:line:text.
# find fuse both lenses: hybrid (semantic) + literal grep on the same query, shown in two
# labeled sections. Best single call for names/bilingual topics. Not deduped on purpose
# (qmd normalizes paths; grep returns real paths).
#
# Examples:
# qmd-search.sh "how do I stop overengineering"
# qmd-search.sh -m search -n 10 sensorium
# qmd-search.sh -m vsearch --json "behavioral health from photos"
# qmd-search.sh -m grep -n 20 "Зигги" # native-spelling literal pass
# qmd-search.sh -m find "Зигги собака" # semantic + literal in one call
# qmd-search.sh --snippet "what helps with anxiety"
set -euo pipefail
MODE="query"
N=5
COLLECTION=""
JSON=0
FULL=0
FORCE=0
SNIPPET=0
MINSCORE=""
die() { echo "qmd-search: $*" >&2; exit 1; }
need_val() { [[ $# -ge 2 ]] || die "flag $1 needs a value"; }
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--mode) need_val "$@"; MODE="$2"; shift 2;;
-n|--num) need_val "$@"; N="$2"; shift 2;;
-c|--collection) need_val "$@"; COLLECTION="$2"; shift 2;;
--min-score) need_val "$@"; MINSCORE="$2"; shift 2;;
--snippet) SNIPPET=1; shift;;
--json) JSON=1; shift;;
--full) FULL=1; shift;;
--force) FORCE=1; shift;;
-h|--help) sed -n '2,39p' "$0"; exit 0;;
--) shift; break;;
-*) die "unknown flag: $1";;
*) break;;
esac
done
QUERY="$*"
[[ -n "$QUERY" ]] || die "no query given (run with -h for help)"
command -v qmd >/dev/null 2>&1 || die "qmd not on PATH — install with: bun install -g @tobilu/qmd"
case "$MODE" in query|search|vsearch|grep|find) ;; *) die "mode must be query|search|vsearch|grep|find (got: $MODE)";; esac
[[ "$N" =~ ^[1-9][0-9]*$ ]] || die "-n must be a positive integer (got: $N)"
[[ -z "$MINSCORE" || "$MINSCORE" =~ ^(0(\.[0-9]+)?|1(\.0+)?)$ ]] || die "--min-score must be 0..1 (got: $MINSCORE)"
[[ "$MODE" == grep && -n "$MINSCORE" ]] && die "--min-score does not apply to grep mode"
err="$(mktemp "${TMPDIR:-/tmp}/qmd-search.XXXXXX")"
trap 'rm -f "$err"' EXIT
# Resolve the on-disk vault root for literal (rg) search: -c <name|path>, $QMD_SEARCH_ROOT,
# or the sole qmd collection's path. Echoes the path or nothing.
collection_path() { qmd collection show "$1" 2>/dev/null | sed -n 's/^[[:space:]]*Path:[[:space:]]*//p' | head -1 || true; }
# Echo the resolved on-disk root, or nothing. Always returns 0 so `root="$(resolve_root)"`
# can't trip `set -e` before the caller's own "unknown root" diagnostic.
resolve_root() {
if [[ -n "$COLLECTION" ]]; then
if [[ -d "$COLLECTION" ]]; then printf '%s' "$COLLECTION"; else collection_path "$COLLECTION"; fi
return 0
fi
if [[ -n "${QMD_SEARCH_ROOT:-}" ]]; then printf '%s' "$QMD_SEARCH_ROOT"; return 0; fi
local names; names="$(qmd collection list 2>/dev/null | grep '(qmd://' | awk '{print $1}')" || true
[[ "$(printf '%s\n' "$names" | grep -c .)" -eq 1 ]] && collection_path "$names"
return 0
}
# Literal ripgrep over the vault markdown, capped at N lines, $HOME abbreviated. Echoes rows.
run_grep() {
rg --no-heading --line-number --color=never \
--glob '*.md' --glob '!**/.git/**' --glob '!**/.obsidian/**' \
--fixed-strings --ignore-case -- "$QUERY" "$1" 2>"$err" \
| awk -v n="$N" -v home="$HOME" 'NR<=n{sub("^" home, "~"); print} NR==n{exit}'
}
# grep mode: literal ripgrep, bypassing the qmd index entirely.
if [[ "$MODE" == grep ]]; then
command -v rg >/dev/null 2>&1 || die "rg (ripgrep) not on PATH"
root="$(resolve_root)"
[[ -n "$root" && -d "$root" ]] || die "grep root unknown — pass -c <collection|path> or set QMD_SEARCH_ROOT"
set +e # rg exits 1 on no-match; don't let set -e/pipefail abort before the sentinel
out="$(run_grep "$root")"
set -e
if [[ -n "$out" ]]; then printf '%s\n' "$out"; exit 0; fi
if [[ -s "$err" ]]; then echo "qmd-search: rg failed" >&2; head -n 5 "$err" >&2; exit 2; fi
echo "(no literal matches for: $QUERY)"; exit 0
fi
# Best-effort guard against a concurrent embed (the #1 cause of empty results).
if [[ $FORCE -eq 0 ]] && pgrep -f "qmd[^ ]* embed" >/dev/null 2>&1; then
die "a 'qmd embed' appears to be running — search would contend and likely return nothing. Wait, or pass --force."
fi
# Shared formatter: read a qmd --json array on stdin, print "score path" (+ optional snippet).
# Exit 0 on a valid array (prints "(no results)" if empty), 3 if stdin isn't a JSON array.
format_rows() {
SNIPPET="$1" node -e '
const showSnippet = process.env.SNIPPET === "1";
let s = "";
process.stdin.on("data", d => s += d).on("end", () => {
let a;
try { a = JSON.parse(s); } catch (e) { process.exit(3); }
if (!Array.isArray(a)) { process.exit(3); }
if (a.length === 0) { console.log("(no results)"); process.exit(0); }
for (const r of a) {
const f = String(r.file || "").replace(/^qmd:\/\/[^/]+\//, "");
const score = (r.score === undefined || r.score === null) ? "" : String(r.score);
console.log(" " + score.padEnd(5) + " " + f);
if (showSnippet && r.snippet) {
let sn = String(r.snippet).replace(/@@[^@]*@@\s*(\([^)]*\))?/g, " ").replace(/\s+/g, " ").trim();
if (sn.length > 160) sn = sn.slice(0, 157) + "...";
if (sn) console.log(" " + sn);
}
}
process.exit(0);
});
'
}
# NOTE on exit codes: hybrid `qmd query` may abort during model teardown (exit 134/SIGABRT)
# *after* writing complete, valid output. So success is judged by output validity, not $rc;
# a genuine failure produces no usable output and IS surfaced.
set +e
# find mode: run hybrid (semantic) + literal grep, in two clearly-labeled sections. No merge/
# dedup — qmd normalizes paths (spaces->hyphens) while grep returns real paths, so a fused list
# would be unreliable. Showing both lenses is the point: semantic recall + literal/native exactness.
if [[ "$MODE" == find ]]; then
qargs=(query "$QUERY" -n "$N")
[[ -n "$COLLECTION" ]] && qargs+=(-c "$COLLECTION")
[[ -n "$MINSCORE" ]] && qargs+=(--min-score "$MINSCORE")
find_status=0
echo "SEMANTIC (hybrid):"
# Capture first (contains qmd's SIGABRT teardown in a subshell; judge by output validity).
qout="$(qmd "${qargs[@]}" --json 2>"$err")"
if ! printf '%s' "$qout" | format_rows "$SNIPPET"; then
echo " (semantic search failed — see stderr)"; head -n 5 "$err" >&2 || true; find_status=1
fi
echo
echo "LITERAL (grep \"$QUERY\"):"
if ! command -v rg >/dev/null 2>&1; then
echo " (skipped: rg not installed)"
else
root="$(resolve_root)"
if [[ -n "$root" && -d "$root" ]]; then
g="$(run_grep "$root")"
[[ -n "$g" ]] && printf '%s\n' "$g" || echo " (no literal matches)"
else
echo " (skipped: grep root unknown — set QMD_SEARCH_ROOT or pass -c)"
fi
fi
exit "$find_status"
fi
# Assemble base args (query/search/vsearch).
args=("$MODE" "$QUERY" -n "$N")
[[ -n "$COLLECTION" ]] && args+=(-c "$COLLECTION")
[[ -n "$MINSCORE" ]] && args+=(--min-score "$MINSCORE")
[[ $FULL -eq 1 ]] && args+=(--full)
# --json passthrough: emit verbatim if it parses as JSON.
if [[ $JSON -eq 1 ]]; then
out="$(qmd "${args[@]}" --json 2>"$err")"; rc=$?
if printf '%s' "$out" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)}catch(e){process.exit(3)}process.stdout.write(s.endsWith("\n")?s:s+"\n")})'; then
exit 0
fi
echo "qmd-search: qmd produced no valid JSON (exit $rc)" >&2; head -n 5 "$err" >&2 || true; exit $(( rc == 0 ? 1 : rc ))
fi
# --full passthrough: emit if non-empty.
if [[ $FULL -eq 1 ]]; then
out="$(qmd "${args[@]}" 2>"$err")"; rc=$?
if [[ -n "$out" ]]; then printf '%s\n' "$out"; exit 0; fi
echo "qmd-search: qmd returned nothing (exit $rc)" >&2; head -n 5 "$err" >&2 || true; exit $(( rc == 0 ? 1 : rc ))
fi
# Default: parse --json (comma/quote-safe) into "score path" (+ optional snippet).
out="$(qmd "${args[@]}" --json 2>"$err")"; rc=$?
if printf '%s' "$out" | format_rows "$SNIPPET"; then
exit 0
fi
# Output didn't parse → a real failure.
echo "qmd-search: qmd exited $rc with no usable output" >&2
head -n 5 "$err" >&2 || true
exit $(( rc == 0 ? 1 : rc ))
#!/usr/bin/env bash
# run-evals.sh — run the qmd-search quality regression set via `qmd bench`.
# Usage: scripts/run-evals.sh [fixture.json] (defaults to evals/fixture.example.json)
# Compare the printed Summary against evals/BASELINE.md after any change to the
# wrapper, the index, or the embedding model.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
FIXTURE="${1:-$HERE/../evals/fixture.example.json}"
[[ -f "$FIXTURE" ]] || { echo "fixture not found: $FIXTURE" >&2; exit 1; }
command -v qmd >/dev/null 2>&1 || { echo "qmd not on PATH" >&2; exit 1; }
# Strip the stderr spinner; show the table + summary.
qmd bench "$FIXTURE" 2>&1 | tr '\r' '\n' \
| grep -vE 'gguf|MB/s|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]' \
| grep -vE '^\s*$'
#!/usr/bin/env bash
# Smoke tests for qmd-search.sh. Requires an indexed `qmd` collection.
# Run: scripts/test_qmd_search.sh (exit 0 = all pass)
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
W="$HERE/qmd-search.sh"
pass=0; fail=0
ok() { pass=$((pass+1)); echo " PASS: $1"; }
bad() { fail=$((fail+1)); echo " FAIL: $1"; }
# 1. help exits 0
"$W" -h >/dev/null 2>&1 && ok "help exits 0" || bad "help exits 0"
# 2. empty query errors non-zero
"$W" >/dev/null 2>&1 && bad "empty query should fail" || ok "empty query fails"
# 3. bad mode errors non-zero
"$W" -m bogus "x" >/dev/null 2>&1 && bad "bad mode should fail" || ok "bad mode fails"
# 4. BM25 returns formatted rows for a known term
out="$("$W" -m search -n 3 sensorium 2>/dev/null)"
echo "$out" | grep -qE '^[[:space:]]+[0-9.]+[[:space:]]+[^[:space:]]+\.md' && ok "BM25 returns score+path rows" || bad "BM25 rows ($out)"
# 5. vsearch returns rows
out="$("$W" -m vsearch -n 3 "embodied computing" 2>/dev/null)"
echo "$out" | grep -qE '\.md$' && ok "vsearch returns .md paths" || bad "vsearch rows ($out)"
# 6. json mode emits valid JSON array
out="$("$W" -m vsearch --json -n 1 anxiety 2>/dev/null)"
echo "$out" | head -c1 | grep -q '\[' && ok "json starts with [" || bad "json output ($out)"
# 7. nonsense query gives clean sentinel, not garbage
out="$("$W" -m search "zzqxwz_no_such_token_42" 2>/dev/null)"
echo "$out" | grep -q "no results" && ok "no-results sentinel" || bad "no-results ($out)"
# 8. output has no ANSI escape bytes
out="$("$W" -m vsearch -n 2 "agents" 2>/dev/null)"
printf '%s' "$out" | grep -q $'\x1b' && bad "ANSI leaked into output" || ok "no ANSI escapes"
# 9. -n rejects non-numeric
"$W" -n abc "x" >/dev/null 2>&1 && bad "-n abc should fail" || ok "-n non-numeric fails"
# 10. missing flag value errors, not crash
"$W" -m >/dev/null 2>&1 && bad "dangling -m should fail" || ok "dangling flag fails"
# 11. hybrid (default mode) returns rows and exits 0 despite qmd's teardown abort
out="$("$W" -n 2 "what helps with anxiety" 2>/dev/null)"; rc=$?
{ [ "$rc" -eq 0 ] && echo "$out" | grep -qE '\.md$'; } && ok "hybrid default returns rows, exit 0" || bad "hybrid default (rc=$rc): $out"
# 12. grep mode finds a literal token (auto-detected root) and prints file:line:text
out="$("$W" -m grep -n 3 "sensorium" 2>/dev/null)"
echo "$out" | grep -qE '\.md:[0-9]+:' && ok "grep returns file:line:text" || bad "grep rows ($out)"
# 13. grep mode no-match prints sentinel and exits 0 (not aborted by set -e)
out="$("$W" -m grep "zzqxnowaymatch_42" 2>/dev/null)"; rc=$?
{ [ "$rc" -eq 0 ] && echo "$out" | grep -q "no literal matches"; } && ok "grep no-match sentinel, exit 0" || bad "grep no-match (rc=$rc): $out"
# 14. bad mode name still rejected (now includes grep in allowed set)
"$W" -m frobnicate "x" >/dev/null 2>&1 && bad "bad mode should fail" || ok "bad mode still fails"
# 15. --snippet adds an indented snippet line under a row
out="$("$W" -m search -n 1 --snippet sensorium 2>/dev/null)"
{ echo "$out" | grep -qE '\.md$' && echo "$out" | grep -qE '^ [^ ]'; } && ok "--snippet renders snippet" || bad "--snippet ($out)"
# 16. --min-score validates range
"$W" --min-score 2 "x" >/dev/null 2>&1 && bad "--min-score 2 should fail" || ok "--min-score range validated"
# 17. --min-score rejected for grep mode
"$W" -m grep --min-score 0.5 "x" >/dev/null 2>&1 && bad "grep+min-score should fail" || ok "grep rejects --min-score"
# 18. find mode prints both labeled sections, exit 0, no abort-trap leakage
out="$("$W" -m find -n 2 "sensorium" 2>&1)"; rc=$?
{ [ "$rc" -eq 0 ] \
&& echo "$out" | grep -q "SEMANTIC (hybrid):" \
&& echo "$out" | grep -q "LITERAL (grep" \
&& ! echo "$out" | grep -qi "Abort trap"; } \
&& ok "find: two sections, clean exit" || bad "find ($rc): $out"
echo "----"
echo "passed=$pass failed=$fail"
[ "$fail" -eq 0 ]