ドラフト "エージェント型デザインパターン - 知識検索(RAG)"

ragailangchainlanggraphtypescriptnextjsvercel
By sko X opus 4.19/21/202511 min read

このガイドでは、TypeScript、Next.js 15、LangChain、LangGraphを使用してVercelプラットフォーム上で洗練されたRAGシステムを実装する方法を説明します。基本的な検索から、自己修正、クエリのインテリジェントなルーティング、複雑な多段階推論を処理する高度なエージェント型RAGパターンまで構築していきます。

メンタルモデル:インテリジェントな研究助手としてのRAG

RAGを、単にドキュメントを取得するだけでなく、コンテキストを理解し、ソースの品質を評価し、情報を統合する研究助手のように考えてください。従来のRAGは図書館のカタログシステムのようなもので、クエリを送ると検索結果が返されます。エージェント型RAGは、いつ検索すべきか、何を検索すべきか、ソースの相互参照、矛盾の特定、さらには「別の場所を探す必要がある」と判断できる博士課程の学生のようなものです。サーバーレスのコンテキストでは、この助手は短時間(777秒以内)で動作しますが、インタラクション間で会話の状態を維持します。

基本例:シンプルなベクトルベースのRAG

1. RAG依存関係のインストール

npm install @langchain/pinecone @pinecone-database/pinecone
npm install @langchain/textsplitters
npm install es-toolkit es-toolkit/compat

ベクトルストレージ用のPinecone、埋め込み用のGoogleのembedding-001、チャンク分割用のテキストスプリッター、ユーティリティ関数用のes-toolkitをインストールします。

2. ベクトルストアの初期化

// lib/vector-store.ts
import { Pinecone } from '@pinecone-database/pinecone';
import { PineconeStore } from '@langchain/pinecone';
import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai';
import { memoize } from 'es-toolkit/compat';

// サーバーレス効率のためにクライアント作成をメモ化
const getPineconeClient = memoize(() =>
  new Pinecone({
    apiKey: process.env.PINECONE_API_KEY!,
  })
);

export async function getVectorStore() {
  const pinecone = getPineconeClient();
  const index = pinecone.index(process.env.PINECONE_INDEX_NAME!);

  const embeddings = new GoogleGenerativeAIEmbeddings({
    modelName: "embedding-001",
    taskType: "RETRIEVAL_DOCUMENT",
  });

  return PineconeStore.fromExistingIndex(embeddings, {
    pineconeIndex: index,
    maxConcurrency: 5, // サーバーレス用に最適化
  });
}

サーバーレス呼び出しごとの再初期化を避けるためにメモ化されたPineconeクライアントを作成し、コスト最適化のためにGoogleの埋め込みを使用します。

3. チャンキングによるドキュメントの取り込み

// lib/ingestion.ts
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { Document } from '@langchain/core/documents';
import { getVectorStore } from './vector-store';
import { chunk, map } from 'es-toolkit';

interface ChunkingConfig {
  chunkSize: number;
  chunkOverlap: number;
  separators?: string[];
}

export async function ingestDocuments(
  texts: string[],
  metadata: Record<string, any>[] = [],
  config: ChunkingConfig = {
    chunkSize: 1500,
    chunkOverlap: 200,
  }
) {
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: config.chunkSize,
    chunkOverlap: config.chunkOverlap,
    separators: config.separators || ['\n\n', '\n', '.', '!', '?'],
  });

  // 並列バッチでドキュメントを処理
  const documents = await Promise.all(
    map(texts, async (text, index) => {
      const chunks = await splitter.splitText(text);
      return chunks.map((chunk, chunkIndex) =>
        new Document({
          pageContent: chunk,
          metadata: {
            ...metadata[index],
            chunkIndex,
            originalIndex: index,
          },
        })
      );
    })
  );

  const flatDocs = documents.flat();
  const vectorStore = await getVectorStore();

  // 効率的なバッチ挿入
  const batches = chunk(flatDocs, 100);
  for (const batch of batches) {
    await vectorStore.addDocuments(batch);
  }

  return flatDocs.length;
}

コンテキストを維持するためのオーバーラップを持つスマートなドキュメントチャンキングを実装し、サーバーレスのタイムアウト制限に最適化された並列バッチでドキュメントを処理します。

4. 基本的なRAGチェーン

// lib/rag/basic-rag.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { PromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';
import { getVectorStore } from '../vector-store';
import { formatDocumentsAsString } from 'langchain/util/document';

