0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RAG × AIエージェントで過去資料を参照しながら企画書を生成するアプリを作った

0
Posted at

はじめに

最近の現場では、AIエージェントを活用したツール開発が急速に進んでいます。自分の周りでもその動きが活発になってきたことをきっかけに、「理解するためにはまず自分で作ってみるのが一番」と考え、実際に手を動かしてみることにしました。

このアプリはそのキャッチアップの集大成であり、ポートフォリオとして公開するために開発したものです。今回のプロジェクトを通じて、以下の技術の習得を目指しました。

  • RAG(検索拡張生成)の概念と実装: ベクトル検索によって外部知識をLLMに渡す仕組みを理解する
  • Multi-stepなAIエージェントの対話設計: 複数ステップのヒアリングを通じて情報を収集するエージェントを構築する
  • Next.js + Vercel AI SDKでのストリーミング実装: ユーザー体験を損なわないリアルタイムな応答を実現する
  • Supabase pgvectorを使ったベクトルDB設計: PostgreSQLの拡張機能でベクトル検索を行う構成を設計する

単なる写経ではなく、「なぜそうするのか」を理解しながら実装することを意識しました。この記事では、設計の意図や実装でハマったポイントも含めて解説します。


作ったもの

Mode 1: QA Bot

スクリーンショット 2026-03-22 231625.png

Mode 2: 企画書生成

スクリーンショット 2026-03-22 232012.png

Doc Planner AI

アップロードした社内資料をもとに、AIとの対話を通じて企画書を自動生成するアプリです。

解決する課題

  • 営業資料・過去のウェビナー企画・レポートが蓄積されているが、必要な情報を探すのが大変
  • 企画書を一から書くのに時間がかかる
  • 過去の成功事例を参考にしたくても、手動でファイルを漁る手間がある

2つのモード

Mode 1 - QA Bot(質問応答)
アップロードした資料にチャットで質問できます。関連する過去資料をベクトル検索で取得し、出典ドキュメント名を付けて回答します。資料に書かれていない内容は「資料には記載がありません」と正直に答えます。

Mode 2 - 企画書生成(AIエージェント)
5ステップのヒアリングを通じて企画の情報を収集し、過去資料を参照しながら企画書を段階的に生成します。ヒアリング中は、RAGで取得した関連資料を参考に「過去の○○資料ではこういった施策が効果的でした」と根拠付きで提案してくれます。

生成される企画書のセクション構成は以下のとおりです。

  1. 背景・課題
  2. 目的・ゴール
  3. ターゲット・ペルソナ
  4. 施策・解決策
  5. 期待効果・KPI

技術スタックと選定理由

用途 技術 選定理由
フロント / API Next.js 14 + TypeScript App RouterでフロントとAPIを一本化できる
AIストリーミング Vercel AI SDK ストリーミング・会話管理が数行で書ける
PDF処理 pdf-parse シンプルなテキスト抽出ライブラリ
Embedding Gemini Embedding API 無料枠で使えるEmbeddingモデル
ベクトルDB Supabase pgvector PostgreSQLの拡張機能なので別サービス不要
LLM Gemini API(gemini-2.0-flash-lite) 無料枠1000req/日で個人開発に十分
デプロイ Vercel Next.jsと相性◎、無料枠あり

このスタックの最大の特徴は、全部無料で構築できることです。 Gemini APIの無料枠、Supabaseの無料プラン、Vercelの無料枠を組み合わせることで、クレジットカードなしでもフル機能のRAGアプリを動かすことができます。個人開発やポートフォリオ制作にはうってつけの構成です。


RAGの仕組みと実装

RAGとは

通常のLLMは学習済みデータしか知りません。そのため「うちの会社の去年のウェビナー企画について教えて」と聞いても、当然答えられません。

RAG(Retrieval-Augmented Generation)はこの問題を解決するアプローチです。

