Gunosy Tech Blog

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

運用者の「欲しい記事」をプロンプトで定義する。LLM×記事選定の活用事例

こんにちは、グノシー開発部 の 本多 です。

こちらの記事は Gunosy Tech Blog Festa の 5 日目の記事です。

今回はグノシープロダクトのプッシュ記事選定において、キーワード検索だけでは表現しきれない「運用者が本当に欲しい記事」の特徴をLLMで抽出し、柔軟な記事探しを可能にした事例をご紹介します。

Gemini の NanoBanana Pro でサムネを作成したらすごいキャッチーになりました

はじめに

弊社ではキュレーションニュースアプリを運営しており、様々なジャンルの記事を扱っています。その中でも、ユーザーの興味を引く記事や世間的に話題になっている出来事を「プッシュ通知」として配信するため、定期的に記事を選定する作業を行っています。

しかし、毎日約数万件以上増えていく記事の中から、候補となる100件以上を選定する作業は負荷が高く、LLMが台頭してきた昨今においてより効率的な方法を模索してきました。

そして今回、LLMに運用者が記述したプロンプトを与えることで、自由に記事情報を抽出し、その情報を元に柔軟な記事検索ができる仕組みを構築しました。 本記事では、その実現方法や工夫した点について共有します。

注意点

本システムでは、社内での運用効率化を目的としてLLMによる記事情報の抽出や要約を行っています。 あくまで検索や選定の補助として利用しており、元となる記事の改変や、LLMを用いて新たな記事本文を作成(生成)することは行っておりませんので、あらかじめご了承ください。

やったこと

今回の実装にあたり、大量のデータをLLMで処理する必要があるため、特に以下の3点を重視しました。

  • プロンプトの自由度: 管理画面からプロンプトを記述し、抽出項目を柔軟に変更できること

  • データ設計: 大量の記事データを効率的に保持・検索できる構造にすること

  • コスト管理: LLMの利用コストが膨らみすぎないよう制御・監視できること

ビジネス側からは「運用者がプロンプトを通じて、記事の特徴(タグや要約など)を自由に抽出したい」という強い要望がありました。 そのため、どのような条件で情報を抽出するか(プロンプト)を管理画面上で保存・管理できる機能を実装しました。

さらに、抽出した特徴データを既存の記事検索システムにも連携させ、検索機能の強化を図りました。

アーキテクチャ

アーキテクチャ図

※ 実際のアーキテクチャとは一部構成が異なる場合があります。

今回、処理を複数のLambdaに分割する「多段Lambda構成」を採用しました。主な理由は以下の3点です。

  1. バッチ処理の並列管理: 複数の抽出ルール(プロンプト)に基づいたバッチが並列実行される可能性があるため、個々の処理ロジックとは切り離し、バッチ全体の進行を統括・管理するオーケストレーターとしてのLambdaが必要でした。

  2. コストと負荷の抑制: 記事全文をそのままLLMに渡して特徴抽出を行うと、トークン消費量が多くコストや負荷が高まります。そのため、前段で一度「要約(サマリ)」を作成し、データ量を圧縮する工程を挟んでいます。

  3. 責務の分離と依存関係の解消: 「要約作成」と「特徴抽出」を同一関数内で行うと、処理の複雑化やタイムアウトのリスクがあります。また、要約データが確実に生成された後に特徴抽出を行いたいため、Lambdaを分割して順次処理させる構成としました。

また、大量の記事本文を並列で取得する際の負荷対策として、新たにCloudFront(CDN)を導入しました。これによりキャッシュを効かせ、オリジン(ECS,S3等)へのリクエストを削減することで、コスト最適化と安定稼働を図っています。

動作フロー

