1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLM を動かすなら RAG より速くて正確な選択肢がある——CAG の実装と設計(実装編)

1
Last updated at Posted at 2026-06-20

はじめに

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 問ぶんの数字と 失敗例の実物 をまとめて出します。


参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?