Sorry, this page is available in Japanese only.

Cステーショングループ(Cステーション・ADステーション・マンガIPサーチ・講談社SDGs)のエンジニアを担当している青木です。今回はタイトルにある通り、マンガIPサーチで RAG を使った自然言語検索機能をリリースできました。

そもそも「マンガIPサーチ」とはどんなサイトなのか、簡単に説明します。マンガIPサーチは、講談社が持つマンガIPを広告・プロモーションに活用したい企業に作品情報やコラボレーション事例を検索・閲覧してもらい、最終的にお問い合わせにつなげることを目的としたサイトです。掲載されている作品数や事例記事は膨大で、「自社のキャンペーンに合うマンガIPを探したい」というユーザーにとって、目的の情報にたどり着くまでの導線が課題になっていました。

この記事では、その課題を解決するために自然言語検索機能を設計・実装した過程を、技術選定からフロントエンド体験まで一貫して紹介します。

完成したのは、自然な言葉で質問するだけで、関連する事例や資料を踏まえた回答が引用つきで返ってくる、AI チャット型の検索機能です。回答の横には、参考にした記事や資料、言及されたマンガ作品がカード形式で並びます。まずは実際の検索の様子をご覧ください。

マンガIPサーチの自然言語検索機能デモ

なぜ自然言語検索だったのか

マンガIPサーチには既にカテゴリやタグによる絞り込み機能がありましたが、ユーザーの検索ニーズは必ずしも既存の分類軸に収まりません。たとえば「飲料メーカーのキャンペーンに合う、20〜30代女性に人気のマンガIP」のような、複数の条件を自然言語で表現したい場面が多くあります。

こうした曖昧で複合的なクエリに対応するには、キーワード一致型の検索では限界があります。ユーザーの意図を理解し、関連性の高い記事や作品を提示できる仕組みが必要でした。そこで、LLM を活用した自然言語検索の導入を検討することになりました。

技術選定 — なぜ RAG、なぜ Amazon Bedrock だったのか

正直に言うと、この開発を始めた時点で LLM や機械学習に関する深い知識があったわけではありません。「自然言語検索をやりたい」という要件に対して、自分なりに調べながら技術選定を進めていきました。

RAG vs ファインチューニング

LLM で検索を賢くする方法として、大きく2つのアプローチがあることがわかりました。

アプローチ概要所感

ファインチューニング

自社データで LLM を追加学習させ、モデル自体にドメイン知識を組み込む

学習データの整備、学習パイプラインの構築、モデル管理など専門的な工数が大きそう

RAG(検索拡張生成)

既存ドキュメントをベクトル化して検索し、検索結果を LLM
に渡して回答を生成させる

モデル自体はそのまま使え、ドキュメントを用意すれば始められる

結論から言うと、RAG を選びました。理由は大きく3つあります。

まず、データの更新頻度とメンテナンス性です。マンガIPサーチには、WordPress(以降は WP と表記)を介して事例記事や営業資料が定期的に追加・更新されます。ファインチューニングの場合、新しいデータが追加されるたびにモデルの再学習が必要になり、その都度コストと時間がかかります。一方 RAG であれば、ドキュメントを追加してデータの同期を実行するだけで新しい情報が検索対象に反映されるため、コンテンツの追加・更新に対する運用負荷が格段に低くなります。継続的にコンテンツが増えていくサービスにおいて、このメンテナンス性の高さは大きなメリットでした。

次に、チームのスキルセットとの相性です。私たちのチームはフロントエンド・バックエンドを中心とした Web エンジニアで構成されており、機械学習エンジニアや LLM の専門家はいません。ファインチューニングでは学習データの整備やモデルの学習・評価パイプラインの構築など専門的なノウハウが求められるため、現実的ではないと判断しました。

最後に、X(旧 Twitter)の技術コミュニティで RAG の活用事例を多く目にしていたことも後押しになりました。「社内ドキュメントを検索して回答する」というユースケースはまさにマンガIPサーチの要件と重なっており、RAG なら手早く PoC を作って感触を確かめられそうだと感じました。

Amazon Bedrock Knowledge Base を選んだ理由

RAG でいくと決めた後、次は「どうやって RAG パイプラインを構築するか」です。

RAG を実装するには、ドキュメントのベクトル化、ベクトル DB の構築・運用、LLM による回答生成、それらをつなぐパイプラインが必要です。KODANSHAtech の他プロジェクトでは、講談社のシステム部が用意している Azure 環境で Azure AI Studio(OpenAI モデル)を利用するのが一般的でした。しかし、マンガIPサーチのインフラはもともと AWS 上に構築されていたため、Azure に寄せるよりも AWS のマネージドサービスで完結できないかを調べました。

