こんにちは!Androidエンジニアのてつです。
皆さんはTRPGをご存知でしょうか?プレイヤーが架空のキャラクターを演じ、ゲームマスター(GM)の語る物語の中で冒険を繰り広げる、想像力を駆使した遊びです。 今回は、趣味で楽しんでいるCall of Cthulhu(CoC)というTRPGを題材に、RAGとMCPを活用したAI-GMシステムを構築した経験をご紹介します。
なぜAI-GMが必要だったのか
CoCでは、守密人(Keeper)と呼ばれるGMが重要な役割を担います。守密人は:
- 規則書に基づいてゲームを進行
- 劇本(シナリオ)に記載された情報を参照
- プレイヤーの行動に応じて適切な情報を提供
- 雰囲気に合った描写や対話を創出
しかし、忙しい社会人にとって、毎回人間の守密人を集めるのは困難です。「AIに守密人をやらせられないか?」という発想から始まりました。
AI-GMの可能性と課題
AIを守密人として使う際、以下の特性があります:
特長:
- 自然な対話や雰囲気に合った描写が得意
- プレイヤーの質問にすぐ応答できる(人間だと、慣れていない箇所は資料を探す必要がある)
- スケジュール調整が不要
課題:
- コンテキストの限界より、対話が進むと序盤の情報が失われる
- 劇本や規則書にない情報を創作してしまう
- 過去の出来事との矛盾が生じる
特に深刻なのが 「対話が長くなるほど、前の情報が失われる」 問題です。TRPGセッションは数時間に及び、プレイヤーとの対話履歴が膨大になります。LLMのコンテキスト長には限界があり、重要な情報が文脈から押し出されてしまいます。
RAGとMCPでの解決案
これらの課題を解決するため、以下の技術を組み合わせました:
RAG:
- 劇本と規則書をベクトルDB化し、必要な情報を意味検索で取得
- 対話履歴をDB化し、後から参照可能に
- DBに存在する情報のみを使わせることで、創作を防ぐ
MCP:
- 検索ツール(劇本検索、規則検索、対話履歴検索)をMCPツールとして提供
- 状態管理ツール(ゲーム進行状態、プレイヤー知識)をMCPで管理
- ゲームロジック(ダイスロール、キャラクター情報取得)をツール化
MCPの概要と選定
MCPは、Anthropic社が発表したオープンプロトコルです。LLMアプリケーションがデータソースやツールと統合するための標準化された方法を提供します。
従来のFunction Callingと比較して、MCPは統一されたプロトコルにより、異なるLLMプロバイダー間でツールを再利用できる点が魅力です。将来的に他のLLMプロバイダーへの移行可能性を保つため、MCPを採用しました。
FastMCPフレームワークを選んだ理由
MCPプロトコルを実装する際、FastMCPを選択しました。
選定理由:
- Pythonの開発経験がある
- APIがシンプル
- SSE(Server-Sent Events)トランスポートが標準搭載
実際のツール定義例
@app.tool( name="coc_rule_query", description="CoC規則書から関連する内容を検索する。", tags={ "query_text": {"type": "string", "description": "検索するテキスト。規則項目やキーワードなど。"} } ) def coc_rule_query_function(query_text: str) -> str: print(f"[MCP Tool Call] CoC規則検索を実行: '{query_text}'") return coc_rule_query( query_text=query_text, embedding_function=embedding_function, rules_collection=rules_collection )
わずか10行程度で、LLMから呼び出し可能なRAG検索ツールを定義できます。
RAGの技術選定
RAGを使う背景
TRPGセッションでは以下の課題がありました:
- 劇本の情報量
- 数万文字に及ぶ詳細な設定
- すべてをLLMのコンテキストに含めると、コスト増大・応答速度低下・情報が埋もれる
- 対話履歴の増大
- 3〜5時間のセッションで数十〜数百ターンの対話
- 序盤の重要情報がコンテキストから押し出される
RAGを導入することで、必要な情報だけを動的に取得し、LLMに提供できます。
ChromaDBを選んだ理由
検証目的かつローカル実行を重視し、ChromaDBを採用しました。
選んだ理由:
pip install chromadbだけでセットアップ完了- ローカルディレクトリに永続化、サーバー不要
- 学習コストが低く、デバッグしやすい
ChromaDBの初期化
# サーバー起動時に一度だけ初期化 client = chromadb.PersistentClient(path=DB_DIR) embedding_function = HuggingFaceEmbeddings( model_name="./models/intfloat_multilingual-e5-large" ) # 用途別にCollection分離 scenarios_collection = client.get_or_create_collection(name="scenarios_collection") # 劇本 rules_collection = client.get_or_create_collection(name="rules_collection") # 規則書 log_collection = client.get_or_create_collection(name="log_collection") # 対話ログ
Embeddingモデルの選定
intfloat/multilingual-e5-largeを採用しました。
選定理由:
- MTEB(Massive Text Embedding Benchmark)で評価が高い
- 日本語・中国語を含む100以上の言語に対応
- 560MBと適度なサイズ(個人用CPUでも実行可能)
- HuggingFaceで容易に入手可能
多言語対応、ローカル実行可能、適度なサイズという要件を満たすバランスの取れた選択です。
実装時の技術的課題と解決策
Markdownのチャンク分割戦略
RAGの精度を左右する最重要要素がチャンク分割戦略です。
ヘッダー階層を保持する理由
TRPGの劇本をMarkdown形式で階層構造を持たせます:
# CoC 劇本:タイトル ## 第1章 ### 場面1:街の酒場 #### 酒場の店主「○○○」 ○○○は40代の男性で、この街で長年酒場を営んでいる...
この階層情報を失うと、検索時に 「どの章の、どの場面の、誰の情報か」 が分からなくなります。
実装:2段階分割戦略
# Tokenizer初期化(正確なtoken数カウント用) tokenizer = AutoTokenizer.from_pretrained("intfloat/multilingual-e5-large") def token_length_function(text: str) -> int: return len(tokenizer.encode(text)) # 第1段階:ヘッダーで分割 markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[...]) chunks = markdown_splitter.split_text(markdown_text) # 第2段階:512 tokenを超える場合のみ、さらに分割 final_chunks = [] for chunk in chunks: if token_length_function(chunk.page_content) > 500: # 安全マージン text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, # 前後のチャンクに重複を持たせる length_function=token_length_function, ) recursively_split_chunks = text_splitter.split_documents([chunk]) final_chunks.extend(recursively_split_chunks) else: final_chunks.append(chunk)
設計ポイント:
- まずヘッダー階層で分割(意味的なまとまりを優先)
- 512 token超過時のみ再分割(不必要な分割を避ける)
chunk_overlap=50で文脈の断絶を防ぐ
検索精度の向上施策
ヘッダー情報強化
単純にMarkdownの本文だけをEmbedding化すると、ヘッダー情報が埋もれてしまう問題がありました。
解決策:
# ヘッダー情報を本文の前に明示的に追加 content_to_add = f"標題: {metadata['header']}\n\n{chunk.page_content}"
ヘッダー情報を本文の前に追加することで、Embedding生成時に ヘッダーの語彙と意味がベクトルに含まれるようになり、検索精度が大幅に向上しました。
メタデータの設計
各チャンクに以下のメタデータを付与:
metadata["name"] = scenario_name # 劇本名 metadata["header_level"] = current_header_level # ヘッダー階層レベル(1〜6) metadata["header"] = current_header_text # 最深層のヘッダーテキスト
これにより、特定の劇本に限定した検索が可能になります:
# メタデータフィルタリング:現在プレイ中の劇本のみ検索 scenario_where_clause = {"name": _scenario_name} scenario_results = scenarios_collection.query( query_embeddings=[query_embedding], n_results=10, where=scenario_where_clause, # ← ここでフィルタリング )
MCPツールの設計思想
今回のコードでは、17個のMCPツール を実装しました。機能ごとにカテゴリ分類:
- 検索系:
scenario_query,coc_rule_query,log_query - データ管理:
markdown_to_db,get_pc_info,list_available_scenarios - 状態管理:
get_game_state,set_game_state,get_player_knowledge - ゲームロジック:
request_dice_roll,log_save,start_game
設計原則:
- 単一責任: 1つのツールは1つの明確な機能のみ
- 命名規則:
動詞_対象の形式 - 説明文: LLMがツールを適切に選択できるよう、明確な説明を記述
その他の課題:グローバル状態管理
ゲームの進行状態を保持するため、グローバル変数を使用していますが、サーバー再起動で状態が失われます。あくまで検証目的のため、シンプルさを優先しましたが、状態管理の再設計が必須です。
実装結果と技術的知見
MCP統合の実用性
メリット:
- デコレーターベースのツール定義により、1ツールあたり10-20行で実装完了
- LLMとの統合が簡単
- デバッグがしやすい、各ツール呼び出しがログに記録され、SSEによりリアルタイム確認可能
デメリット・課題:
- プレイヤーの状態管理が難しい
- System instructionでMCPツールを使うよう明示的に指示しても、LLMが判断でツール呼び出しをスキップすることがある。プレイヤーが再度促す必要が生じる場合もある
RAG検索の精度
チャンク分割戦略の工夫により、明確な固有名詞を含むクエリでは高い関連性スコアを達成しました。一方、以下のような課題も見つかりました:
課題例:
- 表記ゆれ:「SAN値」と「正気度(Sanity)」がEmbedding空間で十分に近くない
- 文脈依存の質問:「彼は誰?」などの代名詞を含むクエリに対応できない
- 検索miss時の不適切な応答:最も深刻な課題として、過去の対話ログ検索でmissした場合の問題があります。例えば、開場時にGMが紹介し、プレイヤーと既に対話したNPCが、数場面後に再登場した際、DB検索でhitしないことがあります。この場合、時間軸やメタデータで関連性を高める工夫をしていても、LLMは「初登場」として扱ってしまい、プレイヤー体験を著しく損ないます
学びと失敗から得たもの
RAG実装で得た知見
- チャンク分割が最重要: 検索精度の80%はチャンク戦略で決まる。ドメイン特性に応じたカスタマイズが必須。
- メタデータ設計の重要性:フィルタリング可能なメタデータを最初から設計する。あとから追加するとデータ再投入が必要。
- 検索結果の順位付け:関連性スコアの閾値設定が重要。
MCPプロトコルの理解
- ツールは小さく保つ: 1ツール1責任が基本。LLMが選択しやすい粒度に分割。
- トランスポート層の選択:SSEはローカル開発に最適
おわりに
本記事では、TRPGのAI守密人という具体的なユースケースを通じて、MCP + RAGシステムの実装における技術的知見を共有しました。
これらの学びは、TRPG支援ツールという特定ドメインを超えて、開発や業務にも応用可能かと感じています。私たちのチームでは、こうした新技術の検証と実践を通じて、継続的に技術力を向上させています。もしあなたも同じことを考えているなら、ぜひ一緒に働きませんか?ぜひ、採用情報からお気軽にご連絡ください。