Skip to content

Retrieval modes

This page is for anyone who has to pick how groundkit searches — including if you will never type the command yourself and just need to understand the tradeoff someone else is choosing on your behalf. groundkit can find results three ways: by matching the literal words in a query, by matching what the query means, or by combining both. Which one is right depends on what you're indexing and on whether "I don't know" needs to be an answer groundkit is allowed to give. By the end of this page you'll be able to make that call yourself, without needing to run anything.

grk search takes --mode bm25 (the default), --mode dense, or --mode hybrid.

The short answer

Use hybrid if you have an embedding provider configured (a running service that turns text into a searchable representation of its meaning — setup cost below) and your users ask open-ended questions. Stay on BM25 if "I found nothing" needs to be a possible answer.

Mode What it is What it buys you What it costs Can it say "I don't know"?
BM25 (the default) Keyword matching — ranks a chunk by how many words it literally shares with your query, the scored version of a Ctrl+F search Works instantly, everywhere, on anything you can already install Nothing beyond the base install — no extra setup, no model download Yes. Returns nothing when no indexed chunk shares a term with the query
Dense Meaning-based matching — text is converted to a vector (a list of numbers standing in for its meaning) by an embedding model, so a query about "cars" can find a chunk about "automobiles" Finds relevant results that don't share any words with the query An embedding provider must be running (a local model download via Ollama, or a cloud endpoint), and your documents must be re-ingested with --dense — see below In principle, yes — but no safe default cutoff has been measured yet, so it ships off
Hybrid BM25 and dense search run together and their two ranked lists are merged into one (a technique called reciprocal rank fusion, or RRF) Combines literal-word and meaning-based matches; measured better than BM25 alone on every retrieval-quality metric Same setup as dense, plus noticeably more time per query No. Always returns its top results, however irrelevant — see the warning below

Measured against the golden corpus (a small, fixed set of documents and questions with known-correct answers — see the eval harness guide) with a real embedding model, hybrid beat the BM25 baseline on every retrieval-quality metric, with no metric regressing. No figure for that is written here on purpose — SPEC.md §2 allows a number in a document only when it is generated or live, and this one is reproducible in a single command:

uv run grk eval --dense --embed-model nomic-embed-text

Read that for what it is: a point estimate over a few dozen queries on one small corpus, not a general result about hybrid retrieval. On a query set that size, a modest delta and the particular queries in the set are not distinguishable by inspecting the number — groundkit.evals.significance is what separates them, and the eval harness guide shows how to put a confidence interval around a stage comparison rather than reading the difference alone. The tradeoff below is what should actually decide the choice for your corpus.

Why BM25 is still the default

Because the default path is not permitted to require a model server. grk works end to end with zero cloud credentials and nothing running locally, and BM25 is what makes that true. Hybrid being better on the metrics does not make it a safe default when it cannot run at all on a machine without Ollama. ADR-0007 records the decision.

The tradeoff the metrics do not show

Two costs sit outside every quality number:

Hybrid cannot abstain

BM25 returns nothing when no indexed chunk shares a term with your query. Hybrid always returns its top-k, however irrelevant, because fused scores are rank-derived and no score threshold applies to them (ADR-0005 decision 6).

Ask a question your corpus cannot answer and BM25 says nothing while hybrid answers confidently. --mode dense does honour score_threshold (a minimum relevance score below which a mode returns nothing at all — the mechanism that lets a mode say "I don't know"), but no defensible default value for it has been measured yet, so it is off.

Hybrid needs a running embedding provider and costs substantially more latency per query. BM25 needs neither.

Building a dense collection

A collection is a named, self-contained index — --collection NAME lets you keep more than one side by side, each searched independently. This is the part that catches people, so it is worth being blunt about: --mode hybrid only works against a collection ingested with --dense, and you cannot upgrade a collection in place.

uv run grk ingest ./docs                       # BM25-only collection
uv run grk search "your query" --mode hybrid   # error: no embedding-identity manifest
uv run grk ingest ./docs --dense               # "0 vectors written" — all hash-skipped

Ingestion is incremental by content hash, and that check runs before embedding — which is exactly what stops unchanged documents being re-embedded on every run, and is also why turning --dense on afterwards backfills nothing. Only documents whose content changes later ever gain vectors, so the collection stays permanently vector-less.

Do this instead, from the first ingest:

ollama pull nomic-embed-text                       # any embedding model works
uv run grk ingest ./docs --collection dense --dense
uv run grk search "your query" --collection dense --mode hybrid

To convert a collection you already have, delete it and re-ingest: remove both .groundkit/<collection>.sqlite3 and .groundkit/<collection>.lance, then run the --dense ingest above. Deleting one without the other leaves a collection whose two halves disagree.

Why the mismatch is an error

The search fails loudly rather than answering (ADR-0008). Before that change it returned BM25's ranking stamped "stage": "fusion" — lexical results labelled as hybrid, with no error and no way for a caller to tell. That silent mislabelling is the failure mode the refusal exists to prevent.

--mode bm25 is unaffected, and a dense-paired retriever may still search bm25; only the modes that actually need vectors are refused. The inverse hazard — a BM25-only ingest over a dense collection, which orphans its vectors — is refused too, per ADR-0011.

Rerank

A reranker is an optional, slower, more accurate second pass that re-scores just the top few search results before they're returned — it does not replace BM25, dense, or hybrid, it refines whichever one already ran. The optional local cross-encoder reranker reorders the best available upstream stage (ADR-0012). It runs entirely in-process — no text is transmitted anywhere to rerank it — behind the rerank extra, whose install cost is covered in Installation.

Raw cross-encoder logits are unbounded and frequently negative, so groundkit sigmoid-normalizes them before they reach a RetrievalResult, whose score contract is non-negative. That normalization is ADR-0005 decision 4, and it exists because ADR-0001 hazard 2 is the case where it was missing.

Next

Eval harness shows how the hybrid-beats-BM25 numbers referenced above are produced, and how to reproduce them on your own corpus. Deployment covers running any of these modes as a service rather than from a terminal.