Published on

Pinecone·Milvus 임베딩 드리프트 탐지와 리인덱싱

Authors

서빙 중인 RAG/시맨틱 서치에서 품질이 갑자기 떨어질 때, 로그만 보고는 원인을 놓치기 쉽습니다. 특히 Pinecone·Milvus 같은 벡터 DB는 “데이터는 그대로인데 검색이 이상해졌다”라는 형태로 문제가 나타나는데, 그 핵심 원인 중 하나가 임베딩 드리프트(embedding drift) 입니다.

임베딩 드리프트는 단순히 모델을 바꿨을 때만 생기지 않습니다. 토크나이저/전처리 변경, 문서 정규화 정책 변경, 코퍼스의 주제 분포 변화, 심지어 동일 모델이라도 배치/버전 차이로 인해 벡터 분포가 미묘하게 달라지면서 검색 품질과 인덱스 효율이 함께 흔들립니다.

이 글에서는 다음을 목표로 합니다.

  • “드리프트가 났다”를 감이 아니라 지표로 판단하기
  • Pinecone·Milvus에서 리인덱싱을 다운타임 없이 진행하기
  • 운영 중 흔히 생기는 함정(차원 불일치, 메타데이터 스키마 변경, HNSW 파라미터 재튜닝)을 피하기

관련해서 Milvus 인덱스 파라미터 자체를 튜닝해야 한다면, HNSW 기준으로는 Milvus HNSW 튜닝 - recall 올리고 p99 낮추기도 같이 보면 좋습니다.

임베딩 드리프트란 무엇이 “드리프트”인가

임베딩 드리프트는 크게 3가지 축으로 나눠 보는 게 실무적으로 유용합니다.

1) 모델/토크나이저 드리프트

  • 임베딩 모델 버전 변경
  • 토크나이저 업데이트
  • 정규화(소문자화, 공백/기호 처리) 변경

증상

  • 이전에는 잘 맞던 쿼리가 갑자기 엉뚱한 문서를 상위로 올림
  • 코사인 유사도 분포가 전반적으로 낮아짐 또는 특정 구간으로 쏠림

2) 코퍼스 드리프트(데이터 분포 변화)

  • 문서 주제가 바뀜(예: 제품 정책 문서에서 장애 대응 문서로 비중 이동)
  • 특정 도메인 용어가 급증

증상

  • 특정 카테고리에서만 품질이 하락
  • 최근 문서만 잘 찾고 과거 문서를 못 찾거나 반대

3) 인덱스/서빙 드리프트(시스템적 변화)

  • 인덱스 타입/파라미터 변경(HNSW efSearch, M 등)
  • 필터 조건 증가로 후보군이 급감
  • 업서트 파이프라인의 지연/누락으로 최신 문서가 인덱스에 없음

증상

  • p95/p99 레이턴시 급증과 recall 동반 하락
  • 필터가 붙는 쿼리만 성능/품질이 무너짐

핵심은 “임베딩만 문제”가 아니라 임베딩-인덱스-필터-서빙 전체가 함께 움직인다는 점입니다.

드리프트를 정량화하는 관측 지표 설계

드리프트는 대체로 “품질 하락”으로 먼저 체감하지만, 품질 지표는 라벨이 없으면 만들기 어렵습니다. 그래서 실무에서는 라벨 없는 지표(unsupervised) + 약한 라벨(weak label) 을 조합합니다.

A. 임베딩 분포 지표(라벨 없이 가능)

  1. 벡터 노름(norm) 통계
  • 평균/표준편차, 상위 p95/p99 변화를 추적
  • 모델이 바뀌거나 정규화가 바뀌면 분포가 흔히 튑니다
  1. 코사인 유사도 분포
  • 쿼리-탑1, 쿼리-탑k 평균 유사도
  • “유사도가 전반적으로 낮아졌는데 결과는 그럴듯하다”면 모델 스케일만 바뀐 것일 수 있고, “유사도도 낮고 결과도 이상”이면 실제 의미 공간이 달라졌을 확률이 큽니다.
  1. 최근 N일 샘플에 대한 centroid shift
  • 문서 임베딩의 중심 벡터를 기간별로 계산해 코사인 거리 변화 추적

