RspamdをローカルLLMで拡張してスパム判定を補助する

自宅鯖で稼働しているメールサーバのスパム判定にはRspamdを使っている。Bayes、Neural、RBL、regexpなどでかなり判定できるが、最近のスパムは件名やFromを微妙に難読化してくるため、スコアが add header 止まりになり、受信箱に入ってしまうことがある。

そこで、RspamdのGPTプラグインを使い、ローカルLLMに「最後のひと押し」の判定をさせる構成にした。

ポイントは次の3つ。

  • メール本文を外部APIに送らない
  • 全メールではなく、Rspamdで微妙なスコアになったメールだけLLM判定する
  • LLMがスパムと判断した場合だけ、Rspamdのスコアを加算する

構成

全体構成はこうなる。

Postfix
  ↓
Rspamd
  ↓
Rspamd GPT plugin
  ↓
localhost:18080 の llama.cpp server
  ↓
Qwen2.5-1.5B-Instruct Q4_K_M

LLMサーバは llama.cpp server を使い、OpenAI互換の /v1/chat/completions APIをローカルだけに公開する。

ローカルLLMサーバ

モデルは軽量な Qwen2.5-1.5B-Instruct-Q4_K_M.gguf を使った。スパム判定の補助用途なら、巨大なモデルよりも、短いプロンプトに安定して答えられる軽量モデルの方が扱いやすい。

Quadlet/Podmanで動かす場合の要点は以下。

stack:
  name: rspamd-llm
  description: Local llama.cpp server for Rspamd spam-classification assist
  data_root_override: /srv/containers

  model:
    url: https://huggingface.co/bartowski/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
    path: /srv/containers/rspamd-llm/models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
    served_name: qwen2.5-1.5b-instruct-q4km

  containers:
    - name: rspamd-llm
      image: ghcr.io/ggml-org/llama.cpp:server

      volumes:
        - /srv/containers/rspamd-llm/models:/models:Z,ro

      publish_ports:
        - 127.0.0.1:18080:8080

      exec: >-
        --model /models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
        --alias qwen2.5-1.5b-instruct-q4km
        --host 0.0.0.0
        --port 8080
        --ctx-size 2048
        --threads 12
        --parallel 1
        --batch-size 512

      restart_policy: on-failure
      timeout_start_sec: 900

外部公開は不要なので、ポートは必ず 127.0.0.1 に閉じる。

publish_ports:
  - 127.0.0.1:18080:8080

Rspamd GPTプラグイン設定

RspamdにはGPTプラグインがあり、openaiollama 型をサポートしている。今回はローカルのOpenAI互換APIを使うため、次のように設定する。

enabled = true;
type = "ollama";
url = "http://127.0.0.1:18080/v1/chat/completions";
model = "qwen2.5-1.5b-instruct-q4km";

timeout = 5s;
connect_timeout = 1s;
write_timeout = 2s;
read_timeout = 4s;

min_words = 1;
reply_trim_mode = "replies";
autolearn = false;
json = false;

reason_header = "X-Local-LLM-Reason";
consensus_spam_threshold = 0.70;
consensus_ham_threshold = 0.20;

モデル出力は、スパム確率・理由・カテゴリの3行だけに制限した。

You are a conservative spam classifier for a personal mail server.

Return exactly three lines and nothing else:
1. Only a decimal spam probability from 0.00 to 1.00
2. A short reason citing the strongest signal
3. One category: pharmacy, adult, phishing, scam, marketing, ham, or uncertain

Classify unsolicited online pharmacy, ED medication, no-prescription/no-script,
generic drug discount, romance bait, and fake wellness promotion as high spam.
Treat legitimate transactional, billing, receipt, account, and human replies as
ham unless there are strong contradictory spam or phishing indicators.

全メールをLLMに投げない

重要なのは、LLMを全メールに対して実行しないこと。

Rspamdで明らかにham/spamなメールは既存ルールに任せる。LLMは、判定が微妙なメールだけに使う。

今回の設定では、次の条件にした。

action が no action / add header / rewrite subject
かつ
score が -1.0 以上 10.0 未満

Lua条件は概ねこうなる。

return function(task)
  local lua_mime = require "lua_mime"
  local llm_common = require "llm_common"

  local result = task:get_metric_result()
  if result then
    if result.passthrough then
      return false, "passthrough"
    end

    local action = result.action or "no action"
    local allowed_actions = {
      ["no action"] = true,
      ["add header"] = true,
      ["rewrite subject"] = true,
    }

    if not allowed_actions[action] then
      return false, "already actioned: " .. action
    end

    local score = tonumber(result.score) or 0.0
    if score < -1.0 or score >= 10.0 then
      return false, "outside gray-zone score: " .. score
    end
  end

  local sel_part = lua_mime.get_displayed_text_part(task, 1)
  local input_tbl = llm_common.build_llm_input(task, {
    max_tokens = 512,
    reply_trim_mode = "replies",
    min_words = 1,
  })

  if input_tbl then
    return true, input_tbl, sel_part
  end

  local subject = task:get_header("Subject") or ""
  local from = task:get_header("From") or ""

  if subject == "" and from == "" then
    return false, "no header content"
  end

  return true, {
    subject = subject,
    from = from,
    url_domains = "",
    text = "",
  }, nil
end

これで、通常のメール配送をLLMの遅延に巻き込みにくくできる。

LLM判定結果をRspamdスコアに反映する

Rspamd側では、LLMが返したカテゴリに応じて追加シンボルを付ける。

