本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

TEI (Text Encoding Initiative)/XML 形式の古典文書コーパス(5,000 件規模)を対象としたベクター検索 RAG (retrieval-augmented generation) に、LLM が問いの種類を見て「道具」を自動で選ぶ Router Agent を導入しました。本記事はその設計(8 ツール構成)と、それを Cloudflare Workers + Azure OpenAI + Vercel AI SDK + TiDB Cloud Serverless というスタックの上で動かすまでに踏んだ 5 つの罠を、一気通貫で記録するものです。

特に「ベクター RAG と構造化データは競合ではなく相補」というのが、今回の実装で最も強く確認された結論でした。

なぜ Router Agent か:ベクター RAG だけでは届かない領域

事前に既存のベクター RAG に対する評価をいただきました。具体的には「コーパス中に出てくる書名の一覧」のような網羅型クエリや、「特定の著者が書いた本への言及」のような属性絞り込み型クエリで再現率が低い、という指摘です。

実際、人手アノテーション済みの書名 50 件強と RAG 出力を突合したところ、再現率は 17〜19% でした。具体的な失敗パターンは以下のような形です。

  • ある作品 A の著者を、別の作家 B(同時代の別人)と取り違えて回答する
  • ある作品が本文中で 7 種の異表記(略称・別号・通称等)で登場するが、ベクター検索ではそのうち 1〜2 件しか拾えない
  • 特定の著者の 3 作品が本文中で言及されているが、すべて取り逃がす

これは個別の RAG の品質問題というより、ベクター類似検索の構造的限界のように見えます。網羅性・属性照合・異表記名寄せは、コサイン類似度では原理的に難しい。一方でコーパスの TEI 側には人名タグ約 2 万件、地名タグ約 1 万件、組織名タグ約 5 千件が既に付与済みで、加えて人力アノテーションで書名 50 件強の seed もあります。

つまり「ベクター検索が苦手なクエリを構造化データ側の道具に振り分ける」仕組みがあれば、各々の強みが活きるのではないか。これが Router Agent の出発点です。

8 ツール構成の設計

LLM(reasoning 系モデル)を router として、以下の 8 ツールから問いに応じて自動選択させました。実装は Vercel AI SDK v6 の tool() + JSON Schema、1 ツール = 1 ファイル (src/lib/agent-tools/<name>.ts) で並列編集の衝突を最小化しています。

#ツール由来得意な問い
1search_diary_ragベクター (text-embedding-3-large)「○○についての考え」「○○年頃の様子」
2search_diary_structured構造化 (persons / places / dates)「△△との交流」「○○を訪れた記録」
3fulltext_diary全文 LIKE「個別作品への言及」
4list_books_by_author書名 seed「特定著者の本を読んだ記録」
5list_all_titles書名 seed「コーパス中の書物一覧」
6find_book_aliases書名 seed「同一作品の異表記すべて」
7suggest_follow_upsAI 自動回答後の追加提案 (2-4 個)
8ask_clarificationAI 自動曖昧すぎる問いへの問い返し

8 ツールのうち 5 つが構造化データ・人手アノテーション由来 という構成です。「ベクター検索だけでは出ない答え」を構造化データ側のツールでカバーする、というのが全体の設計思想になります。

router 本体は streamText + toolChoice: "auto" + stepCountIs(8) で多段呼び出し(再試行・複数ツール併用)を許可しています。System prompt には「結果が薄ければ別の道具で挑戦せよ」「同じツールを同じ引数で繰り返すな」といった retry ガードを明示しました。

動作例

実装後の挙動を 4 シナリオで確認しています。

① 「コーパスに出てくる書名を一覧してほしい」(網羅型)

以前はベクター検索で 15 件取得、人手 seed 50 件強のうち 17〜19% のみ。今回は AI が「これは網羅型なので RAG ではダメ」と判断し list_all_titles を 1 回呼び、重複排除済みの全書名を一気に列挙しました。

② 「特定著者の本を読んだ記録は?」(属性絞り込み)