B. 검색 품질의 약한 라벨 지표

  1. 클릭/체류 기반 nDCG 근사
  • 검색 결과 클릭/스크롤을 약한 relevance로 사용
  1. RAG의 “정답성” 대리 지표
  • 답변에 인용된 문서가 실제로 쿼리와 관련 있는지(휴리스틱)
  • 예: LLM이 생성한 답변과 인용 문서 간 텍스트 유사도, 혹은 규칙 기반 키워드 커버리지
  1. 쿼리 재현성 테스트 세트
  • 운영에서 자주 나오는 쿼리 상위 500개를 고정 세트로 만들고, 주기적으로 top-k 결과의 변동을 추적

C. 운영 지표(성능/안정성)

  • p95/p99 latency
  • timeouts, retry율
  • upsert 지연, 인덱스 빌드 큐 적체

이때 리인덱싱 잡이 장시간 돌면 워커/큐가 누수처럼 쌓여 장애로 번질 수 있습니다. 비동기 워커를 Go로 운영한다면 고루틴 누수 체크리스트도 같이 참고하세요: Go 고루틴 누수 원인 8가지와 진단법

드리프트 탐지 파이프라인: “샘플링 + 비교 + 알림”

아래는 가장 단순하지만 효과적인 구조입니다.

  1. 샘플링
  • 문서: 최근 24시간 업서트된 문서에서 무작위 N=10k
  • 쿼리: 운영 쿼리 로그에서 무작위 M=1k 또는 상위 빈도 쿼리
  1. 비교 대상
  • 현재 프로덕션 임베딩(모델 A)
  • 후보 임베딩(모델 B 또는 전처리 변경 버전)
  1. 비교 방법
  • 동일 문서에 대해 AB 벡터를 모두 생성
  • 분포 지표 + 검색 결과 변동 지표를 계산
  1. 알림 조건(예시)
  • 벡터 노름 평균이 20% 이상 변화
  • 쿼리 top1 코사인 유사도 평균이 0.05 이상 하락
  • 고정 쿼리 세트에서 top10 Jaccard 유사도가 0.6 미만으로 하락

Python 예시: 임베딩 분포와 centroid shift

import numpy as np

def l2_norm_stats(vectors: np.ndarray):
    norms = np.linalg.norm(vectors, axis=1)
    return {
        "mean": float(norms.mean()),
        "std": float(norms.std()),
        "p95": float(np.quantile(norms, 0.95)),
        "p99": float(np.quantile(norms, 0.99)),
    }

def centroid(vectors: np.ndarray):
    c = vectors.mean(axis=0)
    c = c / (np.linalg.norm(c) + 1e-12)
    return c

def cosine(a: np.ndarray, b: np.ndarray):
    return float(np.dot(a, b) / ((np.linalg.norm(a) + 1e-12) * (np.linalg.norm(b) + 1e-12)))

# vectors_a, vectors_b: shape (N, D)
# 예: 동일 문서 샘플에 대해 모델 A/B 임베딩
stats_a = l2_norm_stats(vectors_a)
stats_b = l2_norm_stats(vectors_b)

shift = 1.0 - cosine(centroid(vectors_a), centroid(vectors_b))

print("A norms:", stats_a)
print("B norms:", stats_b)
print("centroid cosine distance:", shift)

이 지표들만으로도 “모델이 바뀌면서 의미 공간이 이동했는지”를 빠르게 감지할 수 있습니다.

Pinecone 리인덱싱 전략: 새 인덱스 + 듀얼라이트 + 컷오버

Pinecone은 인덱스가 논리적 단위라서, 가장 안전한 방식은 새 인덱스를 만들고 컷오버하는 패턴입니다.

1) 새 인덱스 생성

  • 차원 dimension이 바뀌면 기존 인덱스에 절대 혼합 불가
  • metric(코사인/도트/유클리드)도 함께 점검

2) 듀얼라이트(dual write)

  • 일정 기간 동안 업서트를 구 인덱스와 신 인덱스에 동시에 반영
  • 파이프라인이 복잡해지므로 idempotency가 중요합니다

3) 백필(backfill)

  • 과거 문서를 신 인덱스에 재업서트
  • 백필이 끝나기 전까지는 신 인덱스를 서빙에 쓰지 않거나, 카나리로만 사용

4) 쿼리 듀얼리드(dual read) 또는 섀도우 리드

  • 동일 쿼리를 두 인덱스에 날리고 결과를 비교(사용자에게는 구 인덱스 결과만 반환)
  • top-k 겹침률, 유사도 분포, latency를 비교

