📚

5日間でRAGアプリを0から本番デプロイまで完成させた話

に公開

はじめに

「RAGって最近よく聞くけど、実際どうやって作るの?」

そんな疑問から始まった5日間の開発記録です。知識ゼロの状態から、本番環境で動くRAGアプリケーションを完成させるまでの学びを共有します。

完成したアプリ: https://github.com/oharu121/rag-demo

https://youtu.be/cJK8ex2hinQ

何を作ったか

社内ドキュメントに対して自然言語で質問できるRAGアプリケーションを作りました。

主な機能:

  • ChatGPTのようなストリーミングレスポンス
  • ドキュメントのアップロード・管理
  • 回答の出典表示(ファイル名・行番号付き)
  • マルチターン会話

技術スタック:

レイヤー 技術 選定理由
フロントエンド Next.js 16 / React 19 App Router + RSC対応
バックエンド FastAPI 非同期処理・型安全
埋め込みモデル multilingual-e5-large 日本語精度が高い
ベクトルDB Chroma 学習コスト低・OSS
LLM Gemini 2.0 Flash 無料枠あり
デプロイ Vercel + HF Spaces 無料で本番運用可能

Day 1: RAGの基礎を理解する

RAGとは何か

RAG (Retrieval-Augmented Generation) = 検索 + 生成

ユーザーの質問

[検索] ベクトルDBから関連ドキュメントを取得

[生成] LLMに「この資料を参考に回答して」と依頼

根拠のある回答

なぜRAGが必要なのか

LLM単体の問題:

  1. 知識のカットオフ: 学習後の情報を知らない
  2. 自社データへのアクセス不可: LLMは御社のドキュメントを見たことがない
  3. ハルシネーション: 自信満々に嘘をつく

RAGはこれらを「検索で補強」することで解決します。

チャンキングの重要性

最初に躓いたのがチャンキング(テキスト分割)でした。

chunk_overlap = 0 の問題:

「田中さんは東京に住んでいます。彼は | エンジニアです。」
                          ↑ ここで分割されると
                          「彼」が誰か分からなくなる

学び: chunk_overlapchunk_sizeの10-20%が推奨。文脈の断絶を防ぐ。

Embedding(埋め込み)の仕組み

「なぜ単純な文字コード変換ではダメなのか?」という疑問が湧きました。

# ナイーブなアプローチ - 意味を捉えられない
"cat" -> [99, 97, 116]
"dog" -> [100, 111, 103]
# "cat"と"feline"が全く関係ない位置になる!

# Embeddingモデル - 意味を捉える
"cat"      -> [0.82, 0.15, -0.34, ...]
"feline"   -> [0.79, 0.18, -0.31, ...]  # 近い!
"airplane" -> [-0.45, 0.67, 0.12, ...]  # 遠い

Embeddingモデルは「似た文脈で使われる単語は似たベクトルになる」ように学習されています。

Day 2-3: バックエンド実装

FastAPIでストリーミングAPIを実装

ChatGPTのような「文字が流れてくる」体験を実現するため、Server-Sent Events(SSE)を採用しました。

@router.post("/chat")
async def chat(request: ChatRequest):
    async def generate():
        # 1. 関連ドキュメントを検索
        docs = vector_store.similarity_search(request.message)

        # 2. 出典情報を先に送信
        yield f"event: sources\ndata: {json.dumps(sources)}\n\n"

        # 3. LLMからトークン単位でストリーミング
        async for token in llm.stream(prompt):
            yield f"event: token\ndata: {json.dumps({'token': token})}\n\n"

        yield f"event: done\ndata: {{}}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

本番デプロイでハマったFastAPI 404問題

新しいエンドポイントを追加したら、なぜか404が返ってくる。

GET /api/documents/abc123/content
→ {"detail": "Not Found"}  # FastAPIのデフォルト404

カスタムエラー(HTTPException(404, "Document not found"))ではなく、FastAPIのデフォルト404ということは、ルートがそもそもマッチしていない

