- Published on
LangChain RAG 캐시로 LLM 비용 70% 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 RAG(Retrieval-Augmented Generation)를 붙이면 정확도는 올라가지만, 트래픽이 늘수록 비용이 가파르게 증가합니다. 특히 같은 질문이 반복되거나(FAQ), 비슷한 질문이 변형되어 들어오거나(동의어/문장 재구성), 동일 문서 집합에서 매번 임베딩/리트리벌을 반복하는 경우가 많습니다.
이 글은 LangChain 기반 RAG에서 캐시를 어디에, 어떤 키로, 어떤 TTL로 넣어야 실제로 비용이 줄어드는지(현업 기준) 정리합니다. 목표는 “캐시 적중률이 곧 비용 절감”이 아니라, 비싼 단계(임베딩/LLM 호출)부터 구조적으로 줄이는 것입니다.
비용이 새는 지점: RAG는 3번 돈이 든다
RAG 요청 1건을 단순화하면 보통 아래 흐름입니다.
- 질의 전처리(정규화, 언어 감지, 라우팅)
- 질의 임베딩 생성(Embedding API 비용)
- 벡터 검색(리트리벌 비용: DB/네트워크/CPU)
- 컨텍스트 구성(상위
k문서, rerank, 압축) - LLM 생성(토큰 비용이 가장 큼)
여기서 비용의 “큰 덩어리”는 보통 2번과 5번입니다. 3번도 트래픽이 올라가면 인프라 비용과 레이턴시로 체감이 커집니다.
캐시는 그래서 단일 지점이 아니라 계층형으로 접근해야 합니다.
- L1: 최종 답변 캐시(가장 큰 비용 절감, 가장 높은 리스크)
- L2: 리트리벌 결과 캐시(정확도 리스크 낮고 효과 큼)
- L3: 임베딩 캐시(반복 질의가 많을수록 강력)
- L4: 문서 임베딩/청크 캐시(인덱싱 파이프라인 비용 절감)
캐시 전략 0) “질문 문자열” 그대로 캐시하면 실패한다
처음엔 단순히 question 문자열을 키로 캐시하고 싶어집니다. 하지만 실제 트래픽에서는 아래 때문에 적중률이 급락합니다.
- 공백/개행/특수문자 차이
- 날짜/아이디/주문번호 등 가변 토큰
- 존댓말/반말/동의어
- “요약해줘”, “3줄로”, “표로” 같은 포맷 요구
따라서 최소한 정규화(normalization) 와 의도(intent) 분리가 필요합니다.
예) 캐시 키에 포함할 것
- 정규화된 질문 텍스트
- 테넌트/프로덕트/권한 스코프
- 사용한 모델명
- 프롬프트 버전
- 리트리벌 설정(
k, 필터, 컬렉션) - 지식베이스 버전(문서 업데이트 시 캐시 무효화)
아래 예시처럼 키를 “구조화”하면 운영이 쉬워집니다.
import crypto from "crypto";
type CacheKeyInput = {
tenantId: string;
model: string;
promptVersion: string;
kbVersion: string;
retrieval: { k: number; namespace: string; filterHash: string };
normalizedQuestion: string;
};
export function makeCacheKey(input: CacheKeyInput) {
const raw = JSON.stringify(input);
const hash = crypto.createHash("sha256").update(raw).digest("hex");
return `rag:v1:${hash}`;
}
MDX 렌더링에서 부등호 문자가 본문에 노출되면 빌드 에러가 날 수 있으니, 위처럼 코드는 코드블록 안에만 두는 습관이 안전합니다.
1) LangChain 캐시: 답변 캐시(LLM 호출 캐시)
LangChain에는 LLM 호출 결과를 캐싱하는 기능이 있습니다. 핵심은 “같은 입력이면 같은 출력”이 성립할 때만 안전하다는 점입니다.
언제 답변 캐시가 잘 먹히나
- FAQ/헬프센터/정책 안내처럼 답이 비교적 고정
- 고객센터 매크로형 챗봇
- 내부 위키 QnA(문서 업데이트 주기가 길고, 최신성 요구가 낮음)
언제 위험한가
- 시시각각 바뀌는 데이터(재고, 가격, 상태)
- 사용자별 권한/개인정보가 답에 섞이는 경우
- “오늘”, “이번 주” 같은 상대 시간 표현이 많은 경우
LangChain LLM 캐시 예시(메모리/Redis)
아래는 개념 예시입니다. 실제 프로덕션은 Redis 같은 외부 캐시를 권장합니다.
import { setGlobalCache } from "@langchain/core/caches";
import { InMemoryCache } from "@langchain/core/caches";
setGlobalCache(new InMemoryCache());
// 이후 생성되는 LLM 호출이 캐시될 수 있습니다.
Redis 캐시는 보통 팀 표준 인프라를 사용하므로, “키 설계”와 “무효화”가 더 중요합니다. 답변 캐시는 반드시 아래를 키에 포함하세요.
- 프롬프트 템플릿 버전
- 시스템 메시지 버전
- 모델/온도/탑P
- RAG 컨텍스트(혹은 컨텍스트 해시)
컨텍스트를 키에 넣지 않으면, 문서가 바뀌어도 이전 답이 그대로 재사용되는 사고가 납니다.
2) 리트리벌 캐시: 가장 추천하는 ‘안전한 절감’
비용 70% 절감은 보통 “답변 캐시만”으로는 불안정합니다. 대신 리트리벌 결과 캐시 + 생성 토큰 절감을 같이 하면 안정적으로 내려갑니다.
리트리벌 캐시는 아래를 저장합니다.
- 정규화된 질문에 대한 상위 문서 ID 리스트
- 각 문서의 스코어
- (선택) 문서 스니펫/메타데이터
이렇게 하면 다음 요청에서 벡터 DB를 다시 때리지 않고, 동일 문서 집합을 빠르게 재구성할 수 있습니다.
리트리벌 캐시 키
tenantIdkbVersionnormalizedQuestion혹은questionEmbeddingHashnamespacefilterHashk
리트리벌 캐시 구현 예시(의사 코드)
type RetrievalHit = { docId: string; score: number };
type RetrievalCacheValue = {
hits: RetrievalHit[];
createdAt: number;
};
async function getRetrievalHitsWithCache(params: {
cacheKey: string;
ttlSec: number;
retrieve: () => Promise<RetrievalHit[]>;
cacheGet: (k: string) => Promise<string | null>;
cacheSet: (k: string, v: string, ttlSec: number) => Promise<void>;
}) {
const cached = await params.cacheGet(params.cacheKey);
if (cached) return JSON.parse(cached) as RetrievalCacheValue;
const hits = await params.retrieve();
const value: RetrievalCacheValue = { hits, createdAt: Date.now() };
await params.cacheSet(params.cacheKey, JSON.stringify(value), params.ttlSec);
return value;
}
리트리벌 캐시의 장점은 “최종 답”이 아니라 “근거 후보”만 고정한다는 점입니다. 문서 후보가 같아도, LLM 생성 단계에서 사용자 컨텍스트나 최신 지침을 반영해 답을 다르게 만들 수 있어 리스크가 낮습니다.
3) 임베딩 캐시: 쿼리 임베딩 비용을 깎는 확실한 방법
질의 임베딩은 요청당 1회라서 작아 보이지만, 트래픽이 커지면 무시하기 어렵습니다. 그리고 무엇보다 임베딩 API 호출은 레이턴시도 누적됩니다.
임베딩 캐시는 아래에서 효과가 큽니다.
- 동일 질문 반복(“비밀번호 바꾸는 법”, “환불 정책”)
- 템플릿 기반 질문(앱 내 도움말 버튼)
- 검색어 자동완성/추천이 붙은 UI
키는 보통 normalizedQuestion + embeddingModel + kbVersion(선택) 정도로 충분합니다. 임베딩은 지식베이스 버전과 직접 연관은 없지만, 운영 정책상 묶어두면 무효화가 쉬워집니다.
type EmbeddingCacheValue = {
vector: number[];
createdAt: number;
};
async function embedWithCache(params: {
cacheKey: string;
ttlSec: number;
embed: () => Promise<number[]>;
cacheGet: (k: string) => Promise<string | null>;
cacheSet: (k: string, v: string, ttlSec: number) => Promise<void>;
}) {
const cached = await params.cacheGet(params.cacheKey);
if (cached) return (JSON.parse(cached) as EmbeddingCacheValue).vector;
const vector = await params.embed();
await params.cacheSet(
params.cacheKey,
JSON.stringify({ vector, createdAt: Date.now() }),
params.ttlSec
);
return vector;
}
4) 문서/청크 캐시: 인덱싱 비용과 재처리 시간을 줄인다
운영에서 자주 발생하는 비용 폭탄은 “문서가 자주 업데이트되어 재인덱싱이 잦은데, 매번 동일 청크를 다시 만들고 다시 임베딩하는” 상황입니다.
- 문서 원문
contentHash기반으로 청크 결과 캐시 - 청크 텍스트
chunkHash기반으로 임베딩 캐시
이렇게 하면 문서가 일부만 바뀌어도 바뀐 청크만 재임베딩하면 됩니다.
import hashlib
def sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
# content_hash = sha256(document_text)
# chunk_hash = sha256(chunk_text)
5) 캐시 무효화: kbVersion 하나로 운영 난이도 절반 감소
캐시를 붙이면 다음 질문이 생깁니다.
- 문서가 업데이트되면 캐시를 어떻게 지울 것인가?
- 프롬프트를 개선하면 이전 캐시가 섞이지 않는가?
정답은 “정교한 삭제”보다 버전 키로 분리하는 것입니다.
kbVersion: 지식베이스 스냅샷 버전(예: 배포 시각, 인덱스 빌드 번호)promptVersion: 프롬프트/시스템 메시지 변경 시 증가
캐시 키에 이 값을 넣으면, 업데이트 후에는 자연스럽게 “새 키 공간”으로 이동합니다. 오래된 키는 TTL로 자연 소멸시키면 됩니다.
6) 비용 70% 절감이 나오는 조합(현실적인 시나리오)
다음은 많은 팀에서 재현 가능한 조합입니다.
- 리트리벌 캐시로 벡터 DB 호출을 40~80% 절감
- 컨텍스트 압축(Top
k축소, 중복 제거, 요약)으로 프롬프트 토큰 20~50% 절감 - 답변 캐시는 “FAQ 라우트”에만 제한 적용하여 추가 절감
특히 “전체 트래픽 중 30%는 FAQ/정책 문의” 같은 구조라면, 그 구간에만 답변 캐시를 강하게 걸어도 전체 비용이 크게 내려갑니다.
여기서 중요한 포인트는 캐시는 비용 절감 도구이지만, 동시에 정합성 리스크 도구라는 점입니다. 그래서 트래픽을 라우팅해서 “캐시 가능한 요청”을 분리하는 게 효과적입니다.
7) 운영 체크리스트: 캐시 도입 후 꼭 봐야 할 지표
캐시를 붙였는데 비용이 안 줄었다면, 대부분 관측이 부족합니다.
- 캐시 적중률(hit rate): LLM/리트리벌/임베딩 각각
- 토큰 사용량: 프롬프트 토큰과 출력 토큰 분리
- 상위 질문 Top N: 반복 질문 비중
- kbVersion별 오류율: 업데이트 직후 품질 저하 감지
- 레이턴시 p95/p99: 캐시가 빨라도 직렬화/네트워크가 병목일 수 있음
또한 캐시 계층이 Redis 같은 외부 시스템이면 커넥션 관리가 중요합니다. 캐시 도입 후 갑자기 DB나 Redis 커넥션이 늘어 장애가 나는 경우가 있는데, 커넥션 고갈 패턴은 PostgreSQL에서도 흔합니다. 관련 진단 감각은 아래 글이 도움이 됩니다.
8) 배포/보안 관점: 캐시 인프라도 “키 없이” 굴릴 수 있다
프로덕션에서 Redis(ElastiCache 등)나 벡터 DB를 붙이면 CI/CD가 곧바로 민감해집니다. 가능하면 장기 액세스 키를 CI에 박아두기보다 OIDC로 단기 자격증명을 쓰는 구성이 안전합니다.
9) 장애 대응: 캐시 때문에 죽지 않게 만들기
캐시는 “없으면 느리지만, 있으면 빠른” 보조 장치여야 합니다. 캐시 장애가 곧 서비스 장애가 되면 안 됩니다.
권장 패턴:
- 캐시 조회 실패 시 원본 경로로 폴백
- 캐시 쓰기 실패는 무시(로그/메트릭만)
- 타임아웃 짧게(예: 20~50ms) 두고 과감히 폴백
- 서킷 브레이커로 캐시 장애 시 자동 우회
컨테이너 환경에서는 캐시/벡터DB 클라이언트 설정 문제로 프로세스가 반복 재시작되기도 합니다. 운영 중 파드가 계속 뜨고 죽는다면 아래 글의 진단 루틴이 유용합니다.
10) 실전 예시: “FAQ는 답변 캐시, 나머지는 리트리벌 캐시” 라우팅
아래는 간단한 라우팅 아이디어입니다.
- 질문이 FAQ 카테고리로 분류되면 답변 캐시 허용
- 그 외에는 리트리벌 캐시만 적용하고, 답변은 매번 생성
type Route = "FAQ" | "GENERAL";
function classify(question: string): Route {
// 실제로는 키워드+룰, 혹은 소형 분류 모델을 사용
const q = question.toLowerCase();
if (q.includes("환불") || q.includes("비밀번호") || q.includes("정책")) return "FAQ";
return "GENERAL";
}
async function handle(question: string) {
const route = classify(question);
// 1) 임베딩/리트리벌 캐시
// 2) route가 FAQ일 때만 최종 답변 캐시
// 3) kbVersion/promptVersion으로 무효화
}
이 구조는 “정합성이 중요한 영역”을 보호하면서도, 반복 트래픽이 많은 영역에서 비용을 크게 깎습니다.
마무리: 캐시는 기능이 아니라 제품 설계다
LangChain RAG에서 비용 70% 절감은 과장이 아닙니다. 다만 그 성패는 “캐시를 켰다”가 아니라 아래 3가지에 달려 있습니다.
- 어디를 캐시할지: 답변만이 아니라 리트리벌/임베딩까지 계층화
- 키를 어떻게 설계할지:
kbVersion,promptVersion, 스코프를 반드시 포함 - 무효화를 어떻게 단순화할지: 삭제보다 버전 분리 + TTL
먼저 리트리벌 캐시부터 넣고, 그 다음 토큰 절감(컨텍스트 압축), 마지막으로 제한된 범위에서 답변 캐시를 적용하면 안전하게 비용을 내려갈 수 있습니다.