5) 컷오버

  • 라우팅을 신 인덱스로 전환
  • 구 인덱스는 일정 기간 보관 후 삭제

Node.js 예시: 섀도우 리드로 top-k 겹침률 측정

function jaccard(aIds, bIds) {
  const A = new Set(aIds);
  const B = new Set(bIds);
  let inter = 0;
  for (const x of A) if (B.has(x)) inter += 1;
  const union = A.size + B.size - inter;
  return union === 0 ? 1 : inter / union;
}

async function shadowQuery({queryVector, topK, pineconeOld, pineconeNew}) {
  const [oldRes, newRes] = await Promise.all([
    pineconeOld.query({ vector: queryVector, topK, includeMetadata: false }),
    pineconeNew.query({ vector: queryVector, topK, includeMetadata: false }),
  ]);

  const oldIds = oldRes.matches.map(m => m.id);
  const newIds = newRes.matches.map(m => m.id);

  return {
    overlap: jaccard(oldIds, newIds),
    oldTop1: oldRes.matches[0]?.id,
    newTop1: newRes.matches[0]?.id,
  };
}

운영에서는 overlap가 급격히 낮아지는 쿼리군을 모아 “전처리 변경으로 의미가 달라진 케이스”를 역추적하면 원인 파악이 빨라집니다.

Milvus 리인덱싱 전략: 컬렉션 버저닝 + 별도 인덱스 빌드

Milvus는 컬렉션/파티션/인덱스 빌드가 분리되어 있어 유연하지만, 그만큼 운영 선택지가 많습니다. 추천하는 기본 패턴은 컬렉션 버저닝 입니다.

  • docs_v1, docs_v2처럼 컬렉션을 버전으로 나눔
  • 애플리케이션은 alias(또는 설정)로 활성 컬렉션을 바라보게 구성

1) 새 컬렉션 생성(스키마 고정)

  • 벡터 필드 차원 dim 변경 여부 확인
  • 메타데이터 필드 타입 변경(예: int64에서 varchar)은 이때 같이 정리

2) 듀얼라이트 + 백필

  • 업서트 경로에서 v1v2 모두에 write
  • 과거 데이터는 배치로 v2에 insert

3) 인덱스 빌드 및 로드

  • HNSW/IVF 등 인덱스를 빌드
  • 컬렉션을 load해서 메모리에 올린 뒤 서빙

4) 섀도우 리드 + 컷오버

  • 쿼리를 v1v2에 동시에 날려 결과 비교
  • 기준 통과 시 alias를 v2로 전환

Python 예시: Milvus 컬렉션 생성과 HNSW 인덱스

from pymilvus import (
    connections, FieldSchema, CollectionSchema, DataType,
    Collection
)

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

dim = 1024
fields = [
    FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=dim),
    FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=128),
]

schema = CollectionSchema(fields, description="docs v2")
col = Collection(name="docs_v2", schema=schema)

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 32, "efConstruction": 200},
}

col.create_index(field_name="embedding", index_params=index_params)
col.load()

여기서 M, efConstruction, 서치 시 efSearch는 품질과 지연을 크게 좌우합니다. 리인덱싱을 계기로 파라미터를 바꾸려면, 앞서 언급한 Milvus HNSW 튜닝 - recall 올리고 p99 낮추기에서 제시하는 방식으로 offline 벤치마크를 먼저 돌리는 편이 안전합니다.

“리인덱싱이 필요한지” 판단하는 실전 기준

드리프트가 감지되었다고 항상 전량 리인덱싱이 필요한 건 아닙니다. 아래처럼 레벨을 나눠 의사결정하면 비용을 줄일 수 있습니다.

레벨 0: 설정/버그 이슈

  • 전처리 버그로 토큰이 잘려 나감
  • 필터 조건이 의도치 않게 강화됨
  • 업서트 누락/지연

이 경우는 리인덱싱이 아니라 파이프라인 수정 후 누락분만 보충하면 됩니다.

레벨 1: 코퍼스 드리프트

  • 문서 분포가 바뀌었지만 임베딩 공간은 동일

대응

  • 인덱스 재빌드가 도움이 될 수 있으나, 먼저 HNSW efSearch 같은 런타임 파라미터 조정으로 완화 가능한지 확인
  • 자주 조회되는 파티션만 우선 재빌드하는 전략도 가능

