On this page
- English version
- The problem CAG tries to solve
- RAG path vs CAG path
- Two different caches people mix together
- When CAG is useful
- Minimal implementation
- Production risks
- FAQ
- Korean version
- CAG가 해결하려는 문제
- RAG와 CAG의 실행 경로 차이
- 두 종류의 CAG: response cache와 context cache
- 언제 CAG를 쓰고, 언제 RAG를 그대로 둘까
- 최소 구현: query-context-response cache
- Production에서 조심할 것
- 운영 지표는 작게 시작한다
- FAQ
- CAG는 RAG를 대체하나?
- CAG와 prompt caching은 같은가?
- cache key에는 무엇을 넣어야 하나?
- stale answer는 어떻게 막나?
- References
English version
Cache-Augmented Generation is not a replacement for RAG. It is a cache layer in front of RAG that asks a simpler question first: have we already answered this same request with the same usable context? In a production RAG system, I would treat it as the first optimization point before retrieval and generation.
The problem CAG tries to solve
A basic RAG flow is easy to understand. A user asks a question. The system runs embedding search. Relevant chunks are added to the prompt. The LLM generates an answer. That structure works well when the application needs private data or domain-specific documents.
The problem is that not every question is new. Customer support, internal handbooks, pricing FAQs, onboarding guides, and product policies all get repeated questions. If “How do I reset my password?” has already been answered thousands of times, running vector search and LLM generation again for every request is wasteful.
The core idea behind CAG is to store a user query, the context used for the answer, and the final response. When a sufficiently similar request comes in again under the same safe scope, the system can return the cached response instead of paying for retrieval and generation again.
This is not a new magic architecture. It is the same old systems rule applied to an LLM pipeline: store repeated work, reuse stable inputs, and put a cheap check in front of an expensive path.
RAG path vs CAG path
The normal RAG path is miss-first. Once the question arrives, retrieval already runs. Even if the retrieved chunks are weak, the system has already paid that cost. Then it pays the LLM prefill and decoding cost as well.
CAG adds a gate before that path:
User query
-> cache lookup
-> cache hit: return cached response
-> cache miss: run RAG
-> retrieve context
-> generate answer
-> store query-context-response
-> return response
The important shift is not “make retrieval better.” It is “remove requests that do not need retrieval in the first place.” CAG does not solve retrieval quality. Bad chunking, weak reranking, and missing evaluation still need to be fixed in the RAG layer.
That is why CAG is not a RAG replacement. It is a cache gate in front of RAG. If the cache hit is safe, return quickly. If it is unclear, fall through to RAG. If the cached answer is stale, discard it.
Two different caches people mix together
When people say CAG, they often mix two different ideas.
The first is a response cache. The application stores the final answer and reuses it for repeated questions such as password resets, return policies, or invoice download instructions.
The second is a context or prompt cache. Instead of storing the final answer, the runtime or model provider reuses a long prompt prefix or static context. Anthropic describes prompt caching as a way to reduce processing time and cost for repetitive prompts, with cache breakpoints and TTL options. OpenAI's prompt caching guide also emphasizes exact prompt prefix reuse, which means stable instructions and examples should sit near the front of the prompt while variable content should come later.
These are not the same layer.
| Type | Response cache | Context or prompt cache |
|---|---|---|
| What it stores | Final answer | Long prompt prefix or static context |
| Hit condition | Query and scope are safe to reuse | Prompt prefix is reused exactly |
| Benefit | Skips retrieval and generation | Reduces prefill cost and latency |
| Main risk | Stale answer, personalization leak | Bad prompt layout, misunderstood TTL |
| Example | Reusing a password-reset answer | Reusing handbook context across requests |
In production, I would keep ownership separate. A response cache is application policy: who is allowed to reuse which answer? A prompt cache is model API optimization: is the prompt shaped so the provider can reuse stable prefixes?
When CAG is useful
CAG works best when four conditions are true.
First, the question repeats. Python's LRU cache documentation makes the same point for normal functions: caching helps when recent calls predict upcoming calls. LLM products are no different. If yesterday's top support questions are likely to show up again today, a cache can help.
Second, the answer is stable. “Where do I reset my password?” may stay valid for days. “What is my PTO balance today?” depends on the user, date, and policy state. The second question needs a much narrower key or should stay uncached.
Third, the authorization scope is simple. A public FAQ can use a global cache. Internal documents need tenant-level cache. Personal data needs user-level cache. Sensitive domains such as medical, financial, and HR systems need stricter boundaries.
Fourth, freshness can be defined. Redis gives keys expiration and TTL semantics. HTTP caching separates freshness, revalidation, and invalidation. LLM response caches need the same discipline. How long is this answer valid? What happens when the source document changes? Who can force a miss?
CAG is a poor fit for requests that need fresh data every time, depend heavily on user permissions, require substantial reasoning, carry legal or medical responsibility, or look similar while meaning something different.
Minimal implementation
The first version does not need semantic matching or distributed invalidation. Start with a normalized query, a scope, a context hash, and a TTL. Move to Redis or another shared store only after you have evidence that the hit rate matters.
from dataclasses import dataclass
from time import monotonic
@dataclass
class CacheEntry:
response: str
context_hash: str
expires_at: float
class ResponseCache:
def __init__(self, ttl_seconds: int = 300):
self.ttl_seconds = ttl_seconds
self._items: dict[tuple[str, str], CacheEntry] = {}
def _key(self, tenant: str, query: str) -> tuple[str, str]:
normalized = " ".join(query.lower().split())
return tenant, normalized
def get(self, tenant: str, query: str, context_hash: str) -> str | None:
key = self._key(tenant, query)
entry = self._items.get(key)
if entry is None:
return None
if entry.expires_at < monotonic():
self._items.pop(key, None)
return None
if entry.context_hash != context_hash:
return None
return entry.response
def set(self, tenant: str, query: str, context_hash: str, response: str) -> None:
self._items[self._key(tenant, query)] = CacheEntry(
response=response,
context_hash=context_hash,
expires_at=monotonic() + self.ttl_seconds,
)
def answer(cache: ResponseCache, tenant: str, query: str, context_hash: str) -> str:
cached = cache.get(tenant, query, context_hash)
if cached is not None:
return cached
response = f"RAG answer for: {query}"
cache.set(tenant, query, context_hash, response)
return response
def demo() -> None:
cache = ResponseCache(ttl_seconds=60)
first = answer(cache, "acme", "How do I reset my password?", "handbook-v1")
second = answer(cache, "acme", "how do i reset my password?", "handbook-v1")
third = answer(cache, "acme", "how do i reset my password?", "handbook-v2")
assert first == second
assert third == "RAG answer for: how do i reset my password?"
if __name__ == "__main__":
demo()
This example is intentionally small. The tenant is part of the key so answers do not cross organizational boundaries. The query uses only lowercase and whitespace normalization. If the context hash changes, the old answer is not reused. If the TTL expires, the entry is discarded.
Semantic caching should come later. “Password reset” and “admin password reset for a terminated employee” are similar strings, but they can require very different policies. Start with exact-ish keys, measure misses, and add semantic matching only when the failure mode is clear.
Production risks
The hard part of CAG is not the data structure. It is invalidation and boundaries.
The cache key must include the values that can change the answer: tenant, role, locale, product version, policy version, document hash, or user identity where needed. Missing one of those fields can turn a cache hit into a data leak.
Stale answers need an explicit policy. TTL helps, but TTL alone is not enough. If a policy document changes, the context hash or document version should change so old entries naturally miss.
Operators also need control over cache duration. CDN and HTTP cache systems use Cache-Control, max-age, Expires, and TTL. A CAG layer should not hide those decisions in hardcoded defaults.
Finally, measure before optimizing. If repeated questions are only two percent of traffic, CAG may not matter. If the top twenty questions are forty percent of traffic, a response cache can reduce latency and token spend immediately.
My rollout order would be simple:
- Pull the most repeated questions from logs.
- Allowlist only public or tenant-safe questions.
- Build an exact cache on query, tenant, and document version.
- Add TTL and manual invalidation.
- Track hit rate, stale rejections, forced misses, and latency.
- Consider semantic caching only after that.
CAG is an operations policy, not a new framework. The important decision is which questions the cache is allowed to answer and which questions must always fall through.
FAQ
Does CAG replace RAG?
No. CAG checks for a safe cache hit before RAG. On a miss, retrieval and generation still run.
Is CAG the same as prompt caching?
No. Response CAG stores final application answers. Prompt caching reuses prompt prefix processing in the provider or runtime. They can work together, but they have different owners and different risks.
What should be in the cache key?
Anything that can change the answer: normalized query, tenant, document version, context hash, role, locale, and user identity when needed. Smaller keys create more hits but more risk. Larger keys are safer but reduce hit rate.
How do you prevent stale answers?
Use TTL, document versions, context hashes, and manual invalidation together. TTL handles age. Versioning handles source changes. Manual invalidation gives operators an emergency escape hatch.
Korean version
Cache-Augmented Generation은 RAG를 없애는 기술이 아니라, RAG 앞에서 “이미 같은 질문에 답한 적이 있는가?”를 먼저 확인하는 캐시 계층이다. 수업에서는 query-context-response triple을 저장해 반복 retrieval과 generation을 줄이는 방식으로 소개했다. 나는 이걸 production RAG의 첫 번째 최적화 지점으로 본다.
CAG가 해결하려는 문제
RAG 시스템을 처음 만들면 보통 흐름은 단순하다. 사용자가 질문한다. 시스템이 embedding search를 한다. 관련 chunk를 prompt에 붙인다. LLM이 답한다. 이 구조는 이해하기 쉽고, private data를 연결하기 좋다. 수업 노트도 RAG를 “질문, 관련 문서 검색, LLM에 전달” 흐름으로 정리했다.
문제는 모든 질문이 매번 새롭지 않다는 점이다. 고객 지원, 사내 handbook, pricing FAQ, onboarding guide, product policy 같은 영역에서는 같은 질문이 계속 반복된다. 수업 예시는 password reset 질문이다. “How do I reset my password?” 같은 질문이 이미 수천 번 처리됐다면, 매번 vector search와 LLM generation을 다시 할 이유가 없다.
CAG의 기본 아이디어는 여기서 나온다. 이전에 본 user query, 사용한 context, 최종 response를 cache에 저장한다. 다음에 충분히 같은 질문이 들어오면 retrieval과 generation을 건너뛰고 quick response를 반환한다. 수업 자료도 CAG를 previously seen user query, query-context-response triples를 저장하는 lookup cache로 설명한다.
이건 화려한 새 패러다임이라기보다, 오래된 시스템 원칙을 LLM pipeline에 적용한 것이다. 자주 반복되는 계산은 저장한다. 변하지 않는 입력은 재사용한다. 비용이 큰 경로 앞에는 cheap check를 둔다. Lazy하게 말하면, RAG를 매번 돌리기 전에 cache hit부터 보자는 뜻이다.
RAG와 CAG의 실행 경로 차이
RAG의 기본 경로는 miss-first 구조다. 질문이 들어오면 먼저 검색한다. 검색 결과가 맞는지 틀리는지와 관계없이 retrieval 비용은 이미 냈다. 그 다음 LLM prefill과 decoding 비용도 낸다. 수업 노트는 “retrieval chunks are wrong이면 answer is wrong”이라고 적고, 이때 개선 지점이 retrieval이라고 정리했다.
CAG는 경로를 하나 추가한다.
User query
-> cache lookup
-> cache hit: return cached response
-> cache miss: run RAG
-> retrieve context
-> generate answer
-> store query-context-response
-> return response
중요한 차이는 “검색을 잘하자”가 아니라 “검색이 필요 없는 요청을 먼저 제거하자”다. 이건 RAG 품질 문제를 해결하지 않는다. 대신 RAG가 불필요하게 호출되는 비율을 줄인다. RAG가 틀린 chunk를 가져오는 문제는 여전히 retrieval contract, chunking, reranking, evaluation으로 해결해야 한다.
그래서 CAG는 RAG 대체재가 아니다. 더 정확히는 RAG 앞단의 cache gate다. cache가 맞으면 빠르게 답한다. cache가 애매하면 RAG로 내려보낸다. cache가 오래됐으면 폐기한다. 이 정도면 충분하다. “CAG platform” 같은 큰 이름을 붙이기 전에, hit/miss 정책부터 명확해야 한다.
두 종류의 CAG: response cache와 context cache
CAG라고 부를 때 실제로는 두 가지가 섞인다.
첫 번째는 response cache다. 질문과 답변을 저장한다. 같은 password reset 질문, 같은 return policy 질문, 같은 “invoice는 어디서 다운로드하나요?” 같은 요청에 바로 답한다. 수업 자료의 customer support chatbot 예시가 여기에 가깝다.
두 번째는 context cache다. 답변 자체를 저장하는 대신, 긴 system prompt나 static context를 모델 쪽 cache에 올려두고 이후 요청에서 재사용한다. Anthropic은 prompt prefixes를 cache해서 반복 task나 consistent prompt의 processing time과 cost를 줄일 수 있다고 설명한다 Anthropic prompt caching docs. Anthropic 문서는 automatic caching과 explicit cache breakpoints를 제공하고, 5-minute와 1-hour TTL을 언급한다 Anthropic prompt caching docs.
OpenAI의 prompt caching guide도 cache hit가 exact prompt prefix match에 의존한다고 설명한다. 그래서 static instructions와 examples를 prompt 앞쪽에 두고, variable content를 뒤쪽에 두는 구조가 중요하다 OpenAI prompt caching guide.
둘은 같은 문제가 아니다.
| 구분 | Response cache | Context or prompt cache |
|---|---|---|
| 저장하는 것 | 최종 답변 | 긴 prompt prefix 또는 static context |
| hit 조건 | 질문과 scope가 충분히 같음 | prompt prefix가 정확히 재사용됨 |
| 이득 | generation까지 생략 | prefill 비용과 latency 감소 |
| 위험 | stale answer, personalization leak | prefix 구조 실패, cache TTL 오해 |
| 예시 | password reset 답변 재사용 | handbook context를 반복 요청에 재사용 |
나는 production 설계에서 이 둘을 분리해서 본다. response cache는 application policy다. 어떤 답변을 누구에게 재사용해도 되는지 결정해야 한다. prompt cache는 model API optimization이다. prompt layout과 provider별 cache rule을 맞춰야 한다. 이름은 비슷하지만 ownership이 다르다.
언제 CAG를 쓰고, 언제 RAG를 그대로 둘까
CAG가 잘 맞는 요청은 네 가지 조건을 가진다.
첫째, 질문이 반복된다. Python 문서는 LRU cache가 recent calls가 upcoming calls를 잘 예측할 때 유용하다고 설명한다 Python functools docs. LLM 서비스도 같다. yesterday의 인기 질문이 today에도 반복되면 cache가 먹힌다.
둘째, 답이 안정적이다. “비밀번호 재설정은 어디서 하나요?”는 답이 며칠 동안 거의 변하지 않는다. “오늘 내 PTO balance가 얼마야?”는 사용자별, 날짜별, 정책별로 달라진다. 후자는 cache key가 복잡해지고 stale risk가 커진다.
셋째, 권한 scope가 단순하다. 공개 FAQ는 전역 cache가 가능하다. 사내 문서는 tenant cache가 필요하다. 개인 데이터는 user cache가 필요하다. 의료, 금융, HR처럼 민감한 영역은 cache 범위를 더 좁혀야 한다. HTTP caching도 private, no-store 같은 directive로 storage boundary를 구분한다 MDN HTTP caching guide.
넷째, freshness를 정의할 수 있다. Redis의 EXPIRE는 key expiration time을 설정하고 TTL로 남은 시간을 볼 수 있다 Redis EXPIRE docs. LLM response cache도 같은 질문을 해야 한다. 이 답변은 5분 뒤에도 맞나, 하루 뒤에도 맞나, policy 문서가 수정되면 즉시 폐기해야 하나.
반대로 다음 요청은 CAG에 안 맞는다.
- 매번 최신 데이터가 필요한 질문
- 사용자 권한에 따라 context가 달라지는 질문
- 계산이나 reasoning이 핵심인 질문
- 답변이 법적, 의료적, 재무적 책임을 가질 수 있는 질문
- 질문은 비슷하지만 의도가 자주 바뀌는 support escalation
수업 노트도 RAG가 facts를 가져오지만 reasoning이나 행동 방식을 바꾸지는 못한다고 정리했다. CAG도 마찬가지다. cache는 reasoning을 더 똑똑하게 만들지 않는다. 반복 비용을 줄일 뿐이다.
최소 구현: query-context-response cache
처음부터 vector similarity cache, semantic normalization, distributed invalidation을 만들 필요는 없다. 가장 작은 버전은 normalized query와 scope를 key로 삼고, TTL을 둔 in-memory cache로 충분하다. production에서는 Redis 같은 외부 저장소로 옮기면 된다. 먼저 hit rate가 있는지부터 확인하면 된다.
from dataclasses import dataclass
from time import monotonic
@dataclass
class CacheEntry:
response: str
context_hash: str
expires_at: float
class ResponseCache:
def __init__(self, ttl_seconds: int = 300):
self.ttl_seconds = ttl_seconds
self._items: dict[tuple[str, str], CacheEntry] = {}
def _key(self, tenant: str, query: str) -> tuple[str, str]:
normalized = " ".join(query.lower().split())
return tenant, normalized
def get(self, tenant: str, query: str, context_hash: str) -> str | None:
key = self._key(tenant, query)
entry = self._items.get(key)
if entry is None:
return None
if entry.expires_at < monotonic():
self._items.pop(key, None)
return None
if entry.context_hash != context_hash:
return None
return entry.response
def set(self, tenant: str, query: str, context_hash: str, response: str) -> None:
self._items[self._key(tenant, query)] = CacheEntry(
response=response,
context_hash=context_hash,
expires_at=monotonic() + self.ttl_seconds,
)
def answer(cache: ResponseCache, tenant: str, query: str, context_hash: str) -> str:
cached = cache.get(tenant, query, context_hash)
if cached is not None:
return cached
response = f"RAG answer for: {query}"
cache.set(tenant, query, context_hash, response)
return response
def demo() -> None:
cache = ResponseCache(ttl_seconds=60)
first = answer(cache, "acme", "How do I reset my password?", "handbook-v1")
second = answer(cache, "acme", "how do i reset my password?", "handbook-v1")
third = answer(cache, "acme", "how do i reset my password?", "handbook-v2")
assert first == second
assert third == "RAG answer for: how do i reset my password?"
if __name__ == "__main__":
demo()
이 코드는 일부러 작다. tenant를 key에 포함해서 조직 간 답변이 섞이지 않게 한다. query는 lower와 whitespace normalization만 한다. context_hash가 바뀌면 이전 답변을 재사용하지 않는다. TTL이 지나면 버린다. Python의 lru_cache도 cache_info()로 hits와 misses를 보여준다 Python functools docs. 직접 구현할 때도 같은 지표는 남겨야 한다.
여기서 바로 semantic cache를 만들고 싶어질 수 있다. 비슷한 질문이면 같은 답을 주는 방식이다. 하지만 그건 두 번째 단계다. “password reset”과 “admin password reset for terminated employee”는 단어가 비슷해도 policy가 다를 수 있다. 첫 버전은 exact-ish key로 시작하고, miss가 충분히 많을 때만 semantic layer를 붙이는 게 낫다.
Production에서 조심할 것
CAG의 어려운 부분은 cache 자료구조가 아니다. invalidation과 boundary다.
첫째, cache key에 scope를 넣어야 한다. tenant, user role, locale, product version, policy version, document hash 중 무엇이 답변을 바꾸는지 정해야 한다. 이걸 빼먹으면 cache hit가 아니라 data leak가 된다.
둘째, stale answer를 허용할지 정해야 한다. HTTP caching 표준은 freshness, revalidation, invalidation을 별도 개념으로 다룬다 RFC 9111. LLM 답변도 같다. policy 문서가 수정됐는데 cache가 그대로면, 시스템은 빠르게 틀린 답을 한다.
셋째, cache duration을 운영자가 조절할 수 있어야 한다. CDN과 HTTP cache는 Cache-Control, max-age, Expires, TTL 같은 장치로 duration을 조절한다 Cloudflare Cache-Control docs Amazon CloudFront expiration docs. CAG도 hardcoded forever cache면 안 된다.
넷째, hit rate를 먼저 봐야 한다. 반복 질문이 2퍼센트뿐이면 CAG는 큰 도움이 안 된다. 반대로 상위 20개 질문이 traffic의 40퍼센트를 차지하면 response cache 하나로 latency와 token cost가 크게 줄 수 있다. 이 숫자는 추측하지 말고 logs에서 봐야 한다.
다섯째, cacheable answer와 non-cacheable answer를 분리해야 한다. 공개 product FAQ는 cacheable이다. “내 계정의 billing 상태”는 user-scoped cache가 필요하다. “이 환자에게 어떤 처방이 맞나” 같은 질문은 답변 cache보다 evidence replay와 audit trail이 먼저다.
내가 CAG를 production에 넣는다면 가장 작은 순서는 이렇다.
- 상위 반복 질문을 로그에서 뽑는다.
- 공개 또는 tenant-safe 질문만 allowlist한다.
- query, tenant, document_version으로 exact cache를 만든다.
- TTL과 manual invalidation을 넣는다.
- hit rate, stale rejection, forced miss를 metric으로 남긴다.
- 그 다음에만 semantic cache를 검토한다.
이 정도면 충분하다. CAG는 새 framework가 아니라 운영 정책이다. cache가 답해도 되는 질문과 절대 답하면 안 되는 질문을 나누는 게 핵심이다.
운영 지표는 작게 시작한다
CAG를 넣고 나서 봐야 할 metric도 많을 필요가 없다. 처음에는 네 개면 된다. cache hit rate, forced miss rate, stale rejection count, 평균 latency다. hit rate는 CAG가 존재할 이유를 보여준다. forced miss는 cacheable로 보였지만 실제로는 RAG로 보내야 했던 요청을 보여준다. stale rejection은 context_hash나 document version 변경 때문에 버린 답변 수다. 평균 latency는 사용자 체감 이득이다.
비용 지표도 같이 보면 좋다. response cache가 hit되면 retrieval query, reranker call, LLM input token, output token이 모두 줄어든다. prompt cache만 hit되면 최종 generation은 여전히 실행되지만 static prefix 처리 비용이 줄어든다. Anthropic 문서도 prompt caching을 repetitive prompt의 processing time과 cost를 줄이는 기능으로 설명한다 Anthropic prompt caching docs. OpenAI 문서도 cached_tokens와 cache_write_tokens 같은 usage field로 cache behavior를 관찰할 수 있다고 설명한다 OpenAI prompt caching guide.
내 기준에서 첫 release의 성공 조건은 단순하다. cache가 없어도 시스템은 똑같이 맞아야 한다. cache가 있으면 더 싸고 빠르면 된다. 답변 품질을 cache에 의존하기 시작하면 설계가 거꾸로 간다. CAG는 quality layer가 아니라 cost and latency layer다.
그래서 rollout도 조용해야 한다. 먼저 shadow mode로 cache key와 would-hit만 기록한다. 실제 응답은 기존 RAG가 계속 담당한다. 일주일 정도 로그를 본 뒤, 안전한 FAQ 몇 개만 allowlist로 켠다. 이 단계에서 얻는 건 큰 아키텍처가 아니라 실패하지 않는 운영 감각이다.
FAQ
CAG는 RAG를 대체하나?
아니다. CAG는 RAG 앞단에서 cache hit를 먼저 확인하는 계층이다. miss가 나면 여전히 retrieval과 LLM generation을 실행한다. 수업 자료의 흐름도도 cache hit이면 quick response, miss이면 vector search와 LLM generation으로 내려가는 구조다.
CAG와 prompt caching은 같은가?
겹치지만 같지 않다. response CAG는 application이 최종 답변을 저장한다. prompt caching은 provider나 runtime이 prompt prefix 처리를 재사용한다. Anthropic은 prompt prefix 재사용으로 repetitive task의 processing time과 cost를 줄인다고 설명한다 Anthropic prompt caching docs. OpenAI는 exact prefix match가 cache hit에 중요하다고 설명한다 OpenAI prompt caching guide.
cache key에는 무엇을 넣어야 하나?
답변을 바꿀 수 있는 값을 넣어야 한다. 최소로는 normalized query, tenant, document version 또는 context hash가 필요하다. 개인화 답변이면 user id나 role도 들어간다. key가 작으면 잘못된 hit가 늘고, key가 크면 hit rate가 줄어든다.
stale answer는 어떻게 막나?
TTL, document version, manual invalidation을 같이 써야 한다. Redis처럼 expiration을 줄 수 있는 저장소는 기본 TTL 구현에 적합하다 Redis EXPIRE docs. 하지만 TTL만 믿으면 안 된다. 정책 문서가 바뀌는 순간 context_hash나 version을 바꿔 기존 cache를 자연스럽게 miss 처리해야 한다.