extra_symbols = {
  GPT_LLM_PHARMACY = {
    score = 2.5;
    description = "Local LLM detected pharmacy spam content";
    category = "pharmacy";
    group = "GPT";
  }

  GPT_SCAM = {
    score = 2.0;
    description = "Local LLM detected scam content";
    category = "scam";
    group = "GPT";
  }

  GPT_PHISHING = {
    score = 2.0;
    description = "Local LLM detected phishing content";
    category = "phishing";
    group = "GPT";
  }

  GPT_UNCERTAIN = {
    score = 0.0;
    description = "Local LLM was uncertain about classification";
    category = "uncertain";
    group = "GPT";
  }
};

さらに、Rspamdの通常のsymbol score設定で GPT_SPAM に重みを与える。

symbol_scores:
  enabled: true
  entries:
    - symbol: GPT_SPAM
      score: 5.0
    - symbol: GPT_HAM
      score: -2.0

これにより、たとえばスコア7〜9点台で add header 止まりだったメールが、LLM判定によって reject まで上がる。

ローカルregexpも併用する

LLMだけに頼らず、明らかなパターンはRspamdのregexpで拾う。

local_regexp:
  enabled: true
  rules:
    - symbol: LOCAL_PHARMA_SPAM_WORDS
      re: '/\b(?:viagra|cialis|levitra|sildenafil|tadalafil|vardenafil|ozemp[i1l]c|rybe[l1i]sus|semaglutide)\b/i{mime}'
      score: 4.0
      group: local
      description: Locally scored pharma spam keywords in decoded text parts.

    - symbol: LOCAL_PHARMA_BRAND_HEADERS
      re: 'From=/\b(?:canad[i1l]an\s+pharnac|ozemp[i1l]c\s+vs\s+rybe[l1i]sus)\b/i{header} || Subject=/\b(?:canad[i1l]an\s+pharnac|ozemp[i1l]c\s+vs\s+rybe[l1i]sus)\b/i{header}'
      score: 6.0
      group: local
      description: Locally scored obfuscated pharma brand spam in From or Subject headers.

LLMは便利だが、既知の攻撃パターンは通常ルールで潰す方が速く、安定する。

テスト

スパムに模したメールを rspamc に渡して確認する。

cat > /tmp/rspamd-llm-test.eml <<'EOF'
From: "Discount Pharmacy" <postmaster@example.invalid>
Subject: ED medication without prescription
Message-ID: <rspamd-llm-test@example.invalid>
Date: Mon, 15 Jun 2026 09:04:00 +0900
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8

Limited offer for viagra and cialis without prescription.
Cheap generic ED medication.
EOF

rspamc -h 127.0.0.1:11334 /tmp/rspamd-llm-test.eml

実際には以下のような結果になった。

Action: reject
Spam: true
Score: 13.70 / 10.00
Symbol: GPT_SPAM (2.70)[0.95]
Symbol: GPT_LLM_PHARMACY (2.50)
Symbol: LOCAL_PHARMA_SPAM_WORDS (4.00)

GPT_SPAMGPT_LLM_PHARMACY が付いており、LLM判定がRspamdスコアに反映されている。

Infinityについて

同じサーバで、別用途でInfinityも動かしている。InfinityはLLMチャット用というより、embeddingやrerankerをAPIで提供するためのモデルサーバとして使っている。

たとえば以下のような構成。

image: docker.io/michaelf34/infinity:0.0.77-cpu

volumes:
  - /srv/containers/infinity/hf-cache:/cache/hf:Z
  - /srv/containers/infinity/ov-cache:/cache/ov:Z

environment:
  - HF_HOME=/cache/hf
  - HUGGINGFACE_HUB_CACHE=/cache/hf
  - TRANSFORMERS_CACHE=/cache/hf
  - OV_CACHE_DIR=/cache/ov
  - INFINITY_MAX_SEQ_LENGTH=512

exec: >-
  v2
  --model-id BAAI/bge-reranker-v2-m3
  --model-id BAAI/bge-m3
  --served-model-name bge-reranker-v2-m3
  --served-model-name bge-m3
  --engine torch
  --engine torch
  --device cpu
  --device cpu
  --batch-size 32
  --batch-size 32
  --port 7997
  --host 0.0.0.0

Infinityでは以下を提供している。

/rerank      → bge-reranker-v2-m3
/embeddings  → bge-m3

ただし、今回のスパム判定にはInfinityではなく llama.cpp server を使った。理由は、スパム判定には「メールを読んで分類する」生成系/指示追従モデルが必要で、rerankerやembeddingモデルは用途が違うため。

ハードウェア感

今回の構成はCPUのみで動かしている。

参考: 新・自宅鯖のパーツを発注した

目安としては以下。

軽量LLMスパム判定のみ:
  CPU: 8コア以上推奨
  RAM: 16GB以上
  SSD: 必須
  GPU: 不要

Infinityも同居:
  RAM: 32GB以上推奨
  CPU: AVX2 / VNNI対応だと有利

Qwen2.5-1.5B-Instruct-Q4_K_M はモデルサイズが約1GB弱で、常駐メモリも1GB前後に収まる。メールのgray-zone判定だけなら、CPUサーバでも十分現実的だった。

一方、Infinityで bge-m3bge-reranker-v2-m3 を同時に動かすと、数GB単位のメモリを使う。RAGや検索用途と同居させるなら、メモリには余裕を持たせたい。

まとめ

RspamdにローカルLLMを組み合わせると、既存ルールでは判断しにくいスパムを補助的に判定できる。

ただし、重要なのはLLMにすべてを任せないこと。

  • Rspamdの既存ルールで大半を処理する
  • 既知パターンはregexp/multimapで明示的に潰す
  • LLMはgray-zoneだけに限定する
  • LLMが落ちてもメール配送が止まらないようfail-openにする
  • 外部APIではなくlocalhostのモデルに投げる

この設計なら、プライバシーとコストを抑えつつ、スパム判定の最後の一押しとしてLLMを使える。