そこで見つけたのが Amazon Bedrock Knowledge Base です。S3 にドキュメントを配置するだけでベクトルインデックスの構築・更新が自動化され、RetrieveAndGenerate API を叩くだけで「検索 + 回答生成」が一発で返ってきます。既存インフラとの親和性も高く、PoC を作るのに最短ルートだと感じました。

実際に PoC を作ってみると、短期間でそれなりの精度の回答が得られました。「まずは動くものを見せて、そこから改善していく」というアプローチが取れたことで、社内での合意形成もスムーズに進みました。

RAG パイプラインの構築

データソースの取り込み

まず、Knowledge Base にデータを投入するパイプラインについて説明します。

データの取り込みは大きく2つの経路があります。ひとつは ECS Scheduled Task(日次バッチ) で、WP に掲載されている記事や PDF を定期的に取得して S3 にアップロードします。もうひとつは WP 管理画面からの直接アップロードで、運用担当者が PDF を手動で追加するケースに対応します。いずれの経路でも、S3 へのアップロード後に Bedrock SDK を通じて Ingestion(データ同期)が実行されます。

データソースフロー図
データソース取り込みの全体フロー

Ingestion が実行されると、Knowledge Base 内部では以下の4ステップが自動的に処理されます。まず Parsing(ドキュメント解析)でテキストを抽出し、次に Chunking(チャンク分割)で検索しやすい単位に分割します。続いて Embedding(ベクトル化)で埋め込みモデルを使ってテキストをベクトルに変換し、最後に Storing(書き込み)で Amazon S3 Vectors にベクトルデータを保存します。これらの処理は Ingestion をキックするだけで自動的に行われるため、必要な作業は解析戦略やチャンク戦略、埋め込みモデルの選定といった事前の設定のみです。

Knowledge Base のデータソース管理

Knowledge Base に投入するドキュメントは、大きく2種類あります。

  1. 記事データ: マンガIPサーチに掲載されている事例記事をテキスト化したもの
  2. PDFデータ: 営業資料やレポートなど、記事化されていないドキュメント

この2種類はドキュメントの特性が大きく異なるため、Knowledge Base 上ではそれぞれ別のデータソースとして管理し、解析戦略とチャンク戦略を個別に設定しています。

データソース別の解析・チャンク戦略の比較図
データソース別の解析戦略・チャンク戦略の比較

記事データは、前述の日次バッチ内で WP の事例記事を HTML からプレーンテキストに変換したうえで S3 にアップロードしており、S3 上の時点ですでに整形済みのテキストになっているため、Bedrock 側の解析にはテキストをそのまま読み取るデフォルトパーサーで十分でした。
また、記事の内容に関しても、マンガIPサーチでは1記事につき1つのトピックについて一貫した文脈で書かれています。そのため、大きめのチャンクで記事全体の文脈を保持しつつ、オーバーラップによって分割境界付近の情報が失われることを防いでいます。チャンク戦略には固定サイズチャンキング(最大8,192トークン、オーバーラップ20%)を採用しています。

一方、PDFデータは営業資料やレポートなど、表・図・段組みを含む複雑なレイアウトのドキュメントです。デフォルトパーサーではレイアウト情報が失われてしまうため、解析には Claude Haiku 4.5 による基盤モデルパーサーを使い、ドキュメント構造を理解したうえでテキストを抽出しています。チャンク戦略にはセマンティックチャンキング(最大1,024トークン、類似度しきい値95%)を採用しました。
加えて、PDFで提供しているドキュメントには、複数のマンガIPの情報が混在していることが多く、固定サイズで分割すると異なる作品の情報が1つのチャンクに混ざってしまいます。セマンティックチャンキングで意味的なまとまりごとに分割し、厳しめの類似度しきい値を設定することで、なるべく作品単位の細かいチャンクに分かれるようにしています。

データソースの鮮度管理

データソースには新旧さまざまなコンテンツが含まれるため、検索結果の鮮度をどう制御するかも検討しました。

最初に検討したのは、Knowledge Base の metadata filtermeta.json によるメタデータフィルタリング)です。各ドキュメントに公開日のメタデータを付与し、検索時に日付でフィルタリングする方法です。しかし、やりたいことは「新しい記事の優先度を上げる」ことであって、「古い記事を除外する」ことではありません。メタデータフィルターは条件に一致しないドキュメントを検索対象から完全に除外してしまうため、要件と合わず不採用としました。