以前は 3 冊すべて取り逃がし、「明確な記録は見当たらず」と回答(あるいは別の作家の作品を誤って属性付け)。今回は AI が list_books_by_author({author: "..."}) で書名と entry_id を取得、各書名について fulltext_diary を並列実行、結果を統合。3 冊すべてを正しい日付付きで列挙しました。

③ 「ある作品への言及をすべて知りたい」(異表記名寄せ)

以前は正規化書名で 1 件しか拾えない。今回は AI が「異表記が散らばっている可能性」を判断し find_book_aliases で 7 異表記すべての entry_id を一気に取得して統合しました。

④ 「○○について教えて」(曖昧)

以前は何かしら無理に答えようとしていた。今回は AI が「広すぎて検索のしようがない」と判断し ask_clarification でユーザに 1-3 個の質問を返すようになっています。

実装で踏んだ 5 つの罠

ここからが本題です。「設計」自体は上記の通りで、文章で書けば数段落で済むのですが、動かすまでに踏んだ罠が予想以上に多かったので、記録しておきます。同じスタックを触る方の地雷原マップとしてどうぞ。

罠 1: Cloudflare Workers の 3 MiB 上限

CI を通したらデプロイで

Your Worker exceeded the size limit of 3 MiB.
Please upgrade to a paid plan to deploy Workers up to 10 MiB.

handler.mjs のサイズを見ると 8.4 MB。gzip 圧縮で 3 MB 弱になり、Free プランの上限を超過しているようでした。

node_modules を眺めると openai パッケージが約 9 MB(v6.38 の unpacked size)と突出して大きい。これは embed()getChatClient() のためだけに使われていました。一方 @ai-sdk/azure は 184 KB しかありません。同じ Azure OpenAI を叩くなら ai-sdk 側だけで完結できそうです。

リファクタの内容:

  • openaiAzureOpenAI import を撤去
  • embed()@ai-sdk/azure + ai-sdk の embed() 関数に置換
  • planner の .chat.completions.create({ response_format: json_object })generateObject() + zod schema に置換
  • package.json から openai を除去

結果:

uncompressedgzip
Before8.4 MB~3 MB(超過)
After6.7 MB1.72 MB(上限の 54%)

Free プランで余裕で収まる範囲に。

罠 2: Azure OpenAI の URL pattern (v1 Responses API vs legacy)

openai SDK を撤去して @ai-sdk/azure に統一したところ、今度は別のエラー:

API version not supported
url: '<endpoint>/openai/v1/responses?api-version=...'

@ai-sdk/azure はデフォルトで新しい /openai/v1/... URL(Responses API)を使う仕様のようでした。これは新 API のため新しい api-version が必要で、こちらが指定していた 2024-10-21 ではサポートされていません。

最初 useDeploymentBasedUrls: true で旧 URL(/openai/deployments/{id}/...)に戻そうとしましたが、この組み合わせ(legacy URL + 旧 api-version)では generateObject が機能しないようで 404 になりました。

結論:apiVersion を明示しない(SDK の内部既定に任せる)のが正解でした。同じ @ai-sdk/azure を使っている別ファイルは最初から apiVersion を指定せず動いており、それと挙動を揃えれば良かったわけです。なお @ai-sdk/azure の型定義コメントは「Defaults to preview」と書かれていますが、実装上は "v1" を SDK 内部で解決しているように見えました(バージョンにより変わる可能性あり)。

// Before (動かない)
createAzure({
  resourceName: ...,
  apiKey: ...,
  apiVersion: process.env.AZURE_OPENAI_API_VERSION ?? "2024-10-21",
});

// After (動く)
createAzure({
  resourceName: ...,
  apiKey: ...,
  // apiVersion 指定なし → SDK の内部既定に任せる
});

教訓として、その後 Azure provider 生成を 1 ファイルに一元化しました。同じ設定が複数ファイルに分散していると、片方だけ古いまま放置されてこの種の不整合が起きる、というのを身をもって学んだかたちです。