「自社資料を事前にベクトルDBに格納しておき、質問が来たら関連する箇所だけ取り出してLLMに渡す」というシンプルな仕組みです。LLMに全資料を丸ごと渡すのではなく、関連性の高い部分だけを選択的に渡すのがポイントです。

実装の流れ

① PDFアップロード → テキスト抽出

src/lib/pdfParser.ts の extractTextFromPDF 関数でpdf-parseを使ってテキストを取り出します。

// src/lib/pdfParser.ts
import { PDFParse } from "pdf-parse";

export async function extractTextFromPDF(buffer: Buffer): Promise<string> {
  const parser = new PDFParse({ data: buffer });
  const result = await parser.getText();
  return result.text;
}

② チャンク分割

取り出したテキストをそのままEmbeddingするのではなく、適切なサイズに分割します。

// src/lib/pdfParser.ts
export function splitIntoChunks(
  text: string,
  chunkSize: number = 600,
  overlap: number = 100
): string[] {
  const chunks: string[] = [];
  let start = 0;

  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length);
    const chunk = text.slice(start, end).trim();
    if (chunk.length > 0) {
      chunks.push(chunk);
    }
    if (end === text.length) break;
    start += chunkSize - overlap;
  }

  return chunks;
}

チャンクサイズを600文字にした理由は、Geminiのコンテキストウィンドウとのバランスです。大きすぎると関係ない情報がノイズとして混入し、小さすぎると文章の文脈が失われてしまいます。また、オーバーラップを100文字設けることで、チャンクの境界で情報が途切れるのを防いでいます。

③ Embedding生成

分割したチャンクをGeminiの text-embedding-004 モデルでベクトル化します。768次元のベクトルが返ってきます。

// src/lib/embedding.ts
import { genAI } from "@/lib/gemini";
import { createServerSupabaseClient } from "@/lib/supabase";
import type { ChunkResult } from "@/types/index";

const EMBEDDING_MODEL = "text-embedding-004";

export async function generateEmbedding(text: string): Promise<number[]> {
  const model = genAI.getGenerativeModel({ model: EMBEDDING_MODEL });
  const result = await model.embedContent(text);
  return result.embedding.values;
}

④ pgvectorへの格納

Supabaseの document_chunks テーブルに embedding vector(768) カラムで格納します。アップロードAPIの全体像はこちらです。

// src/app/api/documents/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { extractTextFromPDF, splitIntoChunks } from "@/lib/pdfParser";
import { generateEmbedding } from "@/lib/embedding";
import { createServerSupabaseClient } from "@/lib/supabase";

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

export async function POST(req: NextRequest) {
  const formData = await req.formData().catch(() => null);
  if (!formData) {
    return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
  }

  const file = formData.get("file");
  if (!file || !(file instanceof Blob)) {
    return NextResponse.json({ error: "No file provided" }, { status: 400 });
  }

  if (file.size > MAX_FILE_SIZE) {
    return NextResponse.json({ error: "File size exceeds 10MB limit" }, { status: 400 });
  }

  const fileName = file instanceof File ? file.name : "upload.pdf";
  const arrayBuffer = await file.arrayBuffer();
  const buffer = Buffer.from(arrayBuffer);

  // PDFテキスト抽出
  let text: string;
  try {
    text = await extractTextFromPDF(buffer);
  } catch (e) {
    return NextResponse.json({ error: `Failed to parse PDF: ${String(e)}` }, { status: 422 });
  }

  const chunks = splitIntoChunks(text);
  if (chunks.length === 0) {
    return NextResponse.json({ error: "No text content found in PDF" }, { status: 422 });
  }

  const client = createServerSupabaseClient();

  // documentsテーブルにレコード挿入
  const { data: docData, error: docError } = await client
    .from("documents")
    .insert({ name: fileName, file_size: file.size })
    .select("id")
    .single();

  if (docError || !docData) {
    return NextResponse.json(
      { error: `Failed to save document: ${docError?.message}` },
      { status: 500 }
    );
  }

  const documentId: string = docData.id;

  // チャンクをEmbedding生成してSupabaseに保存(1件ずつ処理)
  try {
    for (let i = 0; i < chunks.length; i++) {
      const embedding = await generateEmbedding(chunks[i]);
      const { error: chunkError } = await client.from("document_chunks").insert({
        document_id: documentId,
        content: chunks[i],
        chunk_index: i,
        embedding,
      });

      if (chunkError) {
        throw new Error(chunkError.message);
      }
    }
  } catch (e) {
    // ロールバック: documentsレコードを削除(cascadeでchunksも削除される)
    await client.from("documents").delete().eq("id", documentId);
    return NextResponse.json(
      { error: `Failed to process chunks: ${String(e)}` },
      { status: 500 }
    );
  }

  return NextResponse.json(
    { documentId, chunkCount: chunks.length, name: fileName },
    { status: 201 }
  );
}