そこで補助的な手段として、ユーザーのクエリに年度情報を付加するクエリ拡張を行っています。データソースの記事テキストには 【公開日】2026-01-15 のような形式で公開日が埋め込まれているため、クエリに年度キーワードを含めると、ベクトル検索で新しい記事が回答の候補に拾われやすくなります。ただしベクトル検索は意味的な類似度で結果を返すため、効果はあくまで緩やかな押し上げ程度です。

あわせて、運用側でデータソースの対象を管理するアプローチも併用しています。WP 上で記事や資料などの投稿ごとにデータソースへの同期を ON/OFF できるようにし、編集担当者が古くなったコンテンツを手動で除外できるようにしています。鮮度の判断は記事の内容やビジネス上の文脈に依存するため、機械的なルールでは拾いきれない部分を人間の判断で補う形です。

どちらも鮮度を完璧に制御するための決め手にはなりませんが、検索アルゴリズム側と運用側の両面から補助的にアプローチすることで、実用上は許容できる範囲に収めています。

自然言語検索のリクエストフロー

次に、ユーザーが自然言語検索を実行してから回答が返るまでの流れです。

ブラウザから検索クエリが送信されると、Astro Server を経由して Bedrock Knowledge Base の RetrieveAndGenerateStream API が呼び出されます。Knowledge Base 内部では、まずクエリ分解(Query Decomposition)で複合的な質問をサブクエリに分割し、それぞれについて S3 Vectors に対するベクトル検索を実行します。検索結果は Cohere Rerank v3.5 によるリランキングで関連度の高い上位数件に絞り込まれ、これらの検索結果とシステムプロンプトが Claude Sonnet 4.6 に渡されます。Claude が生成したストリーミング回答は、Bedrock のオーケストレーション層で、回答テキストの根拠(どの範囲がどの参照元か)を示す Citation と組み合わされ、Bedrock → Astro Server → ブラウザへと Server-Sent Events(SSE)で逐次配信されます。ブラウザ側ではテキストアニメーション、引用ハイライト、引用カード、マンガ作品カードの描画が行われます。

自然言語検索リクエストフロー図
自然言語検索のリクエストフロー

クエリ分解(Query Decomposition)

複合的なクエリに対しては、そのまま検索するよりも意図を分解したほうが精度が上がるケースがあります。RetrieveAndGenerateStream API のオプションとしてクエリ分解機能が用意されており、複雑なクエリを複数のサブクエリに分解して並列に検索を実行できます。

具体例

入力(ユーザーの質問)
「お菓子メーカーで就学前後の女子をターゲットとしたキャンペーンでの、IP活用を考えています。おすすめの女子にアピールできるIPとその予算を教えてください。」

↓ LLM が分解した検索キーワード(実際の出力を読みやすく要約したもの)

検索キーワード

狙い

就学前後の女子に人気のあるIPコンテンツは何ですか

ターゲット層で検索

お菓子メーカーのキャンペーンで活用できる女児向けIPにはどのようなものがありますか

業界×用途で検索

IPライセンス契約の一般的な予算や費用相場はどのくらいですか

予算の一般論を検索

女児向けキャラクターIPをお菓子のキャンペーンで使用する際の費用はいくらですか

具体的な費用を検索

このように1つの質問を複数の視点から検索することで、より網羅的に情報を集めることができます。

リランキングによる精度向上

ベクトル検索はクエリと意味的に近いドキュメントを返しますが、それだけでは必ずしも「ユーザーの質問に対して本当に参考になる」文書が上位に来るとは限りません。RetrieveAndGenerateStream API のオプションとしてリランキングモデルの指定が用意されており、Amazon Bedrock では Cohere Rerank v3.5 や Amazon Rerank 1.0 など複数のリランキングモデルから選択できます。今回は精度面での評価が高かった Cohere Rerank v3.5 を採用し、ベクトル検索の結果を質問の文脈に基づいて再評価することで、関連度の高いドキュメントに絞り込んでいます。

システムプロンプトの設計

RAG パイプラインで「何を検索するか」が決まった後、もうひとつ重要なのが「検索結果をもとにどう回答させるか」です。LLM の振る舞いを制御するシステムプロンプトの設計は、回答品質に直結する部分であり、試行錯誤を重ねたポイントでもあります。

2層構造のプロンプト設計

システムプロンプトは、役割の異なる2つの層を結合して構成しています。

説明

① 編集可能パート(WP で管理)

運用担当者が随時調整

② 自動付与パート(コード側で合成)

フロントエンド連携用、編集不可

