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_MLLMサーバは 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:8080Rspamd GPTプラグイン設定
RspamdにはGPTプラグインがあり、openai と ollama 型をサポートしている。今回はローカルの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_SPAM と GPT_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.0Infinityでは以下を提供している。
/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-m3 と bge-reranker-v2-m3 を同時に動かすと、数GB単位のメモリを使う。RAGや検索用途と同居させるなら、メモリには余裕を持たせたい。
まとめ
RspamdにローカルLLMを組み合わせると、既存ルールでは判断しにくいスパムを補助的に判定できる。
ただし、重要なのはLLMにすべてを任せないこと。
- Rspamdの既存ルールで大半を処理する
- 既知パターンはregexp/multimapで明示的に潰す
- LLMはgray-zoneだけに限定する
- LLMが落ちてもメール配送が止まらないようfail-openにする
- 外部APIではなくlocalhostのモデルに投げる
この設計なら、プライバシーとコストを抑えつつ、スパム判定の最後の一押しとしてLLMを使える。