⑤ 類似検索

質問が来たらその文章もEmbeddingし、Supabaseの match_chunks SQL関数でcosine similarityを使って類似度の高いチャンクを取得します。

// src/lib/embedding.ts
export async function searchSimilarChunks(
  queryEmbedding: number[],
  matchCount: number = 5
): Promise<ChunkResult[]> {
  const client = createServerSupabaseClient();
  const { data, error } = await client.rpc("match_chunks", {
    query_embedding: queryEmbedding,
    match_count: matchCount,
  });

  if (error) {
    throw new Error(`Failed to search similar chunks: ${error.message}`);
  }

  return (data ?? []) as ChunkResult[];
}

QAモードでは類似度スコア0.7未満のチャンクを除外しています。この閾値を設けている理由は、関連性の低い情報をLLMに渡してしまうと、ハルシネーションのリスクが高まるからです。

// src/app/api/chat/route.ts(抜粋)
const SIMILARITY_THRESHOLD = 0.7;

// ...
const allChunks = await searchSimilarChunks(queryEmbedding, 5);
const chunks = allChunks.filter((c) => c.similarity >= SIMILARITY_THRESHOLD);

なお、企画書生成モードでは閾値を0.5に緩めています。企画書はアイデア出しの側面が強いため、多少関連性が薄くても参考資料として活用した方が生成品質が高くなる傾向があったためです。


AIエージェントの設計(Multi-step)

なぜMulti-stepにしたか

最初は「企画の情報を一度に全部入力してもらう」シンプルな設計も考えましたが、Multi-stepのヒアリング形式を採用しました。理由は以下のとおりです。

  • ユーザーが一問一答で考えを整理しながら回答できる
  • AIが前の回答を踏まえて次の質問を深掘りできる
  • 企画書の各セクションに必要な情報を確実に・漏れなく収集できる

5ステップのヒアリングフロー

ステップ定義は src/lib/plannerAgent.ts に定数として管理しています。

// src/lib/plannerAgent.ts(抜粋)
export const PLANNER_STEPS = [
  {
    label: "テーマ・概要",
    question: "どんな企画をお考えですか?テーマや概要を教えてください。",
  },
  {
    label: "背景・課題",
    question: "その企画でどんな課題や背景を解決したいですか?",
  },
  {
    label: "ターゲット",
    question: "誰向けの企画ですか?ターゲットや想定ユーザーを教えてください。",
  },
  {
    label: "施策・手段",
    question: "具体的な内容や実現手段はどのようなものを考えていますか?",
  },
  {
    label: "KPI・指標",
    question: "成功の指標(KPI)は何ですか?どうなれば成功と言えますか?",
  },
] as const;

現在のステップは「アシスタントのメッセージ数」から判定しています。この設計のメリットは、ステップ状態をサーバー側でセッション管理する必要がなく、クライアントから送られてくる会話履歴だけで完結する点です。