① 編集可能パートは、AI の役割定義やビジネスルールを記述した本体部分です。WP の管理画面からバージョン管理付きで編集できるようにしており、エンジニアの手を借りずに運用担当者がプロンプトを改善できます。

② 自動付与パートは、フロントエンドの処理や引用 UI と密結合した技術的ルールです。編集者が誤って削除すると挙動が壊れるため、コード側でリクエスト時に編集可能パートへ合成しています。中身については後述します

プロンプト本体の構造化 — Markdown から XML へ

マンガIPのライセンスビジネスでは、AI が不用意な発言をすると契約上のトラブルにつながりかねません。「このマンガIPは御社のキャンペーンに最適です」と AI が言い切ってしまうと、ユーザーがそれを公式な提案と受け取る可能性があります。そのため編集可能パートには NG 行為(可否判断・主観評価・セールストーク等)と 必須行動 を明確に定義しています。

開発初期は、これらのルールを Markdown の見出し構造で書き、## 最重要ルール(必ず守ること) 配下に「NG: 〜は可能です」「OK: 〜の事例がございます」のような対比例を多数並べる構成にしていました。NG 行為と必須行動を、典型的な NGワード・推奨ワードと一緒に網羅的に書き出しておく狙いです。

リリース前の検証で発覚した引用バッジ集約問題

ところが、リリース前の検証で問題が発覚しました。後述する引用バッジ[1] [2] [3] …)が回答全体で1つの [1] に集約され、その中に複数の参照元がパックされてしまうのです。本来は文や段落の単位で複数のバッジに分かれてほしいところでした。

Bedrock は通常、回答テキストと並行して「回答のどの範囲(startend の文字位置)がどの参照元にもとづくか」を示す citation イベント を、文や段落の境界で複数発行します(詳しくは後述する「引用ハイライト」節で扱います)。ところがこのときは citation イベントが1件しか発行されず、その引用範囲(start: 0end: 698)が回答全体を覆ってしまっていました。

// 問題発生時に Bedrock から返ってきた citation
{
  "inlineCitations": [
    {
      "citationNumber": 1,
      "start": 0,
      "end": 698,
      "sources": [
        /* 5件の参照元がここに詰め込まれている */
      ]
    }
  ]
}

RetrieveAndGenerateStream API には引用粒度を直接制御するパラメータはなく、citation の粒度はモデルの応答スタイル(≒ システムプロンプトの内容)と、Bedrock 側が内部で挿入する出力フォーマット指示の競合具合で決まる、と AWS のドキュメントから読み取れました。検証用ブランチで切り分けを進めた結果、原因は 長文の Markdown 見出しと、命令口調の禁止文(「〜は禁止」「必ず〜」など)の繰り返し にありました。

#試したこと結果
1モデル切替・リランキング無効化・プロンプト各部の順序入れ替えいずれも効果なし
2システムプロンプトを丸ごと削除粒度が復活
3システムプロンプトを部分ごとに削って切り分け長さと命令口調の表現が要因と判明
4XML タグ + アトミックな箇条書き化粒度が復活

プロンプトを丸ごと削除すると citation 粒度が復活したことから、プロンプト本文が Bedrock の citation 指示と競合してモデルを「総合的に1文にまとめる」方向に最適化していた、と推測できます。命令口調の禁止文を増やすほど、回答全体を1つの引用範囲にまとめる傾向が強まっていました。

XML タグ構造への書き換え

決定打となったのは、プロンプト本体を XML タグで構造化し、ルールをアトミックな箇条書きに変換する ことでした。ここでいう「アトミック」とは1行につき記載するルールは1つとし、それ以上分割できない粒度に揃えることを指します。1行に複数の指示を詰め込まないことで、モデルが回答も文単位に分けて出力しやすくなります。今回利用している Claude は Anthropic 公式のプロンプトガイドで XML タグでの構造化を推奨されているモデルで、<role> <content_rules> のようなタグでセクションを区切ると、Bedrock 側が挿入する citation 指示との衝突が緩和されます。

最終的に、編集可能パートは以下のような XML 構造に書き換えました(以下は実際のプロンプトから一部を抜粋・簡略化したものです)。

<role>
あなたは、講談社のマンガIPの活用事例を企業担当者に案内するアシスタントです。
検索結果に含まれる事例・資料を、お客さまに積極的に紹介することが第一の役割です。
</role>

<content_rules>
- 検索結果に関連事例があれば、作品名・企業名・施策内容を明記して紹介する
- 事実のみを伝える
- 可否判断・主観評価・セールストーク表現は禁止
- ネガティブ表現や内部状態の露出は避ける
</content_rules>

