LESSON 40分

ストーリー

佐藤CTO
基本的なRAGパイプラインは理解できたな?
佐藤CTO
Naiveな実装だとRetrievalの精度が60%程度。つまり、ユーザーの質問に対して4割は的外れなチャンクを返している。これでは本番投入できない
あなた
どうすれば改善できますか?
佐藤CTO
Naive RAGで止まるのはプロトタイプまでだ。本番にはAdvanced RAGが必要だ。Multi-stage retrieval、HyDE、Re-ranking、Query decomposition。これらを組み合わせて90%以上の精度を目指す

Naive RAG vs Advanced RAG

Naive RAGの限界

graph LR
    Q["ユーザークエリ"] --> VS["単純ベクトル検索"] --> TK["Top-K取得"] --> LLM["LLMに渡す"] --> A["回答"]

    style Q fill:#e3f2fd,stroke:#1565c0
    style A fill:#e8f5e9,stroke:#2e7d32
問題点:
1. クエリと文書の意味的ギャップ(Semantic Gap)
2. 単一ステージの検索では精度限界がある
3. 関連性の低いチャンクがノイズになる
4. 複雑な質問を分解できない

Advanced RAGの全体像

graph TD
    subgraph "Advanced RAG パイプライン"
        subgraph クエリ前処理
            QR["Query Rewriting<br/>(クエリの書き換え)"]
            QD["Query Decomposition<br/>(複雑な質問の分解)"]
            HY["HyDE<br/>(仮想ドキュメント生成)"]
        end
        subgraph 検索戦略
            MS["Multi-stage Retrieval<br/>(多段階検索)"]
            HS["Hybrid Search<br/>(ベクトル + キーワード)"]
            CR["Contextual Retrieval<br/>(コンテキスト補完)"]
        end
        subgraph 後処理
            RR["Re-ranking<br/>(関連度の再評価)"]
            CC["Contextual Compression<br/>(不要部分の除去)"]
            CE["Citation Extraction<br/>(出典の抽出)"]
        end
    end

    クエリ前処理 --> 検索戦略 --> 後処理

クエリ前処理テクニック

1. Query Rewriting

ユーザーの質問をベクトル検索に最適化された形に書き換えます。

class QueryRewriter {
  constructor(private readonly llm: LLMService) {}

  async rewrite(originalQuery: string): Promise<string[]> {
    const prompt = `あなたは検索クエリの最適化エキスパートです。
以下のユーザーの質問に対して、ベクトル検索に最適な3つの異なるクエリを生成してください。
元の意味は保持しつつ、異なる表現やキーワードを使ってください。

ユーザーの質問: ${originalQuery}

JSON形式で回答してください:
{"queries": ["クエリ1", "クエリ2", "クエリ3"]}`;

    const response = await this.llm.complete(prompt);
    const parsed = JSON.parse(response);
    return parsed.queries;
  }
}

// 使用例
// "デプロイ時にDBのマイグレーションはどうすればいい?"
// → ["データベースマイグレーション デプロイ手順",
//    "本番環境でのスキーマ変更方法",
//    "CI/CDパイプラインでのDB更新手順"]

2. HyDE (Hypothetical Document Embeddings)

クエリの代わりに、LLMに仮想的な回答ドキュメントを生成させ、それを使って検索します。

class HyDERetriever {
  constructor(
    private readonly llm: LLMService,
    private readonly embedder: EmbeddingService,
    private readonly vectorStore: VectorStore,
  ) {}

  async retrieve(query: string, topK: number = 5): Promise<RetrievedContext[]> {
    // Step 1: LLMに仮想的な回答を生成させる
    const hypotheticalDoc = await this.generateHypotheticalDocument(query);

    // Step 2: 仮想ドキュメントのエンベディングで検索
    const embedding = await this.embedder.embed(hypotheticalDoc);
    const results = await this.vectorStore.search(embedding, topK);

    return results;
  }

  private async generateHypotheticalDocument(query: string): Promise<string> {
    const prompt = `以下の質問に対する回答を、社内技術ドキュメントの一節として書いてください。
実際のドキュメントがどのような内容かを想像して、それらしい文章を生成してください。

質問: ${query}

ドキュメントの一節:`;

    return this.llm.complete(prompt);
  }
}

3. Query Decomposition

複雑な質問を複数のサブ質問に分解し、それぞれに対して検索を行います。

class QueryDecomposer {
  constructor(private readonly llm: LLMService) {}

  async decompose(complexQuery: string): Promise<string[]> {
    const prompt = `以下の複雑な質問を、それぞれ独立して検索可能なシンプルなサブ質問に分解してください。

質問: ${complexQuery}

JSON形式で回答:
{"subQueries": ["サブ質問1", "サブ質問2", ...]}`;

    const response = await this.llm.complete(prompt);
    const parsed = JSON.parse(response);
    return parsed.subQueries;
  }
}

