왜 임베딩 재계산이 문제인가
RAG 파이프라인에서 문서를 벡터로 바꾸는 임베딩 호출은 지연과 비용의 대부분을 차지한다. 문서 한 건이 바뀌었을 뿐인데 전체 코퍼스를 다시 임베딩하면, 수십만 건 기준으로 API 비용과 인덱싱 시간이 선형으로 증가한다. 대부분의 문서는 배포 사이에 변하지 않으므로, 이 재계산의 90% 이상은 낭비다. 핵심은 "변한 것만 다시 계산"하는 증분 구조와, "같은 입력은 다시 부르지 않는" 캐시를 결합하는 것이다.
콘텐츠 해시 기반 캐시 키 설계
캐시 키를 문서 ID로 잡으면 내용이 바뀌어도 키가 같아 오래된 벡터를 반환한다. 반대로 임베딩 모델을 교체했는데 키에 모델명이 없으면 차원이 다른 벡터가 섞인다. 안전한 키는 정규화된 본문 해시 + 모델명 + 청킹 버전의 조합이다.
import hashlib
def cache_key(text: str, model: str, chunker_ver: str) -> str:
norm = " ".join(text.split()) # 공백 정규화
payload = f"{model}|{chunker_ver}|{norm}"
return hashlib.sha256(payload.encode()).hexdigest()
def embed_with_cache(texts, model, chunker_ver, store, client):
keys = [cache_key(t, model, chunker_ver) for t in texts]
cached = store.mget(keys) # 있는 것만 조회
miss_idx = [i for i, v in enumerate(cached) if v is None]
if miss_idx:
fresh = client.embed([texts[i] for i in miss_idx], model=model)
for j, i in enumerate(miss_idx):
store.set(keys[i], fresh[j])
cached[i] = fresh[j]
return cached
이 구조에서 모델 교체나 청킹 로직 변경은 자동으로 캐시 미스를 유발하므로, 수동 무효화 없이 안전하게 마이그레이션된다.
증분 인덱싱: 무엇이 바뀌었는지 추적
증분화의 전제는 "변경 감지"다. 소스에 updated_at이나 콘텐츠 해시 컬럼을 두고, 마지막 인덱싱 시점 이후 변경분만 뽑는다. 삭제된 문서를 인덱스에서 제거하는 것도 잊으면 안 된다.
-- 마지막 인덱싱 이후 변경/신규 문서
SELECT id, body, content_hash
FROM documents
WHERE updated_at > :last_indexed_at
OR content_hash NOT IN (SELECT content_hash FROM index_state);
-- 소스에서 사라진 문서 = 인덱스에서 삭제 대상
SELECT idx.doc_id
FROM index_state idx
LEFT JOIN documents d ON d.id = idx.doc_id
WHERE d.id IS NULL;
변경분만 임베딩하고, 벡터 DB에는 upsert로 반영한다. index_state 테이블에 문서별 해시와 인덱싱 시각을 기록해 다음 실행의 기준점으로 삼는다.
전체 재인덱싱 vs 증분 인덱싱
| 항목 | 전체 재인덱싱 | 증분 인덱싱 |
|---|---|---|
| 임베딩 호출량 | 전체 문서 수 | 변경분 + 신규분 |
| 구현 복잡도 | 낮음 | 변경 감지·삭제 처리 필요 |
| 모델 교체 대응 | 단순(전부 다시) | 키에 모델 포함 시 자동 |
| 적합한 규모 | 수천 건 이하 | 수만 건 이상 |
규모가 작다면 증분의 복잡도를 감수할 이유가 적다. 반대로 문서가 자주 갱신되고 코퍼스가 크면 증분이 사실상 필수다.
캐시 계층과 TTL 설정
임베딩은 결정적(같은 입력→같은 출력)이므로 캐시 TTL을 짧게 잡을 이유가 없다. 오히려 모델 버전이 키에 포함되면 영구 캐시에 가깝게 운영해도 된다. 다만 캐시 스토어 용량은 관리해야 한다.
cache:
backend: redis
key_prefix: emb:v3 # 청킹 버전 롤아웃 시 프리픽스 교체
ttl_seconds: 0 # 0 = 무만료, 모델명으로 세대 구분
max_memory_policy: allkeys-lru
embedding:
model: text-embedding-3-large
batch_size: 128 # 미스 배치 호출로 왕복 최소화
운영 시 주의점
- 청킹 변경도 캐시 무효화 대상이다. 청크 경계가 달라지면 텍스트 자체가 달라지므로 키에 청킹 버전을 반드시 포함한다.
- 부분 실패 처리: 배치 임베딩 중 일부만 성공하면, 성공분만 캐시에 커밋하고 실패분은 다음 실행에서 재시도되도록
index_state를 갱신하지 않는다. - 삭제 누락이 가장 흔한 버그다. 소스에서 지운 문서가 인덱스에 남아 검색 결과를 오염시킨다. 삭제 동기화를 증분 잡의 필수 단계로 넣는다.
- 재현성 검증: 배포 파이프라인에서 샘플 문서의 캐시 키가 이전과 동일한지 확인하면, 의도치 않은 대규모 재계산을 사전에 잡을 수 있다.
정리
콘텐츠 해시 캐시로 동일 입력의 재호출을 없애고, 변경 감지 기반 증분 인덱싱으로 재계산 범위를 좁히면, 비용과 지연을 함께 줄일 수 있다. 관건은 캐시 키에 모델·청킹 버전을 담아 무효화를 자동화하고, 삭제 동기화를 빠뜨리지 않는 것이다. 이 두 가지만 지키면 코퍼스가 커져도 인덱싱 비용은 변경량에 비례해 완만하게 늘어난다.