<fallback>
該当する事例が無い場合のみ、定型のフォロー文を返す
</fallback>

<closing>
問い合わせフォームへの誘導文を Markdown リンクで出力する
</closing>

ビジネスルールは <content_rules> に集約しています。Markdown 時代の NG/OK 対比例を箇条書き1行に圧縮することで命令口調の強さを和らげつつ、要件は維持しました。さらに <role> で「事例の紹介を第一の役割とする」ことを明示し、過剰に慎重で「見つかりません」と返してしまう挙動も抑えています。

なお、citation 粒度に直接効く <answer_format> や、鮮度優先の <priority_rules> はこの編集可能パートには含めず、フロントエンドが依存する不変ルールとしてコード側で自動付与しています。詳細は後述します

プロンプトテンプレートの配置戦略

Bedrock の RetrieveAndGenerateStream API では、プロンプトテンプレート内にユーザーのクエリ($query$)、検索結果($search_results$)、システムプロンプトを自由に配置できます。

開発初期は、システムプロンプトを 検索結果の後ろ に置く構成にしていました。検索結果が大量に返るケースで NGワード違反が起きやすい印象があり、ルールを入力の末尾に置けば守られやすくなるのでは、と考えたためです。LLM の recency bias(直近の入力をより重視する傾向)を活かす狙いでした。とはいえ、この配置単体での効果を厳密に測定できたわけではなく、recency bias という一般的に知られた手法を、低コスト・低リスクな打ち手の1つとして取り入れた、というのが実際のところです。

XML 構造化に切り替えた際にこの配置も再検討しました。<role><closing> の XML タグでセクションが明確に区切られるようになったため recency bias への依存度が下がり、また検索結果とユーザークエリを <context> / <user_query> で包めば指示と入力の境界も明示できます。最終的に、システムプロンプト本体を先頭、検索結果とユーザークエリを末尾 に置く構成に変えました。

${systemPrompt}              ← <role> 〜 <closing> までの XML 本体

<context>
$search_results$
</context>

<user_query>
$query$
</user_query>

$output_format_instructions$

検索結果とユーザークエリをタグで包むことで、システムプロンプト本文との境界が明確になり、モデルが「どこが指示で、どこが入力か」を取り違える余地も減らせています。

なお、テンプレート末尾の $output_format_instructions$ は、Bedrock が citation の付け方や出力フォーマットに関する指示を実行時に自動で差し込むためのプレースホルダーです。冒頭の引用粒度の調査で触れた「Bedrock 側が挿入する citation 指示」とはこれを指しており、中身は AWS 側が管理しているため開発者は編集できません。システムプロンプト本文がこの自動挿入される指示と競合すると、回答全体が1つの引用範囲にまとめられてしまいます。これが今回の引用粒度問題の核心でした。

自動付与パート — フロントエンドが依存する不変ルール

編集可能パートとあわせて、Bedrock に渡すシステムプロンプトには WP 編集者が触らないコード側のパート を合成しています。中身は、citation UI やフロントエンドの作品名抽出機能と密接に絡み、運用担当者が誤って削除すると挙動が壊れる類のルールです。

<answer_format>

引用粒度を保つための「1文 = 1事実」「複数 source 混合禁止」、フロントエンドの作品名抽出で前提となる「マンガ作品名は『作品名』で囲む」、出力 Markdown のホワイトリスト(コードブロック・引用・取り消し線・画像・生 HTML を禁止)など、UI 側の挙動と密接に絡むルールがここに入ります。<role> 直後に挿入しています。

<answer_format>
- 1文には1つの事実・主張のみを含める
- 複数の検索結果を1文にまとめない(各文は1つの source のみに基づく)
- 複数の事例を紹介する際は段落または箇条書きで分ける
- マンガ作品名は『作品名』で囲む
- 出力は Markdown(コードブロック・引用・取り消し線・画像・生 HTML は禁止)
</answer_format>

前述のとおり、citation 粒度の決め手は「1文 = 1事実」「複数 source を1文にまとめない」のルールです。WP で運用担当者が誤って消すと、引用バッジが再び [1] に集約されてしまうため、コード側で必ず注入しています。「マンガ作品名は『作品名』で囲む」は後述する「マンガ作品カード」機能の前提条件で、これも消えるとフロントエンドの正規表現抽出が機能しなくなります。

<priority_rules>