export async function createBasicRAGChain() {
  const vectorStore = await getVectorStore();
  const retriever = vectorStore.asRetriever({
    k: 4, // 上位4つの関連チャンクを取得
    searchType: 'similarity',
  });

  const prompt = PromptTemplate.fromTemplate(`
    次のコンテキストのみに基づいて質問に答えてください:
    {context}

    質問: {question}

    簡潔に答え、コンテキストの関連部分を引用してください。
  `);

  const model = new ChatGoogleGenerativeAI({
    modelName: 'gemini-2.5-flash',
    temperature: 0.3,
    maxOutputTokens: 1024,
  });

  const chain = RunnableSequence.from([
    {
      context: async (input: { question: string }) => {
        const docs = await retriever.invoke(input.question);
        return formatDocumentsAsString(docs);
      },
      question: new RunnablePassthrough(),
    },
    prompt,
    model,
    new StringOutputParser(),
  ]);

  return chain;
}

コンテキストを取得し、質問とフォーマットし、根拠のある応答を生成する基本的なRAGチェーンを作成します。

5. 基本RAG用のAPIルート

// app/api/rag/basic/route.ts
import { createBasicRAGChain } from '@/lib/rag/basic-rag';
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';
export const maxDuration = 60;

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

    const chain = await createBasicRAGChain();
    const response = await chain.invoke({ question });

    return NextResponse.json({ answer: response });
  } catch (error) {
    console.error('RAGエラー:', error);
    return NextResponse.json(
      { error: 'クエリの処理に失敗しました' },
      { status: 500 }
    );
  }
}

質問を受け付け、基本的なクエリ用に60秒のタイムアウトでRAG拡張された回答を返すシンプルなAPIエンドポイント。

高度な例:自己修正を備えたエージェント型RAG

1. CRAGパターンによる自己修正RAG

// lib/rag/corrective-rag.ts
import { StateGraph, END } from '@langchain/langgraph';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { BaseMessage, HumanMessage, AIMessage } from '@langchain/core/messages';
import { Document } from '@langchain/core/documents';
import { getVectorStore } from '../vector-store';
import { WebBrowser } from '@langchain/community/tools/webbrowser';
import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai';
import { filter, map, some } from 'es-toolkit';

interface CRAGState {
  question: string;
  documents: Document[];
  relevanceScores: number[];
  finalAnswer: string;
  needsWebSearch: boolean;
  webResults: Document[];
}

export function createCorrectiveRAG() {
  const model = new ChatGoogleGenerativeAI({
    modelName: 'gemini-2.5-pro',
    temperature: 0,
  });

  const relevanceModel = new ChatGoogleGenerativeAI({
    modelName: 'gemini-2.5-flash',
    temperature: 0,
  });

  const workflow = new StateGraph<CRAGState>({
    channels: {
      question: null,
      documents: null,
      relevanceScores: null,
      finalAnswer: null,
      needsWebSearch: null,
      webResults: null,
    },
  });

  // ノード:ドキュメントを取得
  workflow.addNode('retrieve', async (state) => {
    const vectorStore = await getVectorStore();
    const retriever = vectorStore.asRetriever({ k: 5 });
    const documents = await retriever.invoke(state.question);

    return { documents };
  });

  // ノード:関連性を評価
  workflow.addNode('evaluate_relevance', async (state) => {
    const relevancePrompt = `
      このドキュメントの質問に対する関連性をスコア付けしてください(0-10):
      質問: {question}
      ドキュメント: {document}

      0-10の数値のみを返してください。
    `;

    const relevanceScores = await Promise.all(
      map(state.documents, async (doc) => {
        const response = await relevanceModel.invoke([
          new HumanMessage(
            relevancePrompt
              .replace('{question}', state.question)
              .replace('{document}', doc.pageContent)
          ),
        ]);
        return parseFloat(response.content as string) || 0;
      })
    );

    // ウェブ検索が必要かチェック(すべてのスコアが7未満)
    const needsWebSearch = !some(relevanceScores, score => score >= 7);

    return { relevanceScores, needsWebSearch };
  });

  // ノード:ウェブ検索フォールバック
  workflow.addNode('web_search', async (state) => {
    if (!state.needsWebSearch) {
      return { webResults: [] };
    }

    const embeddings = new GoogleGenerativeAIEmbeddings({
      modelName: "embedding-001",
    });

    const browser = new WebBrowser({ model, embeddings });
    const searchResult = await browser.invoke(state.question);

    // 検索結果をドキュメントに解析
    const webResults = [
      new Document({
        pageContent: searchResult,
        metadata: { source: 'web_search' },
      }),
    ];

    return { webResults };
  });

  // ノード:回答を生成
  workflow.addNode('generate', async (state) => {
    // 高関連性のドキュメントをフィルタリング
    const relevantDocs = filter(
      state.documents,
      (_, index) => state.relevanceScores[index] >= 7
    );

    // 必要に応じてウェブ結果と組み合わせる
    const allDocs = [...relevantDocs, ...state.webResults];

    const context = map(allDocs, doc => doc.pageContent).join('\n\n');

    const response = await model.invoke([
      new HumanMessage(`
        提供されたコンテキストを使用してこの質問に答えてください:

        コンテキスト:
        ${context}

        質問: ${state.question}

        引用を含む包括的な回答を提供してください。
      `),
    ]);

    return { finalAnswer: response.content as string };
  });

  // ワークフローのエッジを定義
  workflow.setEntryPoint('retrieve');
  workflow.addEdge('retrieve', 'evaluate_relevance');
  workflow.addEdge('evaluate_relevance', 'web_search');
  workflow.addEdge('web_search', 'generate');
  workflow.addEdge('generate', END);

  return workflow.compile();
}

