📚

ローカルのPDFファイルを引用して回答してくれるエージェントを作った

2025/02/25に公開

僕は自分が見たことしか信じない

これは元サッカー選手の内田篤人選手の言葉です。

私たちは昨今のAIの急速な発展により、簡単に幅広い情報にアクセスできるようなりました。わからないことはとりあえず雑に聞けば、常にそこそこの回答が返ってきます。

しかし、業務の特性上「そこそこ」の回答で満足できないことも多いでしょう。特に専門性や正確性が求められる領域で仕事をしている場合、本当にその情報が正しいのかを自分の目で確かめることが重要になってきます。

例えば弊社は、電子カルテのクラウド化支援やAIを活用した病院DX推進を事業として行なっています。医療システムを構築し提供するためには、国が出しているガイドラインを遵守したり、国が提供する医療DX基盤との連携のための資料を読み込んだ上で実装をする必要があります。このような領域では、「そこそこ」の情報ではなく「正しい」情報をしっかり把握した上で実装をすることが求められます。

そこで今回は、上記のような専門性の高い質問に対して正確に回答し、かつ自分の目で最終的に確かめられる安心感を作り出すために、ローカルのPDFファイルを引用しながら回答をしてくれるエージェントを作りました。(GitHub)

さらにこのエージェントをMCPサーバーとして実装し、ClaudeやWindsurfなどの各種ツールの上から呼び出すことができるようにしました。

MCPサーバーやCitationに興味がある方、同様の課題感を抱えている方にご一読いただければと思います。

ワークフロー設計

エージェントは以下のようなワークフローで動作します:

実装の詳細

コードはGitHub上で公開してますので、詳細はそちらを参照してください。ここでは重要な点を抜粋して紹介します。

1. pdf_agent関数の実装

pdf_agent関数は以下のように実装されています。

def pdf_agent(folder_path: str, question: str, evaluation_threshold: float = 0.8) -> PdfAgentResult:
    """PDFファイルから質問に対する最適な回答を生成する。
    
    Args:
        folder_path: PDFファイルが格納されているフォルダのパス
        question: 質問文
        evaluation_threshold: 回答の評価スコアの閾値(デフォルト: 0.8)
        
    Returns:
        PdfAgentResult: 回答、評価結果、参照したPDFのパスを含む結果オブジェクト
    """
    # 1. 指定されたフォルダからPDFファイルを検索
    pdf_files = find_pdfs(folder_path)
    
    # PDFが見つからない場合はエラーメッセージを返す
    if not pdf_files:
        return PdfAgentResult(
            answer="No PDF files found in the specified folder.",
            evaluation=AnswerEvaluation(score=0.0),
            source_pdf=None
        )
    
    # 2. 質問に対する関連度でPDFファイルをソート
    sorted_pdf_files = sort_pdfs_by_relevance(pdf_files, question)
    
    # 3. 最適な回答の初期値
    best_result = PdfAgentResult(
        answer="No suitable answer found in the PDF files.",
        evaluation=AnswerEvaluation(score=0.0),
        source_pdf=None
    )
    
    # 4. 関連度の高い順にPDFを処理
    for pdf_path in sorted_pdf_files:
        # PDFを読み込み、回答を生成
        title, content = read_pdf(pdf_path)
        answer = ask_question(title, content, question)
        
        # 回答の品質を評価
        evaluation_dict = evaluate_answer(question, answer)
        evaluation = AnswerEvaluation(
            score=float(evaluation_dict["score"]),
            reasoning=str(evaluation_dict.get("reasoning", "")),
            improvements=str(evaluation_dict.get("improvements", ""))
        )
        
        # より良い回答が見つかった場合は更新
        if evaluation.score > best_result.evaluation.score:
            best_result = PdfAgentResult(
                answer=answer,
                evaluation=evaluation,
                source_pdf=pdf_path
            )
            
            # 十分に良い回答が見つかった場合は早期終了し、トークンの消費を抑える
            if evaluation.score >= evaluation_threshold:
                break
    
    return best_result

この実装では、以下の主要なステップで処理が行われます:

  1. PDFファイルの検索: find_pdfs関数を使用して、指定されたフォルダ内のPDFファイルを検索します。

  2. 関連度によるソート: sort_pdfs_by_relevance関数を使用して、質問に対する関連度の高い順にPDFファイルをソートします。

  3. 回答の生成: 各PDFファイルに対して:

    • read_pdf関数でPDFを読み込み
    • ask_question関数でClaudeを使用して回答を生成
    • evaluate_answer関数で回答の品質を評価
  4. 最適な回答の選択: 評価スコアが最も高い回答を選択します。十分に良い回答(評価スコア ≥ 閾値)が見つかった場合は、そこで処理を終了します。

この実装により、複数のPDFファイルから最も適切な回答を効率的に見つけ出し、トークンの消費を抑えながら回答の質を上げられるようにしています。

2. 引用符付きで回答を生成する実装

Claudeの機能の一つであるCitationsを使い、引用符付きで回答を生成させています。この機能を使うためには、ClaudeのAPIを叩く際にcitationsのenabledをtrueにするだけでOKです。

def ask_question(title: str, content: str, question: str) -> str:
    response = anthropic_client.client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": content 
                        },
                        "title": title,
                        "citations": {"enabled": True} # ここをtrueにすれば引用符付きで回答をしてくれる
                    },
                    {
                        "type": "text",
                        "text": question 
                    },
                ]
            }
        ]
    )

