Сколько вы теряете на спаме в час?
Если вы до сих пор доверяете фильтрацию спама Google или Яндекс.Почте, вы либо не администрируете свой почтовый сервер, либо любите рисковать. Я просыпался каждое утро с 200 письмами в папке "Спам", из которых треть были нормальными. Платные фильтры типа Mailchimp или Proofpoint стоят денег. А когда у вас 5 ящиков на дешёвом VPS за 4 бакса, каждый пенни на счету.
Решение родилось из отчаяния: собрать многоуровневый фильтр на связке SpamAssassin и компактной модели BERT, которая укладывается в 550 МБ оперативной памяти. Бесплатно, open source, полностью автономно. Да, это не панацея, но мой сервер перестал задыхаться от спама за три вечера.
Я не буду рассказывать, как настроить Postfix и Dovecot. Предполагаю, что у вас уже есть рабочий MTA. Если нет — сначала разверните простую конфигурацию. Всё остальное — здесь.
Почему SpamAssassin один — это провал, а BERT один — это роскошь
SpamAssassin — мощный, но он использует эвристики и байесовские фильтры, которые отлично убивают 80% спама. Но тонкие атаки — семантические вариации, имитация нормальных писем — проходят. BERT (в лёгкой версии distilbert-base-uncased после квантизации до int8) весит ~480 МБ и добавляет ещё 150 МБ во время инференса. Итого 630 МБ, но у меня модель загружена в общую память через ONNX Runtime, и реальный пик — 550 МБ. Искусство минимализма.
Идея: SpamAssassin делает грубую отбраковку (score > 5 — спим), сомнительные письма (score от 2 до 5) отправляются на классификацию BERT. Потом можно дообучить модель прямо на ваших жалобах — это описано в статье "Спам-детектор, который не шпионит".
Такой многоуровневый подход решает проблему ложных срабатываний: если SpamAssassin сомневается, BERT может снять или подтвердить подозрение. Ниже — полная схема.
Архитектура за 30 секунд
- Уровень 1 — SpamAssassin: стандартные правила, Razor, Pyzor.
- Уровень 2 — BERT: если score между 2 и 5, письмо отдаётся на классификацию AI.
- Уровень 3 — Feedback: пользовательские жалобы (жалоба в спам или "не спам") собираются и раз в день дообучают BERT.
Не пытайтесь прогнать все письма через BERT в реальном времени — производительность упадёт до нуля. Используйте асинхронную очередь (например, Redis + Celery) или просто отправляйте письма в SpamAssassin первым делом.
Пошаговый план: от установки до производства
1 Настройка SpamAssassin с кастомным правилом вызова скрипта
Устанавливаем SpamAssassin:
sudo apt install spamassassin spamc
sudo systemctl enable spamassassin
sudo systemctl start spamassassin
Добавляем правило для вызова внешнего скрипта. Создайте файл /etc/spamassassin/custom.cf:
header CUSTOM_BERT_AI X-BERT-Score =~ /.*/
describe CUSTOM_BERT_AI BERT AI evaluation
score CUSTOM_BERT_AI 0.1
Теперь нужно научить SpamAssassin передавать тело письма скрипту. Для этого используем spamc с флагом -x или пишем небольшой скрипт-прокси. Я предпочитаю интегрировать через milter, но для простоты оставлю вариант с фильтром в Postfix: в master.cf добавляем:
smtp inet n - n - - smtpd
-o content_filter=spamassassin
В master.cf:
spamassassin unix - n n - - pipe
flags=R user=spamd argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f $sender $recipient
Теперь все письма проходят через SpamAssassin.
2 Загрузка и квантизация модели BERT
Берём distilbert-base-uncased из Hugging Face. Квантизируем в ONNX:
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
model_name = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)
# Квантизация
dummy_input = torch.randint(0, 1000, (1, 128))
traced = torch.jit.trace(model, dummy_input)
# Сохраняем в ONNX
torch.onnx.export(traced, dummy_input, "spam_model.onnx",
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={"input_ids": {0: "batch"},
"attention_mask": {0: "batch"}})
Теперь модель весит около 260 МБ. Можно ещё сжать до int8 через ORT — даст ещё 2x без потери точности. Итог ~550 МБ на инференс. Если нужно меньше, смотрите статью "Как определить минимальный размер LLM для классификации".
3 Скрипт классификации с вызовом через HTTP
Запускаем простой Flask-сервер, который принимает текст письма и возвращает вероятность спама:
import flask
from onnxruntime import InferenceSession
from transformers import AutoTokenizer
app = flask.Flask(__name__)
model = InferenceSession("spam_model.onnx")
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
@app.route("/classify", methods=["POST"])
def classify():
text = flask.request.data.decode()
inputs = tokenizer(text, return_tensors="np", max_length=128, truncation=True)
outputs = model.run(None, {"input_ids": inputs["input_ids"],
"attention_mask": inputs["attention_mask"]})
prob = 1 / (1 + numpy.exp(-outputs[0][0][1])) # sigmoid
return flask.jsonify({"spam_probability": float(prob)})
app.run(host="127.0.0.1", port=5000)
Сервер потребляет около 550 МБ RAM — проверено. Размещаем его в supervisor или systemd.
4 Интеграция: SpamAssassin -> BERT для сомнительных писем
Теперь создадим скрипт, который запускается по каждому письму и, если score SpamAssassin между 2 и 5, вызывает BERT:
#!/usr/bin/env python3
import sys, requests, subprocess, os
email = sys.stdin.read()
# Получаем score от SpamAssassin (через заголовок X-Spam-Score)
# Допустим, мы используем milter или postfix pipe
# В реальности проще: spamassassin уже добавил заголовок X-Spam-Status
# Для примера: вызовем spamc с результатом
result = subprocess.run(["spamc", "-R"], input=email, capture_output=True, text=True)
# Парсим X-Spam-Status
# Упрощённо: если счёт в пороге - отправляем BERT
score = float(result.stdout.split("X-Spam-Score: ")[1].split()[0])
if 2 <= score < 5:
# Отправляем в BERT
resp = requests.post("http://127.0.0.1:5000/classify", data=email)
prob = resp.json()["spam_probability"]
if prob > 0.7:
# Добавляем заголовок X-BERT-Spam: yes
email = "X-BERT-Spam: yes\n" + email
else:
email = "X-BERT-Spam: no\n" + email
sys.stdout.write(email)
Этот скрипт вставляется как фильтр в Postfix через pipe или milter. Можно аналогично сделать через spamc -x. Главное — не забыть обработать письма, где BERT не нужен.
5 Обратная связь: учим BERT на ваших жалобах
Пользователь отметил письмо как спам — это нужно сохранить. Собираем такие письма в файл, раз в день дообучаем модель (fine-tune). Для этого можно использовать ту же схему, что и в статье про антиспам-бота для Telegram на LSTM — принцип тот же. После дообучения переквантировать модель и перезапустить сервер.
Важно: не переобучиться на маленькой выборке — используйте старый датасет + новые примеры.
Подводные камни, на которые я наступал
- Слишком частые вызовы BERT — сервер начинает гонять в своп. Решение: поставить ограничение — не чаще 5 писем в секунду, остальные считать сомнительными и отправлять в спам.
- Модель BERT на английском — для русского языка берите CoLa или русифицированные tiny-bert. Я использовал distilbert-base-multilingual-cased, он чуть жирнее, но держит кириллицу.
- SpamAssassin не видит заголовки X-BERT* — настройте
header CUSTOM_BERT_AI X-BERT-Spam =~ /yes/. - Ложные срабатывания: если BERT часто ошибается, увеличьте порог до 0.8 и собирайте больше negative примеров.
Если ваш VPS совсем дохлый, вместо BERT можно попробовать логистическую регрессию на TF-IDF — точность чуть ниже, но 0 RAM. Об этом хорошо написано в статье про гибридный поиск BM25+FAISS — там показано, как комбинировать простые и сложные методы.
А что с размерами модели, если денег на GPU нет?
В статье про построение локального AI-сервера я подробно описал, как собирать сервер из хлама. Но для фильтрации спама GPU не нужен — ONNX на CPU даёт 10-20 мс на одно письмо.
Для ещё большей экономии ресурсов изучите архитектуру нейропоиска Discovery AI от VK — там показано, как держать задержку ниже 500 мс при объединении LLM и поиска.
Неочевидный совет, который спас меня от депрессии
Не пытайтесь засунуть BERT в основной поток обработки почты. Сделайте асинхронную очередь: письмо сначала идёт в SpamAssassin, потом кладётся в Redis, а worker с BERT обрабатывает его в фоне и обновляет заголовки. Так ваш MTA не будет висеть в ожидании. Я использовал Celery, но для одного сервера хватит Bash + flock.
И последнее: после настройки хотя бы неделю собирайте метрики — сколько писем BERT переклассифицировал, сколько реально спама/не спама. Без этого вы будете гадать, работает ли система. Если точность ниже 95% — дообучайте модель.