ドキュメントの関連性を評価し、必要に応じてウェブ検索にフォールバックし、検証されたソースから回答を生成するCRAGパターンを実装します。

2. 複雑な質問用のマルチクエリRAG

// lib/rag/multi-query-rag.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { getVectorStore } from '../vector-store';
import { uniqBy, flatten, take } from 'es-toolkit';
import { Document } from '@langchain/core/documents';

interface MultiQueryConfig {
  numQueries: number;
  maxDocsPerQuery: number;
  temperature: number;
}

export class MultiQueryRAG {
  private model: ChatGoogleGenerativeAI;
  private queryGenerator: ChatGoogleGenerativeAI;

  constructor() {
    this.model = new ChatGoogleGenerativeAI({
      modelName: 'gemini-2.5-pro',
      temperature: 0.3,
    });

    this.queryGenerator = new ChatGoogleGenerativeAI({
      modelName: 'gemini-2.5-flash',
      temperature: 0.7, // クエリの多様性のための高い温度
    });
  }

  async generateQueries(
    originalQuery: string,
    config: MultiQueryConfig = {
      numQueries: 3,
      maxDocsPerQuery: 3,
      temperature: 0.7,
    }
  ): Promise<string[]> {
    const prompt = `
      次の情報を見つけるために${config.numQueries}個の異なる検索クエリを生成してください:
      "${originalQuery}"

      以下の特徴を持つクエリを作成してください:
      1. 異なるキーワードと表現を使用
      2. 質問の異なる側面に焦点を当てる
      3. 具体的なものから一般的なものまで幅広く

      クエリのみを返し、1行に1つずつ記載してください。
    `;

    const response = await this.queryGenerator.invoke([
      new HumanMessage(prompt),
    ]);

    const queries = (response.content as string)
      .split('\n')
      .filter(q => q.trim())
      .slice(0, config.numQueries);

    return [originalQuery, ...queries];
  }

  async retrieveWithMultiQuery(
    query: string,
    config?: MultiQueryConfig
  ): Promise<Document[]> {
    const queries = await this.generateQueries(query, config);
    const vectorStore = await getVectorStore();

    // 各クエリに対して並列で取得
    const allResults = await Promise.all(
      queries.map(q =>
        vectorStore.similaritySearch(q, config?.maxDocsPerQuery || 3)
      )
    );

    // コンテンツで重複を排除
    const uniqueDocs = uniqBy(
      flatten(allResults),
      doc => doc.pageContent
    );

    // トップドキュメントを返す
    return take(uniqueDocs, 10);
  }

  async answer(query: string): Promise<string> {
    const documents = await this.retrieveWithMultiQuery(query);

    const context = documents
      .map((doc, idx) => `[${idx + 1}] ${doc.pageContent}`)
      .join('\n\n');

    const response = await this.model.invoke([
      new HumanMessage(`
        次のコンテキストに基づいて回答してください:

        ${context}

        質問: ${query}

        ソースの参照番号[1]、[2]などを含めてください。
      `),
    ]);

    return response.content as string;
  }
}

検索範囲を改善するために複数のクエリバリエーションを生成し、結果の重複を排除し、参照された回答を提供します。

3. 適応型RAGルーター

// lib/rag/adaptive-rag.ts
import { StateGraph, END } from '@langchain/langgraph';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { BaseMessage } from '@langchain/core/messages';
import { createBasicRAGChain } from './basic-rag';
import { MultiQueryRAG } from './multi-query-rag';
import { createCorrectiveRAG } from './corrective-rag';

