TL;DR
- 私が個人開発しているナレッジAI SaaS「ものしりAI」では、社内文書 Q&A の中核に RAG (Retrieval-Augmented Generation) を採用していた
- 本番運用 1 ヶ月で「チャンク化精度に引きずられる回答品質」「S3 Vectors / Cohere Embed の運用コスト」「ドキュメント更新時の再ベクトル化負荷」の 3 つに困った
- 論文 arXiv:2604.14572 の Corpus2Skill に着想を得て、社内ドキュメントを skill ツリー に蒸留し、LLM が自前 tool で navigate する方式に全面移行
- 移行で Bedrock prompt caching によるコスト 90% 削減を実現
- 「あらゆるユースケースで RAG > skill」でも「skill > RAG」でもなく、ドメインによっては skill モードの方が筋が良いという話
はじめに
「社内ドキュメントを AI に答えさせるなら RAG」というのは、ここ 2 年でほぼ既定路線になりました。私もそれに乗って ものしりAI というナレッジ Q&A SaaS を作り、Bedrock の Cohere Embed v4 + S3 Vectors + Claude Haiku 4.5 でフルスタックの RAG を本番運用していました。
ところが運用してみると、想定していなかった「RAG ならではの不便さ」がいくつも出てきました。これを解決するために検証したのが、Anthropic の論文 Corpus2Skill: Hierarchical Skill Distillation for Agentic Retrieval で提案された skill ツリーナビゲーション型のアーキテクチャです。
最終的に RAG を本番から完全撤去し、自社実装の Corpus2Skill (社内では「skill モード」と呼んでいます) に全面移行しました。この記事はその技術判断と、移行で得たもの・失ったものを記録したものです。
「RAG が万能ではないかもしれない」という同じ疑問を持っている方の参考になれば嬉しいです。
1. 本番運用した RAG が直面した 3 つの課題
1.1 チャンク化精度に引きずられる回答品質
ものしりAI の RAG 実装は、よくある構成です。
[アップロードされた PDF / Word / Excel]
↓ テキスト抽出 (loader)
↓ 1024 文字 chunk に分割
[Cohere Embed v4 で 1024 次元ベクトル化]
↓ S3 Vectors に upsert
[ユーザー質問]
↓ 質問もベクトル化 → S3 Vectors で top-K 検索
↓ Claude Haiku 4.5 に検索結果を渡して回答生成
[回答 + 出典]
実装は素直で、Bedrock 完結のため AWS 内に閉じてプライバシー的にも安心。
ただし運用を始めると、以下のようなクエリで品質が崩れることに気づきました。
- 「就業規則と人事評価制度規程を比較して、評価面談の頻度の違いをまとめて」 → 2 つの文書をまたいだ要約が必要だが、検索結果が片方の文書に偏ると比較が成立しない
- 「ハラスメント対応の手順は?」 → 該当チャンクだけ取れば答えは作れるが、根拠を辿るとチャンクが文書の途中で切れていて文脈が読めない
- 「料金プランの月額と上限は?」 → 表 (テーブル) がチャンク境界で分断されると、月額のキーバリュー対応が壊れて誤答する
要するに、「文書のどの部分を読むか」を embedding 類似度で機械的に決めているのが本質的な弱さです。論文も同じ問題を指摘していて、「LLM はコーパス全体の構造が見えず、検索結果を受動的に受け取るだけ」と書いています。
1.2 vector ストア + Embedding モデルの運用負荷
ものしりAI の RAG は以下の AWS リソースで構成していました。
- S3 Vectors: テナント・フォルダごとにインデックスを分離
- Cohere Embed v4 (Bedrock): ドキュメントアップロード時に 1024 次元ベクトル化
- vectorize Lambda (Node.js): S3 ObjectCreated → embedding → S3 Vectors upsert
- delete-vectors Lambda: S3 ObjectRemoved → S3 Vectors から該当 chunk 削除
機能的には素直な構成ですが、「アップロード処理は何段階の Lambda を経由するのか」「失敗時にどこまで巻き戻すのか」を考えるたびに認知負荷がじわじわ上がっていました。回答品質の本丸ではないところで、運用コードの行数が増え続けるのが気になり始めた、というのが正直な感覚です。
1.3 ドキュメント更新時の再ベクトル化負荷
これが一番厄介でした。
ユーザーが PDF を 1 つ差し替えるたびに、
- 旧チャンクを S3 Vectors から削除
- 新ドキュメントをチャンク化して embedding
- 新チャンクを S3 Vectors に upsert
- (旧 chunk と新 chunk の chunkIndex がズレるので、差分追跡が地味に面倒)
特に 「ドキュメントの一部を直したい」というユースケースに弱い。チャンク 0〜49 を再生成するか、全 chunk を消して入れ直すかの判断が運用の度に発生します。
「ベクトル化済み」「処理中」というステータスもユーザーに見せる必要があり、UI 設計も複雑化していました。
2. 検証した代替案
2.1 長文コンテキスト (Claude 1M トークン)
Claude Sonnet 4.5 は 1M トークンの長文コンテキストに対応しています。「ドキュメントを全部プロンプトに詰めれば検索いらない」という案。
ただし、
- コスト: 1 質問あたり数十万〜100 万トークンを毎回送るとコストが現実的でない (cache でも限界がある)
- 遅延: 長文をパースする時間がかかり、UX が悪化
- 精度: 1M トークン入れても、後半の情報を引き出す精度は短いコンテキストに劣る (Lost in the Middle 問題)
- 権限制御: フォルダ単位のアクセス制御をプロンプト時に再構成するのが面倒
→ ものしりAI のドメインでは現実解にはならない、と判断。
このあたりの議論は 公式ブログにも書いた のですが、結論は「現状は用途別にハイブリッドが落とし所」でした。
2.2 ハイブリッド (RAG + 長文コンテキスト)
「短いクエリは RAG、長文要約は長文コンテキスト」というルーティング。
- メリット: それぞれのいいとこ取り
- デメリット: ルーティング判定 (どっちを使うか) を LLM か heuristics で決める必要があり、システムが 2 倍に膨らむ
「ハイブリッドにする工数で第三の道を実装する方が、長期的に楽そう」という判断で、Corpus2Skill の検証に振り切りました。
3. Corpus2Skill (第三の選択肢) を試す
3.1 論文の要点
Corpus2Skill (arXiv:2604.14572) の中核アイデアはシンプルです。
ドキュメント群を意味的にクラスタリングし、階層的な skill ツリーとして整理する。LLM はこのツリーを 自前 tool で navigate しながら、必要な部分だけを読み込んで回答する。
要するに、「事前に 目次を作っておいて、LLM がその目次を辿る」という方式です。
skill ツリー (例)
─ tenants/{tenantId}/folders/{folderId}/skill-tree/
├ MANIFEST.json ← トップレベル目次 (何の skill があるか)
├ skills/
│ ├ 就業規則/
│ │ ├ SKILL.md ← この skill の概要 + INDEX.md への目次
│ │ ├ INDEX.md ← この skill が扱う文書一覧 + 各文書の要約
│ │ └ documents/
│ │ ├ {doc_id}.txt ← 文書本文 (PDF/Word/Excel をテキスト化済み)
│ ├ 人事評価制度規程/
│ │ ├ SKILL.md
│ │ ├ INDEX.md
│ │ └ documents/...
│ └ ハラスメント対応/...
LLM はこのツリーに対して 3 つの tool を持ちます。
-
list_skill_path(path)→ ディレクトリの ls -
read_skill_file(path)→ SKILL.md / INDEX.md を読む -
get_document(doc_id)→ 文書本文を読む
LLM は「ユーザーの質問に答えるには、まず SKILL.md を読んで関連 skill を絞り込み、INDEX.md で文書を選び、必要なら本文を get_document する」という人間と同じプロセスを踏みます。
3.2 実装スタック
-
skill ツリー生成 (オフライン): Python Lambda (
skillify) が S3 ObjectCreated イベントで起動 → ドキュメントを階層クラスタリング + LLM で要約 → S3 に skill ツリーを書き出す - チャット (オンライン): NestJS API が Bedrock Converse API + 自前 tool 3 つ で tool use ループ
- LLM: Claude Haiku 4.5 (主力) + Sonnet 4.5 (難しい質問のみ)
- デプロイ: ECS Fargate + Lambda、両方 Container Image
3.3 PoC の数値結果
ローカル環境で同じクエリ 9 件を Corpus2Skill と既存 RAG で比較しました。
| 観点 | Corpus2Skill | Baseline RAG |
|---|---|---|
| 平均レイテンシ | 6.07s | 3.09s |
| 平均 input tokens | 21,038 | 13,936 |
| 平均 output tokens | 504 | 239 |
| hallucination | ゼロ | ゼロ |
| 「情報なし」の説明力 | 詳細に「これは記載なし、これは記載あり」と返す | 簡潔に「記載なし」 |
| 文書根拠の明示性 | navigation trace で完全に透明 | top-K のスコアで透明 |
| 数値・固有値の正答率 | 高い (本文を直接読むため) | 表が分割されると落ちる |
純粋な速度・コストでは RAG が勝っていますが、「複数文書をまたいだ要約」「数値の正確さ」「『情報がない』ことの説明」では Corpus2Skill が圧勝でした。
ものしりAI のメインユースケース (社内規程・マニュアル・FAQ) では、RAG の不正確さよりも skill モードの「読みに行くコスト」の方が許容しやすい、と判断しました。
4. Bedrock prompt caching でコスト 90% 削減
skill モードは「同じシステムプロンプト + 同じ skill ツリー catalog」を毎回送るため、Bedrock Converse API の prompt caching が劇的に効きます。
実装はシンプルで、messages の system block の最後に cachePoint を入れるだけです。
const command = new ConverseCommand({
modelId: "jp.anthropic.claude-haiku-4-5-20251001-v1:0",
system: [
{ text: systemPrompt },
{ cachePoint: { type: "default" } }, // ← ここで cache する
],
messages: [...],
toolConfig: {
tools: [...],
toolChoice: { auto: {} },
},
});
cache TTL は 5 分。同じユーザーが連続質問するときは 100% cache hit します。
公開時点での実績:
| 指標 | 値 |
|---|---|
| input トークン (cache 込み) | 平均 25,000/質問 |
| うち cache read | 平均 22,000 (88%) |
| 実コスト | cache 部分は 1/10 価格 |
| 月額 Bedrock コスト | $90 → $9 に削減 |
5. 移行で困ったこと・トレードオフ
正直に書くと、銀の弾丸ではないので失ったものもあります。
5.1 レイテンシ
- RAG: 1 回の embedding 検索 + 1 回の LLM 呼び出し
- skill モード: 平均 4 turn の tool use ループ (catalog 読み → SKILL.md 読み → INDEX.md 読み → 本文読み → 回答)
ベンチでは 3s → 6s に伸びました。回答品質を上げる代わりに「考える時間」が見える形です。
これに対しては、「考えています…」「○○を読んでいます…」のリアルタイム進捗表示 + 回答本文の文字単位ストリーミング を実装し、体感速度を補っています。最初の文字が出るまでは依然 3〜5 秒かかりますが、出た後はストリームでスラスラ流れるので、ユーザー側の評価はむしろ上がりました。
5.2 skill ツリーのビルド時間
ドキュメントを 100 件アップロードすると、skill ツリー全体の再生成に数分かかります (Lambda が階層クラスタリング + 各 skill の LLM 要約を生成するため)。
これも debounce 5 秒で trigger + アップロード完了の hook で SQS にキューイング + 裏で skillify Lambda が消化 する形にして、ユーザーは待たなくて良い構成にしました。
5.3 大規模ドキュメントには向かない
skill ツリーは「目次」なので、ドキュメント数が 数千件を超えると目次自体が肥大化して LLM のコンテキストに乗らなくなります。
ものしりAI のターゲット (中小企業の社内ナレッジ、典型的に 100〜数百件) ではまだ余裕がありますが、「ナレッジ部署で 10 万件」みたいな大規模では工夫が要ります (skill ツリーの階層を深くする、トップレベルだけ最初に渡す、等)。
6. どんなチームに skill モードが向くか
私の運用感覚での判断軸です。
| 向く | 向かない |
|---|---|
| 文書数が中規模 (100〜数千) | 数万〜超大規模 |
| 構造化された文書 (規程・マニュアル・FAQ) | 非構造化の長文 (チャットログ、議事録) |
| 出典が重要 (法務・人事・コンプラ用途) | 雰囲気で答えればよい用途 |
| 質問の幅が広い (複数文書をまたいだ要約) | 単一文書の中で完結する Q&A |
| コスト最適化したい (prompt caching が効く) | レイテンシ最優先 (3 秒以内必須等) |
「ナレッジを引きに行くロボット」と「ナレッジを読んでくれる人間」のどっちを作りたいかで選ぶといいと思います。
- 引きに行くロボット = RAG (高速、安価、表面的)
- 読んでくれる人間 = skill モード (低速、コスパ◎、深い理解)
まとめ
「RAG が間違っている」と言いたいわけではありません。RAG は今でも多くのユースケースで最適解です。
ただ、自分のサービスが解こうとしている問題 (中小規模の社内ナレッジ、構造化された文書、出典の正確さ) に対しては、「読みに行く」よりも「目次を辿る」方が明らかに筋が良かった、というのが個人開発 1 年の結論です。
参考リンク
- 論文: arXiv:2604.14572 - Corpus2Skill: Hierarchical Skill Distillation for Agentic Retrieval
- ものしりAI: https://monoshiri.ai/
- 公式ブログ:
- AWS Bedrock prompt caching: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
- Lost in the Middle: https://arxiv.org/abs/2307.03172