Tech Blog

LLM(Ollama/OpenAI)で映画タグを自動生成するバッチを Spring Boot に組み込んだ話

Spring Boot LLM Java AI

はじめに

DVDレンタルアプリで、映画に「雰囲気タグ」を付けたいという要件がありました。

「アクション」「感動」「家族向け」「ホラー」のような、カテゴリより細かい感情タグです。
映画は約1,000本あり、手動でタグを付けるのは現実的ではありません。

そこで、LLMに映画のタイトルと説明文を渡して、タグを自動生成するバッチ処理を作りました。


全体の構成

Spring Boot バッチ処理
  ↓ filmテーブルから未タグ付き映画を取得
  ↓ タイトル + 説明文 をプロンプトに組み立て
  ↓ LLM API(Ollama or OpenAI)に送信
  ↓ レスポンスからタグを抽出
  ↓ film テーブルの taste_tags カラムに保存

LLMのプロバイダ選択

ローカル開発では Ollama(無料、プライバシー安全)、本番では OpenAI API を使い分けました。

プロバイダメリットデメリット
Ollama(llama3)無料、オフライン、プライバシー精度がOpenAIより低め、速度が遅い
OpenAI(GPT-4o-mini)高精度、高速API費用、ネット接続必要

Spring Boot へのLLMクライアント組み込み

依存関係

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0-M6</version>
</dependency>

<!-- または Ollama -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    <version>1.0.0-M6</version>
</dependency>

設定

# application.yml(Ollama使用時)
spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: llama3

---
# application-prod.yml(OpenAI使用時)
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini

バッチ処理の実装

タグ生成サービス

@Service
@Slf4j
public class FilmTagGenerationService {
    
    private final ChatClient chatClient;
    private final FilmTagMapper filmTagMapper;
    
    public FilmTagGenerationService(ChatClient.Builder builder, FilmTagMapper mapper) {
        this.chatClient = builder.build();
        this.filmTagMapper = mapper;
    }
    
    public void generateTagsForAllFilms(int batchSize) {
        // 未タグ付き映画を取得
        List<FilmForTagging> films = filmTagMapper.findFilmsWithoutTags(batchSize);
        log.info("タグ生成対象: {}件", films.size());
        
        for (FilmForTagging film : films) {
            try {
                String[] tags = generateTags(film);
                filmTagMapper.updateTasteTags(film.getFilmId(), tags);
                log.info("タグ付与完了: {} → {}", film.getTitle(), Arrays.toString(tags));
                
                // API レート制限対策
                Thread.sleep(500);
            } catch (Exception e) {
                log.error("タグ生成エラー: filmId={}", film.getFilmId(), e);
            }
        }
    }
    
    private String[] generateTags(FilmForTagging film) {
        String prompt = buildPrompt(film);
        
        String response = chatClient.prompt()
            .user(prompt)
            .call()
            .content();
        
        return parseTagsFromResponse(response);
    }
    
    private String buildPrompt(FilmForTagging film) {
        return String.format("""
            以下の映画の雰囲気を表す日本語タグを5つ以内で生成してください。
            タグはカンマ区切りで返してください。余分な説明は不要です。
            
            タイトル: %s
            説明: %s
            
            例の出力形式: アクション,ハラハラ,家族向け,爽快感,バトル
            """,
            film.getTitle(),
            film.getDescription()
        );
    }
    
    private String[] parseTagsFromResponse(String response) {
        return Arrays.stream(response.split("[,、]"))
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .limit(5)
            .toArray(String[]::new);
    }
}

バッチのトリガー方法

手動実行エンドポイント(管理画面から呼び出す)

@RestController
@RequestMapping("/admin/api/batch")
@PreAuthorize("hasRole('ADMIN')")
public class BatchController {
    
    private final FilmTagGenerationService tagGenerationService;
    
    @PostMapping("/generate-tags")
    public ResponseEntity<Map<String, Object>> generateTags(
            @RequestParam(defaultValue = "50") int batchSize) {
        
        tagGenerationService.generateTagsForAllFilms(batchSize);
        return ResponseEntity.ok(Map.of(
            "message", "タグ生成完了",
            "processedCount", batchSize
        ));
    }
}

スケジュール実行(夜間バッチ)

@Component
public class TagGenerationScheduler {
    
    private final FilmTagGenerationService tagGenerationService;
    
    @Scheduled(cron = "0 0 2 * * ?")  // 毎日深夜2時
    public void scheduledTagGeneration() {
        tagGenerationService.generateTagsForAllFilms(100);
    }
}
# application.yml
spring:
  task:
    scheduling:
      enabled: true

実際の生成結果例

映画タイトル生成タグ
ACADEMY DINOSAUR教育的, 冒険, ファミリー向け, 感動, 楽しい
ACE GOLDFINGERスパイ, スリル, アクション, ミステリー, 大人向け
AFFAIR PREJUDICEロマンス, ドラマ, 感情的, 深い, 大人向け

1,000本の映画に約8分でタグ付け完了(Ollama llama3使用、ローカル環境)。


詰まったポイント

① LLMが指定フォーマット以外で返す

「以下のタグを生成しました: アクション、スリル、家族向け」

のように前置きを付けて返すことがあります。
正規表現でタグ部分だけ抽出するか、Few-shotプロンプティングで出力形式を固定します。

// Few-shot プロンプト例
String fewShotPrompt = """
    指示: 映画の雰囲気タグをカンマ区切りで返してください。

    例1:
    入力: Die Hard(刑事もの・アクション)
    出力: アクション,ハラハラ,スリル,大人向け,興奮

    例2:
    入力: Toy Story(アニメ・子ども向け)
    出力: ファミリー,ほっこり,冒険,楽しい,感動

    入力: %s(%s)
    出力:
    """.formatted(film.getTitle(), film.getDescription());

② API コスト管理

OpenAI APIは呼び出しごとに課金されます。
1,000本 × 1回 = 最大数百円程度(GPT-4o-mini)ですが、誤って繰り返すと費用が膨らみます。

// 既存タグがある映画はスキップ
filmTagMapper.findFilmsWithoutTags(batchSize)
// → taste_tags IS NULL の映画のみ取得するクエリ

まとめ

  • Spring AI を使うとLLMクライアントをSpring Bootに簡単に組み込める
  • ローカル開発はOllama(無料)、本番はOpenAI APIという使い分けが@Profileで実現できる
  • タグ生成のプロンプトはFew-shotで出力形式を固定すると安定する
  • バッチは「未処理のみ対象」にしてべき等性(何度実行しても同じ結果)を保つ

手作業では対応できない規模のデータにLLMを使うと、短時間でコンテンツを充実させられます。


このシリーズの記事マップ

dvdrental 管理アプリと対になるエンドユーザー向けDVDレンタルアプリを作っている話 — Vue 3 + Spring Boot の全体構成と記事マップ

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。