Ratel Docs
Learn more

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.

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, catalog.search(query, top_k) returns the few tools most relevant to the user's message. Only those enter the tool list.
  • Dynamic capability search. The agent carries search_capabilities and invoke_tool and 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):

  1. The tool name, whole and identifier-split: search_files also indexes "search files". Hyphens are not split, so prefer snake_case or camelCase over kebab-case ids.
  2. The description, verbatim.
  3. 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. arg1 is invisible to the index; a status enum 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, set additionalProperties: 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 catalog.search 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 { ToolCatalog } from "@ratel-ai/sdk";

const catalog = new ToolCatalog();
// ...register the exact tools your agent registers in production.

for (const q of ["refund a customer order", "customer wants money back"]) {
  const hits = catalog.search(q, 5);
  console.log(q, "->", hits.map((h) => `${h.toolId}: ${h.score.toFixed(2)}`));
}

SkillCatalog.search works the same way for skills (hits carry skillId in TypeScript, skill_id in Python).

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.

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 or overridden on a single search. The per-call value wins.

from ratel_ai import ToolCatalog

# Per catalog: every search defaults to hybrid.
# register() embeds each new tool eagerly; a model-load
# failure raises at register (fail-fast).
catalog = ToolCatalog(method="hybrid")

# Per call: override the catalog default for one search.
hits = catalog.search("summarize this contract", 5, method="semantic")
import { ToolCatalog } from "@ratel-ai/sdk";

// Per catalog: every search defaults to hybrid.
// register() embeds each new tool eagerly; a model-load
// failure throws at register (fail-fast).
const catalog = new ToolCatalog({ method: "hybrid" });

// Per call: override the catalog default for one search.
// method is the 4th argument, after origin.
const hits = catalog.search("summarize this contract", 5, "direct", "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.

Build embeddings first

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 the cache current automatically: every register embeds the new tool. A BM25-default catalog that wants per-call semantic or hybrid searches must build the cache explicitly:

from ratel_ai import ToolCatalog

catalog = ToolCatalog()      # method defaults to "bm25"
# ...register tools...
catalog.build_embeddings()   # embed anything not yet embedded
hits = catalog.search("summarize this contract", 5, method="hybrid")
import { ToolCatalog } from "@ratel-ai/sdk";

const catalog = new ToolCatalog(); // method defaults to "bm25"
// ...register tools...
catalog.buildEmbeddings();         // embed anything not yet embedded
const hits = catalog.search("summarize this contract", 5, "direct", "hybrid");

The cache is incremental and keyed by id. Adding or re-registering one tool costs one embedding, not N; a call with nothing left to embed is a no-op. The per-language cache walk-throughs — including MCP-ingested tools and the skill corpus — live on Register tools (TypeScript, Python).

Re-registering a tool invalidates its cached vector. On a BM25-default catalog, run build_embeddings again before the next semantic search (an eager catalog rebuilds at register). Both methods are synchronous and fail if the model cannot load: TypeScript throws an Error, Python raises RuntimeError.

A semantic or hybrid search on a catalog whose cache does not cover every tool fails fast, with the message embeddings are not computed for semantic search (hint: construct the catalog with method="semantic"/"hybrid", or build its embeddings first). The guard loads no model, a search never silently embeds the corpus, and BM25 searches on the same catalog keep working.

The local embedding model

Opting in loads BAAI/bge-small-en-v1.5 (384 dimensions) in the SDK's own process:

  • 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_HOME moves the cache; HF_ENDPOINT points the download at a mirror for proxied or air-gapped environments.
  • A cold load slower than 5 s emits an embedder_load trace event with status slow — a hint the machine may be underpowered for local inference. RATEL_EMBED_SLOW_MS overrides the threshold.

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

On this page