Retrieval
kontext searches locally with BM25 and merges whatever connected adapters return.
The local index
A Tantivy index per worktree, derived from three sources:
| Source | Documents | URI |
|---|---|---|
| Store entries | one per entry: title, body, tags, paths, id | kx:<id> |
| Repository docs | Markdown matching sources.include (root *.md, every README.md, AGENTS.md, CLAUDE.md, docs/**), split at ## headings | file:<path> or file:<path>#<section> |
| Commit history | subject, body and trailers of the last index.max_commits commits (5000); file names are searchable but kept out of snippets | git:<sha> |
Noise is filtered: "Merge branch …" commits, Co-authored-by/Signed-off-by lines, files over sources.max_bytes, and anything that looks like a secrets file.
The index refreshes incrementally before a query (entries and docs by modification time and size, commits by SHA), at most once per second per process. When another process refreshed it (a CLI capture, kontext status, a second agent session in the same worktree), a long-running MCP server notices the changed manifest and reloads its reader before answering. A new schema version is built in a temporary directory and renamed into place, never by deleting a directory another process is building. kontext reindex rebuilds it from scratch.
Identifiers
Code-shaped words are expanded so both forms match: orderTotal also indexes order total, user_account_balances indexes user account balances. A query for an identifier additionally searches its words as a phrase, so RunContext finds "run context" without matching every document that says "run".
Folding and stemming
Two shadow fields, title_f and body_f, hold the same text lower-cased, ASCII-folded and run through an English stemmer. They are searched beside the exact fields at a lower boost, so namietka finds námietka, adapter finds "Adapters are configuration", and an exact identifier still ranks first. There is no stemmer for Slovak; folding covers diacritics, not inflection.
Ranking
BM25 over title (boost 2.5), identifier-expanded auxiliary text (1.5), body, and the folded shadow fields at a lower boost. The whole query also counts as a phrase (boost 2): it ranks an entry with the exact wording first without adding hits of its own. Local results have weight 1.0; adapters default to 0.9 (weight per adapter).
Within the local source, each source has a weight before the groups are fused: entries 1.0, doc chunks 0.7, commits 0.6, a nested store's entries 0.95, so a reviewed entry ranks above a doc chunk or a commit at equal rank. The weights are built in ([search] keys for them are planned) and were set on the graded eval set (tests/eval/qrels.tsv, nDCG@5, recall@10, MRR); a change to ranking needs that measurement. A superseded, deprecated or rejected hit is weighed down by half, never hidden, and its status shows the successor: superseded · → superseded by [<id>] <Title>. The phrase bonus is computed from the cleaned query words, without the identifier expansions. A query that is exactly an entry id or a path takes a shortcut to that entry.
Federated search
ctx_search sends the query to every adapter with a search op, in parallel, with a 12-second deadline. With [search] adapters = "fallback" it does so only when the local sources found fewer than min_local_hits hits, or when the caller names an adapter or passes adapters: true; the default, always, asks them every time. Sources are fused by weighted reciprocal rank (each hit scores weight / (8 + rank)), so no source's score scale (BM25 here, cosine similarity there) crowds the others out, and a single source keeps its own order.
Only proven copies are dropped: a hit with a URI seen before, an adapter's copy of a local entry (a synced …/<id>.md), and a hit whose whole normalized title and text equal an earlier one. Of two such copies, a superseded one is never kept over its active successor. Adapter hits are ordered by their mapped score, not by the order of the response's buckets; hits from a nested store carry its marks. Two claims with the same opening are not the same claim: a hit that only starts like an earlier one is kept and shown under it with the part that differs (↳ similar, differs: …). A slow or failing adapter never blocks the answer: it is reported in a note and cools down for 30 seconds.
Restrict a search with kinds (decision, convention, learning, incident, architecture, doc, commit) and sources (local and/or adapter names).
ctx_why
"Why is this like this?" combines several signals, depending on the target:
| Target | Answer |
|---|---|
path or directory | knowledge whose paths cover it, the module summary, decision-shaped commits among the last 60 touching it (plus the latest three), history adapters |
path:line / path:start-end | the same, plus the commits that last changed those lines (git blame) |
| commit sha | message, the knowledge it relates to (via commits or paths), changed files |
| symbol or topic | code adapters locate it (CodeGraph, Serena), then local knowledge and history, then history adapters |
Commits are ranked by decision signals (wording such as replace, migrate, instead of, because; Conventional-Commit type; breaking changes; explanation length) and by kontext trailers (Decision: …).
ctx_log
The decision timeline: date, id, title, status, supersession and the commit that added the file. kontext log --json for scripts.