# ck: two rankers, one command line, and an index you did not ask for

> ck is grep with an embedding model attached, and the interesting part is how it combines the two. A lexical engine and a vector search do not produce comparable scores, so the fusion happens by rank rather than by value, which is why the tool exposes a threshold flag at all. Everything else, from the chunk-level cache hashes to the flags it does not implement, follows from that one decision.

**BeaconBay/ck** — Local first semantic and hybrid BM25 grep / search tool for use by AI and humans! 

- Repository: https://github.com/BeaconBay/ck
- Website: https://beaconbay.github.io/ck/
- Stars: 1,734 · Forks: 74
- Language: Rust
- License: Apache-2.0
- Published: 2026-09-30 · Updated: 2026-09-30 · Language: en
- Canonical page: https://hysenlabs.com/projects/beaconbay-ck

## Two rankers that cannot be compared

The description calls this a semantic and hybrid BM25 tool, and those two halves do not produce numbers you can add together.

One half is lexical. The workspace pulls in Tantivy, which is a search engine built around BM25, the ranking function that scores a document by how often the query terms appear weighted against how rare they are in the corpus. It is the same family of algorithm ripgrep-adjacent tools approximate with literal matching, and its scores are term-frequency statistics.

The other half is embeddings. Code is split into chunks with a tree-sitter parse, each chunk is turned into a vector, and a query vector is compared against them by distance. The scores are on a completely different scale and mean something completely different: one is about lexical overlap, the other is about angular proximity in a learned space.

So how do you combine a list ordered by lexical overlap with a list ordered by angular proximity? ck uses Reciprocal Rank Fusion, which is the elegant answer: ignore the scores entirely and look at positions. Each ranker's output becomes a ranked list, and the fused score for an item is built from how highly each list placed it, not from what either system's score was. An item that both rankers put near the top wins; an item one ranker loves and the other ignored can still surface.

That decision has a visible consequence. A fused score is not a probability and not a similarity, it is a rank-derived number, and it has no natural interpretation. Which is exactly why the tool ships a threshold flag, because a fused ranking with no way to cut it off is not usable in a script:

```bash
ck --hybrid "async timeout" src/
ck --hybrid --scores "cache" src/
ck --hybrid --threshold 0.02 query
```

The second command shows the scores, which is the honest way to calibrate the third.

## grep-compatible on purpose

The compatibility claim is specific and it is a design constraint rather than a convenience feature. Same flags, same behaviour, same output format.

```bash
ck -n -A 3 -B 1 "error" src/
ck -l "error" src/
ck -L "TODO" src/
```

Case insensitivity, line numbers, context lines, listing only the files that match, listing only the files that do not. That is a literal grep surface, and the point of rebuilding it is that a tool which is not a drop-in requires you to remember when you are using which one. Being a drop-in means the semantic mode is an addition you opt into with a flag rather than a different tool you have to learn.

The reverse of that constraint is just as interesting. Grep's flag vocabulary is built around a pattern and a set of files, and semantic search does not have a pattern. So the two modes cannot be unified behind one flag without either pretending a query is a pattern or inventing syntax nobody knows. Hence three separate modes: a semantic flag, a regex path that is just grep, and a hybrid flag that fuses them.

The file filtering follows the same compatibility logic rather than inventing its own rules. The tool respects your git ignore file, plus its own ignore file, plus built-in exclusions, and all three are additive, so turning one off does not turn off the others. There are separate switches to skip either ignore file while keeping the other, and the own ignore file uses git ignore syntax including negation. That is the right call: a second exclusion syntax would be a second thing to get wrong, and negation is the part people actually use.

The built-in defaults are worth reading though, because they are not only about caches and build output. Images, video, audio, binaries and archives are excluded, and so are JSON and YAML configuration files, which the README traces to an issue about searching a repository and getting nothing back.

## Chunk-level caching, hashed on content and trivia

Semantic search over a codebase is expensive the first time and cheap afterwards, and this is where most of the engineering went.

Indexes are transparent. There is no separate index command; the first semantic search builds what it needs and later ones reuse it. But reuse at the file level would be useless, because a developer changes one function in a large file and the whole file would be re-embedded. So the cache is at chunk level, and the README claims a cache hit rate of 80 to 90 percent for typical code changes, which is the number that makes the feature worth having.

