Published on

Milvus 임베딩 드리프트 탐지와 재색인 자동화

Authors

서빙 중인 RAG 시스템에서 검색 품질이 서서히 나빠지는 경우가 있습니다. 로그를 보면 인덱스나 Milvus 장애가 아니라, 임베딩 자체가 바뀌면서(모델 교체, 토크나이저 변경, 정규화 정책 변경, 전처리 규칙 변경) 기존 벡터 분포와 검색 공간이 달라지는 임베딩 드리프트(embedding drift) 가 원인인 경우가 많습니다.

Milvus는 벡터를 잘 저장하고 빠르게 검색해주지만, 임베딩 품질 변화와 재색인(reindex) 운영 은 애플리케이션이 책임져야 합니다. 이 글에서는 다음을 목표로 합니다.

  • 드리프트를 수치로 탐지 하고 경보를 건다
  • 드리프트가 확인되면 영향 범위를 격리 하고
  • Milvus에서 재색인을 자동화 하며
  • 무중단에 가깝게 스왑 및 롤백 할 수 있게 만든다

관련해서 HNSW 튜닝으로 리콜이 흔들리는 상황도 함께 점검하는 것이 좋습니다. 인덱스 파라미터 변화와 드리프트를 혼동하기 쉽기 때문입니다. 필요하면 RAG 리콜 급락? HNSW 파라미터 튜닝 가이드도 같이 보세요.

임베딩 드리프트가 생기는 대표 시나리오

드리프트는 단순히 모델 버전 업뿐 아니라 “임베딩을 만드는 파이프라인” 의 작은 변화로도 발생합니다.

  • 임베딩 모델 교체 또는 버전 변경(예: text-embedding-3-small 에서 text-embedding-3-large 로 변경)
  • 차원 변경(예: 768에서 1536으로 변경)
  • 정규화 변경(예: L2 normalize 적용 여부)
  • 청크 정책 변경(문장 단위에서 토큰 단위, overlap 변경)
  • 언어 감지/정규화/HTML 제거 등 전처리 변경
  • 멀티모달 확장(텍스트 전용에서 이미지 캡션 포함 등)

문제는 “바뀐 임베딩” 과 “기존 인덱스/데이터” 가 섞이는 순간입니다. 동일 쿼리에서 최근 문서만 잘 나오거나, 반대로 오래된 문서만 나오거나, 특정 도메인만 리콜이 급락하는 형태로 나타납니다.

드리프트 탐지 지표: 온라인 + 오프라인의 조합

드리프트 탐지는 한 가지 지표로 끝내기 어렵습니다. 운영에서는 보통 아래 3종을 조합합니다.

1) 온라인 품질 지표(가장 현실적인 조기 신호)

  • top_k 내 클릭률(CTR), 답변 채택률
  • 쿼리당 재질문율, “찾는 내용이 없음” 피드백 비율
  • RAG 답변의 근거 문서 중복률(다양성 저하)

다만 온라인 지표는 사용자 행동과 UI 영향도 커서, 임베딩 드리프트만의 신호로 쓰기엔 노이즈가 있습니다.

2) 분포 기반 통계(임베딩 자체의 변화 감지)

임베딩 벡터의 분포가 변하면 다음 값들이 변합니다.

  • 벡터 L2 norm의 평균/분산(정규화 정책 변경에 특히 민감)
  • 임베딩의 평균 벡터(centroid) 변화량
  • 샘플 간 코사인 유사도 분포(문서끼리 더 비슷해지거나 더 멀어짐)

이 방식은 라벨이 없어도 가능하고, “모델이 바뀌었다” 를 빠르게 감지할 수 있습니다.

3) 앵커 기반 회귀 테스트(가장 신뢰도 높은 품질 검증)

운영 환경에서 추천하는 방법은 앵커 쿼리 세트 를 유지하는 것입니다.

  • 도메인 대표 쿼리 200~2000개
  • 각 쿼리의 기대 문서(또는 문서군) ID를 약하게라도 보유
  • 주기적으로 검색 후 Recall@k, MRR@k, nDCG@k 를 계산

라벨이 완벽하지 않아도 “이 쿼리는 최소한 이 문서가 상위에 있어야 한다” 수준의 약한 라벨만으로도 드리프트 감지에 큰 도움이 됩니다.

