5日間でRAGアプリを0から本番デプロイまで完成させた話
はじめに
「RAGって最近よく聞くけど、実際どうやって作るの?」
そんな疑問から始まった5日間の開発記録です。知識ゼロの状態から、本番環境で動くRAGアプリケーションを完成させるまでの学びを共有します。
完成したアプリ: https://github.com/oharu121/rag-demo
何を作ったか
社内ドキュメントに対して自然言語で質問できる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単体の問題:
- 知識のカットオフ: 学習後の情報を知らない
- 自社データへのアクセス不可: LLMは御社のドキュメントを見たことがない
- ハルシネーション: 自信満々に嘘をつく
RAGはこれらを「検索で補強」することで解決します。
チャンキングの重要性
最初に躓いたのがチャンキング(テキスト分割)でした。
chunk_overlap = 0 の問題:
「田中さんは東京に住んでいます。彼は | エンジニアです。」
↑ ここで分割されると
「彼」が誰か分からなくなる
学び: chunk_overlapはchunk_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)}
/>
オンボーディングフロー


初回訪問者向けに、段階的なヒントを表示:
- 「ドキュメント管理」ボタンを指すツールチップ
- ドキュメントチップを指す「クリックでプレビュー」ヒント
// ツールチップの位置計算
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 で全部やる)も検討しましたが、以下の理由で分離しました:
- Pythonエコシステムの活用: LangChain、Chroma、sentence-transformers等のAI/MLライブラリはPythonが圧倒的に充実
-
非同期処理: FastAPIの
async/awaitはストリーミングレスポンスと相性が良い - デプロイの柔軟性: フロントとバックで最適なプラットフォームを選べる
コスト効率: 無料で本番運用する戦略
個人開発や学習目的では、コストをゼロに抑えることが重要です。今回は以下の構成で完全無料の本番環境を実現しました。
デプロイ先の選定
| コンポーネント | デプロイ先 | 無料枠 | 選定理由 |
|---|---|---|---|
| フロントエンド | 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(無料枠)を採用。
学んだこと総括
技術的な学び
- チャンキング: オーバーラップは必須。chunk_size の10-20%
- ベクトルDB: 学習にはChromaで十分。本番はPinecone等を検討
- ストリーミング: SSEで実装。UX向上に大きく貢献
-
Reactクロージャ: 非同期処理では
useRefか関数型setState
アーキテクチャの学び
[Vercel] [HF Spaces] [Chroma]
Next.js <---> FastAPI <---> Vector DB
フロント バックエンド 永続化
↑ ↑
└──── SSE ─────────┘
フロントとバックエンドを分離することで:
- 独立してスケール可能
- それぞれ最適なプラットフォームにデプロイ
- 無料枠を最大限活用
開発プロセスの学び
-
デバッグエンドポイント: 本番環境には
/debug/routesのような確認用エンドポイントを用意 - 段階的なログ出力: 問題の切り分けに必須
- デプロイ確認: 「そもそもコードがデプロイされているか」を最初に確認
まとめ
5日間でRAGの基礎から本番デプロイまで一通り経験できました。
リポジトリ: https://github.com/oharu121/rag-demo
特に印象的だったのは:
- RAGの威力: LLM単体では不可能な「根拠のある回答」が実現できる
- ステール・クロージャ: Reactの非同期処理では要注意
- 無料で本番運用: Vercel + HF Spaces + Gemini無料枠で実現可能
RAGは「LLMを実務で使える」技術です。このデモアプリが、皆さんの学習の参考になれば幸いです。
Discussion