Invalidation is hash-based, and the input is described precisely: a hash over the text plus what the README calls trivia. Trivia is the parser's term for the parts of a file that carry no semantic content, so a doc comment or a reformat is part of what the cache is keyed on. That is the detail that makes the cache trustworthy, because a reformat that does not change the code should not silently reuse a stale entry, and a doc comment that changes what the code means should not reuse the old vector.

There is a third property on the list that is more subtle than the other two: model consistency, described as preventing silent embedding corruption when you switch models. This is a real and under-discussed failure mode. If your index is full of vectors produced by one embedding model and you re-run with a different one, the query vector is in a different space from every vector in the index, and the results are not obviously wrong, they are just noise. A tool that caches embeddings across runs has to notice that the model changed.

So the caching story is really three problems: detect change cheaply, avoid re-embedding unchanged work, and refuse to mix vector spaces. Any two of them are easy. All three together is the feature.

## Eight crates, one version string, and an extension outside the workspace

The Rust side is a workspace of eight crates, and the split maps onto the pipeline rather than onto features.

There is a core crate, a models crate for the data types, an embed crate, a chunk crate, an index crate, an engine crate, a CLI crate and a TUI crate. Several of them are declared with default features turned off in the central dependency block, which is the sign of a deliberate layering where the upper crates turn features on.

The version management has a comment explaining itself, which is the kind of thing worth quoting:

```toml
ck-core = { path = "ck-core", version = "0.7.11" }
ck-models = { path = "ck-models", version = "0.7.11" }
```

The comment says these are defined centrally so that a version bump touches one version field and eight lines rather than every manifest. That is a normal thing to do and worth pointing out only because it means the workspace is internally consistent at 0.7.11 by construction.

The dependencies explain the rest of the design. Tantivy for the lexical side. Tree-sitter with grammars for a dozen languages, which is what makes chunking a parse rather than a fixed-size split, and a parser-based chunk is why a match can be returned as a whole function. A memory mapping crate and a binary serialisation crate for reading the index without loading it into the heap. A filesystem locking library, which is how two ck processes avoid corrupting the same index. A work-stealing thread pool for parallel embedding, and an async runtime configured with all features rather than a chosen subset.

One structural detail that is easy to miss: there is a VS Code extension directory in the repository, and it is not one of the eight workspace members. So the editor integration is built and released separately from the search engine, which is a sensible separation and also means the extension has its own product requirements document sitting next to the main one.

And the examples directory contains a Haskell source file, text samples and PDFs. So the indexer is not code-only, which is worth knowing if you expected it to skip prose.

## The npm package downloads a binary and its version lags

There are two distribution paths and they are not equivalent, which is the sort of thing that wastes an afternoon.

The Rust path is a normal crate install, and the crate is published under a different name from the binary:

```bash
cargo install ck-search
ck --sem "error handling" src/
```

The npm path is a wrapper. The package is scoped, declares a binary entry pointing at a JavaScript file, and its entire install script runs a Node script. Its one runtime dependency is a tar library, which together tells you what is happening: the install script fetches a prebuilt binary and unpacks it. It does not compile anything.

So the npm user never compiles, and the practical consequences follow. The wrapper declares Node 18 or newer because a Node process has to sit between you and the binary. And the versions do not line up. The npm manifest is at 0.7.7 while the Rust workspace is at 0.7.11, which means an npm install can hand you a binary several patch releases behind the source tree, with nothing at install time telling you which.

That is worth checking deliberately rather than discovering later. If a bug you are chasing was fixed in the Rust source and you installed through npm, you will not have it, and the version string your binary reports is the thing to compare against the workspace rather than the npm package number.

The licence is the dual permissive pair, MIT or Apache-2.0, with both licence files at the top of the repository, and the workspace declares a minimum supported Rust version of 1.88 while using the 2024 edition.

## An MCP server hands an agent six tools and an index

The agent integration is a mode, not a plugin:

```bash
claude mcp add ck-search -s user -- ck --serve
```

That registers the process with an MCP client so an assistant can call it, with a manual JSON configuration given as the alternative for clients that need one. Six tools are exposed, and the set is worth reading in order of how much they cost you.

Two are search. A semantic search tool, a regex search tool, and a hybrid search tool, which is the same three modes the CLI has. Two are about the index: one to check its status and metadata, one to force a rebuild. And one is a health check with diagnostics.

Two observations. First, giving an agent a reindex tool means giving it a way to spend your CPU and disk on purpose, for ever, with no rate limit described. That may be exactly what you want when the agent has just changed a lot of code, and it is also a lever an agent can pull without thinking about whether it should.

