Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

まとめ記事の不適切表現を検出するためにLLMを活用した話

こんにちは、プロダクト開発部のimaiです。

こちらの記事は Gunosy Advent Calendar 2024 の 22 日目の記事です。

この記事では、まとめ記事の不適切表現を検出するためにLLMを活用した取り組みについて紹介します。

背景

グノシーアプリでは、ユーザーに様々な記事を提供しており、その中にまとめ記事も含まれています。まとめ記事とは2ch等のまとめサイトの記事のことです。

これらのまとめ記事の中には不適切なコンテンツが含まれる可能性があるため、そういった記事の露出を防ぐためにまとめ記事を監視して定められた判定ルールに基づき、人の目で記事の露出可否を判定していました。

こういった人手による目視確認を行っておりましたが、運用コストが大きな課題となっていました。

そのため、LLMを活用して不適切表現を検出し、運用者の判断を支援する仕組みを実装することにしました。

システム概要

まとめ記事の判定をLLMを使って半自動化するために、以下のようなシステムを実装しました。

  1. まとめ記事に関するデータを取得

  2. まとめ記事の情報(タイトルと本文)と合わせてプロンプトをLLMに投げる

  3. プロンプトで指定した返答形式に基づいた判定結果のレスポンスを保存

  4. まとめ記事一覧画面でLLMによる判定結果を表示

  5. 運用者が判定結果を確認し、記事の露出可否(CLEAR/NG)を最終判断

  6. 判断結果をDBに保存し、アプリでの記事表示制御に反映

システムは大きく分けて二つの部分で構成されています。

  • LLM判定用バッチ(1~3):このバッチ処理はAWS上で実装しており、EventBridgeで定期的にトリガーされるLambda関数として実装。OpenAI APIを使用して記事の判定を実行
  • 管理画面(4~6):運用者によってLLMによるまとめ記事の判定結果を確認、判定するための画面

システム構成図

LLM判定の実装

システムは大きくLLM判定バッチと管理画面の二つで構成されていますが、この記事ではLLM判定バッチの実装について詳しく説明します。 まとめ記事のLLM判定には、OpenAI APIを利用しています。

判定の仕組み

LLMには「まとめ記事の監視システム」としての役割を与え、記事内容を分析して不適切な表現の検出と数値的な評価を行わせています。

不適切表現の項目としては主に以下のようなものを設定しています

  • 不快表現
  • 企業批判
  • 卑猥
  • 国籍差別

そして検出された不適切表現それぞれについて

  • 不適切表現の検出と該当箇所の特定

  • 判定理由の説明

  • 不適切度合いのスコアリング(0-100)

といった情報を決められた形式で返却するようにしています。

判定結果の形式

判定結果はJSON形式で受け取り、NGカテゴリごとに該当の文章と判定理由、不適切度合いを表すスコアを返します。

以下は実際の判定結果の例です

{
 "check_rules": [
   {
     "ng_label": "不快表現",
     "reason": "年齢に基づく侮蔑的な表現を使用しており、不快感を与える内容であるため。",
     "score": 70,
     "sentence": "33歳とかメスガキやん",
   },
   {
     "ng_label": "職業差別",
     "reason": "特定の年齢層の女性に対して否定的な表現を用いており、職業や社会的地位に基づく差別的な見解を示しているため。",
     "score": 80,
     "sentence": "適齢期のがしたババアって大抵拗らせてるけど男側からしてもその面倒くささを払拭してアプローチするだけの魅力がないから独身が続く負のスパイラルに入ってるよな",
   }
 ]
}

バッチ処理の実装

バッチ自体はGoで実装しています。まとめ記事の判定には、記事のタイトルと本文の両方が必要なため、これらの情報をLLMに適切に伝える必要がありました。

そこで、Goのtext/template パッケージを使用してテンプレートファイルからプロンプトを動的に生成し、記事情報を埋め込める仕組みを実装しました。 また、プロンプトのテンプレートファイルはGoのembed機能を利用して読み込めるようにしました。

  • プロンプトテンプレートに記事のタイトルと本文を埋め込んで生成
//go:embed prompts/check_article_expression.tmpl
var checkArticleExpressionTmpl string

// プロンプト生成用の構造体
type PromptInput struct {
    ArticleTitle string
    ArticleBody  string
}

func generatePrompt(articleTitle string, articleBody string) (string, error) {
    // プロンプトテンプレート読み込み
    tmpl, err := template.New("prompt").Parse(checkArticleExpressionTmpl)
    if err != nil {
        return "", fmt.Errorf("failed to parse template: %w", err)
    }

    // 記事情報を使ってプロンプトを生成
    var buf strings.Builder
    input := PromptInput{
        ArticleTitle: articleTitle,
        ArticleBody:  articleBody,
    }
    if err := tmpl.Execute(&buf, &input); err != nil {
        return "", fmt.Errorf("failed to execute template: %w", err)
    }

    return buf.String(), nil
}
  • LLMによる記事判定の実行
// 記事判定結果の構造体
type Result struct {
    CheckRules []CheckRule `json:"check_rules"`
}

type CheckRule struct {
    NGLabel  string `json:"ng_label"`
    Score    int    `json:"score"`
    Sentence string `json:"sentence"`
    Reason   string `json:"reason"`
}

// LLMによる記事判定
func judgeArticle(ctx context.Context, prompt string) (Result, error) {
    // 期待するレスポンスのJSONスキーマを生成
    var result Result
    schema, err := jsonschema.GenerateSchemaForType(result)
    if err != nil {
        return Result{}, fmt.Errorf("failed to generate schema: %w", err)
    }
    resp, err := openaiClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
        Model: openai.GPT4oMini,
        Messages: []openai.ChatCompletionMessage{
            {
                Role:    openai.ChatMessageRoleSystem,
                Content: "あなたはインターネット記事の内容を監視し、不適切な表現を検出するシステムです。",
            },
            {
                Role:    openai.ChatMessageRoleUser,
                Content: prompt,
            },
        },
        ResponseFormat: &openai.ChatCompletionResponseFormat{
            Type: openai.ChatCompletionResponseFormatTypeJSONSchema,
            JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{
                Name:   "judge_article",
                Schema: schema,
            },
        },
    })
    if err != nil {
        return Result{}, fmt.Errorf("OpenAI API error: %w", err)
    }

    var result Result
    if err := json.Unmarshal(resp.Choices[0].Message.Content, &result); err != nil {
        return Result{}, fmt.Errorf("failed to parse response: %w", err)
    }

    return result, nil
}

LLMからのレスポンスは、JSONスキーマを使って指定した形式のレスポンスを生成するようにしています。当初レスポンスをJSON形式で返すためにレスポンスフォーマットをjson_objectに指定していたのですが、レスポンスの構造が一定にならない問題がありました。そこでjson_schemaに変えたところレスポンスの形式が一定にならない問題を解決できました。

まとめ

今回の記事ではまとめ記事の不適切表現を検出するためのLLMを活用した仕組みについて、GoとOpenAI APIを使用した実装方法を中心に紹介しました。

この仕組みの導入により、一部の記事は自動でNG判定を行い記事の露出を制御できるようになり、人手による確認作業を3割程度削減することができました。 現状はまだ多くの記事でLLMでの判定結果をもとに運用者が最終的に記事の露出可否を判断するようになっていますが、今後はより自動化された判定プロセスの実現を目指していきたいと考えています。