Suppose you are working in a codebase you did not write, and you type the following question:
Where is
ERR_CONN_RESEThandled?
The answer exists. One file contains a catch block that checks for exactly that constant and retries the request.
A purely semantic search may not put that file first. It may return a connection-pool module, a general error-handling utility and a retry helper. All three are about the right topic. None of them necessarily contains the string you asked for.
Now consider a second question:
How do we stop a user from logging in too many times?
The relevant function is called throttleAuthAttempts. Its body talks about a counter and a lockout window. It never uses the words "logging in" or "too many times". A keyword search may miss it. An embedding model has a much better chance of placing it near the question.
This is the trade-off that hybrid retrieval exists to address, and it has been described well elsewhere: lexical search preserves exact identifiers, dense search preserves meaning, and neither preserves both. What is described less often is what happens after you accept that argument and actually build the system. Which BM25 parameters do you use? How does the tokenizer treat getUserById? What does a fused score of 0.03 mean to a user? Where do the chunks come from in the first place?
ctx is a useful place to look for answers, because it is small enough to read and has to make all of those decisions in code. It is a TypeScript command-line tool (published on npm as yodaman-ctx, a maintained fork of an earlier project called Context Expert) that indexes local projects into SQLite, searches them with BM25 and dense vectors fused by RRF, and answers questions through a local model served by Ollama. There is no cloud provider in the code path.
Everything below describes ctx 1.3.3; file paths are relative to the repository root. This article walks through that pipeline in the order data moves through it: chunking, embedding, the two retrievers, fusion, optional reranking, context assembly and evaluation. At each stage, the same two questions apply: what does this step preserve, and what does it give up?
1. Before Retrieval: Deciding What a Document Is
A retriever can only return the units you gave it. In code, choosing those units is most of the work.
Retrieval papers usually start with a corpus of passages. A code-search tool has to create those passages first, and the choice determines what can be found at all.
ctx routes each file by type in src/indexer/chunker/chunker.ts. Config files become a single segment. Markdown goes to a section-based extractor. TypeScript, JavaScript, Python, Go and Rust go to a tree-sitter extractor. Every other language is treated as one segment and split by size.
The tree-sitter extractor (code-extractor.ts) walks the top-level statements of the file in order. Each definition becomes its own segment: a function, class, interface, type alias, enum, or a const bound to an arrow function; in Go a func, method or type; in Rust a fn, struct, enum, trait or impl block. Each segment includes the definition's export keyword, decorators or Rust #[...] attributes, and any comments that sit directly above it with no blank line in between. Everything that is not a definition (imports, constants, module setup, a Python if __name__ == "__main__": block) is grouped with its neighbours into a module segment. The comment at the top of the file states the invariant directly: every line of the file lands in exactly one segment.
Languages whose code lives almost entirely inside one class or namespace per file, such as Java and C#, are not on the list. A top-level split would give each such file one segment, which the size-based splitter already produces.
That invariant matters more than it looks. A chunker that keeps only functions and classes would leave constants, configuration objects and module-level setup in no chunk at all, and no ranking function can retrieve text that was never indexed. Tests in src/indexer/__tests__/chunker.test.ts check the invariant for TypeScript, Python, Go and Rust files: every line appears in exactly one segment.
Markdown follows the same principle at a different granularity. markdown-extractor.ts uses the marked lexer to find headings (so a # inside a code fence is not mistaken for one) and makes each heading plus everything under it, up to the next heading, one segment. A table stays attached to its title. A heading with nothing under it is merged into the next section.
Segments that fit within a size limit are kept whole. The limits are set per content type in chunker/config.ts:
| Content type | Target size | Overlap |
|---|---|---|
| code | 512 tokens | 50 tokens |
| docs | 1,024 tokens | 100 tokens |
| config | 256 tokens | 25 tokens |
"Tokens" here is an estimate, not a tokenizer count: estimateTokens returns Math.ceil(text.length / 4). Oversized code segments are split by a recursive character splitter. Oversized documentation segments are split by a semantic chunker that embeds sentences and starts a new chunk where similarity between neighbours drops below 0.5. If that embedding call fails, the code catches the error, records a warning, and falls back to the size-based splitter instead of aborting the index.
What this step preserves: the boundaries a reader would recognise: a whole function with its doc comment, a whole section with its heading.
What it gives up: precision on very large units. A 2,000-line class is still one definition, so it will be split by size, and the four-characters-per-token estimate means the real token count of a chunk can differ from the configured limit.
2. The Dense Side: One Vector per Chunk, Scanned Exactly
ctx uses a single local embedding model and compares every vector on every query.
The default embedding model is BAAI/bge-large-en-v1.5, which the provider rewrites to Xenova/bge-large-en-v1.5 so that it can run through @huggingface/transformers in the Node process. It produces 1,024-dimensional vectors, L2-normalised. For BGE models the vector is taken from the first ([CLS]) token, which is how BAAI trained them; other models use the mean of all tokens (poolingFor in src/sdk/rag/embeddings/huggingface-provider.ts). The README states that the model is about 340 MB and is cached in ~/.ctx/models. An Ollama embedding model can be configured as a fallback, but the provider code refuses a fallback whose dimension differs from the primary model's, because the stored vectors would not be comparable.
Queries and chunks are not embedded quite the same way. BAAI's model card for the English BGE models gives an instruction to put before a search query, and only before the query: "Represent this sentence for searching relevant passages: ". The Hugging Face provider adds it in embedQuery(), which every search path uses, while chunks are embedded as they are. On the 56-question evaluation described in section 8, scored against one fixed index, the instruction raised MRR from 0.51 to 0.55 and put the expected file in the top five for 80% of questions instead of 73%; eight questions improved and four got worse.
Stored vectors and chunks depend on how they were made, and an index built one way keeps working when the code changes: it just returns worse results. ctx guards against that. Each project records the index format it was built with (index_version, defined in src/database/index-version.ts), written only when an index run finishes. Loading a project whose format is older than the current one prints a warning with the exact re-index command, and ctx check lists it. The comment above the constant states when it must change: whenever stored chunks or vectors change meaning, such as a different pooling method or a different way of splitting a language.
At query time, the vector store is not an approximate nearest-neighbour index. It is an exact scan, and it runs inside SQLite. The embeddings already sit in the chunks table as float32 blobs, and the sqlite-vec extension, loaded on every database connection, scores them in C. src/search/sqlite-vector-store.ts asks for the top k in one query:
SELECT id, content, file_path, file_type, language, start_line, end_line, metadata,
1 - vec_distance_cosine(embedding, ?) AS score
FROM chunks
WHERE project_id = ?
ORDER BY score DESC
LIMIT ?
Why an exact scan, and why in SQL, both come back to the shape of the program. ctx is a CLI: each command is a new process, so anything built in memory is built again for every search. A comment in src/search/store.ts records what an approximate index would cost on those terms: 8.3 seconds to build HNSW over 3,714 vectors, compared with 43 ms to scan 58,728. For a long-running server with millions of vectors, an approximate index is the normal choice. For a CLI that starts, answers one question and exits, the build cost dominates.
The same reasoning applies to loading vectors at all. Measured on synthetic projects with 1,024-dimensional vectors, a project is ready to search in about 5 ms and the process holds almost no extra memory, because nothing is loaded; a query takes 0.13 seconds at 25,000 chunks and 0.97 seconds at 200,000. Loading the same vectors into memory first took 0.33 seconds and 0.2 GB at 25,000 chunks, and between 2.6 and 7.8 seconds and 1.1 GB at 200,000, before a faster scan. A long ctx chat session, which asks many questions in one process, pays a little more per question this way; a single search pays much less. Tests in src/search/__tests__/sqlite-vector-store.test.ts check that the SQL scan returns exactly what the in-memory scan returns.
sqlite-vec ships prebuilt binaries for Linux and macOS on x64 and arm64 and for Windows on x64. Where it cannot load, store.ts falls back to loading a project's vectors into memory, in batches paged by rowid > ? rather than OFFSET so each page is an index seek, and scanning them there. In both cases, a stored vector whose dimension differs from the project's stops the search with an error that tells the user to re-index with --force, rather than comparing incompatible vectors.
What this step preserves: paraphrase and intent. A question about "logging in too many times" can land near throttleAuthAttempts.
What it gives up: any explicit guarantee that a rare exact string will dominate. The identifier has to compete with everything else in the chunk for influence over a single 1,024-dimensional vector.
3. The Lexical Side: BM25 Inside SQLite
The formula is the textbook one. The interesting decisions are in the tokenizer, and in where the index lives.
Where the keyword index lives matters as much as the formula. ctx is a CLI: each command is a new process. An inverted index built in memory would have to be rebuilt from every chunk of a project at the start of every search. So ctx keeps its keyword index on disk too, in SQLite's FTS5 full-text extension, which ships with the better-sqlite3 package it already uses. Migration 008 in src/database/migrate.ts creates the index and three triggers on the chunks table:
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
tokens,
content = '',
contentless_delete = 1,
tokenize = 'unicode61 remove_diacritics 0'
);
CREATE TRIGGER IF NOT EXISTS chunks_fts_insert AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts (rowid, tokens) VALUES (new.rowid, ctx_tokens(new.content));
END;
The index is "contentless": it stores the tokens' positions but not the text, which stays in chunks. The triggers fire on every insert, update and delete, including the delete-then-insert swap ctx uses when re-indexing a project, so no code path has to remember to update the keyword index. The migration also fills the index from any chunks already stored.
Searching is one query in src/search/bm25-store.ts: match any query token, join back to chunks for the project and any file-type or language filter, and order by FTS5's bm25() function. FTS5 uses the common defaults, k1 = 1.2 and b = 0.75, with the standard saturated, length-normalised term frequency. The result is divided by the top score so the best keyword hit is 1.0. Here, a "document" is a chunk.
Two properties of FTS5's scoring are worth knowing. First, it counts how rare a term is (its IDF) over every chunk in the index, not only the project being searched; with several projects indexed, a word common in one project but rare overall weighs more than it would within that project alone. Second, its IDF is log((N − n + 0.5) / (n + 0.5)), with a zero or negative value replaced by 0.000001. Some BM25 implementations add 1 inside the logarithm so every term keeps a small positive weight; FTS5 does not, so a term found in half or more of all chunks adds almost nothing.
Measured on the same synthetic projects, nothing is built when a search starts. A keyword query took between 3 and 9 ms at 25,000 chunks and between 60 and 198 ms at 200,000 across two runs, and indexing pays about 0.3 ms per chunk for the trigger, small next to computing the chunk's embedding.
There is one caveat, which a comment in the migration states: the index is keyed on SQLite's internal row IDs, and a manual VACUUM can renumber those. ctx never runs one.
The part that most affects code search is not the formula but the tokenizer, in src/sdk/rag/retrieval/bm25.ts:
const CAMEL_BOUNDARY = /(?<=\p{Ll})(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})/u;
for (const word of text.split(/[^\p{L}\p{N}]+/u)) {
const parts = word.split(CAMEL_BOUNDARY);
if (parts.length > 1) tokens.push(word.toLowerCase());
for (const part of parts) tokens.push(part.toLowerCase());
}
return tokens.filter((token) => token.length >= 2);
Every run of characters that is not a Unicode letter or digit becomes a separator. A camelCase word is kept whole and also split at its case boundaries. Tokens shorter than two characters are dropped. FTS5 has its own tokenizer, but it does not split camelCase, so ctx registers this function with SQLite as ctx_tokens() (in src/database/connection.ts). The triggers store its output, and the same function tokenizes each query, so the index and the query always split text the same way. Running it on a few identifiers shows what it does:
| Input | Tokens |
|---|---|
ERR_CONN_RESET |
err, conn, reset
|
ZX-4821 |
zx, 4821
|
getUserById |
getuserbyid, get, user, by, id
|
HTTPServer |
httpserver, http, server
|
get user by id |
get, user, by, id
|
Keeping both forms serves two kinds of query. A query that contains getUserById verbatim still matches the whole-word token, which is rare and so carries a high IDF. That is exactly what BM25 is for. A query that says "get user by id" in plain words matches the parts. A tokenizer that kept camelCase as one token would give that second query no lexical match on the identifier at all.
Other limits remain. ZX-4821 becomes two tokens, so a chunk containing ZX-4812 still matches on zx, and only the numeric token distinguishes the two. The parts of a camelCase word are also common words (get, user, id), so their IDF is low; with FTS5's formula, a part that appears in half or more of the chunks adds almost nothing, and the parts help a chunk's score rather than deciding it. It is still worth running a retriever's tokenizer on your own identifiers before assuming what "exact match" means.
What this step preserves: exact rare strings (constants, error codes, function names typed verbatim) and, through the split parts, a lexical link between an identifier and the words it is made of.
What it gives up: any connection between words that do not share a token, such as "authenticate" and checkLogin.
4. Fusion: RRF, Then a Rescale So the Number Means Something
Reciprocal Rank Fusion solves the incompatible-scores problem. It creates a readability problem of its own.
FusionService.search in src/search/fusion.ts runs the dense and BM25 searches in parallel with Promise.all, then fuses the two ranked lists. The underlying function is the standard one, with k = 60:
$$
\text{RRF}(d) = \sum_{r \in R} \frac{1}{60 + \text{rank}_r(d)}
$$
A chunk that is missing from one list receives zero from that list. The implementation also records each retriever's contribution, so a fused result carries a record of which list it came from and at what rank.
Raw RRF scores are small. A chunk ranked first by both retrievers scores 2/61 ≈ 0.033. Shown to a user, a value near 0.03 looks like a poor match, and any minScore threshold on a 0 to 1 scale would remove every result. fusion.ts rescales in three lines:
// Raw RRF tops out near 0.03 (2/61), which read as a poor match and made
// any 0-1 minScore drop every result; divide by the best possible score.
const bestPossible = 2 / (k + 1);
After this division, 1.0 means "ranked first by both", and about 0.5 means "ranked first by one and absent from the other". The order of results does not change; only the scale does. Applying it to a small example:
| Chunk | BM25 rank | Dense rank | Scaled score |
|---|---|---|---|
A: the catch block for ERR_CONN_RESET
|
2 | 3 | 0.976 |
B: a log message mentioning ERR_CONN_RESET
|
1 | 8 | 0.949 |
| C: a general retry helper | not ranked | 1 | 0.500 |
Chunk A is first in neither list but first after fusion, because both retrievers placed it near the top. This is the behaviour hybrid search is designed for.
There is one practical constraint on this example. Each retriever is called with the same topK as the final result count. ctx search defaults to 10, so a dense rank of 8 is inside the list. ctx ask uses final_k, which defaults to 5, so each retriever contributes at most five candidates and chunk B's dense rank of 8 would not be seen at all. In that case, B would score 1/61 ÷ 2/61 = 0.5. The candidate depth of each retriever directly bounds how much agreement fusion can detect.
It is tempting to conclude that deeper lists are always better. A comment in fusion.ts records a test that points the other way. Fetching 50 candidates from each retriever lost an expected file on the project's eight-question fixture, with recall falling from 0.94 to 0.88. With k = 60, a chunk ranked seventh by both retrievers scores 2/67 ≈ 0.030, while a chunk ranked first by one and absent from the other scores 1/61 ≈ 0.016. When the lists are deep enough to contain most of a small corpus, middling agreement can outrank a strong single signal. The fixture is too small to settle the question for large projects, so the code keeps candidate depth equal to topK.
minScore is applied only to the fused score. The retrievers do not receive it, because their own scores are on other scales: raw cosine similarity for the dense search, and a score scaled to the best hit for BM25. Both retrievers count equally in the fusion.
One adjustment is made after it. Documentation describes features in plain words, so a Markdown section about a feature often matches a question about that feature about as well as the code does, and in a mixed repository it can push the code out of the top results. fusion.ts therefore multiplies the fused score of documentation chunks by DOCS_WEIGHT, 0.9. Because fused scores sit close together (ranked first by one retriever: 0.5; ranked fifth: 0.47), the weight mostly decides near-ties in favour of code, and its exact value matters little. On the 56-question evaluation, every weight from 0.6 to 0.95 produced the same ranking, improving 12 questions and worsening none, the documentation questions included. A chunk of documentation that both retrievers rank highly still beats code that only one of them found, and a project that contains only documentation is unaffected, because every chunk is scaled alike.
What this step preserves: agreement. A chunk supported by both kinds of evidence rises above a chunk supported by only one.
What it gives up: the magnitude of the original scores. A BM25 match that is far stronger than everything else and a BM25 match that barely beats second place both count as rank 1. And in a repository of code and documentation, a documentation chunk needs slightly stronger agreement than a code chunk to rank above it.
5. The Optional Reranker
A cross-encoder is often presented as a free improvement. In ctx it is opt-in.
ctx includes a cross-encoder reranker (src/search/reranker.ts), using Xenova/bge-reranker-base. When enabled, it takes up to 50 fused candidates, scores each query–document pair, and returns the top k. In practice the limit is lower: each retriever contributes topK results, so the reranker sees at most 20 candidates at the default of 10. If the model cannot load, the code warns once and returns the results in their fused order rather than failing the search.
It is not enabled by default. It runs only with ctx search --rerank; ask and chat do not rerank.
The README gives the reason in the option's description: in testing, the reranker ranked tables and code lower. A cross-encoder trained mostly on prose judges prose well, and a code-search tool's content is largely not prose. That does not make cross-encoders a poor choice in general. It shows that a stage that improves results on one kind of content can make them worse on another, and that the way to know which applies is to measure on your own content, which section 8's evaluation makes possible.
6. From Results to Prompt: Deduplicate, Order, Budget
Retrieval ends with a ranked list. The model sees something else.
For ctx ask, retrieved chunks pass through an assembler (src/sdk/rag/assembly/base-assembler.ts) before reaching the model. The steps run in a fixed order:
- Keep the top
final_kresults (default 5). - Remove near-duplicates: a chunk whose word set has Jaccard similarity of 0.8 or more with an already-kept chunk is dropped.
- Include chunks in relevance order until the token budget (default 4,000) is used.
- Reorder the chunks that fit with the configured strategy (default:
sandwich). - Format as XML, with each
<source>carrying its file path and line range.
The sandwich ordering is a response to the observation that models tend to use information at the start and end of a long context more reliably than information in the middle. With n results and the default start count of ⌈n/2⌉, the top half goes first and the remainder goes at the end in reverse order. For five results, the order becomes 1, 2, 3, 5, 4.
The order of steps 3 and 4 matters. If the budget ran after ordering, the chunks it considered last would be those at the end of the sandwich, the positions the ordering reserves for the fourth- and fifth-ranked results, so a tight budget would cut those first while the least relevant chunks in the middle survived. Running the budget first, in relevance order, drops the least relevant chunks, and the sandwich is built from what remains. A test in src/agent/__tests__/assembler.test.ts covers this: with room for four of five chunks, the result is 1, 2, 4, 3; budgeting after ordering would keep 1, 2, 3, 5.
The budget loop still includes any chunk that still fits rather than stopping at the first that does not, so a short, lower-ranked chunk can be included after a long one is skipped. With five documentation chunks near the 1,024-token limit, a 4,000-token budget holds three of them.
It is worth inspecting the final context rather than only the ranked list, because stages like these interact. ctx makes that straightforward: ctx ask --context-only --json returns the assembled context without calling the model.
7. Which Project? Routing Without a Model, Mostly
Searching every indexed project is the safe default until there are a dozen of them.
ctx can index many projects and search one or all of them. In ctx chat, a question first goes through QueryIntentClassifier in src/agent/query-router.ts, which uses string matching and no model call. Its rules, in order:
| Condition | Intent | Confidence |
|---|---|---|
| Two or more project names mentioned | multi-project | 0.95 |
| A multi-project keyword (e.g. "compare", "across") and at least one project name | multi-project | 0.85 |
| Exactly one project name mentioned | single project | 0.9 |
| No name, but a phrase like "this project" or "this repo" | single project | 0.85 |
| A multi-project keyword with no project name | general | 0.6 |
| Anything else | general | 0.5 |
A comment in the code explains the second-to-last rule: "compare error handling" with no project named is deliberately not classified as multi-project, to avoid false positives.
What happens to a "general" question depends on state. If a project is focused (with /focus), the question stays in that project. Only when nothing is focused and at least two projects exist does the router ask the local model, with a 5-second timeout and a confidence threshold of 0.7; if the call fails or the confidence is too low, it searches every project. The order is deliberate. A local model can easily exceed a 5-second limit, and the fallback, searching every project, grows with the number of projects; with many of them it can run past the agent's 30-second tool limit and leave an answer with no sources. Staying in the focused project avoids both.
When multiple projects are searched, the multi-project service applies RRF at two levels: the dense and BM25 managers each merge results across projects, and the service then fuses the dense and BM25 lists.
8. Measuring Retrieval Separately from Answers
If the right chunk never enters the context, no prompt can recover it.
ctx ships an evaluation command, ctx eval, with metrics implemented directly in src/eval/metrics.ts: reciprocal rank, precision@k, recall@k, hit rate, NDCG@k with binary relevance, and average precision. Each golden entry pairs a question with the file paths expected in the results:
{
"id": "fixture-002",
"query": "Where is the JWT token verified?",
"expectedFilePaths": ["src/auth/middleware.ts"],
"tags": ["auth"],
"source": "manual"
}
The repository holds two golden sets in fixtures/eval/, and they do different jobs.
The first is a small fixture project (eight source files) with eight queries. A GitHub Actions workflow runs it on pull requests that touch search, indexing, agent or eval code, and weekly on a schedule, using a mock search function; scripts/check-eval-thresholds.js fails the build if MRR falls below 0.6, precision@k below 0.5, or hit rate below 0.8. It checks that the evaluation machinery works, but it says little about search quality: with the real model it scores an MRR of 0.94, close to its ceiling, so most changes leave its scores exactly where they were.
The second is ctx-source-golden.json: 56 questions about ctx's own repository, mixing exact identifiers ("Where is computeRRF defined?"), paraphrases ("How are near-duplicate chunks removed before the context is assembled?") and documentation questions. Each expected file is stored with a pattern it must contain, the code or text that answers the question, and a test fails if a file is renamed, deleted or no longer contains its answer, so the set cannot quietly drift out of date as the code changes. scripts/eval-source.ts indexes the repository (180 files, 1,529 chunks) with the real embedding model, in a temporary home folder, and scores it.
It scores an MRR of 0.60, with the expected file in the top five for 86% of questions (recall 0.80, NDCG 0.63). That leaves room to show changes in either direction, and the breakdown by area of the code shows where search is still weak:
| Questions about | Questions | MRR | Hit rate (top 5) |
|---|---|---|---|
| Database | 4 | 1.00 | 1.00 |
| Embeddings | 3 | 1.00 | 1.00 |
| Documentation | 8 | 0.72 | 0.88 |
| Evaluation | 5 | 0.67 | 1.00 |
| Search | 11 | 0.64 | 1.00 |
| Indexing | 9 | 0.56 | 0.67 |
| CLI | 9 | 0.49 | 0.89 |
| Agent | 4 | 0.19 | 0.50 |
| All | 56 | 0.60 | 0.86 |
A question can belong to more than one area, and some areas have only three or four questions, so the rows are indications rather than measurements. The eight questions that miss the top five share a pattern: the results usually come from the right module but the wrong file. Asked for the largest file that gets indexed, search returns the chunker's types.ts rather than its config.ts; asked what happens when Ollama fails mid-answer, it returns the provider's errors.ts and types.ts rather than response-mapper.ts. Type and error definitions share their vocabulary with the code that uses them, and file-level judging counts those near misses as complete misses. None of this was visible in the eight-question fixture.
Both sets judge relevance at the file level, so a result counts as relevant if it comes from the right file even if it is the wrong chunk of that file. And 56 questions are still few: a change of one or two questions, about 0.02 MRR, is within what a single reordering can cause, which is why the guide in docs/eval-observability.md suggests looking at which questions changed rather than only at the totals.
Conclusion: The Algorithm Is the Short Part
The core of ctx's retrieval is short. RRF is a loop over two lists. BM25 is a function SQLite already provides, over an index it keeps on disk. Cosine similarity is a function a SQLite extension provides, over vectors that are already stored. The original argument for hybrid search, that lexical and semantic retrieval preserve different evidence, is fully visible in a few hundred lines.
Most of the remaining code is about everything around those lines: which units get indexed, how identifiers are tokenised, where an index lives and when it is built, what a score of 0.03 means to a person reading it, how many candidates each retriever contributes, whether a reranker helps on this kind of content, what survives a token budget, and whether an old index still matches the code that reads it. Little of it is about ranking. Much of it exists so that the right text is indexed, a score means what it appears to mean, and no stage silently does nothing.
That is the practical lesson from reading this codebase. Choosing hybrid search is a decision that takes a paragraph to justify. Making it work takes attention to the steps before and after it.
The Practical Takeaway
If you are building retrieval over source code, a few checks follow directly from the code above:
- Check coverage before ranking. Confirm that every line of a file ends up in some chunk. Constants and module-level code are easy to lose with definition-only chunking.
- Run your tokenizer on your identifiers. Look at what happens to camelCase, snake_case and hyphenated codes. "Exact match" means whatever the tokenizer says it means.
- Look at candidate depth, and measure before changing it. RRF can only reward agreement that appears inside both lists. If each retriever returns five results, a document ranked sixth by one of them gets no credit for it. Deeper is not automatically better, though: deep lists let middling agreement outrank a strong single signal.
- Build indexes once, at index time, and search where the data is. Anything loaded or rebuilt when each process starts costs little in a long-running server and dominates a CLI. Both halves of a hybrid search can run in the database you already have: SQLite's FTS5 provides BM25, and sqlite-vec scores stored vectors.
- Read the embedding model's card. Some retrieval models expect an instruction before queries and none before passages. Using the model the way it was trained is the cheapest improvement there is, but it still needs measuring.
- Record what an index was built with. When chunking or embedding changes, old indexes keep working and quietly get worse. A stored version number turns that into a warning.
- Make scores readable. If users or downstream code filter by score, rescale RRF output so its range has a meaning.
- Measure optional stages on your own content. A reranker, a router, an approximate index or a weight on one kind of document is a trade-off, not a guaranteed improvement.
- Inspect the final context, not just the ranking. Ordering and token budgets change which chunks the model actually sees.
ctx is MIT-licensed and available at github.com/Yoda-Man/ctx. The files referenced in this article are under src/search/, src/indexer/chunker/, src/database/, src/sdk/rag/, src/eval/ and fixtures/eval/.
References
Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 3(4), 333–389. https://doi.org/10.1561/1500000019
Cormack, G. V., Clarke, C. L. A., & Büttcher, S. (2009). Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods. Proceedings of SIGIR 2009, 758–759. https://doi.org/10.1145/1571941.1572114
Liu, N. F., Lin, K., Hewitt, J., Paranjape, A., Bevilacqua, M., Petroni, F., & Liang, P. (2024). Lost in the Middle: How Language Models Use Long Contexts. Transactions of the Association for Computational Linguistics, 12, 157–173. https://doi.org/10.1162/tacl_a_00638
Xiao, S., Liu, Z., Zhang, P., Muennighoff, N., Lian, D., & Nie, J.-Y. (2024). C-Pack: Packed Resources For General Chinese Embeddings. Proceedings of SIGIR 2024. arXiv:2309.07597. (The paper introducing the BGE embedding models.)
SQLite. SQLite FTS5 Extension, section 5.1.1, "The bm25() function". https://www.sqlite.org/fts5.html. The IDF formula and its 1e-6 floor are in the source, ext/fts5/fts5_aux.c (function fts5Bm25GetData). https://github.com/sqlite/sqlite/blob/master/ext/fts5/fts5_aux.c
Yoda-Man. ctx source code, version 1.3.3. https://github.com/Yoda-Man/ctx
Top comments (0)