はじめに
ある研究プロジェクトの成果報告書(全10巻)を対象に、自然言語で質問すると関連資料を検索し、出典付きで回答してくれる RAG(Retrieval-Augmented Generation)アプリケーションを開発しました。
本記事では、このアプリの技術スタックと設計上の判断について紹介します。
アーキテクチャ全体像
ユーザー
↓ 質問
Next.js (App Router)
↓ API Route
クエリ補完 (LLM)
↓ 補完された検索クエリ
Embedding生成 (text-embedding-3-small)
↓ ベクトル
Pinecone (ベクトル検索, topK=8)
↓ 関連チャンク
LLM (Claude Sonnet) ← システムプロンプト + コンテキスト
↓ SSEストリーミング
ユーザーに回答表示
フロントエンド
Next.js 16 + React 19 + TypeScript
App Router を採用し、ページ構成はシンプルに3ページです。
| パス | 内容 |
|---|---|
/ | ランディングページ(質問例へのリンク付き) |
/chat | チャットUI |
/about | サイト概要 |
チャットページでは useSearchParams を使い、ランディングページの質問例をクリックすると /chat?q=... でそのまま質問が送信される仕組みにしています。
Tailwind CSS v4
スタイリングには Tailwind CSS v4 を使用。v4 では @import "tailwindcss" だけで設定が完了するため、tailwind.config.js が不要になりました。
ストリーミング表示
回答はSSE(Server-Sent Events)でストリーミング表示します。フロントエンドでは ReadableStream の getReader() でチャンクを逐次読み取り、react-markdown でリアルタイムにMarkdownレンダリングしています。
回答待ちの間はドットパルスアニメーション(CSS @keyframes)を表示し、ユーザーに処理中であることを伝えます。
// 空のアシスタントメッセージでローディングアニメーションを表示
{msg.content ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full dot-pulse" />
<span className="w-2 h-2 rounded-full dot-pulse" />
<span className="w-2 h-2 rounded-full dot-pulse" />
</div>
)}
バックエンド
API Route (/api/chat)
Next.js の Route Handler を1本だけ使い、以下の処理を順に実行します。
- クエリ補完 — 会話履歴がある場合、LLMで検索クエリを自立した質問文に書き換え
- Embedding生成 — 検索クエリをベクトル化
- ベクトル検索 — Pinecone で類似度検索(topK=8)
- フィルタリング — 低品質チャンクを除外(スコア閾値、日本語比率、CSV検出、最小文字数)
- 回答生成 — コンテキスト付きプロンプトでLLMにストリーミング回答を依頼
AIプロバイダー抽象化
src/lib/ai.ts でAIプロバイダーを抽象化し、環境変数 AI_PROVIDER で切り替え可能にしています。
| プロバイダー | Embedding | Chat |
|---|---|---|
| OpenRouter(デフォルト) | OpenAI text-embedding-3-small | Claude Sonnet 4 |
| AWS Bedrock | Amazon Titan Embed Text v2 | Claude Sonnet 4 |
OpenRouter は OpenAI互換APIを提供しているため、様々なモデルを同一インターフェースで利用できます。一方、AWS Bedrock は Anthropic Messages API 形式でリクエストし、レスポンスのストリームを SSE 形式に変換して統一的に扱えるようにしています。
// Bedrock のストリームを SSE 形式に変換
const stream = new ReadableStream({
async start(controller) {
for await (const event of response.body) {
if (event.chunk?.bytes) {
const json = JSON.parse(new TextDecoder().decode(event.chunk.bytes));
if (json.type === "content_block_delta" && json.delta?.text) {
const sseData = {
choices: [{ delta: { content: json.delta.text } }],
};
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(sseData)}\n\n`)
);
}
}
}
controller.close();
},
});
ベクトルDB — Pinecone
ベクトルデータベースには Pinecone を採用しました。
当初は Supabase の pgvector を検討し、スキーマも用意していましたが、マネージドなベクトル検索に特化した Pinecone のほうが運用がシンプルで、フリープランでも十分な性能が得られたため移行しました。
Pinecone の無料枠(Starter プラン)は以下の通りです。
| 項目 | 上限 |
|---|---|
| インデックス数 | 5 |
| ストレージ | 2 GB |
| 書き込み | 200万 write units/月 |
| 読み取り | 100万 read units/月 |
| リージョン | us-east-1 のみ |
今回のような数千チャンク規模のRAGアプリであれば十分収まります。ただし、3週間操作がないとインデックスが一時停止される点には注意が必要です。
// シンプルなクエリ
const results = await index.query({
vector: queryEmbedding,
topK: 8,
includeMetadata: true,
});
データ取り込みパイプライン
対象の歴史資料は HTMLファイル(一部 Shift_JIS)と テキストファイルで提供されています。取り込みスクリプト(scripts/ingest.mjs)で以下の処理を行います。
1. テキスト抽出
- HTML:
cheerioでパースし、script/styleタグを除去してテキスト化 - 文字コード: UTF-8 を優先し、デコードに失敗した場合は
iconv-liteで Shift_JIS としてデコード - TXT: バッファ読み込み後、同様の文字コード判定
対象の資料はHTMLファイルが約7,600件、テキストファイルが約420件と、HTMLが大半を占めています。古い歴史資料のHTMLは Shift_JIS で書かれていることが多く、文字コードの自動判定は地味に重要なポイントでした。
2. チャンク分割
固定長(1000文字)+ オーバーラップ(200文字)のスライディングウィンドウ方式です。50文字未満のチャンクは除外します。
function splitIntoChunks(text, maxChars = 1000, overlap = 200) {
const chunks = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + maxChars, text.length);
const chunk = text.slice(start, end);
if (chunk.length > 50) chunks.push(chunk);
start += maxChars - overlap;
}
return chunks;
}
3. 冪等な取り込み
チャンクのIDはコンテンツ + ソースパスの MD5 ハッシュで生成しています。取り込み前に Pinecone に既存IDを問い合わせ、未登録のチャンクだけを処理します。--fresh フラグで全件再取り込みも可能です。
4. バッチ処理
Embedding 生成と Pinecone への upsert は50件ずつバッチで実行し、レート制限対策として1秒間隔を空けています。失敗時は最大3回リトライします。
検索品質の工夫
クエリ補完(Query Rewrite)
マルチターン会話では「それについて詳しく教えて」のような代名詞を含む質問が来ます。会話履歴を踏まえて検索用の独立した質問文に書き換えることで、ベクトル検索の精度を維持しています。
低品質チャンク除外
ベクトル検索の結果からノイズを除去するために、以下のフィルタを適用しています。
- 類似度スコアが 0.5 未満のチャンクを除外
- 日本語文字の比率が 20% 未満のチャンクを除外(英数字やHTMLの残骸を排除)
- カンマの出現率が 5% を超えるチャンクを除外(CSVデータを排除)
- 100文字未満のチャンクを除外
出典リンク
回答には必ず出典(巻数・資料名)を含め、原典へのリンクを付与しています。LLMへのシステムプロンプトで「参考資料に書かれていない情報は絶対に補完・推測・創作しないでください」と明示し、ハルシネーションを抑制しています。
認証
環境変数 BASIC_USER / BASIC_PASS を設定すると Basic 認証が有効になります。未設定なら認証なしで公開されます。ステージング環境と本番環境の切り替えに便利です。
技術スタックまとめ
| カテゴリ | 技術 |
|---|---|
| フレームワーク | Next.js 16 (App Router) |
| 言語 | TypeScript, React 19 |
| スタイリング | Tailwind CSS v4 |
| ベクトルDB | Pinecone |
| Embedding | OpenAI text-embedding-3-small (via OpenRouter) |
| LLM | Claude Sonnet 4 (via OpenRouter / AWS Bedrock) |
| データ取り込み | cheerio, iconv-lite |
| Markdownレンダリング | react-markdown |
| 認証 | Basic認証(オプション) |
おわりに
歴史資料のような専門的なドメインでは、RAGによって「資料に基づいた回答」を実現できるのが大きな利点です。LLMの知識だけに頼ると不正確な情報を生成しがちですが、ベクトル検索で関連資料を引き当て、出典を明示することで信頼性のある回答が可能になります。
技術的には、OpenRouter による API統一、Pinecone のマネージドベクトル検索、Next.js App Router のストリーミング対応など、それぞれのツールが得意な領域を活かした構成になっています。AWS Bedrock への切り替えもプロバイダー抽象化により環境変数1つで対応できるため、コストや要件に応じて柔軟に選択できます。