Training and Maintaining the rspamd Bayes Classifier: A Corpus-First Playbook
Most "my Bayes is useless" tickets come down to three sins: a dirty or imbalanced corpus, tokens fragmented across the wrong Redis keyspace, and never crossing min_learns. Here is how to build clean ham/spam corpora, learn them correctly, choose global vs per-user, and keep autolearn from poisoning itself.
EvilMail TeamJuly 20, 202612 min read
Why your Bayes is probably lying to you
Run mail long enough and you will open a ticket that reads like this: "BAYES_SPAM fires on a legit newsletter, then five minutes later the same-looking mail gets BAYES_HAM." Or the quieter version: BAYES_HAM and BAYES_SPAM show up in the symbol list at 0.00 weight, contributing nothing, while you assumed they were carrying the filter.
Neither is a bug. A Bayesian classifier is a supervised statistical layer, not a magic autolearn box that "just works." It only earns its weight — up to about +5 for a confident spam verdict, around -3 for confident ham — after you have trained it on a balanced, clean corpus and it has crossed its learn threshold. Skip the corpus discipline and you get exactly the two symptoms above.
In practice, most broken-Bayes reports trace to three failure modes:
Dirty or imbalanced corpus
Training rspamd Bayes from Ham & Spam Corpora — Operator Guide — EvilMail Blog
— you learned newsletters as ham, or you have 40,000 spam messages and 900 ham, so the prior tilts toward "everything is spam."
Wrong Redis keyspace / per-user collision — someone flipped per-user learning on, tokens fragmented per recipient, and now no single mailbox has enough learns to matter.
Never crossing `min_learns` — the module stays silent below the threshold (default 200 per class), so BAYES_* emits with near-zero probability and adds nothing to the score.
This is the operator's playbook for avoiding all three.
How rspamd Bayes actually works (the parts that bite you)
Every incoming message is normalized (Unicode folding, HTML stripped to text) and run through the osb tokenizer — orthogonal sparse bigrams, a sliding window over the normalized words. Rspamd also emits meta-tokens from header names, the Received-chain length, URL structure, and MIME layout. That is why a Bayes trained on your real traffic learns the shape of your infrastructure, not just vocabulary.
Each token carries a ham count and a spam count in Redis. At scan time rspamd looks up every token, combines the per-token probabilities, and produces a single spam probability p. Two gates decide whether that p ever becomes score:
`min_tokens` (default 11) — messages with too few usable tokens are skipped. HTML-only postcards and three-word replies fall through here.
`min_learns` (default 200) — until both classes have at least this many learned messages, the classifier deliberately holds back. This is the single most misunderstood knob in the module.
When both gates pass, rspamd emits BAYES_SPAM (positive) or BAYES_HAM (negative), with the weight scaled by `p` — a 0.99 probability gets close to full weight, a 0.6 barely registers. That dynamic scaling is why a half-trained classifier feels like it "does nothing": the probabilities sit near 0.5 and the scaled weight rounds to noise.
Building a corpus that doesn't poison the model
This is the section that decides whether everything else works. Balance matters more than volume. Keep your ham and spam learn counts within roughly 2x of each other. If spam learns are ten times your ham learns, the prior tilts and marginal mail gets shoved toward spam — which is how you get false positives on transactional mail that "looks" spammy.
Sources, in order of trustworthiness:
Ham: a hand-curated Maildir of *confirmed* real inbox mail — actual human correspondence, not a newsletters-only folder. Train ham exclusively on mailing lists and Bayes learns that list-shaped mail is safe, so real spam that mimics list formatting sails through.
Spam: your own quarantine plus rspamd's high-confidence rejects, deduplicated, optionally topped up with a public corpus. Spam ages fast, so keep feeding fresh samples.
Hard rules, learned the painful way:
Never learn mail you have not verified. One "unsubscribe confirmation" mislabeled as spam teaches the model to distrust its own vocabulary.
Strip your own outbound from ham unless you deliberately want to whitelist your templates' token shapes.
Do not casually learn auto-generated or transactional mail (receipts, OTP codes) into either class — it is high-entropy and drags probabilities toward the middle.
Concrete minimums: 200 is the hard floor where the module wakes up, but do not *trust* it until you have 1000+ per side. Past a few thousand you hit diminishing returns on accuracy; the ongoing job becomes keeping spam fresh, not piling up more.
Learning the corpus: rspamc, Redis, and getting the keys right
The classifier config lives in /etc/rspamd/local.d/classifier-bayes.conf. A sane Redis-backed block:
ini
classifier "bayes" {
tokenizer { name = "osb"; }
backend = "redis";
servers = "127.0.0.1:6379";
# dbname = "11"; # dedicate a Redis DB index to Bayes
new_schema = true;
expire = 8640000; # 100-day token TTL, in seconds
min_tokens = 11;
min_learns = 200;
statfile { symbol = "BAYES_HAM"; spam = false; }
statfile { symbol = "BAYES_SPAM"; spam = true; }
autolearn = {
spam_threshold = 12;
ham_threshold = -2;
check_balance = true;
min_balance = 0.9;
};
}
Now feed it. rspamc walks a Maildir and learns every message:
bash
# learn confirmed spam and ham from Maildirs
rspamc -c bayes learn_spam /var/mail/vhosts/evilmail.pro/*/Maildir/.Junk/cur/
rspamc -c bayes learn_ham /var/mail/vhosts/evilmail.pro/*/Maildir/cur/
# controller needs auth if a password is set
rspamc -h 127.0.0.1:11334 -P "$CONTROLLER_PASSWORD" learn_spam < message.eml
Verify it actually landed:
bash
rspamc stat | grep -A2 BAYES
# Statfile: BAYES_SPAM learned: 4213 ...
# Statfile: BAYES_HAM learned: 3987 ...
# token keys should number in the thousands once trained
redis-cli -n 11 dbsize
Two things trip people up. First, rspamd hashes every learned message into a learn cache to prevent double-learning — re-running the same Maildir reports messages as *skipped*, not learned again. That is correct behavior, not a failure. To genuinely relearn a message you unlearn it or flush the learn cache. Second, "skipped" in learn_spam output almost always means *already learned* or *too few tokens* — check min_tokens before assuming the command failed.
Global vs per-user: pick deliberately, not by accident
The per-user switch looks tempting — personalized filtering, one user's "spam" not affecting another's. Flip it on blindly and you break Bayes for most of your mailboxes.
Global (default): one shared brain. Every user's spam trains a classifier that protects everyone. Excellent for small-to-medium servers and anything high-churn, because cross-user spam patterns are exactly what you want to generalize.
Per-user: isolation and personalization, but it fragments token counts per recipient. Now *each* mailbox needs its own 200+ learns before Bayes does anything for it. For a low-volume or short-lived mailbox, that threshold is never reached and Bayes stays permanently silent for that user.
For a temp-email or high-churn service — evilmail.pro's exact profile — global is almost always correct. Mailboxes are short-lived; per-user learning would mean a cold Bayes for every new address. The pragmatic hybrid, if you have a few heavy business accounts, is a strong global classifier plus per-user *only* for the handful of high-volume mailboxes with a real feedback loop. Do not ship per-user wide.
Autolearn without self-poisoning
Autolearn watches scored mail and trains automatically: score above the spam threshold gets learned as spam, score below the ham threshold gets learned as ham. It is convenient and it is dangerous, because a false positive that autolearns reinforces its own mistake — the model grows more confident about being wrong.
Two rules keep it safe. First, keep `spam_threshold` below your reject threshold. If you reject at 15 and autolearn spam at 12, autolearn only trains on mail you are already sure about but have not yet rejected — it never learns the borderline cases where it is most likely wrong. Second, enable `check_balance` with min_balance = 0.9 so autolearn refuses to widen an already-skewed corpus.
Autolearn is not a replacement for a feedback loop. The highest-quality training signal is a human clicking "this is spam." Wire that to real learning: a Dovecot imapsieve rule (or a cron job) that watches the Junk folder and runs learn_spam on new arrivals, and learn_ham when a user drags a message *out* of Junk. That closes the loop with verified labels instead of the model's own guesses.
Maintenance: expiry, backups, drift, and monitoring
Expiry. Set expire = 8640000 (100 days) so stale spam vocabulary ages out. Campaigns rotate; tokens from a dead campaign should not weigh on today's mail.
Backups. The Bayes brain lives entirely in Redis. A wiped Redis is a wiped classifier. Enable AOF or RDB persistence on the Bayes DB index and treat that dump like any other production database backup.
Drift monitoring. Track two numbers over time: the BAYES symbol hit rate (fraction of scanned mail where Bayes actually contributed non-zero score) and the learn drift (are spam and ham learns still growing in balance?). A hit rate that quietly falls toward zero means your corpus went stale or Redis got trimmed.
Alert on `rspamc stat`. Scrape learned, token counts, and per-class revisions. A sudden drop in learned count is a wiped keyspace; a frozen count is a broken feedback loop.
Rebuild procedure. When the model is genuinely poisoned — flip-flopping despite balanced learns — do not fight it. Flush the Bayes Redis keys, then relearn from your curated corpora from scratch. A clean 2000/2000 rebuild beats endless unlearning.
Operator go-live checklist
Balanced corpus, 1000+ per side, ham/spam learns within ~2x.
rspamc stat confirms both classes crossed min_learns (200).
Redis persistence (AOF/RDB) on, ideally a dedicated DB index for Bayes.
Monitoring on BAYES hit rate, token count, and learn drift.
expire set (100 days) so vocabulary stays fresh.
Backup of the Bayes Redis DB scheduled.
Get the corpus and the keyspace right and Bayes becomes the quiet, reliable backbone of your scoring. Skip that work and it stays exactly what the tickets describe: a coin flip with a confident-sounding name.