Milvus에서 드리프트를 안전하게 처리하는 핵심 설계

Milvus 관점에서 드리프트 대응은 사실상 버전 분리와 스왑 입니다.

컬렉션/파티션 버전 전략

  • 컬렉션을 임베딩 버전 단위로 분리: 예) docs_v1, docs_v2
  • 또는 단일 컬렉션 + 파티션 분리: 예) docs 컬렉션에 emb_v1, emb_v2 파티션

운영 난이도와 안전성은 보통 “컬렉션 분리” 가 낫습니다.

  • 차원 변경이 생기면 같은 컬렉션에 공존 불가(스키마가 달라짐)
  • 인덱스 파라미터를 버전별로 독립 운영 가능
  • 롤백이 쉽다(라우팅만 되돌리면 됨)

메타데이터에 반드시 넣어야 할 필드

문서 벡터만 넣으면 나중에 재색인이 어렵습니다. 아래를 강력 추천합니다.

  • doc_id: 원문 문서 식별자
  • chunk_id: 청크 식별자
  • source_hash: 원문 또는 청크의 해시(변경 감지)
  • embedding_version: 임베딩 파이프라인 버전
  • created_at, updated_at

이 중 source_hash 는 “무엇을 재색인해야 하는지” 를 결정하는 키가 됩니다.

드리프트 탐지 파이프라인 구현 예시(Python)

아래 예시는 다음을 수행합니다.

  • 최근 N개 문서 임베딩 샘플을 수집
  • norm 분포와 centroid shift를 계산
  • 임계치 초과 시 경보 이벤트를 만든다
import numpy as np

def l2_norms(vectors: np.ndarray) -> np.ndarray:
    return np.linalg.norm(vectors, axis=1)

def centroid(vectors: np.ndarray) -> np.ndarray:
    return np.mean(vectors, axis=0)

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    denom = (np.linalg.norm(a) * np.linalg.norm(b))
    if denom == 0:
        return 0.0
    return float(np.dot(a, b) / denom)

def drift_score(prev_vectors: np.ndarray, curr_vectors: np.ndarray) -> dict:
    prev_c = centroid(prev_vectors)
    curr_c = centroid(curr_vectors)

    prev_norm = l2_norms(prev_vectors)
    curr_norm = l2_norms(curr_vectors)

    return {
        "centroid_cosine": cosine(prev_c, curr_c),
        "prev_norm_mean": float(prev_norm.mean()),
        "curr_norm_mean": float(curr_norm.mean()),
        "prev_norm_std": float(prev_norm.std()),
        "curr_norm_std": float(curr_norm.std()),
    }

def is_drifted(score: dict,
               centroid_cosine_threshold: float = 0.98,
               norm_mean_delta_threshold: float = 0.15) -> bool:
    centroid_ok = score["centroid_cosine"] >= centroid_cosine_threshold
    norm_delta = abs(score["curr_norm_mean"] - score["prev_norm_mean"])
    norm_ok = norm_delta <= norm_mean_delta_threshold
    return not (centroid_ok and norm_ok)
  • centroid_cosine 이 갑자기 낮아지면 분포 중심이 이동한 것입니다.
  • norm 평균/표준편차 변화는 정규화 정책 변경이나 모델 특성 변화에 민감합니다.

이 지표는 “드리프트 의심” 을 빠르게 잡는 용도입니다. 실제 재색인 여부는 아래의 앵커 기반 회귀 테스트로 확정하는 것이 안전합니다.

앵커 기반 회귀 테스트: Milvus 검색 결과로 품질을 수치화

Milvus에서 동일 쿼리를 docs_v1docs_v2 에 각각 날려서 비교하면, 재색인의 효과와 드리프트 영향도를 동시에 볼 수 있습니다.

from pymilvus import connections, Collection
import numpy as np

connections.connect(alias="default", host="milvus", port="19530")

def search(collection_name: str, query_vec: list[float], top_k: int = 10):
    col = Collection(collection_name)
    col.load()
    res = col.search(
        data=[query_vec],
        anns_field="embedding",
        param={"metric_type": "COSINE", "params": {"ef": 128}},
        limit=top_k,
        output_fields=["doc_id", "chunk_id", "source_hash"],
    )
    return res[0]