システム全体の処理フローは以下の通りです。

  1. ルールの作成: 管理画面にて、運用者が「どのような条件で特徴を抽出したいか」を定義したルールを作成します。
  2. バッチ起動: CloudWatch Events (EventBridge) で毎分バッチをトリガーし、1.で作成されたルールに基づいて、後続のLambda群を実行します。
  3. LLM処理と保存: プロンプト(抽出条件)をLLMに渡し、返却された構造化データをデータベースに保存します。
  4. 検索への反映: 管理画面の検索機能はデータベースを参照しているため、保存された特徴データを用いて、即座に検索機能が強化されます。

ルール管理テーブル (rules) 抽出ルールを管理するために、以下のテーブルを作成しました。 プロンプトの内容だけでなく、使用するLLMモデルも運用者が選択できる設計にしています。

CREATE TABLE `rules` (
  `key` varchar(100) NOT NULL COMMENT '特徴抽出ルールキー',
  `name` varchar(255) NOT NULL COMMENT '特徴抽出ルールの名前',
  `prompt` mediumtext  NOT NULL COMMENT '特徴抽出ルールのプロンプト',
  `model` varchar(255) NOT NULL COMMENT '特徴抽出ルールのLLMモデル',
  `is_summary_required` tinyint(1) NOT NULL DEFAULT '0' COMMENT '特徴抽出ルールの要約が事前に必要か',
  `target_start_from` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '特徴抽出ルールの対象範囲の開始日時',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`key`)
) COMMENT='特徴抽出ルールのテーブル'

設計のポイント

  • is_summary_required (要約の要否): 前述の通り基本的にはコスト削減のために「要約」を行いますが、要約段階で重要なキーワードが抜け落ちてしまうリスクがあります。そのため、精度を重視したいルールでは要約をスキップし、記事全文を対象にできるようにフラグを持たせました。

  • target_start_from (対象期間の制御): 毎日数万件以上の記事が追加されるため、全記事を対象にするとコストが膨大になります。「いつからの記事を対象にするか」を指定できるようにし、必要な期間の記事のみをバッチ処理対象とすることでコストを制御しています。

実例

具体例として、昨今ニュースなどで世間を騒がせている、今年の漢字にもなっている熊の記事として「熊の出没情報」を抽出するケースを紹介します。

仕組みとしては、プロンプト内の変数としてタイトル(Title)と、本文(Content)または要約(SummaryLong / SummaryShort)を埋め込めるように設計しています。 これに対し、LLMからは以下の3フィールドを持つJSON形式でレスポンスを受け取り、そのままデータベースへ保存します。

  • is_target: 抽出対象かどうかのフラグ
  • value: 抽出した具体的な値(地名や状況など)
  • opts: その他の付加情報
記事が「野生のクマによる被害・事故」にどの程度関連するかを、カテゴリ(「一次情報」「続報」「関連性あり」「無関連」)に分類してください。

- 記事の内容や文脈をもとに、なぜそのカテゴリが最適なのかをステップバイステップで説明し、根拠を明示してください。
- 必ず次の2段階で回答してください。①推論(分類のための根拠や考察)→②分類結果(カテゴリ名称)。
- 推論を先に、分類(結論)を最後に必ず記載してください(結論先出しは禁止)。
- 出力形式はJSONとし、「is_target」(「関連性あり」以上なら1, 「無関連」なら0)、「opts」(推論の詳細説明:200字以上)と「value」(カテゴリ名称。「一次情報」「続報」「関連性あり」「無関連」から1つ)の3項目を出力してください。

# 記事
タイトル: {{.Title}}
サマリ: {{.SummaryLong}}

# レスポンス(JSON形式)
"is_target": "[「関連性あり」以上なら1, 「無関連」なら0]"
"opts": "[記事内容・文脈を分析し、その記事がなぜ・どこまで指定されたテーマに関連するのかを説明。具体的な文章引用や観点を用い、『一次情報(直接的報道)』『続報(新たな追加情報や進行状況の報道)』『関連性あり(直接ではないがテーマに言及/影響を受ける話題)』『無関連(全く関係ない)』かを明示する根拠を記載。200字以上。]"
"value": "[カテゴリ名称]"


