Semantic search over a Markdown knowledge base, served over MCP.
Reference for the groove command line: index, status, serve, search,
graph, validate, doctor, eval, tune, and service.
日本語版: usage.ja.md
groove index --kb-path /path/to/knowledge-base
groove index --kb-path /path/to/knowledge-base --force # full re-index
groove index --kb-path /path/to/knowledge-base --model bge-m3 --force # switch to BGE-M3 (1024 dim, multilingual)
Scans source files under the given directory, skipping the default exclude_dirs set (.obsidian, .git, node_modules, target, .vscode, .idea — see “Directory exclusion” in docs/behavior.md). By default only .md is picked up. Add [parsers].enabled = ["md", "txt"] to groove.toml to also index .txt files — their title is derived from the filename (deep-dive-2026.txt → "deep dive 2026") and the whole body becomes a single chunk. Put that key in a groove.toml groove trusts — one named with --config, one beside the binary, or one groove service install placed; a config merely discovered next to a knowledge base has [parsers] reset to the default (Trusted and untrusted config locations). Files whose content hash has not changed since the last run are skipped unless --force is passed.
[parsers].enabled = ["md", "rs"] (v1.2.0+) additionally indexes Rust source, one chunk per definition rather than per heading — see the source code indexing note in docs/behavior.md. Every other language arrives as a plugin you place yourself: "py" (v1.3.0+) needs groove-grammar-python, "php" (v1.5.0+) needs groove-grammar-php, in the grammar directory first, described in Placing a grammar plugin. Changing [parsers.code].max_chunk_chars changes how existing files would be split, and index cannot tell that from an unchanged file — it warns and names --force, which is what re-chunks them.
--model accepts:
bge-small-en-v1.5 (default) — 384 dim, English-focused, ~130 MB first download.bge-m3 — 1024 dim, multilingual (100+ languages incl. Japanese), ~2.3 GB first download. Recommended for Japanese-heavy knowledge bases.Switching models on an existing index requires --force (the DB records the model/dim in index_meta and rejects mismatched runtimes).
FastEmbed remains the default and keeps embedding inference in-process. To use
an HTTP embedding service instead, put an [embedding] section in a
groove.toml that groove trusts and name it with --config when the file is
project-local:
[embedding]
provider = "openai-compatible"
endpoint = "http://127.0.0.1:8001/v1/embeddings"
query_model = "query-model"
document_model = "document-model"
dimension = 768
request_dimensions = false
timeout_seconds = 60
max_input_chars = 8000
max_retries = 3
The endpoint receives document chunks during indexing and search text during queries. A config that groove merely discovers under the current directory or a Git ancestor has this section ignored, restoring FastEmbed; see Trusted and untrusted config locations.
Each request uses the OpenAI-compatible POST /v1/embeddings JSON shape with
model and input. Set request_dimensions = true only for an endpoint that
supports the optional dimensions field. Every role needs a model alias:
query_model and document_model each override the shared model for their
role, and the shared model fills whichever role is left unset — so one
model alone, both role aliases as above, or model plus one role alias all
work. dimension is mandatory:
groove uses it to open or validate the vector index before constructing the
HTTP provider, then rejects any response whose vectors have a different size.
Requests are sent in batches of at most 64 inputs. Groove does not probe the
endpoint to open, validate or serve an index; the reasoning is in
ADR-0022. The one exception
is a forced rebuild: groove index --force and the MCP tool rebuild_index
with force: true first embed one fixed text, GrooveSeek endpoint probe
(nothing from the knowledge base), under document_model, and if the endpoint
refuses it or answers with the wrong dimension they stop with an error saying
nothing was removed from the index
(ADR-0024).
Without --force nothing is probed. An optional
api_key is sent as a bearer token; GROOVE_EMBEDDING_API_KEY takes
precedence and avoids storing the token in the file.
Changing provider, either model alias, or dimension makes the runtime
incompatible with the existing index and requires groove index --force.
Changing endpoint, api_key, request_dimensions, timeout_seconds,
max_input_chars or max_retries does not, because they are not part of the recorded identity, so groove cannot tell
when the model behind an alias changes. If the endpoint starts serving a
different model under the same alias, run groove index --force yourself;
otherwise old document vectors and new query vectors are searched together
without an error.
--model keeps its historical meaning and selects FastEmbed for that one
invocation, overriding [embedding]. The top-level model key belongs to
FastEmbed alone: groove refuses a config that sets it together with
provider = "openai-compatible", so remove it when switching providers. With
FastEmbed (the default provider) it and [embedding].model name the same
thing, so a config that sets both is refused as well: keep one.
When the endpoint refuses or fails. A file whose input the endpoint refuses
with HTTP 400, 413 or 422 is skipped on its own: groove index prints
warning: <file>: embedding endpoint rejected the input (HTTP <status>); skipped, the index keeps what it had for this file,
counts it under skipped, keeps the row it already had, and goes on. A file
with no row yet is named with skipped, this file is not in the index
instead. Under --force (and MCP rebuild_index {force: true}) every
file is in that position, because the rebuild empties the index first: one
refused file makes the run exit non-zero (MCP: an error beside the counts)
after every other file was indexed and the run finished. If the
run had a file refused before any batch of it was accepted, and the endpoint
accepted no batch at all,
it still finishes (deletions included)
and then exits non-zero: that pattern points at model / document_model or
endpoint, not at the files. MCP rebuild_index answers the same run with an
error. HTTP 429 and 5xx are retried (max_retries); 401, 403 and other 4xx
stop the run at once. Before a status line arrives, a timeout or a failed
connection is retried and any other transport error stops the run; once a
status line has arrived it decides, and a body that stalls or breaks off never
changes that (a 2xx whose body times out is retried). While a daemon
retries it holds the embedder, so searches wait with it; set max_retries = 0
where that matters. Inputs are cut to max_input_chars characters first.
Japanese text can take more than one token per character, so a server with an
8192-token limit may still refuse 8000 characters; lower max_input_chars if
the warnings name many files. A refused Markdown file also counts as not
read when the keys groove-schema.toml declares have changed, so while the
endpoint keeps refusing it the new declared-field set is not recorded, and
search refuses fields / fields_not filters until the file is fixed,
max_input_chars is lowered, or groove index --force is run. The details are in
ADR-0025.
--force is also the repair for a .groove.db that cannot be opened as a database — a truncated write or a process killed mid-migration is enough. Any command that opens the file then fails with a message naming the file (it lives in the parent of --kb-path) and the two ways out: delete it and run groove index, or run groove index --force, which replaces the file and its -wal / -shm sidecars and rebuilds from scratch. The index is entirely derived from the corpus, so nothing is lost. Without --force the file is never touched.
Two flags control how groove index reports progress; they are mutually exclusive and default-off (the existing per-file ` indexed: foo.md (N chunks)` output is unchanged when neither flag is given).
--quiet: suppress per-file output; only print start / Found N source files / Done in ... summary lines. Useful when running from harnesses (e.g. Claude Code Bash tool) that buffer streaming output until exit, so you can recognise “silence = still working” instead of confusing it with a hang. Warnings are not progress and are never suppressed: warning: <path>: failed to parse YAML frontmatter: ... prints under --quiet too.--progress: show progress UI. Auto-detects via IsTerminal on stderr — TTY gets an indicatif bar with elapsed / position / percent / ETA, non-TTY gets periodic Progress: N/M (P%) lines (~20 emits per run plus a 100 % anchor) so tail -f indexing.log works.groove index --kb-path ./big-kb --quiet # silent except for start / done
groove index --kb-path ./big-kb --progress # bar in TTY, periodic lines in pipe
The Done in line counts what the run did: updated (files whose content changed and were re-embedded), renamed, deleted, skipped (present on disk but not indexed — unreadable, over the size cap, or empty), and frontmatter unparsed (v1.7.0+). That last one counts Markdown files whose YAML frontmatter did not parse this run: each is named in a warning: line above, indexed with empty title / date / topic, and tagged frontmatter:unparsed so --tag-any frontmatter:unparsed lists them. A frontmatter-only stub is skipped for having no content and still counted, since it was still named. A file that has not changed since it was last indexed is not re-parsed and so is not re-counted; --force re-parses everything. The exception is the first run of this version over an index an older version wrote: it re-reads the frontmatter of every unchanged Markdown file once, tags and counts what it finds without re-embedding anything, and says so (Tagged N unchanged Markdown document(s) ...); later runs take the fast path again. With [index].fail_on_frontmatter_error = true in groove.toml the run finishes, prints the summary, and then exits 1 when that count is non-zero.
The first groove index or rebuild_index of a version that changes how .docx files are split (reading headings from word/styles.xml, folding a heading with no body text into the next section, or writing each table row as one line) re-reads every unchanged .docx once. A version that changes how .pptx slides are read (v1.19.0: each table row as one line, line breaks kept) re-reads every unchanged .pptx once the same way, with a notice of its own (Re-reading N unchanged .pptx document(s) once: table rows are now one line each, cells separated by tabs, and line breaks are kept; ...), printed after the .docx one when both run; each format’s re-read reads only its own files. The .docx notice reads table rows are now one line each, cells separated by tabs when the table change is the only one it catches up with, and headings now come from word/styles.xml otherwise. v1.20.0 also records where each table row sits in a .docx and .pptx chunk: when that record is the only thing a format catches up with, its notice reads table rows are now marked in each chunk instead (Re-reading N unchanged .docx document(s) once: table rows are now marked in each chunk; ..., and the same for .pptx), and the run re-reads every unchanged .docx and .pptx once and writes only that record, re-embedding nothing and counting nothing as updated. A document whose sections change is re-embedded and counted under updated; one that splits as before is left as it is; one the new reading leaves with nothing to index (for example when exclude_headings now covers every section) is removed from the index and counted under skipped, the way an empty file is. groove index says so before the documents are processed (Re-reading N unchanged .docx document(s) once: ...) unless --quiet is given; MCP rebuild_index runs quietly, so there the re-split documents show only in updated. A .docx or .pptx that run cannot read, over a size cap or failing to parse, is tried again on every later run until it can be, the way a changed file that fails to parse already is, so its Skipping ... line repeats; later runs otherwise take the fast path again. A re-split .docx or .pptx the embedding endpoint refuses is retried the same way on later runs, and a run that embeds nothing else then exits non-zero, as it already does for a changed file (see External OpenAI-compatible embeddings). If you run groove serve, restart it on the new version first, then call rebuild_index or run groove index once: the file watcher re-reads only files that change, so until then unchanged .docx and .pptx files keep the chunks the previous version cut, and a daemon still on the previous version would write edited .docx and .pptx files the old way after the re-read, which only --force undoes. A daemon still on v1.19.0 or earlier writes edited .docx and .pptx files without their table rows after the v1.20.0 re-read, and they stay without them until the next edit or --force.
| Aspect | BGE-small-en-v1.5 | BGE-M3 |
|---|---|---|
| First-time download | ~130 MB | ~2.3 GB |
| Embedding dim | 384 | 1024 (index file ~2.6× larger) |
| RAM when loaded | ~500 MB | ~2 GB |
| Index build time | baseline | ~3–10× slower (CPU inference) |
| Japanese precision | poor (English-centric vocab) | strong (multilingual tokenizer + training) |
| English precision | strong | comparable |
Switching cost (existing index → new model):
groove index --kb-path ... --model <new> --force runs a full re-embedding (no incremental update possible; DELETE FROM documents/chunks/vec_chunks and start over).serve / index call afterwards must pass the same --model (or have it set in groove.toml). A mismatch is rejected at startup by the index_meta check.Practical recommendation: pick the model that matches your knowledge base’s primary language up front. Don’t oscillate between models unless you have a concrete precision problem — the full re-embedding is the expensive step.
groove serve --kb-path /path/to/knowledge-base
groove serve --kb-path /path/to/knowledge-base --model bge-m3 # must match the indexed model
groove serve --kb-path ... --model bge-m3 --reranker bge-v2-m3 # + cross-encoder reranking
groove serve --kb-path ... --transport http --port 3100 # HTTP, multi-client
groove serve --kb-path ... --no-watch # disable live-sync
Starts the MCP server on stdio transport by default (one client at a time). Pass --transport http --port <PORT> (or --bind <SOCKETADDR>) to serve multiple clients simultaneously via Streamable HTTP — details in the HTTP transport section. A --bind outside loopback additionally requires --i-know, because groove ships no authentication. On Linux, where a service manager passes LISTEN_FDS — systemd, or anything else speaking the same protocol — --systemd-socket takes the listening socket it already bound instead of binding one; it cannot be combined with --bind or --port, and serve refuses to start rather than falling back to TCP when no socket was passed. An abstract socket — a ListenStream= whose name starts with @ — is refused as well, because it has no file for SocketMode= to apply to. A build for any other operating system refuses the flag outright. The same applies to [transport.http].systemd_socket in a trusted config: if the transport resolves to stdio, which accepts no socket, serve refuses to start instead of ignoring the key — unless you asked for stdio yourself with --transport stdio, which still runs that config on stdio for the one session.
The server exposes 6 tools (see docs/mcp-tools.md) and keeps the index in-process for low-latency queries. --model must match the model that built the current index, otherwise the server refuses to start with an actionable error message. A file watcher (enabled by default) re-indexes affected files when the contents under --kb-path change — see Live-sync via file watcher.
--reranker (optional, default none) enables a cross-encoder re-ranking pass over the top candidates of the hybrid search:
none — disabled (default).bge-v2-m3 — BAAI/bge-reranker-v2-m3 (multilingual 100+, ~2.3 GB first download). Recommended for Japanese knowledge bases.jina-v2-ml — jinaai/jina-reranker-v2-base-multilingual (multilingual, ~1.2 GB). Lighter alternative. Licensed CC-BY-NC-4.0 by Jina AI: research and evaluation only, no commercial use — commercial use goes through Jina’s API or marketplace offerings. Prefer bge-v2-m3 (Apache-2.0) for anything commercial.bge-base — BAAI/bge-reranker-base (English/Chinese only, ~280 MB). Not recommended for Japanese.Rerank is expensive on CPU, and what costs is the cross-encoder pass rather than the model load. Measured against v1.0.0 on one Windows machine (CPU only, bge-m3 embedder, bge-v2-m3 reranker, a 141-document / 1,855-chunk knowledge base, limit = 5 so the candidate pool is 50 pairs): one query took 74–87 s through groove search and 74–79 s through a resident daemon over /mcp, against 3.1–3.6 s and ~0.1 s for the same query with rerank off. Residency does not rescue it: run_server builds the reranker at startup, so a daemon’s second and later reranked queries have no model left to load, and they still take 74–79 s. What costs is the cross-encoder pass over the candidate pool. Time it on your own hardware before turning it on: repeat groove search "<query>" --reranker bge-v2-m3 against the same command with --reranker none, timing each process from outside it. --rerank-by-default <BOOL> (on by default when --reranker is set) controls whether every search call uses rerank. It takes a value rather than being a bare flag, so turning it off is --rerank-by-default=false; the MCP tool takes rerank: Option<bool> to override per query. Switching the reranker does not require re-indexing (it is index-independent).
As of v0.27.0 the rerank_by_default key is read by groove search as well, so one groove.toml no longer means rerank here and no rerank there. On the command line the per-query override is --reranker itself: naming a model opts that query in even when the file says false, and --reranker none opts it out. (groove eval deliberately ignores the key — it measures whatever --reranker selects, and its run fingerprint records that model, so a run must not silently measure something else.)
Rerank trades latency for precision. The right choice depends on usage pattern:
| Scenario | Recommendation |
|---|---|
Interactive agent flows (the LLM calls search 2–5 times per turn) |
Leave off. At a minute or more per reranked call this is not a latency tax, it is a different interaction; retrieval quality from BGE-M3 + heading-weighted bm25 is usually sufficient. |
| One-shot, precision-critical queries (research, definitive answers) | Enable only if a minute per query is acceptable. It is paid once per turn and the cross-encoder meaningfully promotes semantically relevant candidates — but on CPU that minute is the answer time, so prefer a per-query opt-in (rerank: true) over a standing default. |
| Mixed usage | Start with rerank_by_default = false and let the caller opt in per query — rerank: true on the MCP tool, or --reranker <model> on the command line. |
Symptoms that suggest you should turn rerank on:
Because rerank is index-independent, you can enable it for a week, measure the quality delta, and disable it if the benefit is not visible — no re-indexing needed.
groove service install registers the daemon as an OS-level user service (no admin/sudo required) and configures auto-start at login.
# Default: service name 'groove', bind 127.0.0.1:3100, auto-start ON
groove service install --kb-path /path/to/your-kb
# Multi-instance (= run multiple KBs as separate services)
groove service install --service-name work --kb-path /path/to/work-kb --bind 127.0.0.1:3100
groove service install --service-name personal --kb-path /path/to/personal-kb --bind 127.0.0.1:3101
# Inspect / manage
groove service status # default 'groove'
groove service status --service-name personal # a named instance
groove service list # all instances
groove service uninstall --service-name personal # remove unit, keep config + DB
groove service uninstall --service-name personal --purge --yes # also remove config + DB
OS-specific backends:
~/.config/systemd/user/groove-<name>.service). Run sudo loginctl enable-linger $USER to keep the daemon running after logout.~/Library/LaunchAgents/com.groove.<name>.plist). launchd writes the daemon’s output to groove.out / groove.err in the config home; the plist sets Umask to 0077, so everything the agent creates — those logs, the index database — is readable only by your account.\groove-<name> task).The installer writes a config home at <dirs::config_dir()>/groove/<service-name>/ containing groove.toml (with kb_path and bind). Override the base directory via GROOVE_CONFIG_HOME env var. The registered launch line names that file with --config (v0.20.0+), so the daemon reads the config the installer wrote rather than whatever it discovers from its working directory — see Trusted and untrusted config locations.
Non-loopback bind addresses (e.g. 0.0.0.0:3100) require --i-know since groove has no authentication.
Migration from v0.7.x personal-http recipe: The
grooveseek/examples/deployments/personal-http/templates were removed in v0.8.0. Disable / delete the manually installed unit before runninggroove service install:
- Linux:
systemctl --user disable groove.service && rm ~/.config/systemd/user/groove.service- macOS:
launchctl bootout gui/<uid>/com.groove.groove && rm ~/Library/LaunchAgents/com.groove.groove.plist- Windows:
schtasks /End /TN '\groove' ; schtasks /Delete /TN '\groove' /F(replace\groovewith whatever name the old task used)If you’re carrying settings over from the old
groove.toml(e.g.model = "bge-m3",exclude_dirs,best_practice,fastembed_cache_dir), edit the new config at<dirs::config_dir()>/groove/<service-name>/groove.tomlafter install.kb_pathmust be an absolute path — the new daemon’sWorkingDirectoryisconfig_home, so a relativekb_path = "./knowledge-base"will resolve to<config_home>/knowledge-baseand miss the real KB. Use TOML literal strings (single quotes) to avoid Windows backslash escapes:kb_path = 'C:\Users\you\your-kb'.
groove-tray.exe is a Windows system tray binary that visualizes daemon state and provides Start / Stop / Restart controls. It ships from v0.14.0 in its own archive, groove-tray-x86_64-pc-windows-msvc.zip — not inside the groove archive. Extract it next to groove.exe; groove service install --with-tray looks for it there. (Releases before v0.14.0 did not contain it at all: the two Windows companion binaries were built but never attached, so use v0.14.0 or later.)
Install alongside the daemon:
groove service install --kb-path C:\path\to\kb --with-tray
On next logon the tray icon appears with a colored status dot:
/api/admin/status poll succeeded)Right-click reveals six menu items: Status (read-only line) / Open Web UI / Start / Stop / Restart / Quit Tray. Start runs the scheduled task; Stop terminates the daemon process by the pid it reports at /api/admin/status (v0.14.0+), then confirms it is gone by binding the daemon’s address — Stop-ScheduledTask only ever stopped the launcher, which exits immediately, so it silently did nothing.
Tray logs live at %LOCALAPPDATA%\groove\logs\tray.YYYY-MM-DD (daily rotation). Set GROOVE_TRAY_LOG=debug for verbose output. Pass --debug to attach a console for live stdout/stderr.
Uninstalling the daemon also removes the tray shortcut:
groove service uninstall --service-name groove
To manage the tray shortcut independently of the daemon registration:
groove service tray-install --service-name groove # add shortcut only
groove service tray-install --service-name groove --force # overwrite an existing shortcut
groove service tray-uninstall --service-name groove # remove shortcut only
The tray polls 127.0.0.1:<port>/api/admin/status, so the daemon must be bound to either loopback (127.0.0.1) or a wildcard (0.0.0.0). A daemon bound to a specific NIC such as 192.168.1.5:3100 is not listening on loopback, and the tray logs a warning at startup so the misconfiguration is discoverable.
groove status --kb-path /path/to/knowledge-base
Reports on the existing index, on stdout, so groove status | … works: document and chunk counts, how many documents had unparseable tags frontmatter, the context mode the index was built in (static / off), and the declared-field set the index recorded (v1.11.0+) — the keys --field can filter on, with the number of value rows document_fields holds; pending means no completed groove index run has recorded a set yet, so a search carrying --field would be refused right now (docs/filters.md). A further line reports how many chunks pass the quality filter — only when the effective threshold is above zero, so it is absent under [quality_filter] enabled = false or threshold = 0.0.
When there is no index yet, the “No index found” note goes to stderr and stdout stays empty — the command could not answer, so it produced no result. The wording of the lines above is not frozen (docs/stability.md); for the two counts in a machine-readable form use groove doctor --format json.
For shell scripts or skill bins that just need “search this string in the KB” without standing up an MCP connection:
groove search "RAG server comparison" --limit 3 --format text
groove search "E0382" --category deep-dive --format json | jq '.results[] | .path'
groove search "クエリ最適化" --reranker bge-v2-m3 # optional per-invocation rerank
--format is json (default, a { results, low_confidence, filter_applied } wrapper as documented under “Search filters and citations” below) or text (LLM-friendly blocks separated by ---). All other flags mirror serve: --kb-path, --model, --reranker, --category, --topic, --limit. The quality filter is on by default — pass --include-low-quality or --min-quality 0 to restore the previous (filter-off) behavior for a single query. The groove.toml defaults apply exactly as in serve/index.
How the query is matched (v0.16.0+): the FTS half of the hybrid does not look for the query verbatim. It cuts the query at separators and at script boundaries (kanji / hiragana / katakana / other word characters), joins any fragment under the 3-character trigram floor to its neighbours, and searches for the resulting phrases joined with OR — so 再ランキングの評価について looks for 再ランキング / ランキング / の評価 / について, and a natural-language question matches without appearing word-for-word. Wrap a substring in "..." to keep it together as one verbatim phrase (groove search '"Foundry Local" の設定'); quoting the whole query restores the pre-v0.16.0 substring search exactly. The same applies to the search MCP tool, which runs the same code path; none of this needs a re-index. Details: docs/retrieval-pipeline.md.
Excluding a term (v1.1.0+): prefix a whitespace-delimited group with - to drop it from both halves of the search instead of searching for it, e.g. groove search 'rust -async'. Put the positive term first on the command line — a query that begins with - is read as a flag by the argument parser — and escape a leading exclusion with groove search -- '-async rust'. Quote a leading hyphen ("-foo") to search for it literally. Details: ADR-0011.
Typical skill-bin use: a Claude Code skill places groove.exe + groove.toml in its bin/, then a command like groove search "<user_query>" --format text --limit 3 returns a focused reference excerpt for the LLM to cite.
Starting in v0.3.0 the search MCP tool returns a wrapper object instead of a raw array of hits. This is a breaking change for clients that parse the response as Vec<SearchHit> directly:
{
"results": [{ "score": 0.83, "path": "...", "match_spans": [...], "tags": [...], ... }],
"low_confidence": false,
"filter_applied": { /* the echoed filters that were given; `{}` when none of them was. `min_quality` / `include_low_quality` apply without being echoed */ }
}
results[].match_spans are byte offsets into content, returned when every term the query splits into is ASCII, so MCP clients can quote the source text accurately. They are sorted and non-overlapping, and the 100-span budget is shared across the terms you searched for, so a term matching once is still highlighted when another matches hundreds of times; reordering the words of a query returns the identical array as long as it stays under the 32-phrase cap (v0.18.0+ — see docs/citations.md for the full contract and that one caveat). low_confidence is a rank-based flag (top1.score / mean(top-N.score) < min_confidence_ratio); the threshold defaults to 1.5 and can be tuned via [search].min_confidence_ratio in groove.toml or --min-confidence-ratio per query.
Input bounds (defensive, v0.6.0+): query is capped at 1 KiB; longer inputs are rejected with an ErrorResponse. match_spans is computed only for chunks under 256 KiB and capped at 100 spans per chunk. These exist to bound abuse, not legitimate use — typical chunks are well under the ceilings.
The search tool / CLI also gained these filters in v0.3.0:
groove search "tokio spawn" \
--path-glob "docs/**" --path-glob "!docs/draft/**" \
--tag-any rust,async \
--field status=active --field-not environment=staging \
--date-from 2026-01-01 \
--min-confidence-ratio 1.5
--path-glob <PATTERN> (repeatable) — include / exclude by path glob; !-prefix is an exclude. Give it once per pattern: it does not split on commas, because a glob’s own syntax uses them (docs/{a,b}/** is one pattern, not two). MCP param: path_globs.--tag-any <a,b,c> — pass if the chunk has any of these tags. MCP param: tags_any.--tag-all <a,b,c> — pass only if the chunk has all of these tags. MCP param: tags_all.--date-from <YYYY-MM-DD> / --date-to <YYYY-MM-DD> — lex comparison; chunks with no date are excluded strictly when either bound is set. MCP params: date_from / date_to.--field <KEY=VALUE> (repeatable, v1.9.0+) — keep documents whose frontmatter holds that value for that key; the key must be one groove-schema.toml declared when the index was built. The same key twice is OR, two different keys are AND; split at the first =, so the value may contain = or a comma. MCP param: fields.--field-not <KEY=VALUE> (repeatable, v1.9.0+) — drop documents holding that value; a document without the key is kept. MCP param: fields_not.--min-confidence-ratio <N> — per-query override of the low_confidence threshold. Must be finite and >= 0.0; 0.0 is how the check is turned off. The CLI rejects anything else before it loads a model, because a non-finite ratio compares false against every score and would quietly disable the flag rather than tighten it. The MCP parameter of the same name cannot refuse a value mid-conversation, so it substitutes instead: a non-finite ratio is logged and replaced by the server’s own value, and a negative one is clamped to 0.0 — which disables the check rather than failing the call.CLI groove search --format json answers with the same wrapper — results, low_confidence, filter_applied — and the same hit fields, with one exception: an MCP hit also carries a uri when the document is one the server will hand over, and a CLI hit never does. docs/mcp-tools.md describes when that field is present. See docs/citations.md for match_spans / byte-offset details and docs/filters.md for the full filter reference.
Two opt-in retrieval-quality knobs land in v0.7.0. They are independent — enable either, both, or neither. Both default to off so existing pipelines behave exactly as before.
# MMR diversity re-rank
groove search "tokio runtime" --mmr true --mmr-lambda 0.7
# Parent retriever (expand short chunks to adjacent siblings or whole doc)
groove search "k=60 in RRF" --parent-retriever true
# Both at once
groove search "context management" --mmr true --parent-retriever true
CLI flags (also accepted by groove eval):
--mmr <bool> — enable MMR diversity re-rank. Default false.--mmr-lambda <0..1> — MMR balance: 1.0 is “no diversity” (= MMR off behavior), lower values lean toward exploration / less redundancy. Default 0.7.--mmr-same-doc-penalty <0..1> — extra cost when an already-selected chunk lives in the same document. 0.0 is pure MMR; raise to actively deduplicate same-doc chunks. Default 0.0.--parent-retriever <bool> — when a hit chunk’s token count is below whole_doc_threshold_tokens, expand its content to adjacent siblings (level-aware) or, for very short chunks, the whole document. The score, rank, path, and match_spans of the original hit are preserved; only content (and a new optional expanded_from) changes. Default false.MCP search tool gains the matching per-call params mmr / mmr_lambda / mmr_same_doc_penalty / parent_retriever. Toml defaults live in [search.mmr] and [search.parent_retriever] (see docs/configuration.md). Per-call params override toml; toml overrides built-in defaults.
The pipeline order is RRF → reranker → MMR → parent retriever → match_spans. MMR re-orders candidates while the reranker score is still on the chunks; parent retriever runs last so the expanded content does not contaminate the relevance signal. See docs/retrieval-pipeline.md for the full pipeline narrative and tuning advice.
Each chunk can be prefixed with a short, statically generated context breadcrumb — the document title plus its heading ancestry (Doc Title > Section > Subsection, ` > `-joined) — and that breadcrumb is injected into the embedding input, the FTS5 index (a dedicated third column, scored via a Contextual BM25 weight), and the reranker input. Unlike Anthropic’s original Contextual Retrieval technique, this context is derived purely from document structure at index time — no LLM call, no extra runtime dependency, no staleness beyond what a normal re-index already handles.
Enable it via:
[contextual]
enabled = true
This defaults to off, and the reason is a measured regression, not caution for its own sake: an A/B evaluation on a 574-document dogfood knowledge base (bge-m3 embeddings) showed that with groove’s actual default pipeline (no reranker), enabling static context injection made retrieval worse — recall@5 dropped from 0.707 to 0.627 (-0.080) and MRR dropped by -0.041. The short chunk-local vector signal gets diluted by the prefixed breadcrumb text when nothing downstream re-scores the result.
With a reranker enabled (--reranker bge-v2-m3), the picture flips: context injection improved every metric except a small recall@10 dip — recall@5 went from 0.760 to 0.807, MRR from 0.848 to 0.950, and nDCG@10 from 0.814 to 0.858. The cross-encoder reranker is able to use the extra structural signal that the raw embedding/BM25 stage cannot fully exploit on its own.
Recommendation: only turn [contextual] enabled = true on if you also run with a reranker (--reranker bge-v2-m3 or similar / reranker = "bge-v2-m3" in groove.toml). Leave it off for the plain default pipeline.
Notes:
search / get_document output.groove index --force to rebuild the embeddings and FTS index with context injected; without --force, a config/DB mode mismatch just prints a warning on stderr and the database keeps its current mode (no silent mid-migration mixing of embedding spaces).groove status reports the DB’s current mode as Context mode: static or Context mode: off.When you want to find not just a single document but the semantic neighborhood around it (and neighbors of those neighbors), use the graph subcommand:
groove graph --start deep-dive/mcp/overview.md --depth 2 --fan-out 5
groove graph --start notes/rag.md --dedup-by-path --format text
groove graph --start a.md --exclude-paths junk1.md,junk2.md --min-similarity 0.5
Flags:
--start PATH — required, relative path to an indexed document. MCP param: start.--depth (default 2, clamped to max 3) — BFS hops.--fan-out (default 5, clamped to max 20) — neighbors per node per hop. 0 returns only the seed.--min-similarity (default 0.3) — cosine similarity cut-off. 0.0..=1.0.--seed-strategy — all-chunks (default) expands from each seeded chunk of the start doc; centroid averages them (L2-renormalized) into a single seed node, leaving all of --max-nodes except that one node for connections. Both see only the first --max-seed-chunks chunks. (The MCP tool spells this value all_chunks; both spellings are accepted on both sides — see stability.md.)--max-nodes (default 100, clamped to max 2000) — total nodes; also caps the number of KNN queries.--max-seed-chunks (default 32, clamped to 1..=1000) — chunks of the start document used as seeds.--exclude-paths — comma-separated paths to drop from results. The start path itself is always excluded. MCP param: exclude_paths.--dedup-by-path — collapse same-path hits so each document appears at most once.--category / --topic — apply category / topic filters to every hop.--format json|text|dot|svg — json (default) and text are the machine- and human-readable listings; dot and svg draw the walk (v0.25.0+, see below).--format dot / --format svg)json and text list the same nodes, but neither shows where the walk branched — which is the part worth looking at. Two drawing formats do:
groove graph --start notes/rag.md --format dot > graph.dot # then: dot -Tsvg graph.dot
groove graph --start notes/rag.md --format svg > graph.svg # opens in any browser
dot is a Graphviz program: pipe it to dot -Tsvg / -Tpng / -Tpdf, open it in a DOT viewer, or paste it into one of the web ones. svg is a finished picture that needs nothing installed — groove lays it out itself, taking no drawing dependency, because the graph is a tree and a tree lays out in one pass.
Both color nodes by BFS depth, label edges with the similarity score, and say so when a limit cut the walk short, so a picture is never mistaken for the whole neighbourhood. For a wide graph the Graphviz route gives the more compact page; the built-in SVG stacks one row per leaf, so --max-nodes is the knob that keeps it readable.
The output is a flat array of nodes with parent_id / depth / score so the consumer can reconstruct the tree if it wants. Good use cases: “give me 30 chunks of related context around this note for the LLM to read”, or “walk two hops from this overview to see what topics it touches”.
If your knowledge base follows a frontmatter convention, groove validate checks every .md file against a TOML schema and reports violations. See the Frontmatter schema validation section for the schema format; the command itself is:
groove validate --kb-path /path/to/knowledge-base
groove validate --kb-path ... --format json | jq '.files[]'
groove validate --kb-path ... --format github # ::error annotations for CI
Flags:
--schema <PATH> — read the schema from somewhere other than
<kb-path>/groove-schema.toml. This is the only way to point validate at a
schema that does not sit beside the knowledge base — one shared schema for
several bases, or a stricter one kept in CI.--strict (v1.9.0+) — report a frontmatter key the schema does not declare
as an undeclared_field violation, one per key. Same effect as
[options] allow_unknown_fields = false in the schema; the flag only ever
tightens, and with no schema file the command still exits 0.--fail-fast — exit 1 at the first violation instead of scanning the rest.
Useful when the answer you want is “is it clean”, not “what is wrong”.--no-color — drop ANSI color from --format text. Color is already off
when stdout is not a TTY, so this is for the case where it is one.Exit codes: 0 (no violations), 1 (violations), 2 (schema load error). A file whose frontmatter block is not valid YAML counts as one violation (frontmatter_unparsed, v1.8.0+), so it exits 1 like any other. When groove-schema.toml is absent under --kb-path, the command exits 0 with a short “no schema found” note, so adding groove validate to an existing workflow is non-disruptive until you actually write a schema.
groove index reads the same <kb-path>/groove-schema.toml (v1.9.0+): the keys it declares are stored per document so groove search --field can filter on them. A schema that does not load stops groove index, the way a groove.toml that does not load stops the binary.
The schema file is read through the checks .grooveignore gets: a hard link, something that is not a regular file, a file over 1 MiB and, on Unix, a symlink are refused, and so is a file that cannot be opened for any reason other than not being there. For the schema a refusal is a load error — validate exits 2, and index, doctor and the MCP rebuild_index tool stop — rather than a missing schema, because the schema decides which fields the index holds. A path given with --schema is held to the same checks. See Behavior notes.
groove validate checks your documents. groove doctor checks the index:
groove doctor --kb-path /path/to/knowledge-base
groove doctor --kb-path ... --format json | jq '.findings[]'
Search reads three tables that have to agree about a chunk — its text, its embedding, and its full-text row. When they stop agreeing nothing errors: a chunk with no embedding is simply never a vector hit, and one with no full-text row is never a keyword hit. Until now the only way to find out was to run a full index and watch it repair things. doctor asks directly, and also reports which indexed documents the MCP resource surface is holding back and why — an extension no longer in [parsers].enabled, a document larger than a resource read returns, a binary document while [index].max_decompressed_size is raised past the built-in default (binary-uris-withheld, a warning: they stay searchable, and lowering the key restores their URIs), or a size not recorded yet because it was indexed by an earlier version.
It also names the source files that were chunked by lines rather than at their definitions — because a definition sat past the nesting bound, or because the file wanted more chunks than one file may contribute. Those files are whole and searchable; what their chunks lack is the symbol kind, heading and scope a definition carries, so a query shaped like a definition cannot reach them. The remedy is the file rather than a command: an index run reaches the same bound and makes the same choice.
It also looks at the declared-field set (v1.11.0+): an index with no recorded set — a run in progress, one that was interrupted, or an index no groove index run has completed on since it was created or since it was upgraded from 1.8.0 — is reported as declared-fields-pending, because a search carrying --field is refused until a run records the set, whether or not a groove-schema.toml exists, and even when the index holds no documents yet; and a recorded set that no longer matches what the schema declares is reported as declared-fields-stale, because --field answers from the recorded set until the next run refreshes the rows. Both are warnings, and groove index clears both without re-embedding. A schema file that does not load is treated the way groove index and groove validate treat it: the command stops (exit 2) before opening the database.
On Windows it also names documents that an earlier version indexed under a name Windows cannot open as written — a reserved device name such as CON.md, a path with a segment ending in a dot or a space, or one holding a character Windows refuses in a name — as name-not-spellable-on-windows (a warning). Such names are no longer indexed, so the next groove index removes the rows; rename the files to keep them. See ADR-0023.
An index built before v1.6.0 gets a different answer first. Up to that release a file over the chunk limit was truncated, and a file whose content has not changed is never re-chunked, so such an index may still hold files whose tails are missing — with nothing on the document to find them by. doctor says so rather than reporting a clean bill: it checks whether the index recorded which chunking policy built it, and where it did not and the index holds source files, it reports that the question cannot be answered yet. groove index --force re-chunks them and the note goes away.
An index built by v1.13.0 or earlier may also hold a source file with a chunk that has no visible text: a file chunked by lines could end in a piece of nothing but blank lines, which was embedded and indexed like any other chunk. The same rule keeps it — an unchanged file is not re-chunked — so doctor reads the stored chunks and names those files as blank-code-chunks (a warning). groove index --force re-chunks them and the finding goes away.
Exit codes: 0 (nothing to report), 1 (findings), 2 (could not run — usually no index, or one that cannot be opened; the message on stderr names the file and the command that repairs it). Findings are reported, never repaired: each one names what fixes it, which is groove index or groove index --force for everything structural, and a change to the document itself where no command can.
Like
searchandeval, this opens the database, and opening it applies any pending schema migration. It is read-only about its findings, not about the file.
Optional power-user feature. groove eval takes a small file of questions with known answers, runs them through the same hybrid search the search tool uses, and reports recall@k / MRR / nDCG@k with diffs against the previous run. Useful when comparing models or tuning [quality_filter] / RRF parameters.
Regular users running groove index + groove serve do not need this — without a golden file, eval just errors with a hint and exits.
# 1) Write a golden YAML at <kb_path>/.groove-eval.yml
cat > knowledge-base/.groove-eval.yml <<'EOF'
queries:
- query: "What does the k parameter in RRF do?"
expected:
- { path: "docs/ARCHITECTURE.md", heading: "Data flow" }
- { path: "src/db.rs" } # heading omitted = file-level hit
EOF
# 2) Run against the indexed DB
groove eval --kb-path knowledge-base
# 3) Re-run after tweaking config / model to see the diff
groove eval --kb-path knowledge-base --reranker bge-v2-m3
Output: aggregate metrics + per-query rows for regressions / misses only. JSON (--format json) exposes the full per-query detail. History lives at <kb_path>/.groove-eval-history.json and keeps the last 10 runs for diff display.
If you keep notes about the evaluation inside the knowledge base being evaluated, those notes compete with the real answers. From v0.24.0 every run scans the corpus and warns on stderr when a document quotes two or more golden queries verbatim (findings in --format json) — the shape of a note written about the golden set. It reports only; the exit code is unchanged. Details, including why one match is not enough to report: docs/eval.md.
For CI: pass --fail-on-regression (v0.6.0+) to exit with code 1 when any aggregate metric (recall@k / MRR / ndcg@k) regressed from the previous fingerprint-compatible run by more than regression_threshold (default 0.05). Updating the golden YAML changes the hash, so the next run skips the comparison rather than triggering a false positive. Details: docs/eval.md.
See docs/eval.md for the golden YAML reference, metric definitions, diff output guide, and troubleshooting.
groove tune, v0.13.0+)[search.fusion] exposes the RRF constant and the bm25 column weights, but the
defaults are the industry convention and RRF is documented as requiring no
tuning. If you want evidence rather than a guess for your KB, run:
groove tune --kb-path knowledge-base
It sweeps a fixed grid against your golden query set, guards the result with
leave-one-query-out cross-validation, and prints either a paste-ready snippet
or the conclusion that the defaults should stay. It applies nothing on its own.
Note that the parameters can only move queries that reach the bm25 stage at
all, so tune opens with a pre-flight that reports the effective N and exits
2 when it is 0. See docs/eval.md.
There is no verbosity flag. Verbosity comes from the RUST_LOG environment
variable, which every subcommand reads; unset, it behaves as info.
RUST_LOG=grooveseek=debug groove serve --kb-path ./knowledge-base
RUST_LOG=grooveseek=debug groove search "query" --kb-path ./knowledge-base
RUST_LOG=debug groove index --kb-path ./knowledge-base # dependencies too, very noisy
grooveseek=debug is the useful setting: it raises this project’s own targets
and leaves the HTTP stack and the ONNX runtime at info. What it adds:
get_best_practice returning “not found” logs the paths it actually
probed. The response deliberately reports only how many templates were tried,
because the paths come from [best_practice].path_templates and would hand an
unauthenticated caller the server’s directory layout — so the operator reads
them here or not at all.Two things you might go looking for are not behind RUST_LOG at all. Which
groove.toml won is logged at info, so it is already visible without
raising anything: loaded config source=… path=… trust=… (see
docs/configuration.md). And index’s progress — the
Indexing …, ` indexed: … and Done in … lines, and its per-file
warning: lines — is written directly rather than through the logger, so it
appears at any level; the progress is controlled by –quiet / –progress`
instead, and the warnings by nothing.
Logs go to stderr on every subcommand, so raising the level never disturbs
output being piped from stdout — which matters most for serve, where over the
default stdio transport stdout is the MCP protocol and stderr is the only
place logs can go. Log wording is explicitly not part of the stable surface
(docs/stability.md): read it, do not parse it.
For a daemon registered with groove service install, the level is set where
the service is defined rather than in your shell. The systemd unit carries
Environment=RUST_LOG=info and the launchd plist an equivalent entry; edit and
restart to change them. The Windows scheduled task sets no RUST_LOG at all, so
it falls back to the same info default — raising it there means setting the
variable in the environment the task runs under.
docs/configuration.md — giving these flags defaults in groove.tomldocs/clients.md — pointing an MCP client at a running serverREADME.md — install and quick start