Zenn
🗂

オンプレミスRAGシステム構築:設計から実装まで

2025/03/26に公開

オンプレミスの医療環境で動作するRAG(Retrieval-Augmented Generation)システムの構築について、設計思想から技術的詳細、そして実運用での成果までを解説します。

プロジェクト背景と課題

医療分野では、患者データの機密性保持のため、インターネット接続のないオンプレミス環境でのAI活用が求められています。また、専門医は膨大な医療文書をレビューする時間的負担を抱えています。これらの課題を解決するため、完全オンプレミス環境で動作し、医療専門知識を活用した高精度な質問応答・要約システムの構築が必要でした。

システム設計の全体像

RAGシステムの中核は「検索(Retrieval)」と「生成(Generation)」の2段階プロセスです。私たちのアプローチの特徴は、これらを疎結合に設計したことにあります。

![RAGシステムアーキテクチャ図]

なぜ疎結合設計なのか?

疎結合アーキテクチャには以下の利点があります:

  1. モジュール別の最適化が可能:検索精度と生成品質はそれぞれ独立して調整できる
  2. 柔軟な拡張性:新しい検索エンジンや言語モデルへの置き換えが容易
  3. 障害分離:一方のコンポーネントの問題が他方に波及しにくい

Retriever部分の設計と実装

埋め込みモデルの選定

医療文書の特性を考慮し、複数の埋め込みモデルを評価しました:

モデル 医療同義語一致率 略語解決精度 推論速度
SBERT 68.2% 59.7% 高速
ClinicalBERT 83.5% 74.2% 中速
BiomedBERT 81.8% 70.5% 中速

ClinicalBERTは医療同義語や略語の処理において顕著な優位性を示したため、このモデルを採用しました。例えば「MI」という略語が「心筋梗塞(Myocardial Infarction)」を指すコンテキストを正確に捉えられます。

ハイブリッド検索アーキテクチャ

検索システムには用途に応じた2つの選択肢を実装しました:

  1. Faiss(Facebook AI Similarity Search)

    • HNSW(Hierarchical Navigable Small World)アルゴリズムを採用
    • メモリ上での超高速な類似ベクトル検索が可能
    • 主に診断レポートなど大量データの検索に使用
  2. ElasticSearch

    • キーワードベース検索とベクトル検索の融合が可能
    • より構造化されたFAQデータの検索に最適
    • フィルタリング機能を活用し、特定の診療科や条件に限定した検索が容易

チャンキング戦略の最適化

医療文書は一般テキストと異なる特性を持つため、独自のチャンキング戦略を開発しました:

def medical_report_chunking(document, overlap_size=50):
    # 段落を基本単位として分割
    paragraphs = document.split('\n\n')
    chunks = []
    
    for i, para in enumerate(paragraphs):
        # セクション見出しを検出(例:「臨床所見:」「検査結果:」)
        if re.match(r'^[A-Za-z\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]+[::]', para):
            # セクション見出しを単独チャンクとして保持せず、次の内容と結合
            if i + 1 < len(paragraphs):
                chunks.append(para + '\n\n' + paragraphs[i+1])
        else:
            # 既にセクション見出しと結合済みでない場合のみ追加
            if i == 0 or not re.match(r'^[A-Za-z\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]+[::]', paragraphs[i-1]):
                chunks.append(para)
    
    # スライディングウィンドウでオーバーラップを作成
    overlapped_chunks = []
    for i in range(len(chunks)):
        if i + 1 < len(chunks):
            # 現在のチャンクと次のチャンクの先頭部分を結合
            current_chunk = chunks[i]
            next_chunk_head = chunks[i+1][:overlap_size]
            overlapped_chunks.append(current_chunk + '\n\n' + next_chunk_head)
        else:
            overlapped_chunks.append(chunks[i])
    
    return overlapped_chunks

このアプローチにより:

  • 医療文書の論理的な構造を維持
  • セクション間の関連性を保持
  • 単純な文字数や単語数による分割よりも意味的なまとまりを確保

再ランク機構の導入

最初の検索結果をさらに精緻化するため、Top-k+rerank戦略を実装しました:

  1. 最初に類似度スコアに基づいてTop-k(例:k=20)の文書を取得
  2. 以下の要素を考慮した複合スコアで再ランク:
    • オリジナルの類似度スコア(重み:0.7)
    • 文書の新しさ(重み:0.1)
    • 文書の権威性(監修医のレベルなど)(重み:0.2)

また、MMR(Maximal Marginal Relevance)アルゴリズムを導入し、内容の多様性も確保しています:

def mmr_reranking(query_vector, doc_vectors, initial_ranking, lambda_param=0.6, top_n=5):
    """
    多様性を考慮した再ランキング(MMR)
    lambda_param: 関連性と多様性のバランスを制御(1に近いほど関連性重視)
    """
    selected_indices = []
    remaining_indices = list(range(len(initial_ranking)))
    
    # 最も関連性の高い文書を最初に選択
    selected_indices.append(remaining_indices.pop(0))
    
    while len(selected_indices) < top_n and remaining_indices:
        max_score = -float('inf')
        max_idx = -1
        
        for idx in remaining_indices:
            # 関連性スコア(クエリとの類似度)
            relevance = cosine_similarity(query_vector, doc_vectors[idx])
            
            # 多様性スコア(既に選択された文書との最大類似度)
            diversity_scores = [cosine_similarity(doc_vectors[idx], doc_vectors[sel_idx]) 
                               for sel_idx in selected_indices]
            diversity = min(diversity_scores) if diversity_scores else 0
            
            # MMRスコア計算
            mmr_score = lambda_param * relevance - (1 - lambda_param) * diversity
            
            if mmr_score > max_score:
                max_score = mmr_score
                max_idx = idx
        
        if max_idx != -1:
            selected_indices.append(max_idx)
            remaining_indices.remove(max_idx)
    
    return [initial_ranking[i] for i in selected_indices]

Generator部分の設計と実装

モデル選定とチューニング

オンプレミス環境の制約を考慮し、以下の判断を行いました:

  1. ベースモデル: Llama-3-8B-Instructを採用

    • 8Bサイズは一般的なGPUでも推論可能
    • Instructバージョンは指示対応能力が高い
  2. LoRAによる医療特化チューニング:

    • 医療分野特有の語彙・表現に対応するため、約5,000件の医療QAデータでLoRAチューニングを実施
    • ランク:16、アルファ:32の設定で学習率2e-4で微調整
    • 複数回のA/Bテストで最も高い回答精度を示したモデルを採用

プロンプト設計の最適化

プロンプト設計では、Context-aware型が明らかに優れた結果を示しました:

Answer-only型(シンプルな質問と回答):

質問: {質問}
回答:

Context-aware型(参照情報付き):

以下の医療文書情報を参考に、質問に回答してください。
必ず参照した情報源を明示してください。
情報がない場合は「この質問に回答するための十分な情報がありません」と答えてください。

参考情報:
{検索結果1}
ソース: {ソース名1}

{検索結果2}
ソース: {ソース名2}

...

質問: {質問}
回答:

Context-aware型プロンプトは以下の点で優れていました:

  • 根拠のない回答の生成を抑制
  • 情報源の明示による信頼性向上
  • 情報がない場合の適切な棄却

入力調整プロセッサの実装

モデルのコンテキストウィンドウ制限(8Bモデルでは4,096トークン)に対応するため、入力調整プロセッサを実装しました:

def adjust_input_for_context_window(prompt, retrieved_contexts, max_tokens=3500):
    """入力をコンテキストウィンドウに収まるよう調整"""
    base_prompt_tokens = len(tokenizer.encode(prompt.replace("{検索結果}", "")))
    available_tokens = max_tokens - base_prompt_tokens
    
    total_context_tokens = 0
    selected_contexts = []
    
    for ctx in retrieved_contexts:
        ctx_tokens = len(tokenizer.encode(ctx['content']))
        if total_context_tokens + ctx_tokens <= available_tokens:
            selected_contexts.append(ctx)
            total_context_tokens += ctx_tokens
        else:
            # 最後の文脈が収まりきらない場合、一部を切り取る
            if not selected_contexts:  # 最初の文脈が大きすぎる場合
                truncated_content = tokenizer.decode(
                    tokenizer.encode(ctx['content'])[:available_tokens]
                )
                selected_contexts.append({
                    'content': truncated_content,
                    'source': ctx['source']
                })
            break
    
    return selected_contexts

まとめ

オンプレミス医療RAGシステムの構築は、技術的チャレンジと医療特有の要件のバランスを取る必要がある取り組みでした。疎結合アーキテクチャ、医療特化型モデルの選定、そして文書特性に適したチャンキング戦略が成功の鍵となりました。

結果として、医療文書処理の効率化と専門医の負担軽減という当初の目標を達成し、オンプレミス環境でも高性能なAIシステムが構築可能であることを実証しました。

Discussion

ログインするとコメントできます