// 使用例
// "KubernetesとTerraformを使ったマイクロサービスのデプロイ手順と、
//  そのときのモニタリング設定方法を教えて"
// → ["Kubernetesでのマイクロサービスデプロイ手順",
//    "Terraformによるインフラ構成管理",
//    "マイクロサービスのモニタリング設定方法"]

Multi-stage Retrieval

2段階検索パターン

第1段階で広く候補を取得し、第2段階で精密に絞り込みます。

class MultiStageRetriever {
  constructor(
    private readonly embedder: EmbeddingService,
    private readonly vectorStore: VectorStore,
    private readonly reranker: RerankerService,
  ) {}

  async retrieve(query: string, finalTopK: number = 5): Promise<RetrievedContext[]> {
    // Stage 1: Broad Retrieval(広範囲検索)
    // 最終的に必要な数の3-5倍を取得
    const queryEmbedding = await this.embedder.embed(query);
    const candidates = await this.vectorStore.search(queryEmbedding, finalTopK * 4);

    // Stage 2: Re-ranking(精密な関連度評価)
    const reranked = await this.reranker.rerank(query, candidates);

    // 上位のみ返す
    return reranked.slice(0, finalTopK);
  }
}

Re-ranking の実装

interface RerankerService {
  rerank(query: string, candidates: RetrievedContext[]): Promise<RetrievedContext[]>;
}

// Cross-Encoder ベースの Re-ranker
class CrossEncoderReranker implements RerankerService {
  async rerank(
    query: string,
    candidates: RetrievedContext[]
  ): Promise<RetrievedContext[]> {
    // Cohere Rerank API の例
    const response = await fetch('https://api.cohere.ai/v1/rerank', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.COHERE_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'rerank-multilingual-v3.0',
        query,
        documents: candidates.map(c => c.chunk.content),
        top_n: candidates.length,
      }),
    });

    const data = await response.json();
    return data.results.map((r: { index: number; relevance_score: number }) => ({
      ...candidates[r.index],
      score: r.relevance_score,
    }));
  }
}

// LLM ベースの Re-ranker(コストは高いが精度は最高)
class LLMReranker implements RerankerService {
  constructor(private readonly llm: LLMService) {}

  async rerank(
    query: string,
    candidates: RetrievedContext[]
  ): Promise<RetrievedContext[]> {
    const prompt = `以下の質問に対して、各ドキュメントの関連度を1-10で評価してください。

質問: ${query}

${candidates.map((c, i) => `[Doc${i + 1}]: ${c.chunk.content.slice(0, 200)}`).join('\n\n')}

JSON形式で回答: {"scores": [{"doc": 1, "score": 8}, ...]}`;

    const response = await this.llm.complete(prompt);
    const parsed = JSON.parse(response);

    return parsed.scores
      .sort((a: { score: number }, b: { score: number }) => b.score - a.score)
      .map((s: { doc: number; score: number }) => ({
        ...candidates[s.doc - 1],
        score: s.score / 10,
      }));
  }
}

Agentic RAG

Self-RAGパターン

LLMが自ら検索の必要性を判断し、検索結果の品質を評価するパターンです。

class AgenticRAG {
  constructor(
    private readonly retriever: MultiStageRetriever,
    private readonly llm: LLMService,
  ) {}

  async answer(query: string): Promise<{ answer: string; sources: string[] }> {
    // Step 1: 検索が必要か判断
    const needsRetrieval = await this.assessRetrievalNeed(query);

    if (!needsRetrieval) {
      const answer = await this.llm.complete(
        `以下の質問に簡潔に回答してください: ${query}`
      );
      return { answer, sources: [] };
    }

    // Step 2: 検索実行
    let contexts = await this.retriever.retrieve(query);

    // Step 3: 検索結果の品質評価
    const qualityAssessment = await this.assessContextQuality(query, contexts);

    if (qualityAssessment.needsMoreInfo) {
      // 追加検索が必要な場合
      const additionalQueries = qualityAssessment.suggestedQueries;
      for (const additionalQuery of additionalQueries) {
        const moreContexts = await this.retriever.retrieve(additionalQuery);
        contexts = [...contexts, ...moreContexts];
      }
      // 重複除去
      contexts = this.deduplicateContexts(contexts);
    }

    // Step 4: 回答生成
    const answer = await this.generateAnswer(query, contexts);

    // Step 5: 回答の忠実性検証
    const isGrounded = await this.verifyGroundedness(answer, contexts);

    if (!isGrounded) {
      return {
        answer: '申し訳ございませんが、お手持ちの情報源からは正確な回答を生成できませんでした。',
        sources: [],
      };
    }

    return {
      answer,
      sources: contexts.map(c => c.chunk.metadata.source as string),
    };
  }

