はじめに
LLM の出力を速くしたくて、このブログでは プレフィックスキャッシュで TTFT が約9割短くなること まで実測してきました。「同じ長文を毎回 prefill するのはもったいない」という話です。
その延長で、ひとつ気づいたことがあります。vLLM のようなローカル LLM を自前で動かしているなら、そもそも検索(RAG)を捨ててしまう設計が選べる のです。KV キャッシュの使い回しを自分で直接コントロールできるからです。API 経由で LLM を使っている場合は、この制御は原則できません。
その「検索を捨てる設計」に名前をつけた論文が、2024 年末に出ています。「Don't Do RAG」 ——知識を全文コンテキストに積みっぱなしにして、KV キャッシュごと使い回す CAG(Cache-Augmented Generation) です。
プレフィックスキャッシュを手元で触ってきた身としては、これは試さないわけにいきません。検索を消して全部入れるほうが 速くて正確 なのだとしたら、わざわざチャンクに割って埋め込んで DB を立てる意味は、ローカルで動かしているかぎりかなり薄くなります。
——本当にそうなのでしょうか。論文の評価は英語・大規模 GPU が前提です。
そこで、日本語の小説 50 問・RTX 4070(12GB)・4B モデル という自分の手元の条件で、RAG と CAG を同じ土俵で比べてみました。
この実装編では、その「土俵」をどう組んだのかを、実際のコードまで開いて説明します。CAG が全文を積んでも遅くならない仕組み、12GB で 4B モデルを動かすための設定、そして比較を歪ませないために気をつけた点——順番に見ていきます。
勝敗の数字は次の〈結果編〉でまとめて出します。
TL;DR
LLM に手元の資料を読ませて答えさせる方法は、大きく 2 つあります。「持ち込み可の試験」にたとえると分かりやすいです。
- RAG(Retrieval-Augmented Generation) は、質問のたびに関係しそうな箇所だけを資料から探し出して LLM に渡す方式です。試験のたびに、必要そうなページだけコピーして持ち込むイメージです。資料が分厚すぎてコンテキストに入り切らないときでも、必要な部分だけ渡せば正確に答えられる ——これが RAG の最大の強みです。
- CAG(Cache-Augmented Generation) は、資料を丸ごと最初から LLM に渡しておく方式です。教科書を 1 冊まるごと持ち込んで、机に広げっぱなしにするイメージです。成り立つ条件は 「資料がそもそもコンテキストに収まる大きさであること」。ローカル GPU はメモリが限られるので、ここは重要な前提になります。
- 違いは「LLM が一度にどれだけ見えているか」です。RAG は探して当たった数ページだけ、CAG は全文が見えています。今回の結果の差は、ほぼここから生まれます。
- 「全部渡したら、読むのに時間がかかって遅いのでは?」と思うところですが、同じ資料に何度も質問するなら、資料の読み込み計算(プレフィル)は最初の 1 回だけで済みます。計算結果を保存して使い回す仕組み(プレフィックスキャッシュ)があるからです。毎回同じ前置きを送る使い方ほど、2 回目以降の計算がまるごと節約できます——ここが今回いちばん面白い点です。
- ただし CAG は、vLLM のようなローカル LLM を自分で動かしている場合にだけ選べます。ChatGPT のような API 越しの利用では、この"使い回し"を自分で操作できないからです。
- 本記事(実装編)は 仕組みと土台の作り方 までを扱います。速度・精度の実測と勝敗 は〈結果編〉に置きます。
技術背景 — なぜ「知識の渡し方」が論点になるのか
LLM 単体は、学習した時点までの知識しか持っていません。社内文書や最新情報、特定ドメインの知識を扱わせるには、推論時に外から知識を渡す必要があります。その代表が RAG(Retrieval-Augmented Generation) です。
RAG は知識を外部ストア(ベクトル DB)に置き、質問のたびに関連箇所だけを検索して LLM に渡します。大規模な知識ベースにもスケールする一方で、チャンク分割・埋め込み・ベクトル検索というパイプライン を運用し続けるコストがかかります。さらに「検索が外した瞬間に、LLM は正しく答える材料を失う」という構造的な弱さも抱えています。
ここ数年で状況を変えたのが、コンテキスト長の拡大 と KV キャッシュの再利用 です。モデルが数万トークンを一度に読めるようになり、しかも「同じ前置きの計算結果を使い回す」最適化が実用化されました。すると、こんな発想が成り立ちます。
知識ベースが小さくてコンテキストに収まるなら、検索などせず全文を入れてしまえばいい。計算結果をキャッシュしておけば、毎回読み直すより速い。
冒頭で触れた論文 「Don't Do RAG」 が、まさにこれを正面から主張し、CAG(Cache-Augmented Generation) という名前を広めました。検索という工程そのものを消してしまう設計です。
"Don't Do RAG: When Cache-Augmented Generation is All You Need for Knowledge Tasks"
Brian J. Chan et al.(国立陽明交通大学/中央研究院), arXiv:2412.15605, ACM Web Conference 2025 採択
GPTシリーズのトークン数
| モデル | 最大出力トークン数 | コンテキスト長 |
|---|---|---|
| GPT-3.5 Turbo | 4,096 | 16,385 |
| GPT-4 | 8,192 | 8,192 |
| GPT-4o | 16,384 | 128,000 |
| GPT-5 | 128,000 | 400,000 |
| GPT-5 Chat | 16,384 | 128,000 |
最近はGPU側のメモリやモデルの能力が向上し、扱えるトークン数が伸びてきましたが、RAGが出始めの2024年前半ごろはGPT3.5 turbo の16KやGPT4の8Kとたった2年前まではこれしか使えなかったのですから、最近のモデルのトークン能力の向上には驚かされます。
なぜ自分で測り直すのか
論文のベンチマークは英語(HotPotQA / SQuAD)で、GPU も研究用の大型環境が前提です。私が使うのは RTX 4070(12GB)で、扱うのは日本語のテキストですから、論文と条件がそろっていません。同じ傾向が出るのかどうか、数字をそのまま信じる気にはなれませんでした。
もうひとつ、気になっていた点があります。定番のベンチマークには「答えが本文に書かれていない質問」という種類が含まれていません。けれども実際に使えば、そういう質問は必ず来ます。「知らないことを、正直に知らないと言えるか」——ここが RAG と CAG の設計差が最も出る場面だと考えていました。
そこで、今回はLLMが知らない知識として、Codex(GPT-5.5)に4000文字程度の物語を作らせ、その物語をRAG or 全文キャッシュで入れ、日本語・コンシューマ GPU・unknown 型を含む 50 問 という自分なりの条件をそろえて、両者を同じ設定で動かしました。CAG は vLLM を自前で動かしているからこそ試せた設計で、API ユーザーにはそもそも選択肢になりません。だからこそ、手元で検証できるうちに数字を出しておきたかったのです。この記事はその「土俵の作り方」を、コードまで開いて説明します。出てきた数字は〈結果編〉でまとめて出します。
RAG と CAG は何が違うのか
RAG は「質問のたびに探して、当たった断片だけ渡す」方式です。CAG は「最初から全部入れておき、あとは質問するだけ」の方式です。
設計上の本質的な違いは、「LLM が何を見ているか」 に集約されます。
| RAG | CAG | |
|---|---|---|
| 知識の置き場 | 外部(ベクトル DB) | LLM のコンテキスト(プロンプト)内 |
| 質問時の処理 | 検索 → 上位チャンク抽出 → 注入 | そのまま投げるだけ |
| LLM が見る範囲 | 当たった3チャンクのみ | 物語の全文 |
| 速くする鍵 | 検索を速くする | KV キャッシュの使い回し |
「全部入れたら遅いのでは?」への答え — プレフィックスキャッシュ
答えから言うと、遅くなりません。理由は KV キャッシュの使い回し(プレフィックスキャッシュ) です。LLM は、同じ前置き(プレフィックス)に対する内部状態を、一度計算してしまえば再計算せずに使い回せます。全文をコンテキストに入れても、その prefill 計算は最初の 1 回で済むわけです。
全文部分を一度計算してキャッシュしておけば、2 問目以降はその計算をまるごとスキップできます。 これが「全文を入れても速い」種明かしです。
vLLM では、これが Automatic Prefix Caching (APC) として実装されています。今回はこれをそのまま使います。本当に速くなるのかどうか、実測値は〈結果編〉で出します。
実験のセットアップ
公平に比べるため、LLM・質問・採点者はすべて共通 にしました。変えたのは「知識をどう渡すか」だけです。
| 要素 | 中身 |
|---|---|
| 題材 | 自作の日本語短編小説『潮霧湾の門守り』(約2,450トークン) |
| 質問 | 50問。6タイプ(simple / detail / relation / timeline / inference / unknown)に分類 |
| LLM |
Qwen3.5-4B-FP8(vLLM, ローカル, RTX 4070 12GB) |
| 埋め込み(RAG用) |
cl-nagoya/ruri-v3-310m(日本語特化) |
| 採点 | LLM-as-a-Judge(Gemini 3.1 Flash Lite, 0/1/2点) |
LLM-as-a-Judge とは、人手の採点の代わりに、別の LLM を採点者として使う評価手法です。100 回答を人間が採点する手間を避けつつ、単純な文字列マッチよりも意味を汲んだ採点ができます。今回は Gemini を採点者にして、RAG・CAG どちらの回答かを伏せたブラインド方式で採点しました。詳しくは後ろの「採点」の節で説明します。
設計でこだわったのは、次の 2 点です。
- 題材を自作小説にしました。モデルが事前学習で答えを"知っている"可能性を消すためです。Wikipedia のような有名なテキストだと、コンテキストを見なくても答えてしまい、検証になりません。
-
unknown(本文に答えがない)型を質問に混ぜました。「知らないことを知らないと言えるか」を測るためです。ここは RAG と CAG の設計差が最も出るはずの観点だと考えました。
RAG 側をどう組んだか
RAG は「チャンク分割 → 埋め込み → 検索 → 生成」の 4 工程です。
固定長で切らず、意味の切れ目で分割する
隣り合う文の埋め込みのコサイン類似度を見て、類似度が下位 25% に落ちる境界(=話題の変わり目)で区切ります。段落や章見出しは強制的に区切ります。狙いは「ひとつのチャンクに複数の話題を混ぜないこと」です。約 2,450 トークンの小説が、32 チャンク になりました。
セマンティックチャンク分割のコア(クリックで展開)
# 隣接文のコサイン類似度を計算し、下位25パーセンタイルを「意味の切れ目」とする
sims = [float(np.dot(emb[i], emb[i + 1])) for i in range(n - 1)]
threshold = float(np.percentile(sims, 25))
# 目標256字。意味の切れ目 or サイズ超過で chunk を確定
semantic_break = i < len(sims) and sims[i] < threshold
size_break = cur_len >= target * 1.5
if is_last or must_break or (semantic_break and cur_len >= target * 0.5) or size_break:
flush()
埋め込みモデルの作法を守る
日本語特化の cl-nagoya/ruri-v3-310m には、固有の作法があります。クエリ側にだけ "クエリ: " プレフィックスを付ける (ドキュメント側には付けない)というものです。これを忘れると検索精度がはっきり落ちます。地味ですが、ここを外すと RAG 全体が実力よりも弱く見えてしまう、見逃せない一点です。
QUERY_PREFIX = "クエリ: "
def retrieve(model, index, chunks, query, top_k=3):
vec = model.encode(
[QUERY_PREFIX + query], # ← クエリにだけプレフィックス
normalize_embeddings=True,
).astype(np.float32)
scores, indices = index.search(vec, top_k) # FAISS でコサイン類似度検索
return [chunks[i] for i in indices[0]]
検索エンジンには FAISS(Meta 製のベクトル検索ライブラリ、faiss-cpu)を使います。GPU は vLLM が占有しているため、埋め込みと検索はすべて CPU で回します。
検索結果をプロンプトに詰める部分(クリックで展開)
def build_prompt(question, context_chunks):
context = "\n\n".join(
f"[チャンク{i+1}]\n{c['text']}" for i, c in enumerate(context_chunks)
)
return (
"以下の文章を参考にして、質問に日本語で簡潔に答えてください。\n\n"
f"【参考文章】\n{context}\n\n"
f"【質問】\n{question}\n\n【回答】"
)
ここに RAG の本質が表れています。LLM が見られるのは、検索で当たった 3 チャンクだけ で、物語の全体像は見えていません。
CAG 側をどう組んだか
CAG は、驚くほどシンプルです。検索もチャンクも DB もありません。小説全文を system プロンプトに丸ごと入れて、質問を投げるだけ です。
SYSTEM_PROMPT_TEMPLATE = (
"あなたは以下の物語についての質問に答えるアシスタントです。"
"物語の内容だけを根拠にして、日本語で簡潔に答えてください。"
"物語に記載のない情報は「記載なし」と答えてください。\n\n" # ← この一文が効く
"【物語】\n{story}"
)
messages = [
{"role": "system", "content": system}, # ← 小説全文がここに丸ごと
{"role": "user", "content": question},
]
「記載のない情報は『記載なし』と答えて」 という一文に注目してください。全文が見えている CAG は、この指示にきちんと従えます。一方の RAG は当たったチャンクしか見ていないので、「本当に記載がないのか」を判断する材料そのものを持っていません。
プレフィックスキャッシュを効かせる
起動時にウォームアップを一発投げて、小説全文をキャッシュに焼いておきます。1 問目はキャッシュ未生成(miss)、2 問目以降は再利用(hit) として、両者の所要時間を別々に測れるようにしました。
ウォームアップとキャッシュ計測の仕込み(クリックで展開)
# 起動直後に全文をキャッシュへ焼く
if args.warmup:
call_llm(system, "物語の舞台はどこですか?", client)
# 1問目=miss, 2問目以降=hit として TTFT を別集計
cache_status = "miss" if i == 1 else "hit"
vLLM の起動設定には、RTX 4070(12GB)で全文コンテキストを回すための工夫が詰まっています。vLLM の Automatic Prefix Caching ドキュメント も参考にしてください。
vllm serve RedHatAI/Qwen3.5-4B-FP8-dynamic \
--dtype auto \
--max-model-len 32768 \ # CAG で全文+余裕を確保
--gpu-memory-utilization 0.90 \
--enforce-eager \ # CUDA graph は 12GB だと OOM
--enable-prefix-caching # ← CAG の心臓部
| 設定 | 値 | 理由 |
|---|---|---|
| 量子化 | FP8 | BF16 は 12GB で OOM になる |
| 実行モード | --enforce-eager |
CUDA graph も 12GB だと OOM になる |
| 最大コンテキスト長 | --max-model-len 32768 |
CAG で全文+余裕を確保する |
| GPU メモリ使用率 | 0.90 |
KV キャッシュ領域を確保する |
| プレフィックスキャッシュ | --enable-prefix-caching |
CAG の心臓部 |
採点 — LLM-as-a-Judge
50 問 × 2 手法 = 100 回答を、人手ではなく Gemini に 0/1/2 の 3 段階 で採点させました。RAG と CAG は 別々に・独立して 採点します(どちらの手法の回答かを採点者に知らせません)。生成は両手法とも temperature=0.0・ストリーミングで行い、TTFT(最初のトークンが返るまでの時間)まで計測しています。
採点基準は「模範解答と照らして、事実として正しいか」です。unknown 型では「記載なしと正しく答えられたか」を見ます。
実装で得た知見 — そのまま使える勘所
同じことをやる人がつまずかないよう、効いた判断を 5 つ残しておきます。
| 勘所 | なぜ重要か |
|---|---|
クエリにだけ "クエリ: " を付ける |
ruri-v3 系はクエリ/ドキュメントで前処理が非対称。付け忘れると RAG が実力より弱く見え、比較結果そのものが歪む |
12GB なら FP8 + --enforce-eager |
BF16 も CUDA graph も即 OOM。FP8 量子化と eager 実行で、4B モデル+32K コンテキストがぎりぎり載る |
| チャンクは固定長でなく意味境界で切る | 1 チャンクに話題が混ざると検索の解像度が落ちる。隣接文の類似度の谷で切ると「当たり」の純度が上がる |
| 採点はブラインド&独立で | RAG/CAG どちらの回答かを採点者に伏せる。手法バイアスを抜かないと、精度比較は信用してもらえない |
| コーパスは自作する | 既知のテキストだとモデルが暗記で答え、コンテキスト依存を測れない。自作小説でリークを断つ |
どれも「動かすだけ」なら不要ですが、「比較の数字を信用できるものにする」ためには外せない判断でした。比較実験は、前処理ひとつで結論がひっくり返ってしまいます。
ここまでの設計まとめ
実装の負荷だけで見ると、RAG はパイプライン一式が必要で、CAG は system プロンプトに全文を入れるだけです。
条件をそろえた実験台が整いました。同じ LLM、同じ 50 問、同じ採点者。違うのは知識の渡し方だけです。立てた問いは、次の 3 つです。
6.4 倍も長いプロンプトを毎回投げる CAG は、本当に RAG より速いのか。
検索で"当たり"を引く RAG と、全文を見渡す CAG、正答率はどちらが上なのか。
そして「答えが本文にない」質問で、両者はそれぞれ何を答えたのか。
ここに、自分でも予想していなかった 速度の逆転 と、はっきりとした 設計由来の差 が出ました。次の〈結果編〉で、50 問ぶんの数字と 失敗例の実物 をまとめて出します。
参考
- 実装コード・データ・計測スクリプト一式(本記事のリポジトリ): ai-systems-notes/local-llm-rag-cag-benchmark
- Brian J. Chan et al., "Don't Do RAG: When Cache-Augmented Generation is All You Need for Knowledge Tasks", arXiv:2412.15605, ACM Web Conference 2025
- vLLM — Automatic Prefix Caching
cl-nagoya/ruri-v3-310m(Hugging Face)- FAISS — Meta AI のベクトル検索ライブラリ
RedHatAI/Qwen3.5-4B-FP8-dynamic(Hugging Face)