MastraでRAGを組み、検索ToolをAgentへ接続した。ローカル検索は成功している。ところがMastra Studioから質問すると、Agentは答えない。Toolの実行欄には赤い×が表示され、OpenAI APIから400エラーも返ってきた。
結論から書くと、原因は一つではなかった。
- ToolのJSON SchemaがOpenAIの要件を満たしていなかった
-
toModelOutputの戻り値が、モデルへ渡す形式になっていなかった - 取り込み処理とMastra Studioが、別々のSQLiteファイルを開いていた
この三つを直すと、Studio上のAgentは検索結果を根拠に回答し、末尾へ出典も表示するようになった。本記事では、直したコードだけでなく、どの順番で調べると原因へたどり着きやすいかをまとめる。
検証時の主なバージョンは
@mastra/core 1.64.0、mastra 1.27.3、@mastra/libsql 1.22.3。Mastraは更新が速いため、別バージョンではAPIや挙動が異なる可能性がある。
作っていたもの
題材は、架空サービス「AtlasNote」のヘルプAgentだ。FAQ、料金プラン、リリースノートなど5文書を読み込み、次の経路で回答させる。
文書を読み込む
↓
チャンクへ分割する
↓
埋め込みを作り、LibSQLのベクトル索引へ保存する
↓
検索Toolが関連チャンクを取得する
↓
Agentが根拠と出典を付けて回答する
取り込み時には、次のログが出ていた。
Indexed 6 chunks from 5 documents in atlasnote_chunks.
これは「5つの元文書を分割した結果、合計6チャンクを索引へ登録した」という意味だ。文書数とチャンク数が一致しないのは正常である。長い文書だけが複数チャンクへ分割されることもある。
最初の症状――Agentへ質問する前に400エラーになる
Mastra Studioから質問すると、OpenAI APIが以下のエラーを返した。
Invalid schema for function 'search_atlasnote_documents':
In context=('properties', 'resumeData', 'anyOf', '0'),
schema must have a 'type' key.
注目すべき箇所は resumeData だ。自分で定義した検索Toolの入力は query と topK だけで、resumeData は書いていなかった。
調べると、この環境では autoResumeSuspendedTools: true により、Toolの自動再開に使う resumeData が生成スキーマへ追加されていた。そのスキーマの一部をOpenAI側が有効なJSON Schemaとして受け付けず、Toolを呼ぶ前のリクエスト検証で止まっていた。
今回は、検索Toolに中断・再開が不要だったため、自動再開を無効にした。
export const agent = new Agent({
// ...
defaultOptions: {
maxSteps: 100,
autoResumeSuspendedTools: false,
},
});
これで生成スキーマから resumeData が外れ、最初の400エラーは解消した。
ただし、この設定には影響範囲がある。ユーザーへの確認や承認など、Toolを中断して後から自動再開する仕組みを使うAgentでは、単純に無効化してよいとは限らない。今回は「検索Toolを動かす」という要件に合わせた対処であり、中断・再開も必要なら、利用中のMastraバージョンに対応したスキーマと再開方法を公式ドキュメントで確認したい。
次の症状――Toolは動くが、結果をモデルへ返せない
最初のエラーを直すと、別の400エラーが現れた。
Missing required parameter: input[n].output
検索処理自体は実行されていた。しかし、検索結果をモデルへ渡すための toModelOutput が、加工後の文字列をそのまま返していた。
// 問題が起きた形
toModelOutput: (result) => {
return result.chunks.map(/* ... */).join('\n\n');
},
このバージョンでは、toModelOutput はモデル向けコンテンツの形式で返す必要がある。テキストなら、type と value を持つオブジェクトにする。
toModelOutput: (result) => {
if (result.chunks.length === 0) {
return {
type: 'text' as const,
value: 'コーパス内に、この質問への回答根拠は見つかりませんでした。',
};
}
return {
type: 'text' as const,
value: result.chunks
.map(
(chunk) =>
`[出典: ${chunk.citation}; チャンク: ${chunk.source.chunkIndex}]\n${chunk.content}`,
)
.join('\n\n'),
};
},
ここで大事なのは、検索結果そのものと、モデルに読ませる表現を分けることだ。
-
executeは、スコアやメタデータを含む構造化データを返す -
toModelOutputは、モデルが回答に使いやすい短いテキストへ整形する
Mastra公式の変更履歴でも、toModelOutput は生のTool結果をモデル向けに変換し、モデルへ渡す情報量を抑える仕組みとして紹介されている。
最後の症状――エラーは消えたのに「根拠がない」と答える
Toolのスキーマと出力形式を直した後も、Studio上のAgentは正しい回答を返さなかった。
一方、ターミナルから検索スクリプトを実行すると、FAQは取得できていた。
bun run search -- "パスワードを忘れました" --top-k 3
検索結果には、次の回答根拠が含まれていた。
サインイン画面の「パスワードを再設定」からメールを送信してください。
再設定リンクの有効期限は30分です。
つまり、取り込み処理、埋め込み、ベクトル検索は壊れていない。次にMastra Server経由で検索Toolだけを呼んだ。
./node_modules/.bin/mastra api tool execute \
search_atlasnote_documents \
'{"query":"パスワードを忘れました","topK":3}'
すると、こちらではSQLiteエラーが返った。
SQLITE_ERROR: no such table: atlasnote_chunks
同じコードを呼んでいるはずなのに、CLI検索ではテーブルが見つかり、Studio側では見つからない。実際に開かれているDBファイルを確認すると、二つの mastra.db ができていた。
プロジェクトルート/mastra.db
src/mastra/public/mastra.db
取り込みスクリプトは前者へデータを保存していた。Mastra Studioの開発サーバーは、初期化時のカレントディレクトリを基準に後者を開いていた。そのためStudioから見れば、索引テーブルは存在しなかった。
原因になった設定は、相対パスだった。
url: 'file:./mastra.db'
相対パスは、実行時のカレントディレクトリから解決される。ソースファイルが置かれた場所は基準にならない。同じアプリケーションでも、スクリプト、開発サーバー、ビルド後の実行で基準位置が変われば、別ファイルを開き得る。
ここが盲点だった。
そこで、プロジェクトルートを探して絶対パスを組み立て、取り込み処理とStudioで同じURLを共有した。
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
export function resolveLocalDatabaseUrl(
startDirectory = process.cwd(),
): string {
let directory = resolve(startDirectory);
while (!existsSync(join(directory, 'package.json'))) {
const parentDirectory = dirname(directory);
if (parentDirectory === directory) break;
directory = parentDirectory;
}
return `file:${join(directory, 'mastra.db')}`;
}
export const RAG_DATABASE_URL =
process.env.TURSO_DATABASE_URL || resolveLocalDatabaseUrl();
ベクトルストアとMastraのストレージは、どちらもこの RAG_DATABASE_URL を使う。
export const ragVectorStore = new LibSQLVector({
id: 'rag-vector-store',
url: RAG_DATABASE_URL,
});
const storage = new LibSQLStore({
id: 'mastra-storage',
url: RAG_DATABASE_URL,
});
修正後に開発サーバーを再起動すると、Studio経由の検索ToolもFAQを返した。Agentの最終回答は次の形になった。
サインイン画面の「パスワードを再設定」からメールを送信してください。
再設定リンクの有効期限は30分です。
出典: faq.txt
RAGの不具合は、Agentから調べ始めない
今回いちばん効いたのは、処理を一段ずつ切り離したことだった。RAG Agentが答えないときは、次の順番で確認すると原因の範囲を狭めやすい。
1. 取り込み件数を確認する
bun run ingest
文書数とチャンク数が想定どおりかを見る。0件なら、AgentやToolへ進む前にローダー、対象パス、チャンク化を調べる。
2. 検索関数を単体で呼ぶ
bun run search -- "パスワードを忘れました" --top-k 3
期待する本文、出典、スコアが返るかを見る。ここで失敗するなら、埋め込みまたはベクトルストアより手前を調べる。
3. 開発サーバー経由でToolだけを呼ぶ
まず、プロジェクトで定義したスクリプトから開発サーバーを起動する。
bun run dev
別のターミナルでToolを実行する。
./node_modules/.bin/mastra api tool execute \
search_atlasnote_documents \
'{"query":"パスワードを忘れました","topK":3}'
検索関数は成功するのにここで失敗するなら、環境変数、実行ディレクトリ、DB接続先、Toolの入出力スキーマを疑う。
4. 最後にAgentを通す
./node_modules/.bin/mastra api agent run agent \
'{"messages":"パスワードを忘れました","activeTools":["search_atlasnote_documents"]}'
Tool単体は成功するのにAgentが呼ばない場合は、AgentへのTool登録、Toolの説明文、Agentの指示文、activeTools を確認する。Toolは呼ばれるが回答内容がおかしい場合は、toModelOutput と実際の検索結果を見る。
5. Studioの赤い×を、回答品質の問題として片付けない
Toolカードの赤い×は、Tool呼び出しが失敗した合図だ。検索順位の低さを表す印ではない。開発サーバーのターミナル、Studioのトレース、Toolの入出力を確認する。Mastra StudioはAgentの試運転に加え、どの段階で止まったかを見るデバッガーとして使うと便利だった。
症状から当たりを付ける早見表
| 症状 | 最初に疑う場所 | 今回の原因 |
|---|---|---|
| モデルAPIがTool実行前に400を返す | Toolの入力JSON Schema | 自動追加された resumeData
|
Tool実行後に input[n].output で失敗する |
モデルへ渡すTool出力 |
toModelOutput が文字列を直接返していた |
| ローカル検索は成功し、Studioだけ失敗する | 実行環境の差 | 別の mastra.db を開いていた |
| Toolは成功するがAgentが呼ばない | Agent設定 | Tool登録、説明文、指示文、activeTools
|
| Toolは成功するが回答がずれる | 検索結果とプロンプト | 上位チャンクの内容、検索語、回答制約 |
再発防止のために追加したテスト
今回の問題は、単体の検索精度テストだけでは防げなかった。境界部分をテストへ追加した。
test('モデル向け出力をtext形式で返す', () => {
expect(tool.toModelOutput!(result)).toEqual({
type: 'text',
value: expect.stringContaining('出典: faq.txt'),
});
});
test('検索Toolへ不正なresumeDataスキーマを追加しない', async () => {
const options = await agent.getDefaultOptions();
const tools = await agent.getToolsForExecution({
autoResumeSuspendedTools: options.autoResumeSuspendedTools,
});
expect(
tools.search_atlasnote_documents.parameters.jsonSchema
.properties?.resumeData,
).toBeUndefined();
});
test('Studioの実行ディレクトリからも同じDBを解決する', () => {
const studioDirectory = resolve('src/mastra/public');
expect(fileURLToPath(resolveLocalDatabaseUrl(studioDirectory)))
.toBe(resolve('mastra.db'));
});
「検索できる」に加え、「モデルへ渡せる」「Studioから同じDBが見える」までをテストするのがポイントだ。
まとめ――RAGは一本の機能ではなく、境界の連続である
RAG Agentが答えないと、ついプロンプトやモデルを疑いたくなる。今回は、その前後にある境界で問題が起きていた。
入力スキーマ
→ Tool実行
→ Tool出力の変換
→ ベクトルDB接続
→ 検索結果
→ Agentの回答
一気にStudioだけで確認すると、「Agentが答えない」という一つの症状に見える。検索関数、Server上のTool、Agentの順に分ければ、どの境界でデータが途切れたかを確認できる。
Mastra初心者に限らず、フレームワーク、モデルプロバイダー、ローカルDBをつないだ開発では、境界の食い違いが起きる。エラーメッセージの名詞を手掛かりに、一段ずつ単体実行する。今回の3件から得た、いちばん再利用しやすい教訓だった。
参考資料
X投稿文案
MastraでRAG Agentを作ったら、ローカル検索は成功するのにStudioでは答えない。
原因は、①ToolのJSON Schema、②toModelOutputの形式、③SQLiteの相対パスで別DBを開いていたこと、の3つでした。
「検索関数 → Tool単体 → Agent」の順に切り分けた手順と修正内容をまとめました。
[記事URL]
#Mastra #RAG #AIエージェント #TypeScript