- Published on
Production RAG 벡터 DB 캐시 계층 설계와 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 RAG에서 병목은 대개 LLM이 아니라 retrieval입니다. 특히 벡터 DB는 kNN 검색 자체가 무겁고, 네트워크 왕복과 필터 평가, 재랭킹까지 붙으면 p95가 빠르게 튑니다. 트래픽이 늘면 단순 스케일 아웃보다 먼저 캐시 계층을 설계하는 편이 비용과 안정성에 훨씬 유리합니다.
이 글은 “벡터 DB 앞에 Redis 하나 붙이면 끝” 같은 접근이 아니라, 어떤 것을 캐시해야 효과가 나는지, 무효화와 일관성을 어떻게 잡는지, 운영에서 튜닝을 어떻게 반복하는지를 Production 기준으로 정리합니다.
관련해서 벡터 인덱스 자체 튜닝은 아래 글이 더 직접적입니다.
또한 RAG 운영에서 프롬프트/출력 안전장치가 필요하다면 아래도 같이 보세요.
Production RAG에서 캐시가 필요한 이유
RAG retrieval 경로를 단순화하면 다음과 같습니다.
- 사용자 질의 정규화(언어 감지, 전처리)
- 질의 임베딩 생성
- 벡터 DB
topK검색 + 메타데이터 필터 - (옵션) 재랭킹, 하이라이트, 스니펫 생성
- 컨텍스트 구성 후 LLM 호출
여기서 캐시가 먹히는 지점은 크게 네 가지입니다.
- 임베딩 캐시: 동일/유사 질의의 임베딩 재사용
- 검색 결과 캐시:
query_embedding + filter + topK조합의 결과 재사용 - 문서/청크 본문 캐시: ID로 본문을 가져오는 단계 가속
- 후처리 캐시: rerank 결과, 스니펫, 하이라이트 등 CPU 비용 절감
핵심은 “벡터 검색은 입력이 실수 벡터라 캐시가 어렵다”가 아니라, 캐시 키를 ‘벡터’가 아니라 ‘질의 표현’으로 잡는 방식을 채택하면 충분히 캐시가 됩니다.
캐시 계층의 목표를 수치로 정의하기
설계를 시작하기 전에 목표가 먼저입니다.
- p95 latency 목표: 예)
retrieval p95를350ms에서120ms로 - 비용 목표: 예) 벡터 DB QPS를
40%감소 - 신선도 목표: 예) 문서 업데이트 후
5분내 반영 - 정확도 목표: 캐시로 인한
recall@k하락을1%이내로
이 목표에 따라 캐시 TTL, 무효화 전략, 캐시할 대상이 달라집니다.
어떤 것을 캐시할 것인가: 4-레이어 모델
1) Query Normalization Cache
사용자 입력은 사소한 차이(공백, 대소문자, 구두점, 날짜 포맷)로 캐시 히트가 깨집니다. 따라서 캐시 키는 “원문 문자열”이 아니라 정규화된 표현이어야 합니다.
정규화 예시:
- 공백/개행 축약
- 유니코드 정규화
NFKC - 숫자/날짜 토큰화(선택)
- 언어별 stemming은 과하면 의미가 변할 수 있어 주의
정규화는 캐시 히트율과 직결됩니다.
2) Embedding Cache
임베딩 생성이 외부 API이거나 GPU를 쓰면 비용이 큽니다. 동일 질의가 반복되는 서비스(FAQ, 사내 검색, 고객센터)는 임베딩 캐시만으로도 큰 효과가 납니다.
- 키:
embedding_model_version + normalized_query - 값: 임베딩 벡터(예:
float32배열) - TTL: 보통 길게(예:
7d) 가능 - 무효화: 모델 버전이 바뀌면 키 프리픽스 변경으로 일괄 무효화
임베딩 벡터 저장은 메모리/네트워크 비용이 있으므로, Redis라면 byte 직렬화, 압축 여부를 검토합니다. float32를 그대로 넣기보다 float16로 저장해도 retrieval 품질이 거의 안 변하는 경우가 많습니다(서비스별 실험 필수).
3) Vector Search Result Cache
가장 큰 비용 절감 포인트입니다.
- 키 구성 요소
embedding_model_versionnormalized_query또는query_fingerprintfilter_fingerprint(예: 테넌트, 권한, 카테고리)topK,search_params(예:nprobe,efSearch)index_version또는corpus_version
- 값
doc_id,chunk_id,score,metadata_digest
여기서 중요한 점은 필터를 반드시 키에 포함해야 한다는 것입니다. 멀티테넌트에서 필터를 빼면 데이터 누출로 직결됩니다.
TTL은 짧게 시작합니다. 예: 30s~5m. 그리고 “신선도 요구사항”이 빡세면 TTL보다 버전 기반 무효화가 더 안전합니다.
4) Document/Chunk Payload Cache
검색 결과는 보통 ID 목록이고, 실제 LLM 컨텍스트를 만들려면 본문을 가져와야 합니다.
- 키:
chunk_id - 값: chunk 텍스트, 제목, URL, 권한 태그
- TTL: 중간(예:
1h~24h) - 무효화: 문서 업데이트 이벤트 기반 삭제가 이상적
이 캐시는 벡터 DB가 아니라 “문서 스토어”가 느릴 때 특히 효과가 큽니다.
캐시 키 설계: 충돌, 누출, 무효화를 동시에 잡기
캐시 키는 다음 원칙을 지키면 운영 사고가 크게 줄어듭니다.
- 버전 프리픽스:
v1:같은 고정 버전이 아니라,embedding_model=...,index=...를 포함 - 테넌트/권한 포함:
tenant_id,acl_hash는 필수 - 파라미터 포함:
topK,search_params가 다르면 결과 분포가 달라짐 - 길이 제한: 원문 질의가 길면 해시로 줄이기
예시(개념):
rag:srch:tenant=acme:model=e5-large-v3:index=kb_2026_02:topk=20:filter=9f2a:q=1c0b...
filter와 q는 sha256 같은 해시로 축약합니다.
무효화 전략: TTL만 믿으면 안 되는 경우
TTL 기반
가장 단순하고 안전합니다. 다만 다음 문제가 있습니다.
- 업데이트 직후에도 TTL 동안 오래된 결과가 나감
- TTL을 짧게 하면 히트율이 급격히 하락
초기에는 TTL로 시작하되, 운영이 안정되면 이벤트 기반을 섞는 게 일반적입니다.
버전 기반(권장)
문서 코퍼스나 인덱스를 재빌드할 때 corpus_version 또는 index_version을 올리고, 캐시 키에 포함합니다.
- 장점: 대규모 무효화를 “삭제” 없이 자연스럽게 처리
- 단점: 구버전 캐시가 TTL까지 메모리를 점유
운영에서는 버전 전환 시점에 old_version 프리픽스를 별도 스캔/삭제하거나, 버전 TTL을 짧게 가져가는 식으로 메모리를 관리합니다.
이벤트 기반(정교)
문서 업데이트가 발생하면 관련 캐시를 지웁니다.
chunk_idpayload 캐시는 비교적 쉽게 삭제 가능- 검색 결과 캐시는 “어떤 질의가 그 문서를 포함했는지” 역인덱스가 필요해 어려움
그래서 실무에서는 보통:
- payload 캐시는 이벤트 기반 삭제
- search result 캐시는 TTL + 버전 기반
조합이 가장 비용 대비 효과가 좋습니다.
캐시 계층 아키텍처 패턴
패턴 A: In-process LRU + Redis + Vector DB
가장 흔한 3단 구조입니다.
- 프로세스 내부 LRU: 초저지연, 짧은 TTL(예:
5s~30s) - Redis: 공유 캐시, 중간 TTL
- Vector DB: 소스 오브 트루스
프로세스 내부 LRU는 “동일 파드에 몰리는 순간 트래픽”에서 p95를 크게 낮춥니다.
패턴 B: 요청 코얼레싱(single-flight) 추가
캐시 미스가 동시에 터지면 벡터 DB에 스파이크가 갑니다. 동일 키에 대해 동시에 100개가 미스 나면 100번 검색합니다.
해결은 single-flight 또는 request coalescing입니다.
- 같은 키의 in-flight 요청은 하나만 벡터 DB를 호출
- 나머지는 그 결과를 기다렸다가 공유
패턴 C: Warmup/Precompute
트래픽이 예측 가능하거나 “인기 질의”가 명확하면, 배치로 미리 채웁니다.
- 상위 N개 질의의 임베딩/검색 결과를 주기적으로 갱신
- 릴리즈 직후 캐시 콜드 스타트 완화
튜닝 포인트: 히트율만 보면 실패한다
1) 캐시 히트율보다 중요한 것
- p95/p99 latency
- 벡터 DB QPS 감소율
- 오류율(타임아웃, 서킷 브레이커)
- 정확도(오프라인 평가)
히트율이 높아도 “잘못된 키 설계”로 인해 권한이 섞이면 바로 사고입니다. 반대로 히트율이 낮아도 p95가 크게 떨어지는 구간이 있습니다(특정 비싼 쿼리를 잘 잡는 경우).
2) Negative caching
결과가 없는 검색도 비용이 듭니다. “없음”을 짧게 캐시하면 반복 질의에서 큰 이득이 납니다.
- TTL은 짧게: 예)
10s~60s - 문서가 새로 들어오는 서비스라면 너무 길게 금지
3) Stale-while-revalidate
신선도와 지연을 동시에 잡는 패턴입니다.
- TTL이 만료되어도
stale_window동안은 일단 오래된 값을 반환 - 백그라운드에서 갱신
사용자 경험은 좋아지지만, “정확히 최신이어야 하는 검색”에는 맞지 않습니다.
4) 캐시 값 크기와 네트워크
벡터 검색 결과에 메타데이터를 과하게 담으면 Redis 네트워크가 병목이 됩니다.
권장:
- 캐시에는
chunk_id,score,doc_version_digest정도만 - 본문/메타데이터는 payload 캐시에서 별도로
5) 필터 fingerprint 튜닝
필터가 복잡하면 키 폭발이 납니다.
- 필터를 정규화(정렬, 기본값 채우기)
- 동치인 필터는 동일 fingerprint로
- “권한 필터”는 사용자 단위가 아니라 역할/그룹 단위 해시로 묶기(가능한 경우)
장애 시나리오와 방어
캐시 장애는 retrieval 장애로 번진다
Redis가 느려지거나 다운되면 retrieval 경로 전체가 느려질 수 있습니다.
방어:
- 캐시 타임아웃을 짧게: 예)
5ms~20ms - 캐시 실패 시 즉시 폴백(벡터 DB)
- 서킷 브레이커로 캐시를 잠시 우회
- 캐시 미스 폭주 시 rate limit
스탬피드(Stampede)
TTL이 동시에 만료되면 폭발합니다.
- TTL jitter: 만료 시간을 랜덤 분산
- single-flight
- 인기 키는 soft TTL + 백그라운드 갱신
운영 환경에서의 관측성
최소한 아래 메트릭은 있어야 튜닝이 가능합니다.
cache_hit_total{layer=...}/cache_miss_total{layer=...}cache_get_latency_ms{layer=...}vector_db_query_latency_msvector_db_qpsretrieval_latency_ms(end-to-end)stale_served_total
로그에는 반드시 cache_key_components를 그대로 남기기보다, hash와 version, tenant 정도만 남겨 개인정보/보안 리스크를 줄입니다.
예시 구현: Python에서 single-flight + Redis 캐시
아래 코드는 개념 예시입니다. Redis에 검색 결과를 캐시하고, 같은 키에 대한 동시 미스를 single-flight로 합칩니다.
import asyncio
import hashlib
import json
from typing import Any, Dict, List, Optional
import redis.asyncio as redis
def sha256_hex(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def normalize_query(q: str) -> str:
return " ".join(q.strip().split()).lower()
def make_search_cache_key(
tenant_id: str,
model_ver: str,
index_ver: str,
query: str,
filter_obj: Dict[str, Any],
topk: int,
search_params: Dict[str, Any],
) -> str:
nq = normalize_query(query)
filter_norm = json.dumps(filter_obj, sort_keys=True, separators=(",", ":"))
params_norm = json.dumps(search_params, sort_keys=True, separators=(",", ":"))
qh = sha256_hex(nq)
fh = sha256_hex(filter_norm)
ph = sha256_hex(params_norm)
return (
f"rag:srch:tenant={tenant_id}:model={model_ver}:index={index_ver}:"
f"topk={topk}:f={fh}:p={ph}:q={qh}"
)
class SingleFlight:
def __init__(self):
self._locks: Dict[str, asyncio.Lock] = {}
self._global = asyncio.Lock()
async def do(self, key: str, coro_factory):
async with self._global:
lock = self._locks.get(key)
if lock is None:
lock = asyncio.Lock()
self._locks[key] = lock
async with lock:
return await coro_factory()
async def vector_db_search(
embedding: List[float],
filter_obj: Dict[str, Any],
topk: int,
search_params: Dict[str, Any],
) -> List[Dict[str, Any]]:
# 실제 구현에서는 Milvus/Qdrant/PGVector 등 호출
await asyncio.sleep(0.03)
return [
{"chunk_id": "c1", "score": 0.91},
{"chunk_id": "c2", "score": 0.88},
][:topk]
async def get_search_results(
r: redis.Redis,
sf: SingleFlight,
cache_key: str,
embedding: List[float],
filter_obj: Dict[str, Any],
topk: int,
search_params: Dict[str, Any],
ttl_sec: int = 120,
) -> List[Dict[str, Any]]:
cached = await r.get(cache_key)
if cached:
return json.loads(cached)
async def compute_and_set():
# double-check: lock 대기 중 누군가 채웠을 수 있음
cached2 = await r.get(cache_key)
if cached2:
return json.loads(cached2)
results = await vector_db_search(embedding, filter_obj, topk, search_params)
await r.set(cache_key, json.dumps(results), ex=ttl_sec)
return results
return await sf.do(cache_key, compute_and_set)
포인트는 다음과 같습니다.
- 캐시 키는
tenant,model_ver,index_ver,filter,params,topk를 모두 포함 single-flight로 동시 미스를 합침- lock 내부에서
double-check로 중복 계산 방지
Redis 튜닝 체크리스트(벡터 DB 캐시 관점)
- 값은 가능한 작게: 결과는 ID+score 중심
- TTL jitter: 예)
ttl = base_ttl + rand(0, base_ttl * 0.1) - eviction 정책:
allkeys-lfu가 검색 캐시에 잘 맞는 경우가 많음(워크로드에 따라 검증) - 파이프라이닝/멀티겟: payload 캐시에서
MGET로 chunk 본문을 묶어 가져오기 - 핫키 방지: 인기 질의가 과도하게 몰리면 single-flight + local LRU로 완화
벡터 DB 튜닝과 캐시의 경계
캐시로 해결하려다 “원래 벡터 DB가 너무 느린데 캐시로 숨기는” 상황이 생길 수 있습니다. 다음은 캐시가 아니라 인덱스/검색 파라미터를 먼저 봐야 하는 신호입니다.
- 캐시 미스 시 p95가 과도하게 큼(예:
1s이상) - topK를 줄여도 큰 차이가 없음(필터가 병목일 가능성)
- 특정 필터 조합에서만 급격히 느림
이 경우 인덱스 타입(HNSW, IVF 계열), 검색 파라미터(예: efSearch, nprobe), 필터 전략(프리필터 vs 포스트필터)을 함께 튜닝해야 합니다. 자세한 비교는 위의 Milvus 글을 참고하세요.
운영에서 자주 하는 실수 7가지
- 캐시 키에
tenant_id또는acl을 빼서 데이터가 섞임 topK나 검색 파라미터를 키에 포함하지 않아 품질이 흔들림- TTL을 너무 길게 잡아 업데이트 반영이 늦어짐
- negative caching을 안 해서 “없음” 질의가 벡터 DB를 두드림
- 캐시 타임아웃을 길게 둬서 Redis 지연이 전체 지연으로 전파
- payload까지 한 키에 크게 넣어 네트워크가 병목
- 히트율만 보고 성공이라 착각하고, p95와 정확도 평가를 안 함
정리: 추천 시작점
처음 Production RAG에 캐시를 넣는다면 다음 순서가 재현성이 좋습니다.
normalized_query기반 임베딩 캐시- 검색 결과 캐시(짧은 TTL) + single-flight
- payload 캐시 +
MGET최적화 - TTL jitter, negative caching, stale-while-revalidate는 트래픽 패턴을 본 뒤 단계적으로
- 코퍼스/인덱스 버전 키로 무효화 체계를 고도화
캐시는 “정답을 빠르게”가 아니라 “정답을 안정적으로” 제공하기 위한 계층입니다. 특히 RAG는 필터/권한/버전이 조금만 꼬여도 품질 문제가 아니라 보안 사고가 되므로, 키 설계와 무효화 전략을 먼저 단단히 잡고 튜닝을 반복하는 것이 가장 안전합니다.