「【公開日】が新しい事例を優先する」というルールで、生成段階で効きます。検索段階のクエリ拡張が「新しい記事を検索結果に入りやすくする」ものだったのに対し、こちらは「検索で取得できた事例のうち、新しいものを優先して回答に提示する」役割です。検索で取得できなかった記事は対象にできないため、両者は補完関係にあります。優先順位の年度(当年・前年・前々年)は new Date().getFullYear() から実行時に組み立てるため、年が変わっても古びません。

function buildPriorityRulesXml(): string {
  const y0 = new Date().getFullYear();
  return `<priority_rules>
- 似たような関連度の事例が複数ある場合、【公開日】が新しい方を優先する
  (優先順位: ${y0}年 > ${y0 - 1}年 > ${y0 - 2}年)
- 古い事例でも質問者の意図に合致する場合は提示してよい
</priority_rules>`;
}

合成側では、これら2つを編集可能パートの所定位置に挿入しています。<answer_format><role> 直後に、<priority_rules><fallback> の直前に差し込むことで、XML 構造を崩さずに不変ルールを組み込めます。WP 側の編集可能プロンプトに同名タグが残っているケース(古いバージョンを復元したときなど)でも、注入前に取り除く処理で二重定義を防いでいます。

// server-config.ts(簡略化)
function combineSystemPromptParts(editablePrompt: string): string {
  const sanitized = stripAutoInjectedTags(editablePrompt);
  return injectFixedParts(sanitized, {
    answerFormat: ANSWER_FORMAT_XML,
    priorityRules: buildPriorityRulesXml(),
  });
}

プロンプトのバージョン管理

プロンプトの改善は一度で終わるものではなく、運用しながら継続的に調整していくものです。そこで、WP の管理画面上でプロンプトのバージョン管理ができる仕組みを用意しました。

運用担当者は管理画面から新しいバージョンのプロンプトを作成し、アクティブなバージョンを切り替えることができます。過去のバージョンも保持されるため、変更後に品質が下がった場合は以前のバージョンに戻すことも可能です。プロンプトは毎リクエスト時にデータベースから取得する設計にしており、デプロイなしで即座に反映されます。

回答品質の評価

プロンプトを改善したら、その効果を定量的に確認する仕組みが必要です。WP の管理画面にテスト機能を追加し、プロンプトのバージョンごとに回答品質をスコア化できるようにしました。

評価は2種類の基準を組み合わせて行います。ルールベース評価では、NGワードが含まれていないか、問い合わせ導線で締められているかといった機械的にチェック可能な項目を PASS/FAIL で判定します。LLM ベース評価では、回答の正確性や自然さといった定性的な項目を Claude に5段階のルーブリックで採点させます。あらかじめ登録しておいたテストケース(質問と期待される振る舞い)に対して一括実行し、各基準のスコアを重み付きで集計することで、プロンプトバージョンごとの総合スコアを算出します。

評価基準の管理画面

テストケースの編集画面

テスト結果の詳細画面

正直に言うと、プロンプトの工夫だけでは劇的な改善には至りませんでした。回答品質に最も大きく影響したのは、回答生成モデルを Claude Sonnet 4.5 から 4.6 にアップグレードしたことでした。モデルのバージョンアップ後は、NGワード違反の減少や、検索結果に基づいた正確な回答の生成といった面で明確にスコアが向上しました。プロンプトの改善はもちろん意味がありますが、モデル自体の能力向上による恩恵が大きかったというのが実感です。

フロントエンド体験の実装

前述のフロー図の最後に示した通り、ブラウザ側ではストリーミングで受信したデータをもとに、いくつかのフロントエンド体験を組み合わせて描画しています。マンガIPサーチにはもともと事例記事・資料ダウンロード(PDF)・マンガ作品といったリソースが揃っており、LLM の回答からこれらの既存コンテンツへシームレスにつなぐことで、検索体験をより豊かにしています。ここでは、SSE によるストリーミング応答、テキストアニメーション、引用ハイライト、マンガ作品カードの順に、それぞれの実装を紹介します。

SSE によるストリーミング応答

LLM の回答は数秒かかるため、回答が完成するまで待ってから表示するのではユーザー体験が悪くなります。SSE を使って、生成された回答をリアルタイムにストリーミング表示するようにしました。

// stream.ts(簡略化)
const response = await queryBedrockKnowledgeBase(query, config);

return new Response(
  new ReadableStream({
    async start(controller) {
      for await (const chunk of response.stream) {
        if (chunk.output?.text) {
          controller.enqueue(
            `data: ${JSON.stringify({
              type: "text",
              content: chunk.output.text,
            })}\n\n`,
          );
        }
      }
      // Bedrock のセッション機能で会話履歴が保持され、
      // フォローアップ質問で前の回答を踏まえた応答が可能になる
      controller.enqueue(
        `data: ${JSON.stringify({
          type: "done",
          sessionId,
        })}\n\n`,
      );
      controller.close();
    },
  }),
  {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  },
);