interface AdaptiveRAGState {
  query: string;
  complexity: 'simple' | 'medium' | 'complex';
  answer: string;
  confidence: number;
}

export function createAdaptiveRAG() {
  const classifier = new ChatGoogleGenerativeAI({
    modelName: 'gemini-2.5-flash',
    temperature: 0,
  });

  const workflow = new StateGraph<AdaptiveRAGState>({
    channels: {
      query: null,
      complexity: null,
      answer: null,
      confidence: null,
    },
  });

  // ノード:クエリの複雑さを分類
  workflow.addNode('classify', async (state) => {
    const prompt = `
      このクエリの複雑さを分類してください:
      "${state.query}"

      Simple: 事実的、単一ホップの質問
      Medium: 統合が必要な多面的な質問
      Complex: 推論、検証、または複数のソースが必要な質問

      simple、medium、またはcomplexのみで応答してください
    `;

    const response = await classifier.invoke([
      new HumanMessage(prompt),
    ]);

    const complexity = (response.content as string).trim().toLowerCase() as
      'simple' | 'medium' | 'complex';

    return { complexity };
  });

  // ノード:シンプルRAG
  workflow.addNode('simple_rag', async (state) => {
    if (state.complexity !== 'simple') return {};

    const chain = await createBasicRAGChain();
    const answer = await chain.invoke({ question: state.query });

    return { answer, confidence: 0.9 };
  });

  // ノード:マルチクエリRAG
  workflow.addNode('multi_query_rag', async (state) => {
    if (state.complexity !== 'medium') return {};

    const multiRAG = new MultiQueryRAG();
    const answer = await multiRAG.answer(state.query);

    return { answer, confidence: 0.8 };
  });

  // ノード:修正RAG
  workflow.addNode('corrective_rag', async (state) => {
    if (state.complexity !== 'complex') return {};

    const crag = createCorrectiveRAG();
    const result = await crag.invoke({
      question: state.query,
      documents: [],
      relevanceScores: [],
      finalAnswer: '',
      needsWebSearch: false,
      webResults: [],
    });

    return {
      answer: result.finalAnswer,
      confidence: 0.7
    };
  });

  // 複雑さに基づく条件付きルーティング
  workflow.setEntryPoint('classify');

  workflow.addConditionalEdges('classify', (state) => {
    switch (state.complexity) {
      case 'simple':
        return 'simple_rag';
      case 'medium':
        return 'multi_query_rag';
      case 'complex':
        return 'corrective_rag';
      default:
        return 'simple_rag';
    }
  });

  workflow.addEdge('simple_rag', END);
  workflow.addEdge('multi_query_rag', END);
  workflow.addEdge('corrective_rag', END);

  return workflow.compile();
}

複雑さの分類に基づいてクエリを適切なRAG戦略にルーティングし、速度と精度の両方を最適化します。

4. 進行状況更新を伴うストリーミングRAG API

// app/api/rag/adaptive/route.ts
import { createAdaptiveRAG } from '@/lib/rag/adaptive-rag';