罠 3: 構造化検索で 0 件量産 (JSON_CONTAINS の盲点)

search_diary_structured で人名を検索すると、軒並み 0 件。

調べると、TEI 側の <persName corresp="..."> は姓のみの略称が主流のようでした。

corresp出現回数
姓のみのタグ A多数
フルネームのタグ A'少数

JSON_CONTAINS(persons, JSON_QUOTE("フルネーム")) は完全一致なので、姓のみのタグを完全に取り逃がしていたわけです。

修正は 3 段構え:

  1. SQL を JSON_SEARCH(persons, 'one', CONCAT('%', ?, '%')) に変更。%...% の部分一致で姓・フル両方を拾う。最初 JSON_TABLE での双方向 fuzzy も試したが、TiDB は公式ドキュメント上 JSON_TABLE を未サポート関数として明記しているため拒否され、単純な部分一致(contains 検索)のみに
  2. tool description で LLM に「姓のみ推奨」を伝える。「タグは略称中心のため、フルネームよりも姓だけを渡せ」
  3. 敬称・爵位ストリッパを追加。「○○男爵」「○○公」「○○翁」のような末尾の敬称(男爵 / 子爵 / 伯爵 / 侯爵 / 公爵 / 公 / 翁 / 侯 / 候 / 氏 / 君 / 殿 / 先生 / 博士 …)を stripHonorific() で除去してから検索
const HONORIFIC_RE = /(?:閣下|大臣|長官|大使|総裁|社長|頭取|主任|博士|先生|男爵|子爵|伯爵|侯爵|公爵|公|侯|候|翁|殿|氏|君|様|さん)$/;

export function stripHonorific(name: string): string {
  let cur = name.trim();
  for (let i = 0; i < 3; i++) {
    const stripped = cur.replace(HONORIFIC_RE, "");
    if (stripped === cur || stripped.length === 0) break;
    cur = stripped;
  }
  return cur || name;
}

この 3 段で「○○男爵」型の検索が 0 件 → 30 件超に改善しました。

罠 4: Reasoning モデルのマルチターン (msg_/rs_ チェイン崩れ)

ようやく動き出した agent で多ターン会話を試すと:

エラー: Item 'msg_09e0e...' of type 'message' was provided without
its required 'reasoning' item: 'rs_09e0e...'

Reasoning 系モデルは、assistant 出力を (msg_*, rs_*) のペアで返します。Azure Responses API はマルチターンでこのペアを揃えて送ることを要求するようなのですが、フロントが履歴を再送する prepareSendMessagesRequesttext パートだけ通していたため、msg_ だけ送られて rs_ が無い状態になり拒否されていたようです。

// Before
parts: m.parts.filter((p) => p.type === "text"),

// After
parts: m.parts.filter(
  (p) => p.type === "text" || p.type === "reasoning",
),

tool 呼び出しのパート(重い)は引き続き drop しますが、reasoning は残す。これで follow-up クリック時のチェイン整合性が保たれます。非 reasoning モデルでは reasoning パートが発生しないので影響ありません。

罠 5: TPM (Tokens Per Minute) ボトルネック

レート制限が頻発するので Azure OpenAI のキャパシティを確認したところ、agent と planner で使っていた deployment が 10K TPM に絞られていました。agent の multi-step tool loop は 1 リクエスト 20-30K tokens 使うので、即座にレート制限ヒットしていたようです。

az cognitiveservices account deployment list \
  --name <resource-name> \
  --resource-group <rg>

で同アカウントの他デプロイを見ると、別の reasoning モデルが別リソースに 100K TPM で未使用で待機しているのを発見。これは agent の用途には品質も上です。

そこで Azure provider のヘルパにエージェント専用の env vars(AZURE_OPENAI_AGENT_ENDPOINT / AZURE_OPENAI_AGENT_API_KEY / AZURE_OPENAI_AGENT_DEPLOYMENT)を追加し、agent だけ別リソースの高 TPM 側を向くように分離しました。

