SQL Primitives
Predictive Models
Train scikit-learn models from a SELECT, serve them as operators, and evaluate them — all from SQL.A trained model in RVBBIT is just another operator with a receipt. You define it
from a SELECT, a worker fits it, and the result is a predict_<model>(row jsonb)
function you can call from ordinary SQL. The same shape that backs
semantic functions and
capability packs also backs classical supervised
models: tabular classifiers and regressors trained with scikit-learn.
Nothing here is UI-only. Every step below is a SQL call you can run from psql
or DataGrip. Model Studio in Data Rabbit is observability sugar on top of these
exact statements.
Train From A SELECT#
train_model registers a model and queues a training run. The training data is
whatever your query returns: the listed feature columns plus the target column.
SELECT rvbbit.train_model(
model_name => 'churn_risk',
source_sql => $$
SELECT tenure_months, monthly_charges, plan_tier, region,
support_tickets, churned AS label
FROM analytics.customers
WHERE signup_at < now() - interval '90 days'
$$,
target_column => 'label',
task => 'classification',
feature_schema => $$[
{"name":"tenure_months","type":"float8"},
{"name":"monthly_charges","type":"float8"},
{"name":"plan_tier","type":"text"},
{"name":"region","type":"text"},
{"name":"support_tickets","type":"float8"}
]$$::jsonb,
training_opts => $$ {"estimator":"random_forest","n_estimators":200,"test_size":0.25} $$::jsonb,
description => 'Predict 90-day churn from account features.'
);
train_model returns the run id. It does not block on training — it enqueues a
run into rvbbit.ml_training_runs and returns immediately.
Numeric columns (float8, int) are treated as numeric features; text
columns are one-hot encoded. task is classification or regression (the
schema also reserves forecasting, anomaly, and others for future workers).
training_opts.estimator accepts random_forest (default), extra_trees,
logistic (classification), or ridge (regression).
Not sure of the schema? Let RVBBIT infer it, and pre-flight the query first:
SELECT rvbbit.validate_training_sql(
'SELECT tenure_months, plan_tier, churned AS label FROM analytics.customers',
'label');
SELECT rvbbit.infer_feature_schema(
'SELECT tenure_months, plan_tier, churned AS label FROM analytics.customers',
'label');
The Trainer Worker#
Training is done by a SQL-bound worker, not inside the backend. The worker claims
a queued run with SKIP LOCKED, fits the model with scikit-learn, registers a
serving backend and a predict_<model> operator, then marks the run complete.
rvbbit-trainer watch --include-unmanaged --serve-local --serve-host db-host
watch polls for queued runs, fits each one, and (--serve-local) launches the
serving sidecar so predictions go live without any further steps. By default it
only claims managed runs (see below); --include-unmanaged tells it to also pick
up plain train_model runs. To process one specific run instead of polling, use
rvbbit-trainer train-run <run_id>.
You can watch a run move through the queue entirely in SQL:
SELECT name, status, latest_run_status, operator_name, trained_at
FROM rvbbit.ml_model_status
WHERE name = 'churn_risk';
Predict#
Once the worker registers the model, calling it is one function. The operator
takes a jsonb row of features and returns a jsonb result.
SELECT rvbbit.predict_churn_risk(jsonb_build_object(
'tenure_months', 4,
'monthly_charges', 89.0,
'plan_tier', 'pro',
'region', 'us-west',
'support_tickets', 3
));
-- {"label":"churned","score":0.78,"scores":[...],"prediction":"churned"}
Score a whole table by building the feature row per record:
SELECT id,
rvbbit.predict_churn_risk(jsonb_build_object(
'tenure_months', tenure_months,
'monthly_charges', monthly_charges,
'plan_tier', plan_tier,
'region', region,
'support_tickets', support_tickets)) ->> 'label' AS predicted
FROM analytics.customers
WHERE churned IS NULL;
A regression model returns a numeric value instead of a label. Like every
operator, each prediction is recorded in receipts — and
reused when the same input row recurs — so scoring is auditable and idempotent.
Evaluate#
evaluate_model runs the predict operator over a labeled query and records the
result as a first-class, re-runnable evaluation. Classification yields accuracy
and a confusion matrix; regression yields RMSE, MAE, R², and residuals.
SELECT rvbbit.evaluate_model(
model_name => 'churn_risk',
eval_sql => $$
SELECT tenure_months, monthly_charges, plan_tier, region,
support_tickets, churned AS label
FROM analytics.customers
WHERE signup_at >= now() - interval '30 days' -- held-out, recent accounts
$$,
label_column => 'label',
eval_name => 'churn_last_30d'
);
SELECT n_rows, metrics
FROM rvbbit.ml_evaluations
WHERE model_name = 'churn_risk'
ORDER BY created_at DESC
LIMIT 1;
Evaluate on data the model has not seen. Scoring the training rows will look perfect because the model has memorized them — the honest signal comes from a holdout. The confusion matrix is often more informative than the headline accuracy: a model that just predicts the majority class will post a respectable accuracy while the matrix shows every row landing in one column.
Managed Training (No Terminal Required)#
The basic path above needs a worker process to fit each queued run. The managed
path removes that step from your hands: an operator deploys the standing
Warren agent once, as a service (like Postgres itself), and from
then on you just run one SQL statement per model. The same always-on daemon that
deploys serving sidecars claims the job, trains the model, serves it, and
registers the predict_<model> operator. There is no per-model command to run in
a terminal.
train_model_managed enqueues the run and a Warren model_training job with
a target selector. An agent only claims a job its node is eligible for: the
selector is matched against the node's labels, so a job aimed at a GPU box is
picked up by a GPU node and nothing else.
SELECT rvbbit.train_model_managed(
model_name => 'churn_risk',
source_sql => $$ SELECT ... FROM analytics.customers $$,
target_column => 'label',
task => 'classification',
deploy => true, -- also stand up serving
target => '{"gpu": true}'::jsonb -- placement: a GPU node
);
That returns immediately with the run and job ids. Watch the whole lifecycle — queued, which node claimed it, training, completed, active — from SQL:
-- the full queue: each run with its Warren placement and serving state
-- (the view also exposes task, job_phase, run_id, operator_name, metrics, ...)
SELECT model_name, run_status, job_status, node, deploy
FROM rvbbit.training_queue
ORDER BY created_at DESC;
-- the quick "is it ready?" for one model
SELECT model_status, run_status, job_status, node, operator_name
FROM rvbbit.training_status('churn_risk');
When training_status shows model_status = active, the predict_churn_risk
operator is live — no different from a model you trained by hand.
The operator that runs this is the Warren agent (warren-agent); an operator
starts it once as a service, exactly like Postgres itself. Running the basic
train_model + rvbbit-trainer flow by hand is still supported — it's the
"bring your own worker" option — but managed training is the SQL-only default.
To deploy serving for an already-trained model, or to move it to a new host:
SELECT rvbbit.deploy_model_serving('churn_risk', '{"host":"gpu-1"}'::jsonb);
Training-Free Tabular Prediction#
Sometimes you do not want to train and register a model at all — you have a small
labeled support set and want predictions for new rows right now. predict_tabular
fits in context against a foundation operator and returns predictions in one call.
SELECT rvbbit.predict_tabular(
support_sql => $$ SELECT feat_a, feat_b, label FROM training_examples $$,
predict_sql => $$ SELECT id, feat_a, feat_b FROM rows_to_score $$,
target_column => 'label',
task => 'classification',
dry_run => true -- inspect the assembled bundle without calling the backend
);
predict_tabular returns a single jsonb result ({"n_queries":…,"predictions":[…]}),
or, with dry_run => true, a preview of the assembled bundle
({"n_support":…,"n_queries":…,"task":…,"target":…,"sample_support":…,"sample_query":…})
and no backend call. It routes through a tabular foundation
capability pack (default operator
predict_tabular_foundation) rather than producing a persistent operator, so the
live path requires that a tabular_foundation capability is deployed — without one
the call raises. It is the right tool for small, ad-hoc problems; train_model is
better when you want a named, versioned, re-evaluable model.
The shipped tabpfn-foundation pack is an example-visibility CPU reference
implementation of the TabPFN request contract, not a GPU TabPFN forward pass; treat
training-free prediction as a working contract whose high-accuracy foundation
backend is still being filled in.
Distillation#
distill_model turns an expensive labeler into a cheap model. It labels up to
n_label rows of the unlabeled query with any SQL expression — an LLM-backed
operator, a semantic function, a heuristic —
materializes the labels into a staging table, infers a feature schema, and trains
a tabular model on them.
SELECT rvbbit.distill_model(
model_name => 'ticket_priority',
unlabeled_sql => $$ SELECT id, subject, channel, account_tier FROM tickets $$,
label_expr => $$ rvbbit.classify(subject, 'p0,p1,p2') $$,
n_label => 500,
task => 'classification'
);
label_expr is any expression over the query's columns. Here classify(text, categories) takes the candidate labels as a single comma-separated text
argument, not an array; it comes from the DeBERTa zero-shot
capability packs and exists only after one is installed.
The labeling step is just operators, so it lands in receipts with its own cost.
You pay the labeler once for the sample, then serve predictions from the distilled
model for free. By default distill_model trains by enqueuing a local
train_model run; pass managed => true (with a target selector) to hand the
training off to a Warren agent instead.
Lifecycle And Monitoring#
Models are managed entirely in SQL.
-- pause / resume serving without dropping the model
SELECT rvbbit.disable_model('churn_risk');
SELECT rvbbit.enable_model('churn_risk');
-- retire a model (optionally drop its predict operator too)
SELECT rvbbit.drop_model('churn_risk', drop_operator => true);
-- clean up runs that died mid-flight
SELECT rvbbit.reap_stale_training_runs(interval '1 hour');
Three read surfaces answer the common questions:
| View | Answers |
|---|---|
rvbbit.ml_model_status |
What is each model's state, operator, backend, and latest run? |
rvbbit.ml_model_versions |
Which trained revisions exist, and when were they trained? |
rvbbit.ml_accuracy_series |
How has evaluated accuracy moved over time? |
Model Studio#
Model Studio in Data Rabbit is a view over everything
above: a model list with Overview, Evaluate, Predict, Train, and Monitor tabs. It
auto-builds a prediction form from the feature schema, renders the confusion matrix
and residual scatter from ml_evaluations, and shows per-prediction receipts. Every
action surfaces the SQL it runs, so anything you do in the UI you can copy into
psql or DataGrip unchanged. Model behavior lives in the SQL functions above —
Model Studio is sugar over them, not a separate runtime.
Are These Still Worth Training?#
Yes — and they pair well with the rest of RVBBIT. Large models and LLM calls are
strong at messy text and reasoning, but a small tabular model is faster, cheaper,
auditable, and deterministic on structured columns. The interesting move is to
combine them: use semantic functions to turn free
text into structured features (a semantic_case bucket, an extracted field, an
embedding-derived label), then feed those features into a classical model — or
use distill_model to bake an expensive labeler down into a cheap operator. The
Bigfoot field notebook walks through a
worked classifier on a real dataset, including an honest look at when the
features simply do not carry the signal.