Self-improving agent
Adaptive usage ranking feeds what your agent actually invokes back into retrieval. Experimental opt-in, off by default.
BM25 and dense retrieval score text similarity only — they have no memory. Some gaps no description rewrite can close: "why is the build broken" ranks docker_build first on the token build, even when every such query ends in gh_run_list.
Adaptive usage ranking closes that gap with real usage (ADR 0014). A search followed by an invoke becomes a weighted edge in an in-memory IntentGraph; when a later query matches one of the graph's clusters, the capabilities that history supports get a boost.
Experimental and off by default. The entry points carry an experimental prefix and may change until the capability graduates. IntentGraph and its wire form are stable protocol/v1.
How the boost works
- Matched clusters add a sub-unit RRF arm fused beside the BM25/dense arms. The arm never overrides a strong lexical/dense match — it lifts capabilities usage history supports, and cannot conjure ones the base ranker did not retrieve.
- A query that matches no cluster ranks exactly as if no graph were attached.
- Every hit now carries
rank(0-based position — order and threshold on it) andfused(whetherscoreswitched to an RRF scale). Treatscoreas a within-list hint. - Any method learns: a BM25 catalog clusters queries lexically, reaching repeats and near-repeats; a semantic or hybrid catalog clusters by meaning, at no extra embedding cost.
Turn it on
Attach a graph and the catalog learns from every search that leads to an invoke — a normal agent loop is all the training there is. Pass the same graph to your tool and skill catalogs so both learn into one set of clusters.
from ratel_ai import IntentGraph
graph = IntentGraph()
catalog.experimental_enable_adaptive_ranking(graph)
# The graph listens to each search and the invoke that follows it.
catalog.search("why is the build broken", 5)
await catalog.invoke("gh_run_list", {})import { IntentGraph } from "@ratel-ai/sdk";
const graph = new IntentGraph();
catalog.experimentalEnableAdaptiveRanking(graph);
// The graph listens to each search and the invoke that follows it.
catalog.search("why is the build broken", 5);
await catalog.invoke("gh_run_list", {});experimental_disable_adaptive_ranking() / experimentalDisableAdaptiveRanking() turns the arm off; the graph keeps what it learned.
Persist the learning
The graph lives in memory and you own storage: serialize to the protocol/v1 JSON wire form and write it wherever you keep state — file, DB, blob.
saved = graph.to_json()
# A later process: reload, attach, keep learning.
restored = IntentGraph.from_json(saved)
catalog.experimental_enable_adaptive_ranking(restored)const saved = graph.toJson();
// A later process: reload, attach, keep learning.
const restored = IntentGraph.fromJson(saved);
catalog.experimentalEnableAdaptiveRanking(restored);graph.revis a monotonic write counter. Save only when it moved since your last write, and compare it to a stored graph'srevbefore overwriting to detect a concurrent writer — single-writer is the supported model.- A saved graph contains the raw text of past queries. Treat it like your telemetry log: restrict permissions, keep it out of version control.
Changing the embedding model
Cluster centroids are tied to the model that built them. Load a graph under a different embedding model and the usage arm pauses — base ranking is untouched — rather than compare across incompatible vector spaces. (There is also a one-time stderr warning; suppress it with warn_on_model_mismatch=False / warnOnModelMismatch: false.)
catalog.experimental_adaptive_ranking_status # "paused: model mismatch"
await catalog.experimental_rebuild_intent_graph() # re-embed under the current modelcatalog.experimentalAdaptiveRankingStatus.status; // "paused: model mismatch"
await catalog.experimentalRebuildIntentGraph(); // re-embed under the current modelThe rebuild re-embeds the clusters' members; support and edges survive, only the centroids move. For zero-touch recovery, enable with rebuild_on_model_change=True ({ rebuildOnModelChange: true }): the next dense search re-embeds a paused graph before ranking. Off by default — a rebuild is an embedding pass: cost, possible failure, and a graph mutation.
Try it
examples/adaptive-ranking-python— the full loop over BM25: learn, persist, reload,rev-gated saves. No model or API key.examples/adaptive-ranking-ts— the same demo in TypeScript.
Next steps
Retrieval and search
How BM25, dense, and now the usage arm rank and fuse — and what rank and fused mean on a hit.
@ratel-ai/sdk reference
The full experimental* adaptive-ranking API and IntentGraph on the TypeScript catalogs.
ratel-ai reference
The same surface in Python: experimental_enable_adaptive_ranking and friends.