3. MCPサーバーの実装

MCPサーバーはFastMCPを使用して実装しています。主要な処理はlocal_pdf_citation関数で行われます:

# Create an MCP server
mcp = FastMCP("LocalPDFAgent")

@mcp.tool()
def local_pdf_citation(question: str) -> Dict[str, Any]:
    """Generate answers with citations from local PDF files."""
    result = pdf_agent(os.environ["PDF_FOLDER_PATH"], question)
    return {
        "answer": result.answer,
        "evaluation": {
            "score": result.evaluation.score,
            "reasoning": result.evaluation.reasoning,
            "improvements": result.evaluation.improvements
        },
        "source_pdf": result.source_pdf
    }

if __name__ == "__main__":
    mcp.run()

これにより、Claude DesktopやWindsurfなどのMCPクライアントから、このPDF引用機能を簡単に利用することができます。

セットアップと使用方法

このコードをMCPサーバーとして動かし、Claude Desktopと連携するには、以下の手順を実行します。

  1. 環境変数の設定:
ANTHROPIC_API_KEY=your-api-key-here
PDF_FOLDER_PATH=/path/to/your/pdf/folder
  1. MCPサーバーの設定(claude_desktop_config.json):
{
  "mcpServers": {
    "LocalPDFAgent": {
      "command": "/path/to/uv",
      "args": [
        "--directory",
        "/path/to/local-pdf-agent",
        "run",
        "--env-file",
        ".env",
        "mcp",
        "run",
        "tool.py"
      ]
    }
  }
}

使用例

実際にエージェントを使用して、PDFファイルに対して質問をしてみましょう。上記のMCPサーバーを連携させたClaude Desktopから質問を投げ、どのような回答が返ってくるかを検証してみます。

例として、国が提供してくれている電子処方箋のシステムとの連携において、電子カルテ側がどのような形式で情報を送るべきかについて質問をしてみます。

質問:
ローカルのPDFファイルを引用し、電子処方箋発行のcsvのどの行に患者指名を書くか、記載例も含めて教えて欲しい。どのファイルに書いてあるかも教えて

回答:
電子処方箋のCSVファイルにおける患者氏名の記載行について調べてみましょう。

「電子処方箋管理サービス記録条件仕様(院内処方編)_Ver1.10」のPDFファイルを確認したところ、患者氏名は以下のように記載する必要があります:

レコード構成:

  1. 患者氏名は「患者氏名レコード」(レコード№11)に記載します
  2. 1行に以下の4項目をカンマ区切りで記載:
    • レコード№情報(必須):「11」
    • 患者コード(省略可):最大15バイト
    • 患者漢字氏名(必須):最大60バイト
    • 患者カナ氏名(必須):最大60バイト

記載例:

# 患者コードなしの場合
11,,電子 太郎,デンシ タロウ

# 患者コードありの場合
11,1234,電子 太郎,デンシ タロウ

注意点:

  1. レコードの終端には改行コード(LF)を記録する必要があります
  2. カンマとカンマの間(項目)には改行コード(CR、LFおよびCRLF)を記録しません
  3. 各項目の値は「'」(シングルコーテーション)および「"」(ダブルコーテーション)で囲まない
  4. 項目を省略する場合は、空文字(Null)とし、半角スペースおよび全角スペースは記録しない

結果

PDFの方も合わせて確認をし、正確な情報が返ってきていることが確認できました!

課題

一通り動くものを実装してみましたが、いくつか課題も見えてきました。

トークン消費量

Citation機能ではPDFをまるっと処理するため、上記のような工夫をしたとしてもかなりトークンを消費します。具体的にとれくらいか消費するかの計算はまだちゃんとできてないのですが、何も考えずに使いまくると一瞬でLimitに達してしまいます。

MCPサーバーのResource機能等を活用すればもう少しPDFの探索は効率的にできるかもしれませんし、実装上の工夫はまだできるかと思いますが、それは今後の課題とします!

ページ数の記載

今の実装でもどのファイルに記載されているかを聞けば答えてくれますが、ページ数までは答えてくれませんでした。一応、citationsのレスポンスには含まれているようなのですが、ちゃんとパースして回答結果の中に明示するように微調整をする必要がありそうです。

まとめ

本記事では、ローカルのPDFファイルに対して自然言語で質問し、引用付きの回答を得られるエージェントの実装について解説しました。主な特徴は以下の通りです:

  1. 効率的なPDF処理

    • 質問に関連するPDFファイルを優先的に処理
    • 十分な品質の回答が得られた時点で処理を終了し、トークン消費を最適化
  2. 高精度な回答生成

    • 引用機能により、回答の根拠を明確に提示
    • 回答の品質を自動評価し、最適な結果を選択
  3. 使いやすい設計

    • MCPサーバーとしての実装により、Claude Desktopから簡単に利用可能
    • 環境変数、PDFフォルダのパスを指定するだけで即座に利用可能

このエージェントは、技術文書の参照、マニュアルの検索、規約の確認など、様々な用途で活用できます。同様のユースケースでLLMを使いたい方や、エージェントを開発したい方はぜひ参考にしてください!

参考

ispec inc.

Discussion