デバッグ手法:

# 1. デバッグ用エンドポイントを追加
@router.get("/debug/routes")
async def debug_routes():
    return {"routes": [...]}

# 2. 起動時にルート一覧を出力
for route in app.routes:
    print(f"{route.methods} {route.path}")

結果、デプロイ時にコードが反映されていなかっただけでした。

教訓: 本番環境のデバッグでは「そもそもコードがデプロイされているか」を最初に確認する。

Day 4: フロントエンド実装

SSEストリーミングの受信

export async function* streamChat(message: string, history: Message[]) {
  const response = await fetch(`${API_URL}/chat`, {
    method: "POST",
    body: JSON.stringify({ message, history }),
  });

  const reader = response.body?.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // SSEイベントをパース
    const lines = buffer.split("\n");
    for (const line of lines) {
      if (line.startsWith("event: ")) {
        // イベント処理...
        yield { type: eventType, data };
      }
    }
  }
}

Reactのステール・クロージャ問題

最も学びが深かったバグです。

症状: AIが古いメッセージに対して回答する

1. 「休暇制度について教えて」 → 正常に回答
2. 「支社について教えて」 → なぜか休暇制度の回答が返ってくる

原因: useCallbackのステール・クロージャ

// バグのあるコード
const sendMessage = useCallback(async (content: string) => {
  // この `messages` は useCallback 作成時の値!
  const history = messages.map(m => ({ role: m.role, content: m.content }));

  await streamChat(content, history); // 古いhistoryが送られる
}, [messages]); // 依存配列があってもタイミング問題が発生

解決策: setStateの関数型アップデートで現在の状態を取得

// 修正後
const sendMessage = useCallback(async (content: string) => {
  let capturedHistory: Message[] = [];

  // setMessagesの関数型アップデートで現在の状態を取得
  setMessages((prev) => {
    capturedHistory = prev.map(m => ({
      role: m.role,
      content: m.content
    }));
    return [...prev, userMessage, assistantMessage];
  });

  // capturedHistoryは確実に最新
  await streamChat(content, capturedHistory);
}, []); // 依存配列は空でOK

学び: 非同期コールバック内で最新の状態が必要な場合:

  • useRefで状態を同期する
  • setStateの関数型アップデートを活用する

Day 5: UX改善

ドキュメントプレビュー機能

ユーザーが「どのドキュメントを検索対象にしているか」を確認できるよう、プレビュー機能を追加しました。

// DocumentChipsBar - 常に表示されるドキュメント一覧
<DocumentChipsBar
  documents={allDocuments}
  onPreview={(doc) => setPreviewDoc(doc)}
/>

// クリックでモーダル表示
<DocumentPreviewModal
  doc={previewDoc}
  onClose={() => setPreviewDoc(null)}
/>

オンボーディングフロー

初回訪問者向けに、段階的なヒントを表示:

  1. 「ドキュメント管理」ボタンを指すツールチップ
  2. ドキュメントチップを指す「クリックでプレビュー」ヒント
// ツールチップの位置計算
useEffect(() => {
  if (targetRef.current) {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({
      top: rect.bottom + 12,
      right: window.innerWidth - rect.right,
    });
  }
}, [targetRef]);

プロジェクト構成

simple-rag-app/
├── frontend/              # Next.js フロントエンド
│   ├── app/
│   │   ├── components/    # UIコンポーネント
│   │   ├── page.tsx
│   │   └── layout.tsx
│   ├── lib/               # API・定数・型定義
│   ├── hooks/             # カスタムフック (useChat, useDocuments等)
│   └── package.json

├── backend/               # Python バックエンド
│   ├── app/
│   │   ├── routers/       # APIエンドポイント (chat, documents)
│   │   ├── services/      # ビジネスロジック (RAG, Document管理)
│   │   ├── models/        # Pydanticスキーマ
│   │   └── utils/         # レート制限、エラーハンドリング
│   ├── pyproject.toml
│   └── Dockerfile

