GrooveSeek

Semantic search over a Markdown knowledge base, served over MCP.

View the Project on GitHub alphabet-h/grooveseek

Usage

Reference for the groove command line: index, status, serve, search, graph, validate, doctor, eval, tune, and service.

日本語版: usage.ja.md

Build / rebuild the search index

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:

Switching models on an existing index requires --force (the DB records the model/dim in index_meta and rejects mismatched runtimes).

External OpenAI-compatible embeddings

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.

Progress reporting flags (v0.7.8+)

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).

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.

Model selection trade-offs

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):

  1. 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).
  2. Every 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.

Start the MCP server

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:

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.)

When to enable reranking

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.

Registering groove as an OS service (v0.8.0+)

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:

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 running groove 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 \groove with 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.toml after install. kb_path must be an absolute path — the new daemon’s WorkingDirectory is config_home, so a relative kb_path = "./knowledge-base" will resolve to <config_home>/knowledge-base and miss the real KB. Use TOML literal strings (single quotes) to avoid Windows backslash escapes: kb_path = 'C:\Users\you\your-kb'.

Tray monitor (Windows only, v0.9.0+)

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:

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.

Show index status

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.

One-shot search from the command line

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.

Search filters and citations (v0.3.0+)

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

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.

Diversity (MMR) and parent retriever (v0.7.0+)

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):

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.

Contextual Retrieval (v0.12.0+, opt-in)

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:

Connection graph from a starting document

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:

Seeing the shape (--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”.

Validate frontmatter against a TOML schema

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:

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.

Check the index itself (v0.23.0+)

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 search and eval, this opens the database, and opening it applies any pending schema migration. It is read-only about its findings, not about the file.

Evaluate retrieval quality against a golden query set

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.

Measuring the fusion parameters (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.

Turning up the logging

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:

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.