0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人情報をLLMに送る前に匿名化する:日本語テキストの検出・マスキング・復元パイプライン

0
Last updated at Posted at 2026-10-20

LLMに問い合わせログやメール本文を渡すと、要約・分類・回答生成はかなり便利になります。一方で、氏名、住所、電話番号、メールアドレスなどをそのまま外部モデルへ送る設計には注意が必要です。

この記事では、日本語テキストから個人情報らしい文字列を検出し、プレースホルダーへ置き換えてからLLMへ送信し、返ってきた文章を必要に応じて復元するパイプラインを実装します。

先に結論を書くと、正規表現だけでは不十分です。メールアドレスや電話番号は正規表現、氏名や住所は日本語NER(固有表現抽出)を組み合わせるのが現実的でした。また、復元用の対応表はLLMへ渡さず、アプリケーション側だけで保持することが重要です。

1. 構成と考え方

匿名化からLLMへの送信、応答の検査、必要に応じた復元までの流れは次の通りです。

今回の処理は次の順番にします。

  1. 入力テキストから個人情報候補を検出する
  2. 候補を<<PII_PERSON_001>>のようなトークンに置換する
  3. 匿名化済みテキストだけをLLMへ送信する
  4. LLMの応答に含まれるトークンを、アプリケーション側で復元する

例えば、次の文章を考えます。

山田太郎さん(東京都渋谷区、電話: 090-1234-5678)へ、
taro.yamada@example.com の件を確認してください。

匿名化後は、次のようになります。

<<PII_PERSON_001>>さん(<<PII_LOCATION_002>>、電話: <<PII_PHONE_003>>)へ、
<<PII_EMAIL_004>> の件を確認してください。

LLMはプレースホルダーを維持したまま、「確認依頼のメールに書き換えてください」といった処理を行います。モデルに実データを渡さないため、ログやプロンプト履歴に個人情報が残る範囲も減らせます。

2. 環境を準備する

氏名や住所の抽出には、spaCyと日本語モデルのGiNZAを使いました。

python -m venv .venv
.venv\Scripts\activate
pip install spacy ginza ja-ginza

macOSやLinuxの場合は、仮想環境の有効化が次のようになります。

source .venv/bin/activate

GiNZAの固有表現ラベルは、モデルのバージョンによって多少異なる場合があります。最初に、手元の環境でどのラベルが返るか確認しておくと安全です。

import spacy

nlp = spacy.load("ja_ginza")
doc = nlp("山田太郎は東京都渋谷区に住んでいます。")

for ent in doc.ents:
    print(ent.text, ent.label_)

私が確認した範囲では、氏名、場所、都道府県、市区町村などを表すラベルが取得できました。ただし、NERは必ず正しく抽出できるわけではないため、電話番号やメールアドレスは正規表現で補います。

3. 検出とマスキングを実装する

次のコードは、メールアドレス、電話番号、郵便番号を正規表現で検出し、氏名や住所候補をGiNZAで検出します。

import re
import spacy

nlp = spacy.load("ja_ginza")

PATTERNS = [
    (
        "EMAIL",
        re.compile(
            r"(?<![\w.+-])[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}"
            r"(?![\w.-])"
        ),
    ),
    (
        "PHONE",
        re.compile(
            r"(?<!\d)0\d{1,4}[-ー-]?\d{1,4}[-ー-]?\d{3,4}(?!\d)"
        ),
    ),
    (
        "POSTAL_CODE",
        re.compile(r"〒?\d{3}[-ー-]?\d{4}"),
    ),
]

# 会社名も隠したい場合は "Organization" を追加する
NER_LABELS = {
    "Person",
    "Location",
    "City",
    "Province",
    "Country",
    "GPE",
    "LOC",
    "FAC",
}


def overlaps(start, end, selected):
    return any(start < old_end and old_start < end
               for old_start, old_end, _, _ in selected)