  private async assessRetrievalNeed(query: string): Promise<boolean> {
    const prompt = `以下の質問に回答するために、外部知識ベースの検索が必要ですか?
一般知識で回答可能な場合は "no"、社内固有の情報が必要な場合は "yes" と回答してください。

質問: ${query}
回答 (yes/no):`;

    const response = await this.llm.complete(prompt);
    return response.trim().toLowerCase() === 'yes';
  }

  private async assessContextQuality(
    query: string,
    contexts: RetrievedContext[]
  ): Promise<{ needsMoreInfo: boolean; suggestedQueries: string[] }> {
    const prompt = `以下の質問に対して、検索結果は十分な情報を含んでいますか?

質問: ${query}

検索結果:
${contexts.map(c => c.chunk.content.slice(0, 150)).join('\n---\n')}

JSON形式で回答:
{"sufficient": true/false, "missingTopics": ["不足しているトピック"], "suggestedQueries": ["追加検索クエリ"]}`;

    const response = await this.llm.complete(prompt);
    const parsed = JSON.parse(response);
    return {
      needsMoreInfo: !parsed.sufficient,
      suggestedQueries: parsed.suggestedQueries ?? [],
    };
  }

  private async verifyGroundedness(
    answer: string,
    contexts: RetrievedContext[]
  ): Promise<boolean> {
    const contextText = contexts.map(c => c.chunk.content).join('\n');
    const prompt = `以下の回答が、提供された情報源に基づいているか検証してください。
情報源に含まれない情報が回答に含まれている場合は "no" と回答してください。

情報源:
${contextText}

回答:
${answer}

検証結果 (yes/no):`;

    const response = await this.llm.complete(prompt);
    return response.trim().toLowerCase() === 'yes';
  }

  private deduplicateContexts(contexts: RetrievedContext[]): RetrievedContext[] {
    const seen = new Set<string>();
    return contexts.filter(c => {
      if (seen.has(c.chunk.id)) return false;
      seen.add(c.chunk.id);
      return true;
    });
  }

  private async generateAnswer(
    query: string,
    contexts: RetrievedContext[]
  ): Promise<string> {
    const contextText = contexts
      .map((c, i) => `[出典${i + 1}] ${c.chunk.content}`)
      .join('\n\n');

    const prompt = `参考情報のみを根拠にして質問に回答してください。
回答に出典番号を含めてください。

参考情報:
${contextText}

質問: ${query}
回答:`;

    return this.llm.complete(prompt);
  }
}
Contextual Retrieval パターン

Anthropicが提案したContextual Retrievalは、チャンクにドキュメント全体の文脈を埋め込むことで検索精度を向上させるテクニックです。

class ContextualRetrieval {
  constructor(private readonly llm: LLMService) {}

  async enrichChunks(
    document: string,
    chunks: Chunk[]
  ): Promise<Chunk[]> {
    const enrichedChunks: Chunk[] = [];

    for (const chunk of chunks) {
      const contextPrefix = await this.generateContext(document, chunk.content);
      enrichedChunks.push({
        ...chunk,
        content: `${contextPrefix}\n\n${chunk.content}`,
      });
    }

    return enrichedChunks;
  }

  private async generateContext(
    fullDocument: string,
    chunkContent: string
  ): Promise<string> {
    const prompt = `<document>
${fullDocument}
</document>

上記のドキュメント内の以下のチャンクについて、簡潔なコンテキスト(50-100文字)を生成してください。
このコンテキストは、チャンクの内容をドキュメント全体の中で位置づけるものです。

<chunk>
${chunkContent}
</chunk>

コンテキスト:`;

    return this.llm.complete(prompt);
  }
}

RAGパターンの選択ガイド

パターン実装コスト効果推奨場面
Query Rewriting最初に試すべき改善
Hybrid Searchキーワードが重要な場面
Re-ranking検索精度が不十分な場合
HyDE中-高短いクエリが多い場合
Query Decomposition複雑な質問が多い場合
Contextual Retrievalインデックス時に一手間かけられる場合
Agentic RAG最高品質要求が最も高い場合

まとめ

ポイント内容
Naive RAGの限界単純なベクトル検索では本番品質に到達しない
クエリ前処理Query Rewriting、HyDE、Query Decompositionで検索品質を向上
Multi-stage広範囲検索 → Re-rankingの2段階で精度向上
Agentic RAGLLMが検索の必要性と品質を自律的に判断

チェックリスト

  • Naive RAG と Advanced RAG の違いを理解した
  • クエリ前処理の3つのテクニックを理解した
  • Multi-stage Retrieval と Re-ranking を理解した
  • Agentic RAG の概念を理解した

次のステップへ

高度なRAGパターンを学びました。次のセクションでは、構築したRAGシステムの品質をどう測定するかを学びます。測れないものは改善できません。

RAGの品質改善は、仮説→検証→改善の繰り返しです。


推定読了時間: 40分