RAG

Retrieval Augmented Generation(検索拡張生成)

RAG を分かりやすく

RAG(Retrieval Augmented Generation、検索拡張生成)は、AI の最も重要な応用技術の一つです。でも、名前だけ聞いても何のことか分からないですよね。

例え話をしましょう。あなたが試験を受けるとします。2 つの方法があります:

方法 A: 自分の記憶だけで答える

  • メリット: 速い
  • デメリット: 記憶違いがあるかも、古い情報かも、知らないことは答えられない

方法 B: 参考書を見ながら答える

  • メリット: 正確な情報、最新の情報、幅広いトピックに対応
  • デメリット: 参考書を探す時間がかかる

RAG は「方法 B」です。AI が回答を生成する前に、関連する情報をデータベースから検索して、それを参照しながら回答を作ります。これにより、AI の「幻覚(Hallucination)」を減らし、より正確で最新の情報を提供できるのです。

RAG が解決する問題

通常の LLM には、いくつかの制限があります:

  1. 知識の更新日が古い: GPT-4 の知識は 2023 年 4 月までなど、訓練時点で知識が固定される
  2. ハルシネーション: 知らないことを適当に答えてしまう
  3. プライベートデータにアクセスできない: あなたの会社のドキュメントは当然知らない
  4. ソースを示せない: どこから情報を得たのか不明

RAG はこれらすべての問題を解決します。

RAG のアーキテクチャ

RAG システムは主に 3 つのステップで動作します:

  1. 文書の埋め込み(Embedding): テキストを数値ベクトルに変換してデータベースに保存
  2. 検索(Retrieval): ユーザーの質問に関連する文書を検索
  3. 生成(Generation): 検索した文書を参考にして LLM が回答を生成

Next.js × TypeScript で RAG を実装する

それでは、実際に RAG システムを構築してみましょう。今回は Pinecone(ベクトルデータベース)と OpenAI を使用します。

ステップ 1: 環境構築

npm install @pinecone-database/pinecone openai ai

環境変数を設定:

# .env.local
OPENAI_API_KEY=sk-...
PINECONE_API_KEY=...
PINECONE_ENVIRONMENT=...
PINECONE_INDEX=documents

ステップ 2: ベクトルデータベースのセットアップ

// lib/pinecone.ts
import { Pinecone } from '@pinecone-database/pinecone'

let pineconeClient: Pinecone | null = null

export async function getPineconeClient() {
  if (pineconeClient) {
    return pineconeClient
  }

  pineconeClient = new Pinecone({
    apiKey: process.env.PINECONE_API_KEY!,
  })

  return pineconeClient
}

export async function getIndex() {
  const client = await getPineconeClient()
  return client.index(process.env.PINECONE_INDEX!)
}

ステップ 3: 文書の埋め込みと保存

// lib/embeddings.ts
import OpenAI from 'openai'
import { getIndex } from './pinecone'

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})

// テキストを埋め込みベクトルに変換
export async function createEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  })

  return response.data[0].embedding
}

// 文書を分割してベクトル化し、Pinecone に保存
export async function indexDocuments(
  documents: Array<{ id: string; content: string; metadata?: Record<string, any> }>
) {
  const index = await getIndex()

  // 文書を並列で処理
  const vectors = await Promise.all(
    documents.map(async (doc) => {
      const embedding = await createEmbedding(doc.content)

      return {
        id: doc.id,
        values: embedding,
        metadata: {
          content: doc.content,
          ...doc.metadata,
        },
      }
    })
  )

  // Pinecone に一括アップロード(100件ずつ)
  const batchSize = 100
  for (let i = 0; i < vectors.length; i += batchSize) {
    const batch = vectors.slice(i, i + batchSize)
    await index.upsert(batch)
  }

  return { indexed: vectors.length }
}

ステップ 4: 文書のアップロード API

// app/api/documents/upload/route.ts
import { indexDocuments } from '@/lib/embeddings'