# 注意点
- 「opts」パートは、分類判定に至るまでに最低でも2つ以上の観点(例:記事の直接性・間接性、具体的言及の有無など)を挙げて説明してください。
- どのカテゴリにも属さない場合は「無関連」に分類してください。
- 推論(opts)→分類(value)の順番だけを厳守してください。

---

重要な指示と目標の再掲:
記事が指定のテーマにどれほど関連するかを、「一次情報」「続報」「関連性あり」「無関連」いずれかに、推論(根拠解説)→分類(結論)の順でJSONで出力してください。
{
  "is_target" : "1",
  "value": "一次情報",
  "opts": "記事は複数の地域で発生しているクマによる被害や事故について報じており、直接的に「野生のクマによる被害・事故」というテーマに関連している。具体的には、山梨県北杜市での子グマの目撃情報、青森県深浦町での農作物(ふかうら雪人参)への親子グマによる被害(約500万円)、秋田県潟上市での人を恐れないクマの出没と住民の不安、そしてクマがキウイを食べる様子などが記述されている。これらの内容は、クマの出没状況、被害の実態、そしてそれに対する人々の反応といった、テーマに直接関わる情報を提供している。特に、農作物の被害額や具体的な目撃場所・状況が示されている点は、一次情報に近い報道と言える。複数の地点での事例を網羅的に紹介しており、テーマの全体像を把握する上で有用であるため、「一次情報」またはそれに準ずる情報として分類するのが適切である。",
}

運用後の改善と課題

一定期間の運用を経て、いくつかの課題が見えてきました。ここでは試行錯誤した点や、今後の改善方針について共有します。

レートリミット対策

本システムでは、各ルールごとに使用するLLMモデルを指定できるため、理論上の最大リクエスト数は以下のようになります。

実行回数 = ルール数 × 対象記事数

そのため、単純に並列実行するとAPIのレートリミット(RPM)に抵触するリスクがありました。 そこで、バッチ実行時に「各モデルの同時実行数」や「処理するルール数」をSQL(DB取得時)の段階で制限する仕組みを導入しました。これにより、アプリケーション側で複雑な制御を行わずとも、仕組み上レートリミットを超えないように安全に管理できています。

例)gpt-4o:10000RPM

1000記事/ルール → 最大10ルール/分

WITH gpt_4o AS (
SELECT
    r.key,
    r.is_summary_required,
    r.target_start_from
FROM
    `rules` AS r
WHERE
    r.`model` = 'gpt-4o'
LIMIT 10 -- 10000RPM
)

モデルの追加・更新フロー

現在は OpenAI (GPT系) と Google (Gemini系) の2種類をサポートしていますが、レートリミットやコスト感の違いがあるため、新規モデルの追加は開発者が手動で検証・設定を行っています。

高額なモデルを誤って大量利用してしまうリスクも考慮する必要があります。今後は、これらのモデル管理や検証フローをより自動化・簡易化できる仕組みを検討しています。

過去記事への遡及適用(コスト管理)

抽出対象期間を長く設定しすぎると、LLMへのリクエストが一気に集中し、コストが急増する課題がありました。

現在は運用者と開発者の双方でコストを監視していますが、今後は LiteLLM や Arch といったLLM Gateway(Proxy)を導入し、トークン使用量や予算管理をシステムレベルで統合管理する必要性を感じています。

実際、gpt5-nano を用いて過去記事を一括回遊した際にコストが跳ね上がってしまったケースもありました。

gpt5nano-cost

まとめ

今回は、弊社でのプッシュ記事選定において、LLMを活用することで、運用者の意図や記事のニュアンスを反映した「新しい記事選定フロー」を構築した事例をご紹介しました。

まだまだ改善の余地はありますが、同じような課題をお持ちの方や、これから社内でLLMを活用しようとしている方にとって、少しでもヒントになれば嬉しいです。