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 の全体構成と記事マップ