Second, pagination is built in, with page size controls, cursors and snippet length management. That is not a nicety, it is the actual constraint on this kind of integration. A semantic search across an index returns ranked chunks, and there is no reason for the top results to be a bounded set that fits in a context window, so the agent has to be able to ask for a page and then ask for the next one by cursor. Without that, either the results truncate silently or the whole result set floods the conversation.

The snippet length control is the subtler half, and it is the difference between a search result that costs a thousand tokens and one that costs ten thousand, for the same answer.

## Where ck is the wrong tool

Three cases, and the first is the most common.

If you know the string you are looking for, grep is faster and exact. Semantic search on a cold index is slower, consumes disk, and can return a chunk that is conceptually related without containing what you wanted. The tool's compatibility with grep is an admission of this: the literal path is the default shape of the tool, and the semantic flags are the addition.

If you are searching configuration, be careful. The built-in ignore defaults exclude JSON and YAML files, and the README ties that to an issue about it. That is a sensible default for a code indexer, since configuration files are mostly noise, and a trap for anyone looking for a setting, an environment variable name or a value in a manifest. The result is not a wrong answer, it is no answer, which is the harder failure to notice.

If you cannot have an index on disk, the semantic modes are not available to you. They need somewhere to put the vectors, they need to read them back efficiently, and they need to write them again when a chunk changes.

On the maintenance side, the release pattern is worth a note. Three versions shipped within a couple of hours of each other on a single afternoon in May 2026, and the last push was in September. Patch releases clustered that tightly usually means a fix and its immediate follow-ups, which is a healthy pattern for a young tool, and it is also a reminder that the release history is not a useful guide to how much has changed since a given date.

## Conclusion

Adopt ck when you know the concept you are after but not the words, and keep it beside grep rather than in place of it, because the flags are identical and the semantic mode is the slow path. Read the ignore defaults before you trust a search: the indexer excludes JSON and YAML configuration files, so a query for a config value returns nothing and says nothing. Check which binary you actually have, since the npm wrapper sits at 0.7.7 while the Rust workspace is at 0.7.11 and the npm install downloads a prebuilt instead of compiling.

## FAQ

### What is ck and how does it differ from grep?

ck is grep that also searches by meaning. It keeps grep's flags, behaviour and output format, and adds a semantic mode that finds code by concept, so a query for retry logic can surface backoff code and circuit breakers that never use the words you typed. The two result sets can be combined with a hybrid mode.

### How does ck combine semantic and keyword search?

With Reciprocal Rank Fusion. The BM25 engine and the embedding search each produce a ranked list, and the fused ranking is built from each list's positions rather than from their scores, because lexical scores and vector distances are not comparable numbers. Because the fused score has no natural interpretation, ck exposes a flag to display scores and one to filter by a minimum relevance threshold.

### Does ck need to be indexed before I search?

No. Semantic and hybrid searches create and refresh their indexes transparently, so the first search builds what it needs and later ones reuse it. The cache works at chunk level with hash-based invalidation over text plus trivia, and the README claims an 80 to 90 percent cache hit rate for typical code changes.

### How do I install ck?

With `cargo install ck-search`, which builds from Rust source and requires a recent toolchain. There is also an npm package with a wrapper script whose install step downloads a prebuilt binary, so check the version your binary reports against the Rust workspace version, because the npm manifest can lag behind the source.

### Which files does ck skip when indexing?

It excludes cache directories and build artifacts by default, and respects git ignore files and its own ignore file, with all exclusion layers additive and separately disableable. The built-in defaults also skip images, video, audio, binaries and archives, and skip JSON and YAML configuration files, which means a search for a config value will return nothing.

### What tools does the ck MCP server expose to an AI agent?

Six: semantic search, regex search, hybrid search, an index status tool, a tool to force a reindex, and a health check. Results support pagination with page size controls and cursors, and snippet length is configurable so an agent can control how much of each match it reads.

## Sources

- [BeaconBay/ck on GitHub](https://github.com/BeaconBay/ck)
- [License: Apache-2.0](https://github.com/BeaconBay/ck/blob/main/LICENSE)
- [Project website](https://beaconbay.github.io/ck/)
- [README](https://github.com/BeaconBay/ck/blob/main/README.md)
- [Releases](https://github.com/BeaconBay/ck/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/beaconbay-ck