export const runtime = 'nodejs';
export const maxDuration = 300;

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

  const encoder = new TextEncoder();
  const stream = new TransformStream();
  const writer = stream.writable.getWriter();

  const workflow = createAdaptiveRAG();

  (async () => {
    try {
      // 進行状況イベントを送信
      await writer.write(
        encoder.encode(`data: ${JSON.stringify({
          type: 'status',
          message: 'クエリの複雑さを分析中...'
        })}\n\n`)
      );

      const events = await workflow.stream({
        query,
        complexity: 'simple',
        answer: '',
        confidence: 0,
      });

      for await (const event of events) {
        // 中間更新を送信
        if (event.complexity) {
          await writer.write(
            encoder.encode(`data: ${JSON.stringify({
              type: 'complexity',
              complexity: event.complexity,
              message: `${event.complexity} RAG戦略を使用`
            })}\n\n`)
          );
        }

        if (event.answer) {
          await writer.write(
            encoder.encode(`data: ${JSON.stringify({
              type: 'answer',
              content: event.answer,
              confidence: event.confidence
            })}\n\n`)
          );
        }
      }

      await writer.write(
        encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
      );
    } catch (error) {
      await writer.write(
        encoder.encode(`data: ${JSON.stringify({
          type: 'error',
          error: String(error)
        })}\n\n`)
      );
    } finally {
      await writer.close();
    }
  })();

  return new Response(stream.readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

複雑さ分析、戦略選択、信頼スコア付きの最終回答を含むRAG実行の進行状況をストリーミングします。

5. 適応型RAG用のReactコンポーネント

// components/AdaptiveRAGInterface.tsx
'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { groupBy } from 'es-toolkit';

interface RAGEvent {
  type: 'status' | 'complexity' | 'answer' | 'error' | 'done';
  message?: string;
  complexity?: string;
  content?: string;
  confidence?: number;
  error?: string;
}

export default function AdaptiveRAGInterface() {
  const [query, setQuery] = useState('');
  const [events, setEvents] = useState<RAGEvent[]>([]);
  const [answer, setAnswer] = useState('');

  const ragMutation = useMutation({
    mutationFn: async (userQuery: string) => {
      setEvents([]);
      setAnswer('');

      const response = await fetch('/api/rag/adaptive', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query: userQuery }),
      });

      if (!response.ok) throw new Error('RAG失敗');

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

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

        const chunk = decoder.decode(value);
        const lines = chunk.split('\n');

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            try {
              const event = JSON.parse(line.slice(6)) as RAGEvent;
              setEvents(prev => [...prev, event]);

              if (event.type === 'answer') {
                setAnswer(event.content || '');
              }
            } catch (e) {
              // 解析エラーを無視
            }
          }
        }
      }
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (query.trim()) {
      ragMutation.mutate(query);
    }
  };

  // 表示用にイベントをタイプ別にグループ化
  const eventGroups = groupBy(events, event => event.type);

  return (
    <div className="w-full max-w-4xl mx-auto">
      <div className="card bg-base-100 shadow-xl">
        <div className="card-body">
          <h2 className="card-title">適応型RAGシステム</h2>

          <form onSubmit={handleSubmit} className="space-y-4">
            <div className="form-control">
              <label className="label">
                <span className="label-text">ご質問</span>
              </label>
              <textarea
                className="textarea textarea-bordered h-24"
                placeholder="質問を入力..."
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                disabled={ragMutation.isPending}
              />
            </div>

            <button
              type="submit"
              className="btn btn-primary"
              disabled={ragMutation.isPending || !query.trim()}
            >
              {ragMutation.isPending ? (
                <>
                  <span className="loading loading-spinner"></span>
                  処理中...
                </>
              ) : '回答を取得'}
            </button>
          </form>

          {/* 進行状況インジケーター */}
          {events.length > 0 && (
            <div className="mt-6 space-y-4">
              {eventGroups.complexity && (
                <div className="alert alert-info">
                  <span>
                    クエリの複雑さ:
                    <span className="badge badge-primary ml-2">
                      {eventGroups.complexity[0].complexity}
                    </span>
                  </span>
                </div>
              )}

              {eventGroups.status && (
                <div className="mockup-code">
                  {eventGroups.status.map((event, idx) => (
                    <pre key={idx} data-prefix={`${idx + 1}`}>
                      <code>{event.message}</code>
                    </pre>
                  ))}
                </div>
              )}
            </div>
          )}

          {/* 回答表示 */}
          {answer && (
            <div className="mt-6">
              <div className="divider">回答</div>
              <div className="prose max-w-none">
                <div className="p-4 bg-base-200 rounded-lg">
                  {answer}
                </div>
                {events.find(e => e.confidence) && (
                  <div className="mt-2">
                    <progress
                      className="progress progress-success w-full"
                      value={events.find(e => e.confidence)?.confidence || 0}
                      max="1"
                    />
                    <p className="text-sm text-center mt-1">
                      信頼度: {((events.find(e => e.confidence)?.confidence || 0) * 100).toFixed(0)}%
                    </p>
                  </div>
                )}
              </div>
            </div>
          )}

          {ragMutation.isError && (
            <div className="alert alert-error mt-4">
              <span>エラー: {ragMutation.error?.message}</span>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

RAG実行の進行状況、複雑さの分類、信頼スコア付きの回答を表示するインタラクティブなUIコンポーネント。

まとめ

この実装では、基本的なRAGから、クエリをインテリジェントにルーティングし、ウェブ検索フォールバックで自己修正し、複雑さに基づいて戦略を適応させる洗練されたエージェント型パターンへの進化を示しています。Vercelのサーバーレスアーキテクチャはコスト効果的なスケーリングを保証し、LangGraphのステートマシンは777秒の制限内で複雑なワークフローを可能にします。主要なパターンには、自己修正のためのCRAG、包括的な検索のためのマルチクエリ、最適なパフォーマンスのための適応型ルーティングが含まれます。es-toolkitの使用により、クリーンで機能的なコードパターンが保証され、ストリーミングレスポンスは複雑なクエリでも優れたユーザーエクスペリエンスを提供します。