def anonymize(text):
    candidates = []

    # メールアドレス、電話番号、郵便番号
    for kind, pattern in PATTERNS:
        for match in pattern.finditer(text):
            candidates.append(
                (match.start(), match.end(), kind, match.group())
            )

    # 氏名、住所など
    doc = nlp(text)
    for ent in doc.ents:
        if ent.label_ in NER_LABELS:
            candidates.append(
                (ent.start_char, ent.end_char, ent.label_.upper(), ent.text)
            )

    # 同じ範囲の候補がある場合は、先に見つかった候補を採用する
    selected = []
    for candidate in sorted(
        candidates,
        key=lambda item: (item[0], -(item[1] - item[0]))
    ):
        if not overlaps(candidate[0], candidate[1], selected):
            selected.append(candidate)

    mapping = {}
    value_to_token = {}
    counter = 1

    # 後ろから置換することで文字位置がずれないようにする
    masked = text
    for start, end, kind, original in reversed(selected):
        normalized_kind = re.sub(r"[^A-Z0-9]+", "_", kind)
        key = (normalized_kind, original)

        if key not in value_to_token:
            token = f"<<PII_{normalized_kind}_{counter:03d}>>"
            value_to_token[key] = token
            mapping[token] = original
            counter += 1
        else:
            token = value_to_token[key]

        masked = masked[:start] + token + masked[end:]

    return masked, mapping


def restore(text, mapping):
    # 長いトークンから先に置換する
    for token, original in sorted(
        mapping.items(),
        key=lambda item: len(item[0]),
        reverse=True,
    ):
        text = text.replace(token, original)
    return text


if __name__ == "__main__":
    source = (
        "山田太郎さん(東京都渋谷区、電話: 090-1234-5678)へ、"
        "taro.yamada@example.com の件を確認してください。"
    )

    masked, mapping = anonymize(source)

    print("匿名化後:")
    print(masked)
    print("\n復元後:")
    print(restore(masked, mapping))

この実装では、同じ個人情報が本文中に複数回登場した場合、同じトークンを再利用します。<<PII_PERSON_001>>が同一人物を指し続けるため、LLMが文章を整理しやすくなります。

4. LLMへ渡すときの注意点

LLMへは、匿名化済みの本文と次のような指示だけを渡します。

以下の文章を丁寧な社内メールに書き換えてください。
<<PII_...>> の形式のトークンは変更・削除しないでください。
トークンの中身を推測して補完しないでください。

本文:
...

LLMの応答を受け取ったら、すぐに復元するのではなく、まずトークンの妥当性を検査します。

TOKEN_RE = re.compile(r"<<PII_[A-Z0-9_]+_\d{3}>>")

def validate_tokens(response, mapping):
    used = set(TOKEN_RE.findall(response))
    known = set(mapping)

    unknown = used - known
    missing = known - used

    return {
        "unknown": unknown,
        "missing": missing,
        "valid": not unknown,
    }

未知のトークンが含まれていた場合は、復元せずエラーにする方が安全です。LLMがトークン名を変更したり、存在しないトークンを生成したりする可能性があるためです。

また、復元用のmappingはLLMのプロンプト、アプリケーションログ、分析基盤へ出力しないようにします。必要であればリクエスト単位の短期メモリに置き、処理終了後に破棄します。

5. 実運用で追加したい対策

正規表現とNERを組み合わせても、検出漏れは発生します。特に日本語の氏名や住所は文脈依存で、一般名詞と区別しにくいケースがあります。

実運用では、次のような対策を追加します。

  • 社内で使う顧客ID、会員番号、注文番号のパターンを追加する
  • マイナンバーなど高リスク情報は専用ルールで検出する
  • 検出結果を人手で確認できるテストデータを用意する
  • 匿名化前のログを保存しない
  • トークンの置換後に、メールアドレスや電話番号が残っていないか再スキャンする
  • LLMの応答を復元する前に、出力先が本当に社内向けか確認する

特に重要なのは、匿名化できたことを「安全性の証明」と解釈しないことです。匿名化処理は、送信する情報を減らすための防御層の一つです。アクセス制御、ログ管理、保持期間、利用するモデルの契約条件なども合わせて確認する必要があります。

まとめ

日本語テキストの匿名化では、正規表現だけで全てを検出するのは難しいため、正規表現と日本語NERを組み合わせる方法が扱いやすいと感じました。

実装上の要点は次の3つです。

  1. メールアドレスや電話番号は正規表現で検出する
  2. 氏名や住所はNERで候補を抽出し、業務データに合わせて調整する
  3. 復元用の対応表はLLMへ送らず、アプリケーション側だけで管理する

最初から完全な検出器を目指すより、実際の入力例を匿名化テストとして蓄積し、検出漏れと過剰マスキングを少しずつ改善する方が、現場では運用しやすいアプローチです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?