Retrieval and search
How Ratel ranks the catalog: BM25 keyword search over a schema-aware projection by default, semantic and hybrid ranking when you opt in, and how to choose between them.
Every registered tool and skill definition competes for the model's attention. Stack up a few dozen and each turn opens with the model reading definitions it will never call, paying in tokens and accuracy.
Ratel keeps the catalog outside the prompt and ranks it per query. The default engine is BM25 over a schema-aware text projection of each definition: it runs in your process, deterministically: no model, no vector DB, no service.
Keyword search
Two ways to consume the ranking
Both SDKs expose one retrieval engine through two patterns, and most agents run both at once:
- Pre-filter (top-K). Before each model call, a host-side search (
r.tools.search(query, topK)in TypeScript,catalog.search(query, top_k)in Python) returns the few tools most relevant to the user's message. Only those enter the tool list. - Dynamic capability search. The agent carries
search_capabilitiesandinvoke_tooland reaches the rest of the catalog itself when the pre-filtered set is not enough. See Progressive disclosure.
Whichever path a query arrives through, the ranking below is the same.
BM25 over a schema-aware projection
BM25 is the lexical algorithm behind most search engines. Ratel runs it in-process: the index is rebuilt from the registered definitions at each search call, and an empty catalog simply returns no hits.
The scorer is tuned for short documents like tool definitions (k1 = 0.9, b = 0.4; fixed, not a knob). Ties break by tool id, so the same query over the same catalog returns the same hits in the same order on any machine.
What gets indexed
BM25 does not rank raw JSON. Each tool is flattened into a schema-aware text projection (ADR 0004):
- The tool name, whole and identifier-split:
search_filesalso indexes "search files". Hyphens are not split, so prefersnake_caseorcamelCaseover kebab-case ids. - The description, verbatim.
- Every property of the input and output schemas: the property name (whole and split), its description, and each string value of its enum, recursing through nested objects and array items. Non-string enum values are skipped.
For each skill, the projection is the name, each tag, and the description. The body is excluded by design: a 15 KB runbook would drown the description's term weights.
Structural JSON Schema tokens (type, required, braces) are never indexed. The emitted text is tokenized at index and query time (lowercased, stemmed, stop words removed), so matching is not verbatim.
The consequence is the point: your vocabulary is the recall mechanism. The projection is a contract: every retrieval method ranks exactly this text, and changing the flattening is treated as a breaking change.
Write definitions the index can find
Every definition serves two readers. The index decides whether it surfaces; the model reads the description to decide when to call and the schema to decide how.
- Say what it does and when to use it. One sentence for the what, then
Use when .... The "when" clause is what separates a tool from its sibling in a shared result set. - Use the caller's vocabulary. Stemming matches "refunded" to "refund"; nothing maps "monetary adjustments" to "money back". Add distinct trigger phrasings, not repetitions: term-frequency scoring saturates fast.
- Avoid near-duplicate descriptions. Two tools described in the same words split the score and the model's confidence. Merge them, or rewrite each "when to use" until they no longer overlap.
- Name parameters and enum values like they will be searched.
arg1is invisible to the index; astatusenum of["pending", "shipped", "refunded"]adds query terms and constrains the call. - Keep schemas tight for the model. Structure is not indexed, but it stops malformed calls: type every property, list
required, setadditionalProperties: false. - Tag your skills. The body is not indexed, so tags and the description are where an intent prompt has to land. Fold in category labels ("billing") and raw task phrases ("issue a refund").
Anti-patterns:
- Paraphrase chains. "Searches files. Finds files. Locates files." Saturated terms, still no "when to use".
- Marketing tone. No caller types "seamless" or "powerful". Concrete verbs and domain nouns only.
- One-line anemia. "Refund tool." Nothing to match, nothing for the model to decide with.
- Structural noise. Restating the schema in prose, or documenting internals (transport, timeouts, id formats).
Spot-check with a host-side search — r.tools.search in TypeScript, catalog.search in Python — using realistic queries: the user's phrasing, not the tool's name. The intended tool should top each phrasing you expect to serve.
from ratel_ai import ToolCatalog
catalog = ToolCatalog()
# ...register the exact tools your agent registers in production.
for q in ["refund a customer order", "customer wants money back"]:
hits = catalog.search(q, top_k=5)
print(q, "->", [(h.tool_id, round(h.score, 2)) for h in hits])import { ratel } from "@ratel-ai/sdk";
const r = ratel();
// ...register the exact tools your agent registers in production.
for (const q of ["refund a customer order", "customer wants money back"]) {
const hits = r.tools.search(q, 5);
console.log(q, "->", hits.map((h) => `${h.toolId}: ${h.score.toFixed(2)}`));
}Skills work the same way — r.skills.search in TypeScript, SkillCatalog.search in Python; hits carry skillId / skill_id.
The ratel-tune-definitions skill in the skills suite
automates this audit: it inventories every definition, flags these failure modes,
and writes a prioritized plan of before/after rewrites.
When keyword search misses
BM25 misses the query that shares no words with the right definition: "summarize this contract" will not surface extract_document_text unless the overlap is written in. The first fix is better definitions.
When paraphrase queries keep missing, opt into Semantic and hybrid search.
Semantic and hybrid search
Better definitions are the first fix for that kind of miss, but they cannot close every gap: when the query and the right tool genuinely share no vocabulary, two opt-in methods pick up where BM25 leaves off (ADR 0011). Both rank the same indexed projection the default engine does; they only change how it is scored.
The two methods
"semantic"embeds the query with a local model and cosine-ranks it against pre-built per-tool vectors."hybrid"runs both the BM25 and semantic arms, each to a depth of at least 100, and fuses them with Reciprocal Rank Fusion (k = 60, no reranker).
RRF fuses rank positions, not raw scores. BM25's unbounded scores and cosine's [-1, 1] never need reconciling, which also means scores are not comparable across methods. Treat each as the engine's raw score for that query.
All three methods rank the same indexed projection; embeddings are computed over that text, not raw JSON.
Enable it per catalog or per call
The method is a string: "bm25" (default), "semantic", or "hybrid", chosen at catalog construction. The synchronous search is BM25-only; searchAsync / search_async runs any method and takes a per-call override, and the per-call value wins. In TypeScript, ratel({ method }) forwards the choice to both the tool and skill catalogs.
import asyncio
from ratel_ai import ToolCatalog
# Per catalog: every search defaults to hybrid.
catalog = ToolCatalog(method="hybrid")
async def main():
# register() is async and takes one tool or an iterable. On a
# semantic/hybrid catalog it embeds the batch, so await it; a
# model-load or embedding failure surfaces here as EmbedderError.
await catalog.register(tools)
# Per call: override the catalog default for one search.
return await catalog.search_async("summarize this contract", 5, method="semantic")
hits = asyncio.run(main())import { ratel } from "@ratel-ai/sdk";
// Per core: every search defaults to hybrid.
const r = ratel({ method: "hybrid" });
// register() is async and variadic — pass the whole batch in one call.
// On a semantic/hybrid core it embeds the batch, so await it; a
// model-load or embedding failure surfaces here as EmbedderError.
await r.tools.register(...tools);
// Per call: override the core default for one search.
const hits = await r.tools.searchAsync("summarize this contract", 5, "semantic");SkillCatalog takes the same method option and per-call override; skills rank in their own corpus with the same engines.
One consumer never overrides: search_capabilities always searches with the catalog's construction-time default, and Ratel Local inherits it the same way. To change the engine your agent uses, change the catalog.
The embedding cache
Semantic and hybrid rank against a pre-built embedding cache. A search only ever embeds the query, never the corpus.
A catalog constructed as semantic or hybrid keeps that cache current itself: every await register(...) embeds the new or changed tools in one pass, incrementally; adding or re-registering one tool costs one embedding, not N. There is no separate build step and no buildEmbeddings; register is the only way in, so pass a whole batch at once to embed it in a single request.
The BM25 default loads no model and holds no vectors. You cannot retrofit embeddings onto a "bm25" catalog. Construct it with "semantic" or "hybrid" up front. Recovering from a model or dimension change is the same move: build a new catalog and re-register.
The per-language walk-throughs, including the typed failure modes, live on Semantic & hybrid search (TypeScript, Python); the cache lifecycle details are on Register tools (TypeScript, Python).
A semantic or hybrid search on a catalog with no embeddings fails fast, with the message embeddings are not computed for semantic search (hint: embed the corpus before running a semantic/hybrid search). The guard loads no model, a search never silently embeds the corpus, and BM25 searches on the same catalog keep working. Model-load, endpoint, auth, and dimension failures surface from await register(...) (or await searchAsync(...)) as a typed EmbedderError carrying a stable code; a vector-width change raises its DimensionMismatchError subclass.
The embedding model
By default, opting in loads BAAI/bge-small-en-v1.5 (384 dimensions) in the SDK's own process:
- English-only.
bge-small-en-v1.5is trained on English; queries in other languages rank poorly. For multilingual retrieval, configure a multilingual embedding model. - Pure-Rust Candle inference, CPU-only. No service, no API key, no GPU.
- Pinned HuggingFace revision, so embeddings are reproducible across machines and over time.
- Downloaded on first use into the shared HuggingFace cache (
~/.cache/huggingface), then loaded offline. Nothing downloads at install time or inside a search. - Roughly 130 MB resident while loaded.
HF_HOMEmoves the cache;HF_ENDPOINTpoints the download at a mirror for proxied or air-gapped environments.- A cold load slower than 5 s emits an
embedder_loadtrace event with statusslow, a hint the machine may be underpowered for local inference.RATEL_EMBED_SLOW_MSoverrides the threshold.
The source is configurable: pass an embedding spec at catalog construction to select a different HuggingFace model, a local Candle model directory, a local Ollama model, or an OpenAI-compatible endpoint. The in-process sources stay local and keyless like the default; a configured endpoint is an out-of-process call that can be remote and require an API key. The four sources, their options, and how to switch models live on Embedding models.
Keyword vs semantic vs hybrid
- Stay on BM25 (the default) until you see missed recall. When it misses, the first fix is usually better definitions, not a different engine.
- Pick semantic when your queries genuinely paraphrase: query and tool share no vocabulary, and rewording the definitions cannot close the gap.
- Pick hybrid to keep BM25's exact-identifier precision and add semantic's paraphrase recall. If you opt in at all, hybrid is usually the safer choice.
- Semantic and hybrid pay the model's costs: a one-time download, resident memory, embedding work at register time, and possible load failures. BM25 pays none of that.
Next steps
Progressive disclosure tools
The contract the three capability tools expose to the model, and how their buckets are ranked.
Register tools (TypeScript)
Fill the catalog the ranking searches: local functions and upstream MCP servers.
Register tools (Python)
The same registration flow, idiomatic Python.
Optimize agent capabilities
Why a searchable catalog cuts tokens per turn, with the benchmark numbers.
Recall modes
Two ways the catalog's top-K reaches the model: eager recall appended by the host, or on-demand search the model runs itself. Both preserve prompt caching.
Embedding models
Configure the model behind semantic and hybrid search: a HuggingFace repo, a local checkpoint, Ollama, or any OpenAI-compatible endpoint.