export async function POST(req: Request) {
  try {
    const { documents } = await req.json()

    // バリデーション
    if (!Array.isArray(documents) || documents.length === 0) {
      return new Response('documents 配列が必要です', { status: 400 })
    }

    // 文書をインデックス化
    const result = await indexDocuments(documents)

    return Response.json({
      success: true,
      indexed: result.indexed,
    })
  } catch (error) {
    console.error('Document indexing error:', error)
    return new Response('文書のインデックス化に失敗しました', { status: 500 })
  }
}

ステップ 5: RAG による質問応答 API

// app/api/rag/chat/route.ts
import OpenAI from 'openai'
import { OpenAIStream, StreamingTextResponse } from 'ai'
import { createEmbedding } from '@/lib/embeddings'
import { getIndex } from '@/lib/pinecone'

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})

export const runtime = 'edge'

export async function POST(req: Request) {
  try {
    const { messages } = await req.json()

    // 最新のユーザーメッセージを取得
    const lastMessage = messages[messages.length - 1]
    const userQuery = lastMessage.content

    // 1. ユーザーの質問を埋め込みベクトルに変換
    const queryEmbedding = await createEmbedding(userQuery)

    // 2. 類似文書を検索
    const index = await getIndex()
    const searchResults = await index.query({
      vector: queryEmbedding,
      topK: 5, // 上位 5 件を取得
      includeMetadata: true,
    })

    // 3. 検索結果から文脈を構築
    const context = searchResults.matches
      .map((match) => match.metadata?.content)
      .filter(Boolean)
      .join('\n\n---\n\n')

    // 4. 文脈を含めたプロンプトを作成
    const systemPrompt = `あなたは優秀なアシスタントです。以下の文脈情報を基に、ユーザーの質問に正確に答えてください。
文脈に含まれていない情報については、「提供された情報からはわかりません」と答えてください。

# 文脈情報:
${context}

# 指示:
- 文脈情報を基に回答してください
- 文脈にない情報は推測しないでください
- 回答の根拠となる情報源を明示してください`

    // 5. LLM に質問と文脈を送信
    const response = await openai.chat.completions.create({
      model: 'gpt-4',
      stream: true,
      messages: [
        {
          role: 'system',
          content: systemPrompt,
        },
        ...messages,
      ],
      temperature: 0.3, // RAG では低めの temperature が推奨
    })

    const stream = OpenAIStream(response)
    return new StreamingTextResponse(stream)
  } catch (error) {
    console.error('RAG Chat Error:', error)
    return new Response('エラーが発生しました', { status: 500 })
  }
}

ステップ 6: フロントエンドの実装

// app/rag/page.tsx
'use client'

import { useChat } from 'ai/react'
import { useState } from 'react'