def recall_at_k(result, expected_doc_ids: set[str]) -> float:
    hit = 0
    for hit_item in result:
        if str(hit_item.entity.get("doc_id")) in expected_doc_ids:
            hit = 1
            break
    return float(hit)
  • expected_doc_ids 는 쿼리별로 최소 1개라도 있으면 충분합니다.
  • 앵커 세트 전체에 대해 평균 Recall@k 를 내고, 이전 대비 하락 폭이 임계치 이상이면 재색인 트리거를 걸 수 있습니다.

재색인 자동화의 운영 패턴: 듀얼 컬렉션 + 백필 + 스왑

재색인을 “한 번에 갈아엎기” 로 접근하면 다운타임과 리스크가 커집니다. 추천 패턴은 아래입니다.

1) 새 임베딩 버전용 컬렉션 생성

  • 예: docs_v2
  • 스키마는 차원, 필드 구성, 인덱스 타입을 새 버전에 맞춘다

2) 백필 작업(Backfill)로 과거 데이터 재임베딩

  • 원문 저장소에서 문서/청크를 읽는다
  • 새 임베딩을 생성한다
  • docs_v2 에 upsert 또는 insert 한다

3) 증분 동기화(Delta sync)

백필 도중에도 문서는 계속 들어옵니다.

  • 변경 스트림(예: Kafka, SQS, DB CDC)을 구독
  • source_hash 기반으로 변경분만 docs_v2 에 반영

4) 품질 검증 후 트래픽 스왑

  • 앱에서 라우팅 설정을 docs_v1 에서 docs_v2 로 변경
  • 카나리로 일부 트래픽만 먼저 전환
  • 문제 없으면 100퍼센트 전환

5) 롤백 플랜

  • 라우팅만 되돌리면 즉시 롤백
  • docs_v1 은 일정 기간 유지 후 삭제

이 구조는 “Milvus 인덱스 재구축” 과 “임베딩 재생성” 을 함께 포함하는 사실상의 재색인 전체를 안전하게 운영하게 해줍니다.

재색인 워커 예시: 큐 기반 비동기 처리

대규모 재색인은 배치 작업이 아니라 큐 기반 워커 로 쪼개는 것이 장애 대응과 재시도에 유리합니다.

  • 작업 단위: doc_id 또는 chunk_id
  • 멱등성 키: doc_id + embedding_version + source_hash
import time
from dataclasses import dataclass

@dataclass
class ReindexJob:
    doc_id: str
    source_hash: str
    embedding_version: str

class JobQueue:
    def pop(self) -> ReindexJob | None:
        raise NotImplementedError
    def ack(self, job: ReindexJob):
        raise NotImplementedError
    def retry(self, job: ReindexJob, delay_sec: int):
        raise NotImplementedError

def embed(text: str) -> list[float]:
    # 실제로는 임베딩 API 또는 로컬 모델 호출
    return [0.0] * 1536

def worker_loop(queue: JobQueue, source_repo, milvus_collection):
    while True:
        job = queue.pop()
        if job is None:
            time.sleep(1)
            continue

        try:
            doc = source_repo.get(job.doc_id)
            if doc.source_hash != job.source_hash:
                # 오래된 잡이면 폐기하거나 최신 해시로 재발행
                queue.ack(job)
                continue

            vectors = []
            rows = []
            for chunk in doc.chunks:
                vec = embed(chunk.text)
                vectors.append(vec)
                rows.append({
                    "doc_id": doc.doc_id,
                    "chunk_id": chunk.chunk_id,
                    "source_hash": doc.source_hash,
                    "embedding_version": job.embedding_version,
                    "embedding": vec,
                })

            milvus_collection.insert(rows)
            queue.ack(job)

        except Exception:
            queue.retry(job, delay_sec=30)

핵심은 다음입니다.

  • 멱등성: 동일 문서가 여러 번 처리되어도 결과가 일관되게 유지되어야 합니다.
  • 재시도 설계: 임베딩 API 레이트 리밋이나 네트워크 오류는 흔합니다. 레이트 리밋 대응은 OpenAI 429와 Rate Limit 헤더로 재시도 설계를 참고해 백오프와 지터를 넣는 것이 좋습니다.
  • 변경 감지: source_hash 로 “이 잡이 아직 유효한가” 를 판정합니다.