└── README.md

なぜフロントとバックエンドを分離したか

モノリス構成(Next.js API Routes で全部やる)も検討しましたが、以下の理由で分離しました:

  1. Pythonエコシステムの活用: LangChain、Chroma、sentence-transformers等のAI/MLライブラリはPythonが圧倒的に充実
  2. 非同期処理: FastAPIのasync/awaitはストリーミングレスポンスと相性が良い
  3. デプロイの柔軟性: フロントとバックで最適なプラットフォームを選べる

コスト効率: 無料で本番運用する戦略

個人開発や学習目的では、コストをゼロに抑えることが重要です。今回は以下の構成で完全無料の本番環境を実現しました。

デプロイ先の選定

コンポーネント デプロイ先 無料枠 選定理由
フロントエンド Vercel 100GB帯域/月 Next.jsの開発元。ゼロコンフィグでデプロイ可能
バックエンド HF Spaces 2vCPU, 16GB RAM Dockerサポート。ML系ライブラリのインストールが容易
LLM Gemini API 15 RPM, 100万トークン/日 無料枠が最も寛大
ベクトルDB Chroma (HF内) - HF Spaces内で永続化

なぜVercel + Hugging Face Spacesか

Vercel(フロントエンド):

  • Next.jsの開発元なので、App Router、RSC等の最新機能と相性抜群
  • GitHubにpushするだけで自動デプロイ
  • エッジネットワークで高速配信
  • 無料枠でも商用利用可能

Hugging Face Spaces(バックエンド):

  • Dockerサポートがあり、複雑な依存関係も対応可能
  • ML/AI系のライブラリ(PyTorch、sentence-transformers等)がプリインストール環境あり
  • 16GB RAMで埋め込みモデルも余裕で動作
  • Secretsで環境変数を安全に管理
  • コールドスタート問題はあるが、デモ用途なら許容範囲

コスト比較: LLM選定

RAGアプリでは、LLMのAPI費用が最大のコストになりがちです。

モデル 入力 (1M tokens) 出力 (1M tokens) 特徴
Gemini Flash $0.075 $0.30 無料枠あり
GPT-4o mini $0.15 $0.60 バランス良い
Claude Haiku $0.25 $1.25 日本語が得意
GPT-4o $2.50 $10.00 高精度

選定結果: 学習・デモ目的なのでGemini Flash(無料枠)を採用。

学んだこと総括

技術的な学び

  1. チャンキング: オーバーラップは必須。chunk_size の10-20%
  2. ベクトルDB: 学習にはChromaで十分。本番はPinecone等を検討
  3. ストリーミング: SSEで実装。UX向上に大きく貢献
  4. Reactクロージャ: 非同期処理ではuseRefか関数型setState

アーキテクチャの学び

[Vercel]          [HF Spaces]        [Chroma]
Next.js    <--->   FastAPI    <--->  Vector DB
フロント          バックエンド         永続化
   ↑                  ↑
   └──── SSE ─────────┘

フロントとバックエンドを分離することで:

  • 独立してスケール可能
  • それぞれ最適なプラットフォームにデプロイ
  • 無料枠を最大限活用

開発プロセスの学び

  1. デバッグエンドポイント: 本番環境には/debug/routesのような確認用エンドポイントを用意
  2. 段階的なログ出力: 問題の切り分けに必須
  3. デプロイ確認: 「そもそもコードがデプロイされているか」を最初に確認

まとめ

5日間でRAGの基礎から本番デプロイまで一通り経験できました。

リポジトリ: https://github.com/oharu121/rag-demo

特に印象的だったのは:

  • RAGの威力: LLM単体では不可能な「根拠のある回答」が実現できる
  • ステール・クロージャ: Reactの非同期処理では要注意
  • 無料で本番運用: Vercel + HF Spaces + Gemini無料枠で実現可能

RAGは「LLMを実務で使える」技術です。このデモアプリが、皆さんの学習の参考になれば幸いです。

参考資料

Discussion