export default function RAGChatPage() {
  const [isUploading, setIsUploading] = useState(false)
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: '/api/rag/chat',
  })

  // 文書アップロード
  const handleUploadDocuments = async (content: string) => {
    setIsUploading(true)
    try {
      // 文書を段落ごとに分割(簡易実装)
      const paragraphs = content
        .split('\n\n')
        .filter((p) => p.trim().length > 0)
        .map((p, i) => ({
          id: `doc-${Date.now()}-${i}`,
          content: p.trim(),
          metadata: {
            uploadedAt: new Date().toISOString(),
          },
        }))

      const response = await fetch('/api/documents/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ documents: paragraphs }),
      })

      if (response.ok) {
        const data = await response.json()
        alert(`${data.indexed} 件の文書をインデックス化しました`)
      } else {
        alert('アップロードに失敗しました')
      }
    } catch (error) {
      console.error('Upload error:', error)
      alert('エラーが発生しました')
    } finally {
      setIsUploading(false)
    }
  }

  return (
    <div className="container mx-auto max-w-4xl p-4">
      <h1 className="text-3xl font-bold mb-6">RAG チャットシステム</h1>

      {/* 文書アップロードセクション */}
      <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
        <h2 className="text-lg font-semibold mb-2">文書のアップロード</h2>
        <textarea
          className="w-full border border-gray-300 rounded p-2 mb-2"
          rows={6}
          placeholder="ここに文書を貼り付けてください..."
          id="document-content"
        />
        <button
          onClick={() => {
            const textarea = document.getElementById(
              'document-content'
            ) as HTMLTextAreaElement
            handleUploadDocuments(textarea.value)
          }}
          disabled={isUploading}
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:bg-gray-300"
        >
          {isUploading ? 'アップロード中...' : 'アップロード'}
        </button>
      </div>

      {/* チャットセクション */}
      <div className="bg-gray-50 rounded-lg p-4 h-[500px] overflow-y-auto mb-4">
        {messages.length === 0 ? (
          <div className="text-center mt-8 text-gray-500">
            <p>文書をアップロードしてから、質問を送信してください</p>
          </div>
        ) : (
          messages.map((message) => (
            <div
              key={message.id}
              className={`mb-4 ${message.role === 'user' ? 'text-right' : 'text-left'}`}
            >
              <div
                className={`inline-block rounded-lg px-4 py-2 max-w-[80%] ${
                  message.role === 'user'
                    ? 'bg-blue-500 text-white'
                    : 'bg-white border border-gray-200'
                }`}
              >
                <p className="text-xs mb-1">
                  {message.role === 'user' ? 'あなた' : 'AI(RAG)'}
                </p>
                <p className="whitespace-pre-wrap">{message.content}</p>
              </div>
            </div>
          ))
        )}
      </div>

      {/* 入力フォーム */}
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          value={input}
          onChange={handleInputChange}
          placeholder="質問を入力..."
          className="flex-1 border border-gray-300 rounded-lg px-4 py-2"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
        >
          送信
        </button>
      </form>
    </div>
  )
}

高度な RAG テクニック

1. ハイブリッド検索(ベクトル + キーワード)

// より精度の高い検索
export async function hybridSearch(query: string, topK: number = 5) {
  const index = await getIndex()

  // ベクトル検索
  const queryEmbedding = await createEmbedding(query)
  const vectorResults = await index.query({
    vector: queryEmbedding,
    topK,
    includeMetadata: true,
  })

  // キーワード検索(メタデータフィルタリング)
  // 実際にはElasticsearchなどと組み合わせることが多い

  return vectorResults.matches
}

2. チャンキング戦略

長い文書を適切なサイズに分割することが重要です。

// lib/chunking.ts
export function chunkText(text: string, chunkSize: number = 500, overlap: number = 50) {
  const chunks: string[] = []
  let start = 0

  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length)
    chunks.push(text.substring(start, end))
    start = end - overlap // オーバーラップを設定
  }

  return chunks
}

3. メタデータフィルタリング

// 日付やカテゴリでフィルタリング
const searchResults = await index.query({
  vector: queryEmbedding,
  topK: 5,
  filter: {
    category: { $eq: 'technical' },
    publishedAt: { $gte: '2024-01-01' },
  },
  includeMetadata: true,
})

まとめ

RAG は、AI アプリケーションにおいて最も実用的で重要な技術の一つです。Next.js と TypeScript を使うことで、型安全で保守性の高い RAG システムを構築できます。

重要なポイント:

  1. ハルシネーションの削減: 実際の文書を参照するため、正確な回答が可能
  2. 最新情報の活用: データベースを更新すれば、常に最新情報を提供できる
  3. プライベートデータ対応: 社内文書など、LLM が学習していないデータも活用可能
  4. ソースの明示: どの文書から情報を得たか追跡可能
  5. コスト効率: ファインチューニング不要で、特定ドメインに対応できる

RAG を活用することで、企業の知識ベース、製品マニュアル、カスタマーサポートなど、様々な用途で AI を実用化できます。