Milvus 인덱스 재구축 자동화 체크리스트

임베딩 버전이 바뀌면 보통 인덱스도 다시 잡습니다. 컬렉션 분리 전략이면 새 컬렉션에서 인덱스를 새로 만들면 됩니다.

  • 인덱스 타입: HNSW, IVF_FLAT, IVF_PQ
  • 메트릭: COSINE 또는 IP(정규화 여부와 세트로 결정)
  • HNSW라면 M, efConstruction, 서치 시 ef 를 함께 관리

운영에서는 “재색인 후 리콜이 떨어졌다” 를 드리프트로 오해하기도 합니다. 이때는 임베딩은 동일한데 인덱스 파라미터가 달라졌을 가능성이 큽니다. 그래서 임베딩 버전과 인덱스 파라미터를 같은 변경 세트 로 묶고, 앵커 회귀 테스트로 함께 검증해야 합니다.

무중단 스왑을 위한 라우팅 계층 설계

애플리케이션에서 컬렉션명을 하드코딩하면 스왑이 어렵습니다. 다음 중 하나를 권장합니다.

  • 설정 서버 또는 환경변수로 ACTIVE_COLLECTION=docs_v2 를 주입
  • DB에 라우팅 테이블을 두고 캐시(예: Redis)로 읽기
  • 피처 플래그로 카나리 비율을 조절

카나리 중에는 동일 쿼리를 docs_v1docs_v2 에 동시에 질의해 결과 차이를 기록하는 “shadow read” 도 유용합니다. 다만 비용이 크므로 트래픽 샘플링을 적용합니다.

장애와 비용을 줄이는 실전 팁

임베딩 생성 비용 최적화

  • 변경 없는 문서는 재생성하지 않는다: source_hash 로 스킵
  • 청크 정책이 바뀌었다면 해시 기준이 달라지므로, chunker_version 도 해시에 포함
  • 임베딩 API 호출은 동시성 제한과 백오프 필수

데이터 무결성

  • 문서 삭제/비공개 처리도 증분 동기화로 반영
  • doc_id 단위 삭제가 잦으면, Milvus에서 삭제 마커가 누적될 수 있어 주기적 compaction 정책을 검토

운영 관측성

  • 재색인 진행률: processed_docs / total_docs
  • 큐 적체량, 평균 처리 시간, 실패율
  • Milvus 쿼리 p95, p99 지연
  • 인덱스 빌드 시간과 메모리 사용량

쿠버네티스에서 대규모 인덱스 빌드나 임베딩 워커가 메모리를 많이 쓰면 공유 메모리나 OOM 이슈가 날 수 있습니다. 환경이 EKS라면 EKS에서 Pod /dev/shm 부족으로 OOM 해결하기 같은 케이스도 함께 점검해두면 좋습니다.

권장 운영 플로우 요약

  1. 임베딩 파이프라인 변경 시 embedding_version 를 올린다
  2. 드리프트 탐지
    • 분포 지표로 조기 감지
    • 앵커 회귀 테스트로 재색인 필요성 확정
  3. docs_v2 생성 및 인덱스 구성
  4. 백필 + 증분 동기화로 데이터 채우기
  5. 카나리 스왑, shadow read로 품질/지연 검증
  6. 100퍼센트 전환 후 일정 기간 docs_v1 유지
  7. 문제 시 즉시 롤백(라우팅 되돌리기)

마무리

Milvus 자체는 벡터 검색 엔진이지만, 임베딩이 바뀌는 순간부터는 “데이터베이스 운영” 이 아니라 “모델 운영(MLOps) + 데이터 파이프라인 운영” 문제가 됩니다. 드리프트를 지표로 조기에 잡고, 컬렉션 버전 분리로 안전하게 재색인을 자동화하면 검색 품질 하락을 장기 장애로 키우지 않고 통제할 수 있습니다.

다음 단계로는 앵커 세트의 자동 확장(실제 유저 쿼리에서 대표 쿼리 샘플링), 도메인별 슬라이스 지표(카테고리별 리콜), 그리고 인덱스 파라미터 실험 자동화까지 붙이면 운영 성숙도가 크게 올라갑니다.