export function getAgentModel() {
  return createAzure({
    resourceName: resourceName(agentEndpoint()),  // 別リソース
    apiKey: agentApiKey(),
  })(
    process.env.AZURE_OPENAI_AGENT_DEPLOYMENT ??
      process.env.AZURE_OPENAI_CHAT_DEPLOYMENT!,
  );
}

agent は高 TPM 側、planner は低 TPM 側、という非対称構成です。planner の負荷は軽いので低 TPM でも持ちます。

Azure OpenAI の TPM は 2 層構造で、(1) サブスクリプション × region × model の quota cap(Azure が決定、増やすには support 申請)と、(2) 各 deployment の capacity 割当(自分で決定、quota 内で自由)になっているようでした。今回触ったのは (2) ですが、(1) も az cognitiveservices usage list --location <region> で確認できます。

評価データセット:失敗パターンを資産化する

実装中に踏んだ罠は、そのまま「将来また踏まないための回帰テスト」になります。今回は agent 用に golden JSON を作り、本記事で扱った各失敗パターンをケース化しました(計 14 件)。

{
  "id": "agent-honorific-strip",
  "description": "敬称付き ('○○男爵') で structured 検索 → 略称に正規化されて 30+件取れること",
  "discovered": "JSON_CONTAINS→JSON_SEARCH + stripHonorific 追加",
  "user_input": "○○男爵が訪れた他の日の記録を調べて",
  "expectations": {
    "tool_called_any": ["search_diary_structured"],
    "tool_result_count_min": 5,
    "answer_must_not_contain": ["見つかりませんでした"]
  }
}

runner は agent endpoint の UIMessage stream を Python から parse し、tool 呼び出しと最終回答に対して assertion をかけます。レート制限 transient を吸収するための retry も組み込み済みで、現状 13/14 件 pass しています(残り 1 件は reasoning chain のマルチターンテストで、これは UI 経由でないと完全再現が難しく Playwright E2E 待ち)。

「実装で踏んだ罠 → 失敗テストケース → 修正 → テストが pass する」というループを回せる状態を作っておくと、後の改修で同じ穴を再発させにくく、また「○○ を変更したらどこに影響するか」が機械的に分かるようになります。LLM ベースのシステムは挙動が非決定的で、回帰の検知が人手では追いつかないので、こういう資産化は割が良い印象でした。

今回の作業を通じて確認できた点

ベクター RAG と構造化データは相補

特定の問いではどちらか一方しか機能しない場面が多々ありました。「○○についての考え方は?」のような概念的な問いはベクター検索の独壇場、「○○年に△△を訪れた記録」のような属性絞り込みは構造化検索の独壇場。LLM tool calling は両者をルーティングするレイヤーとして自然に機能するようでした。

データ整備(構造化タグ・人手アノテーション・典拠管理)への投資は、LLM 時代でも(むしろ LLM 時代こそ)効くという感触を得ています。

設計より動かすまでの摩擦が大きい

「8 ツール構成にする」という設計判断は数分で決まりますが、それを Cloudflare Workers + Azure OpenAI + Vercel AI SDK + TiDB Cloud という現実のスタックの上で動かすには上記 5 つの罠を踏みました。

特に:

  • LLM 特有の問題(罠 3 のタグ命名ゆれと LLM 出力の不一致、罠 4 の reasoning chain)は SDK ドキュメントだけ読んでも気付きにくい
  • インフラの上限(罠 1 の Worker サイズ、罠 5 の TPM)は早めに気付くと選択肢が広がる
  • SDK の URL/api-version の互換性(罠 2)はバージョンアップ時に静かに壊れることがある

評価データセットを並行で育てる

実装中に踏んだ罠を、その場で golden JSON のテストケースとして固定化したことで、後の改修やモデル切替に対する安心感がだいぶ違うように感じました。LLM ベースのシステムは挙動が確率的で、人手レビューで全網羅は難しい。「これだけは絶対通したい」を JSON で書き溜めておくのが効くように思います。