// アシスタントメッセージ数からステップを返す(1〜5、完了は6)
export function getCurrentStep(messages: UIMessage[]): number {
  const assistantCount = messages.filter((m) => m.role === "assistant").length;
  return Math.min(assistantCount + 1, 6);
}

RAGとの連携

ヒアリング中は、ユーザーの各回答をその場でEmbeddingし、過去資料を検索して関連情報をプロンプトに注入します。これにより「過去の○○資料ではこういった施策が効果的でした」と根拠付きで提案できるようになっています。

buildPlannerPrompt 関数がこの連携の中心です。

// src/lib/plannerAgent.ts
export function buildPlannerPrompt(
  step: number,
  messages: UIMessage[],
  ragContext: string
): string {
  const hasRag = ragContext.trim().length > 0;
  const ragSection = hasRag
    ? `\n\n【参考資料(RAG)】\n${ragContext}\n過去の資料に関連情報がある場合は「過去の○○資料では〜という事例がありました」という形で根拠を示してください。`
    : "";

  const baseRule = [
    "あなたは企画書作成を支援するプロのコンサルタントです。",
    "ユーザーへのヒアリングを1問ずつ行い、企画書に必要な情報を収集します。",
    "一度に複数の質問をせず、必ず1つだけ質問してください。",
    "ユーザーの回答を短く認識・要約してから次の質問に進んでください。",
    ragSection,
  ]
    .filter(Boolean)
    .join("\n");

  if (step === 1) {
    return [
      baseRule,
      "",
      "これがヒアリングの最初のステップです。",
      `次の質問をしてください: 「${PLANNER_STEPS[0].question}」`,
    ].join("\n");
  }

  if (step >= 2 && step <= 5) {
    const answers = extractPlanningAnswers(messages);
    const summary = answers
      .map((a, i) => `${PLANNER_STEPS[i].label}: ${a}`)
      .join("\n");

    return [
      baseRule,
      "",
      "【これまでのヒアリング内容】",
      summary,
      "",
      `現在はステップ${step}です。`,
      `ユーザーの直前の回答を1〜2文で受け止め、次の質問をしてください: 「${PLANNER_STEPS[step - 1].question}」`,
    ].join("\n");
  }

  // step === 6: 全ステップ完了
  const answers = extractPlanningAnswers(messages);
  const summary = answers
    .map((a, i) => `${PLANNER_STEPS[i]?.label ?? ""}: ${a}`)
    .join("\n");

  return [
    baseRule,
    "",
    "【ヒアリング完了内容】",
    summary,
    "",
    "全ステップのヒアリングが完了しました。",
    "ユーザーへの最後の回答を1〜2文で受け止め、",
    "「ヒアリングが完了しました。企画書を生成します。少々お待ちください。」と伝えてください。",
    "それ以外の文章は追加しないでください。",
  ].join("\n");
}

全ヒアリング完了後(step === 6)は、5つのセクションを Promise.all で並列生成して処理時間を短縮しています。

// src/lib/plannerAgent.ts(抜粋)
export async function generateFullPlan(
  answers: string[],
  ragContext: string
): Promise<string> {
  const title = answers[0] ? answers[0].slice(0, 40) : "企画書";

  const sections = [
    "背景・課題",
    "目的・ゴール",
    "ターゲット・ペルソナ",
    "施策・解決策",
    "期待効果・KPI",
  ];

  const sectionContents = await Promise.all(
    sections.map((s) => generateSection(s, answers, ragContext))
  );

  const parts = [`# ${title}`, ""];
  for (let i = 0; i < sections.length; i++) {
    parts.push(`## ${sections[i]}`);
    parts.push(sectionContents[i]);
    parts.push("");
  }

  return parts.join("\n");
}

実装でハマったこと

pgvectorのivfflatインデックス設定

pgvectorではインデックスを張る際に lists パラメータを指定する必要があります。

CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