プロキシを経由する環境では、レスポンスがバッファリングされてしまうことがあります。バッファリングが起きると、サーバー側では逐次送信しているにもかかわらず、プロキシがある程度のデータを溜め込んでからまとめてクライアントに転送するため、ストリーミングの意味がなくなり、ユーザーには長い待ち時間のあとに回答が一気に表示されるという体験になってしまいます。X-Accel-Buffering: no は Nginx 系のリバースプロキシでバッファリングを無効化するヘッダーで、no-transform は中間プロキシによるレスポンス変換を抑制します。

テキストアニメーションの工夫

SSE で受信したテキストをそのまま DOM に反映すると、チャンクの到着タイミングに依存してカクカクした表示になります。そこで、バッファにチャンクを溜めて requestAnimationFrame で1フレームずつ描画するアニメーションバッファを実装しました。

// TextAnimationBuffer(簡略化)
class TextAnimationBuffer {
  private buffer = "";
  private displayedLength = 0;

  append(text: string) {
    this.buffer += text;
    if (!this.animationFrameId) this.startAnimation();
  }

  private startAnimation() {
    const animate = () => {
      if (this.displayedLength < this.buffer.length) {
        const remaining = this.buffer.length - this.displayedLength;
        // バッファ残量に応じて1フレームの描画文字数を調整
        const charsToAdd = remaining > 50 ? 4 : remaining > 20 ? 3 : 2;

        this.displayedLength += Math.min(charsToAdd, remaining);
        this.onUpdate(this.buffer.slice(0, this.displayedLength));
        this.animationFrameId = requestAnimationFrame(animate);
      }
    };
    this.animationFrameId = requestAnimationFrame(animate);
  }
}

ポイントは、バッファの残量に応じて1フレームあたりの描画文字数を2〜4文字で動的に変えている点です。バッファが溜まっている間は速く消化し、少なくなると速度を落とすことで、ネットワーク状況に左右されない滑らかな「タイピング」のような体験を実現しています。

引用ハイライト

LLM の回答には、どの記事や PDF を根拠にしたかを示す引用情報が付随します。Bedrock の Citation オブジェクトには、回答テキスト中のどの範囲どのドキュメントを参照しているかが含まれています。

// Bedrock が返す Citation の構造(簡略化)
{
  generatedResponsePart: {
    textResponsePart: {
      span: { start: 0, end: 128 } // 回答テキスト上の文字位置
    }
  },
  retrievedReferences: [
    {
      location: { s3Location: { uri: "s3://bucket/article/123.txt" } },
      metadata: { title: "コラボ事例記事のタイトル" }
    }
  ]
}

サーバー側では、ストリーミング完了後にこの Citation を加工し、連番の引用番号を振りつつ、S3 URI からドキュメントの種別(記事 or PDF)やページ番号を抽出して、フロントエンドが扱いやすい形に変換します。

// フロントエンドに渡す引用データの型
type AiSearchInlineCitation = {
  citationNumber: number; // [1], [2], ...
  start: number; // 回答テキスト上の開始位置
  end: number; // 回答テキスト上の終了位置
  sources: {
    type: "article" | "pdf";
    id: number;
    title: string;
    pageNumber?: number; // PDF の場合のみ
    url: string;
  }[];
};

ここからが技術的にチャレンジだった部分です。この start / end元テキスト上の文字位置なので、Markdown をパースして表示用に変換すると記号が消えた分だけオフセットがずれます。

元テキスト(21文字): "これは**重要な**事例です"
                        ^^     ^^        ← Markdown記号(計4文字)
パース後(17文字):   "これは重要な事例です"
                      ↑ オフセットが4文字分ずれる

これに対しては、パーサーの各ノードに sourceOffset(元テキスト上の位置)を持たせることで、パース後も元の位置を追跡できるようにしました。

このオフセット調整を経て、最終的に各テキストノードの sourceOffset と引用の start / end を突き合わせ、「このテキストノードのどこからどこまでが引用範囲か」をローカルオフセットに変換します。

// getCitationsInRange(簡略化)
function getCitationsInRange(citations, rangeStart, rangeEnd) {
  return citations
    .filter((c) => c.start < rangeEnd && c.end > rangeStart)
    .map((c) => ({
      citation: c,
      // 絶対位置 → テキストノード内の相対位置に変換
      localStart: Math.max(0, c.start - rangeStart),
      localEnd: Math.min(rangeEnd - rangeStart, c.end - rangeStart),
      // 引用の末尾がこのノード内にあればバッジ [1] を表示
      showBadge: c.end > rangeStart && c.end <= rangeEnd,
    }));
}