레벨 2: 모델/차원/메트릭 변경

  • 임베딩 모델 버전업
  • 벡터 차원 변경
  • 코사인에서 도트로 변경

대응

  • 사실상 “새 인덱스/새 컬렉션”이 정답
  • 듀얼라이트 + 섀도우 리드 + 컷오버로 진행

리인덱싱 시 흔한 함정 6가지

1) 차원 불일치

  • 가장 빈번한 장애 요인
  • 예방: 임베딩 생성 서비스가 dim을 메타로 노출하고, 업서트 전에 검증

2) 정규화 정책 혼합

  • 동일 인덱스에 L2 normalize된 벡터와 아닌 벡터가 섞이면 품질이 급락
  • 예방: 임베딩 버전 태그를 메타데이터에 저장하고, 쿼리도 같은 버전으로만 검색

3) 메타데이터 스키마 변경으로 필터가 달라짐

  • 필터 키 이름 변경, 타입 변경이 있으면 “검색이 안 되는” 현상이 발생
  • 예방: 필터 스키마를 버전 관리하고, 컷오버 전 섀도우 리드에서 필터 포함 쿼리를 별도로 검증

4) 듀얼라이트의 순서/멱등성 문제

  • 백필 중에 최신 업데이트가 덮여써지는 문제
  • 예방: updated_at 기반의 last-write-wins, 또는 문서 버전 넘버를 저장

5) 인덱스 빌드 중 성능 저하

  • 특히 Milvus에서 대규모 빌드/로드는 리소스를 크게 사용
  • 예방: 빌드 작업을 서빙 클러스터와 분리하거나, 오프피크에 수행

6) 검증 없이 컷오버

  • “지표는 좋아 보이는데 특정 쿼리군이 망가짐”이 자주 발생
  • 예방: 상위 빈도 쿼리 + 필터 포함 쿼리 + 엣지 케이스(짧은 쿼리, 오타, 숫자 포함)를 고정 세트로 테스트

운영 설계: 임베딩 버전과 인덱스 버전을 분리하라

장기 운영에서는 아래 3가지를 분리해 관리하는 게 좋습니다.

  • embedding_version: 모델/토크나이저/전처리 조합의 버전
  • index_version: Pinecone 인덱스명 또는 Milvus 컬렉션 버전
  • serving_route_version: 라우팅(어느 인덱스를 읽는지) 버전

예를 들어 문서 메타데이터에 embedding_version을 넣고, 쿼리도 해당 버전으로만 검색하게 만들면 “혼합으로 인한 품질 붕괴”를 구조적으로 막을 수 있습니다.

예시: 업서트 페이로드에 버전 태그 포함

{
  "id": "doc_123",
  "values": [0.01, 0.02, 0.03],
  "metadata": {
    "embedding_version": "e_v3",
    "source": "handbook",
    "updated_at": 1700000000
  }
}

그리고 검색 시에는 필터로 embedding_version을 강제하거나(가능한 DB라면), 애플리케이션 레벨에서 인덱스/컬렉션 자체를 분리해 라우팅합니다.

체크리스트: 드리프트 대응 런북(runbook)

  • 드리프트 알림 발생
    • 벡터 노름/유사도/overlap/latency 중 무엇이 변했는지 확인
  • 원인 분류
    • 모델/전처리 변경 여부
    • 코퍼스 분포 변화 여부
    • 필터/서빙 변경 여부
  • 대응 결정
    • 파이프라인 버그면 수정 + 누락 보정
    • 모델/차원 변경이면 새 인덱스(또는 새 컬렉션)로 리인덱싱
  • 리인덱싱 실행
    • 새 인덱스 생성
    • 듀얼라이트 시작
    • 백필 진행
    • 섀도우 리드로 품질/성능 비교
    • 컷오버
    • 구 인덱스 보관 후 삭제

마무리

Pinecone·Milvus를 운영하면서 임베딩 드리프트를 “한 번에” 없애기는 어렵습니다. 대신 드리프트를 조기에 탐지할 지표, 다운타임 없는 리인덱싱 절차, 버전 분리 설계를 갖추면 품질 하락을 사건이 아니라 일상적인 변화로 다룰 수 있습니다.

특히 모델 업데이트가 잦은 조직일수록, 리인덱싱을 프로젝트가 아니라 파이프라인으로 만들어 두는 것이 장기적으로 비용을 크게 줄입니다.