벡터 검색 시스템은 비용이 두 곳에서 새어 나갑니다. 하나는 문서를 임베딩으로 바꾸는 임베딩 API 호출 비용, 다른 하나는 그 벡터를 색인에 넣고 유지하는 인덱싱 연산 비용입니다. 코퍼스가 수십만 건을 넘고 그중 일부만 매일 바뀌는 상황이라면, 매번 전체를 다시 임베딩하고 인덱스를 통째로 재구축하는 방식은 금세 감당하기 어려운 비용이 됩니다.
이 글에서는 두 최적화를 다룹니다. 하나는 동일하거나 거의 동일한 텍스트를 반복 임베딩하지 않는 임베딩 캐시, 다른 하나는 바뀐 문서만 골라 색인에 반영하는 증분 인덱싱입니다. 두 전략은 독립적이지만 함께 쓰면 “바뀐 것만, 한 번만 임베딩한다”는 이상에 가까워집니다. 예시는 특정 프로바이더에 종속되지 않되 실제로 붙일 수 있는 코드로 설명합니다.
비용이 새는 곳을 먼저 계량하라
최적화에 앞서 어디서 얼마가 새는지부터 숫자로 잡아야 합니다. 벡터 검색 비용은 임베딩 생성(토큰 과금), 인덱스 구축·병합(CPU·I/O), 쿼리 시점 연산(임베딩 1회 + ANN 탐색)으로 나뉩니다. 많은 팀이 세 번째만 보지만 청구서를 키우는 것은 대개 앞의 둘이며, “매일 밤 전체 재색인” 크론잡은 코퍼스의 99%가 어제와 동일한데도 100%를 다시 임베딩합니다. 일일 신규·수정·삭제 비율(낮을수록 증분 효과가 큼)과 중복 텍스트 비율(높을수록 캐시 히트율이 오름)을 먼저 측정하세요.
임베딩 캐시의 키: 내용 해시 + 모델 버전
임베딩 캐시의 핵심은 키 설계입니다. 같은 텍스트를 같은 모델로 임베딩하면 (거의) 같은 벡터가 나오므로 정규화된 텍스트의 해시를 키로 삼습니다. 반드시 지켜야 할 불변식은 모델 식별자와 전처리 버전을 키에 포함하는 것입니다. 모델을 바꾸면 벡터 공간이 달라져, 옛 벡터를 재사용하면 검색 품질이 조용히 망가지기 때문입니다.
정규화도 신중해야 합니다. 과하면 의미가 다른 텍스트가 같은 키로 충돌하고, 전혀 안 하면 공백 차이로 히트율이 떨어집니다. 앞뒤 공백 제거와 연속 공백 축약 정도의 보수적 정규화가 무난합니다.
import hashlib
# 전처리 버전은 정규화 로직을 바꿀 때마다 올린다
PREPROC_VERSION = "v3"
def normalize(text: str) -> str:
# 앞뒤 공백 제거 + 연속 공백 1칸으로
return " ".join(text.split())
def embed_cache_key(text: str, model: str) -> str:
norm = normalize(text)
# 모델·전처리 버전을 키에 포함 → 모델 교체 시 자동으로 캐시 분리
material = f"{model}|{PREPROC_VERSION}|{norm}".encode()
return "emb:" + hashlib.sha256(material).hexdigest()
임베딩 모델은 부동소수점 연산 순서나 배치 구성에 따라 완전히 결정적이지 않을 수 있습니다. 하지만 목적은 같은 텍스트에 같은 캐시 벡터를 재사용하는 것이므로, 한 번 저장한 벡터를 그대로 쓰는 이상 이 비결정성은 문제가 되지 않습니다. 중요한 건 캐시의 일관성입니다.
캐시 저장소: 배치 조회로 왕복을 줄인다
문서가 수천 건이면 캐시를 한 건씩 조회하는 것은 낭비입니다. Redis의 MGET처럼 여러 키를 한 번에 조회해 히트/미스를 가른 뒤 미스만 모아 배치로 임베딩합니다. 벡터는 float32 바이트로 직렬화하면 JSON 대비 저장 공간과 파싱 비용이 크게 줄어듭니다.
import numpy as np
import redis
r = redis.Redis(host="localhost", port=6379, db=1)
def get_or_create_embeddings(texts, model, embed_fn):
keys = [embed_cache_key(t, model) for t in texts]
cached = r.mget(keys) # 배치 조회
result = [None] * len(texts)
miss_idx, miss_texts = [], []
for i, raw in enumerate(cached):
if raw is not None:
result[i] = np.frombuffer(raw, dtype=np.float32) # 캐시 히트
else:
miss_idx.append(i)
miss_texts.append(texts[i])
if miss_texts:
# 미스만 모아 한 번에 임베딩
new_vecs = embed_fn(miss_texts, model=model)
pipe = r.pipeline()
for j, i in enumerate(miss_idx):
vec = np.asarray(new_vecs[j], dtype=np.float32)
result[i] = vec
# 30일 TTL — 오래된 벡터는 자연 소멸
pipe.setex(keys[i], 60 * 60 * 24 * 30, vec.tobytes())
pipe.execute()
return result
여기서 캐시 히트율을 반드시 로깅하세요. len(miss_texts) / len(texts)가 미스율입니다. 이 값이 예상보다 높다면 정규화가 지나치게 엄격하거나, 텍스트에 타임스탬프 같은 동적 요소가 섞여 캐시가 무력화될 가능성이 큽니다.
증분 인덱싱: 무엇이 바뀌었는지 알아내기
증분 인덱싱의 출발점은 “어떤 문서가 신규·수정·삭제되었는가”를 아는 것입니다. 가장 견고한 방법은 각 문서의 내용 지문(content fingerprint)을 상태 저장소에 유지하고, 읽을 때마다 지문을 다시 계산해 이전 지문과 대조하는 것입니다.
def compute_fingerprint(doc: dict) -> str:
# 검색 대상 필드만 반영 (표시용 메타데이터 변경은 무시)
material = "|".join([
doc["title"].strip(),
doc["body"].strip(),
]).encode("utf-8")
return hashlib.sha256(material).hexdigest()
def diff_corpus(current_docs, prev_state: dict):
"""prev_state: {doc_id: fingerprint} 형태의 이전 스냅샷"""
added, updated, unchanged = [], [], []
seen = set()
for doc in current_docs:
seen.add(doc["id"])
fp = compute_fingerprint(doc)
old_fp = prev_state.get(doc["id"])
if old_fp is None:
added.append(doc)
elif old_fp != fp:
updated.append(doc) # 내용이 실제로 바뀐 것만
else:
unchanged.append(doc["id"]) # 재임베딩 생략
# 이전엔 있었으나 이번엔 없는 문서 = 삭제
deleted = [doc_id for doc_id in prev_state if doc_id not in seen]
return added, updated, deleted, unchanged
지문을 검색 대상 필드로만 계산하는 것이 핵심 요령입니다. 조회수·수정시각 같은 표시용 메타데이터가 바뀌었다고 벡터까지 다시 만들 이유는 없으므로, 임베딩에 영향을 주는 필드가 바뀐 경우에만 updated로 잡히게 지문 재료를 좁게 잡으세요.
삭제와 갱신: 벡터 인덱스의 아킬레스건
증분 인덱싱에서 가장 까다로운 부분이 삭제와 갱신입니다. HNSW 같은 그래프 기반 ANN 인덱스는 삭제에 약합니다. 노드를 물리적으로 떼어내면 연결성이 깨질 수 있어, 많은 구현이 소프트 삭제(tombstone)로 표시 후 재구축 때 정리합니다.
갱신은 “삭제 후 재삽입”으로 다룹니다. 옛 벡터를 삭제 표시하고 새 벡터를 삽입하되, 쿼리 시점에 옛 벡터가 섞이지 않게 필터링합니다. 이를 위해 인덱스와 별개로 doc_id → 유효 벡터 id 매핑을 유지합니다.
# 소프트 삭제 + 재삽입으로 갱신을 표현
class IncrementalIndex:
def __init__(self, ann):
self.ann = ann # HNSW 등 ANN 백엔드
self.active = {} # doc_id -> vector_id
self.tombstones = set() # 삭제 표시된 vector_id
self._next_id = 0
def upsert(self, doc_id, vector):
# 기존 벡터가 있으면 tombstone 처리
old = self.active.get(doc_id)
if old is not None:
self.tombstones.add(old)
vid = self._next_id
self._next_id += 1
self.ann.add(vid, vector)
self.active[doc_id] = vid
def delete(self, doc_id):
old = self.active.pop(doc_id, None)
if old is not None:
self.tombstones.add(old)
def dead_ratio(self):
# 임계치 초과 시 재구축
total = self._next_id
return len(self.tombstones) / total if total else 0.0
쿼리 시에는 ANN에서 넉넉히 후보를 뽑은 뒤 tombstones를 걸러 상위 K개를 반환합니다. 묘비가 쌓이면 후보 상당수가 버려져 실효 재현율(recall)이 떨어지므로, dead_ratio()가 임계치(예: 0.2)를 넘으면 살아 있는 벡터만으로 재구축합니다.
주기적 컴팩션: 증분과 전체 재구축의 균형
증분만 무한히 이어가면 묘비와 그래프 열화로 검색 품질이 나빠집니다. 그래서 로그 구조 저장소의 컴팩션(compaction) 같은 발상, 즉 평소엔 증분·일정 조건에서만 전체 재구축하는 이중 전략이 필요합니다. 트리거는 시간이 아니라 상태 기반이어야 낭비가 없습니다. 아래 중 하나라도 충족되면 재구축합니다.
- 묘비 비율이 임계치(예: 20%)를 초과.
- 증분 반영 누적 횟수가 N회 초과(그래프 열화).
- 모델·전처리 버전이 바뀌어 전체 재임베딩이 불가피할 때.
def nightly_index_job(source, prev_state, index, embed_fn, model):
docs = source.load_all()
added, updated, deleted, _ = diff_corpus(docs, prev_state)
# 신규 + 수정 문서만 임베딩 (캐시가 중복을 흡수)
to_embed = added + updated
texts = [f'{d["title"]}n{d["body"]}' for d in to_embed]
if texts:
vecs = get_or_create_embeddings(texts, model, embed_fn)
for d, v in zip(to_embed, vecs):
index.upsert(d["id"], v)
for doc_id in deleted:
index.delete(doc_id)
# 상태 스냅샷 갱신 (다음 실행 기준선)
new_state = {d["id"]: compute_fingerprint(d) for d in docs}
save_state(new_state)
# 상태 기반 재구축 판단
if index.dead_ratio() > 0.2:
index.rebuild_from_active()
return {"added": len(added), "updated": len(updated),
"deleted": len(deleted), "embedded": len(texts)}
정합성: 스냅샷과 인덱스가 어긋나지 않게
증분 파이프라인의 가장 위험한 실패 모드는 상태 스냅샷과 실제 인덱스의 불일치입니다. 색인은 성공했는데 상태 저장 직전 잡이 죽으면 같은 문서를 또 수정으로 인식해 중복 작업이 생기고, 반대로 상태만 갱신되고 색인이 실패하면 바뀐 문서가 영영 반영되지 않는 조용한 유실이 생깁니다. 원칙은 인덱스 반영을 먼저, 상태 저장을 마지막에 두는 것입니다. 그러면 최악의 경우 멱등한 중복 작업만 생길 뿐 유실은 막힙니다. 여기에 active 개수와 소스 문서 수를 대조하는 드리프트 알람을 붙이면 정합성 붕괴를 조기에 잡을 수 있습니다.
흔한 함정과 검증 포인트
마지막으로 반복적으로 사고를 부르는 지점입니다. 모두 “조용히” 품질을 떨어뜨려, 지표로 감시하지 않으면 알아채기 어렵습니다.
- 모델 교체 후 캐시 재사용: 키에 모델 식별자가 없으면 옛 벡터가 새 쿼리 임베딩과 뒤섞여 재현율이 급락합니다. 모델 변경은 곧 전체 재임베딩 신호입니다.
- 지문에 동적 필드 포함: 수정시각·조회수를 지문에 넣으면 안 바뀐 문서가 매번
updated로 잡혀 증분 효과가 사라집니다. - 묘비 누적 방치: 재구축 트리거가 없으면 삭제·갱신이 쌓여 실효 재현율이 무너집니다.
- 정규화 불일치: 일부 벡터만 L2 정규화하면 코사인 스코어 스케일이 어긋납니다. 저장 전 일관되게 적용하세요.
검증은 히트율, 델타 규모, 묘비 비율, 골든 쿼리 세트의 재현율 회귀 테스트 네 가지를 정기적으로 확인하면 충분합니다. 재구축 전후로 골든 쿼리의 상위 K 결과가 흔들리는지 비교하면 품질 저하를 조기에 감지할 수 있습니다.
마무리
벡터 검색 비용을 줄이는 두 레버는 명확합니다. 임베딩 캐시는 “같은 텍스트를 두 번 임베딩하지 않는다”는 원칙 위에 서고, 그 열쇠는 모델·전처리 버전을 포함한 결정적 캐시 키입니다. 증분 인덱싱은 “바뀐 것만 반영한다”는 원칙 위에 서고, 그 열쇠는 검색 대상 필드로만 계산한 지문과 소프트 삭제·컴팩션의 균형입니다. 둘을 함께 붙이면 매일 밤 전체를 다시 만들던 파이프라인이 “바뀐 것만, 한 번만” 처리하는 구조로 바뀝니다. 어느 쪽이든 조용히 실패하니, 지표 감시가 정확성의 마지막 안전망입니다.