この関数の結果をもとに、引用範囲のテキストをハイライト用のコンポーネントで包み、引用が終わる位置にツールチップ付きのバッジ [1] を挿入します。バッジにホバーすると React Context を通じて対応するテキスト範囲のハイライトが連動し、クリックすると参照元の記事や PDF へのリンクがツールチップとして表示されます。

引用ハイライトの実装例。引用範囲のテキストがハイライトされ、バッジをクリックすると参照元のツールチップが表示される
ハイライト・バッジ・ツールチップの表示例

マンガ作品カード

LLM の回答に登場したマンガ作品は、回答の横(モバイルでは下)のサイドバーに、表紙画像・著者名・掲載誌を含むカード UI として表示しています。

ここで「マンガ作品のデータ自体を Knowledge Base のデータソースに含めればいいのでは?」と思われるかもしれません。実際、マンガ作品の DB は存在するのですが、巻数分のデータが格納されており、そのレコード数は膨大です。これをデータソースに含めてしまうと、ベクトル検索時にマンガ作品の情報がノイズとなり、本来見つけたい事例記事や営業資料が検索結果の上位に出にくくなるという問題がありました。そこで、マンガ作品データはデータソースには含めず、LLM が事例記事や資料をもとに回答した中で言及した作品名だけを抽出し、その作品に対してカード表示を行うという設計にしています。こうすることで、検索精度を維持しつつ、ユーザーにとって関連性の高い作品情報だけを提示できるようになっています。

まず、回答テキストから 『作品名』 パターンで作品名を正規表現で抽出します。次に、抽出した作品名リストを API に投げて、表紙画像・著者名・掲載誌などのメタデータを取得します。このとき、LLM が出力した作品名と作品データベースのタイトルをマッチングする必要がありますが、NFKC 正規化で全角・半角の揺れを吸収したうえで完全一致を試み、見つからない場合は前方一致によるフォールバックに切り替えています。これは、LLM が副題やシリーズ名を省略して出力するケースへの対応です。たとえば正式タイトルが「進撃の巨人 The Final Season」であっても、LLM が 『進撃の巨人』 とだけ出力した場合に正しくマッチさせられます。

取得した作品データは、サイドバーのカード UI として描画します。

回答の横のサイドバーに表紙画像・著者名・掲載誌を含むマンガ作品カードが並ぶ表示例
マンガ作品カードの表示例

レスポンスレンダリングパイプラインの全体像

ここまで紹介してきた SSE によるストリーミング応答、テキストアニメーション、引用ハイライト、マンガ作品カードは、それぞれ独立した処理ではなく、1つのパイプラインとして連携しています。以下の図は、Bedrock からのストリーミングレスポンスがブラウザ上の各 UI 要素として描画されるまでの処理フローをまとめたものです。

レスポンスレンダリングパイプラインの処理フロー図
レスポンスレンダリングパイプラインの処理フロー

ポイントは、「Markdown 解析 + 引用ハイライト挿入」と「作品 API から作品データを取得」が独立したコンポーネントで並列に動くことです。回答本文を描画するコンポーネントは Markdown 解析を同期的に行い、受信したテキストをすぐに画面へ反映します。一方、その隣に並ぶ作品カードのコンポーネントは、データ取得ライブラリの SWR を使って非同期に作品 API を叩き、データが返り次第カードを差し込みます。両者は互いの完了を待たず同じ描画サイクルで同時に走り出すため、本文の表示が API レスポンスの到着を待たされることはありません。

おわりに

今回の開発を振り返ると、ポイントは次の3点です。

  • Amazon Bedrock Knowledge Base のようなマネージドサービスを使えば、機械学習の専門知識がなくても RAG を使った機能を導入できる。ベクトルインデックスの構築から検索・回答生成まで、普段の Web 開発の延長線上で実装できました。
  • ストリーミング表示や引用ハイライトといったチャット UI も、専用ライブラリに頼らず自前で実装できる。SSE・テキストアニメーション・引用ハイライトを組み合わせることで、検索体験を大きく豊かにできました。
  • これらを Claude Code や Codex と壁打ちしながら進めることで、それほど工数をかけずに実装できた。インフラからフロントエンドまでレイヤーを横断する開発を、スムーズに形にできました。

自然言語検索機能の導入を検討している方の参考になれば幸いです。