lists は「クラスタ数」に相当し、データ件数の平方根が目安とされています。ただし、データが少ない段階でivfflatを張ると検索精度が下がることがあるため、開発初期はインデックスなしで動かし、ある程度データが溜まってから適用しました。

Embeddingのレート制限対策

アップロードAPIでは、チャンクのEmbedding生成を for ループで1件ずつ順番に処理しています。

// 1件ずつ処理(並列処理するとレート制限に引っかかる)
for (let i = 0; i < chunks.length; i++) {
  const embedding = await generateEmbedding(chunks[i]);
  // ...
}

最初は Promise.all で並列処理しようとしましたが、Gemini Embedding APIの無料枠はレート制限が厳しく、大きなPDFを処理すると429エラーが頻発しました。直列処理にすることで安定しましたが、大きなファイルのアップロードには時間がかかるというトレードオフがあります。

Vercel AI SDKのストリーミングとApp Routerの組み合わせ

Vercel AI SDKの streamText はNext.js App Routerと組み合わせると非常にシンプルに書けますが、カスタムHTTPヘッダーを付加したい場合に少し工夫が必要でした。

// src/app/api/chat/route.ts(抜粋)
const result = streamText({
  model: google("gemini-2.0-flash-lite"),
  system: systemPrompt,
  messages: modelMessages,
  // ...
});

// toUIMessageStreamResponse()で返した後にヘッダーを付け替える
const response = result.toUIMessageStreamResponse();
const headers = new Headers(response.headers);
headers.set("X-Source-Documents", JSON.stringify(sourceNames));

return new Response(response.body, { status: response.status, headers });

result.toDataStreamResponse() ではなく toUIMessageStreamResponse() を使う必要があることに気づくまで少し時間がかかりました(UIMessage形式を期待するクライアントと型が合わなかったため)。

チャンクサイズの最適化

チャンクサイズの調整は試行錯誤でした。

チャンクサイズ 問題
200文字 文章が途切れすぎて文脈が失われる
1000文字 関係ない内容がノイズとして混入する
600文字 + オーバーラップ100文字 文脈を保ちつつ、適切な粒度で検索できた

最終的には「1つの段落がおおよそ収まるサイズ」を目安に600文字に落ち着きました。オーバーラップを設けることでチャンク境界での情報の分断も緩和できました。


まとめ

このプロジェクトを通じて、RAGの概念をコードレベルで理解できました。「ベクトル検索で関連資料を取得してLLMに渡す」という仕組みは言葉では理解していたつもりでしたが、実際にチャンク分割・Embedding・コサイン類似度検索を実装して初めて、各パラメータの意味や調整の感覚がつかめました。

また、Multi-stepなAIエージェントの設計は思った以上に面白い体験でした。ステップ管理・プロンプト設計・RAGとの連携を組み合わせることで、単純なチャットボットとは一線を画すインタラクションが実現できます。「AIが前の会話を踏まえて次の質問を深掘りし、さらに過去資料から根拠を引用する」という体験は、作っていて素直に楽しかったです。

今後追加したい機能としては以下を考えています。

  • マルチエージェント化: 「資料調査エージェント」「構成設計エージェント」「文章生成エージェント」を分けることで、より高品質な企画書を生成できるはず
  • ドキュメントのタグ絞り込み検索: 「ウェビナー資料のみ参照」「営業資料のみ参照」といった絞り込みができると実用性が高まる
  • 企画書の編集・エクスポート機能: 生成したMarkdownをそのまま編集・PDF出力できると実務での活用が進む

RAGやAIエージェントを「理解したい」と思っているエンジニアの方には、ぜひ一度実際に手を動かして作ってみることをおすすめします。ドキュメントを読むだけでは見えなかったことが、実装を通じてたくさん見えてきます。このリポジトリがその参考になれば嬉しいです。

GitHubリポジトリ: https://github.com/sgm-engineer/doc-planner-ai

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?