Published on

Pinecone·Milvus 검색 품질 하락? 임베딩 드리프트 진단

Authors

서빙 중인 RAG나 시맨틱 검색에서 어느 날부터 "예전엔 잘 찾던 문서가 안 나온다"는 피드백이 쌓이면, 많은 팀이 먼저 HNSW 파라미터나 top_k를 만집니다. 하지만 Pinecone·Milvus 같은 벡터DB에서 검색 품질이 체감상 급락하는 가장 흔한 원인은 인덱스가 아니라 임베딩 드리프트(embedding drift) 입니다.

임베딩 드리프트는 한마디로 "같은 의미의 텍스트가 예전과 다른 벡터 분포로 인코딩되는 현상"입니다. 문제는 이게 천천히 진행되기도 하고, 모델/전처리/차원/정규화가 바뀌는 순간 하룻밤 사이에 터지기도 한다는 점입니다.

이 글에서는 Pinecone·Milvus 공통으로 적용 가능한 형태로, 임베딩 드리프트를 증상 분리 → 지표화 → 원인 특정 → 복구까지 진단하는 방법을 정리합니다. (HNSW 튜닝 자체는 아래 글에서 별도로 다뤘습니다.)

1) 임베딩 드리프트가 의심되는 전형적 증상

다음 중 2개 이상이면 인덱스 튜닝보다 먼저 드리프트를 점검하는 게 빠릅니다.

1.1 같은 쿼리인데 상위 결과가 바뀐다

  • 동일한 쿼리 문자열, 동일한 필터 조건인데도 top_k 결과가 며칠 전과 다르게 바뀜
  • 특히 "정답 문서가 1페이지에서 10페이지 밖으로 밀림" 같은 현상

1.2 점수 분포가 갑자기 납작해진다

  • cosine 유사도 기준으로 상위 결과 점수가 예전엔 0.82, 0.79, 0.77…이었는데 요즘은 0.62, 0.61, 0.60…처럼 뭉개짐
  • 혹은 반대로 상위 몇 개만 과도하게 높고 나머지가 급락

1.3 특정 카테고리/언어/길이에서만 급락한다

  • 한국어만, 짧은 쿼리만, 특정 도메인 문서만 급락
  • 이는 전처리(토크나이즈/정규화/클리닝) 변경이나 데이터 분포 변화와 강하게 연결됩니다.

2) 드리프트를 “감”이 아니라 지표로 진단하기

드리프트 진단의 핵심은 고정된 평가 세트벡터 분포 비교입니다. 검색 품질 문제는 주관적 피드백으로 시작하지만, 복구는 수치로 해야 롤백/재학습/리임베딩 의사결정이 빨라집니다.

2.1 골든 쿼리 세트 만들기 (최소 50~200개)

  • 프로덕션에서 자주 들어오는 쿼리 + 사람이 "이 문서가 정답"이라고 합의한 레이블
  • 정답이 1개가 아니어도 됩니다. 정답 후보 집합으로 관리하세요.

예시 스키마(간단 JSONL):

{"qid":"q001","query":"환불 정책","relevant_doc_ids":["doc_12","doc_98"]}
{"qid":"q002","query":"S3 프리사인 URL 만료","relevant_doc_ids":["doc_77"]}

2.2 검색 품질 지표: Recall@K, MRR@K부터

  • Recall@K: 정답 문서가 상위 K에 하나라도 포함되는 비율
  • MRR@K: 정답의 순위가 높을수록 점수가 커지는 지표

드리프트는 보통 Recall@K를 먼저 무너뜨립니다.

2.3 벡터 분포 지표: norm, mean cosine, centroid shift

임베딩 모델이 바뀌거나 정규화가 빠지면 벡터의 L2 norm 분포가 달라집니다. cosine 기반이라도, 내부 구현이나 후처리(정규화 여부)에 따라 결과가 크게 흔들립니다.

다음은 두 시점(또는 두 버전)의 임베딩을 비교하는 최소 진단 코드입니다.

import numpy as np

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

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

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

# X_old, X_new: 동일한 텍스트 샘플(예: 5천개)을 과거/현재 임베딩으로 만든 행렬
# shape: (n, d)

old_norm = l2_norms(X_old)
new_norm = l2_norms(X_new)

print("norm mean old/new:", old_norm.mean(), new_norm.mean())
print("norm std  old/new:", old_norm.std(), new_norm.std())

c_old = centroid(X_old)
c_new = centroid(X_new)
print("centroid cosine:", cosine(c_old, c_new))

# 동일 텍스트의 임베딩 방향이 얼마나 바뀌었는지(샘플별 cosine)
per_item_cos = np.array([cosine(X_old[i], X_new[i]) for i in range(len(X_old))])
print("per-item cosine mean:", per_item_cos.mean())
print("per-item cosine p05:", np.quantile(per_item_cos, 0.05))

해석 가이드:

  • norm mean이 예전 1.0 근처였는데 지금 20~40으로 튄다: 정규화가 빠졌거나 모델 출력 스케일이 달라짐
  • centroid cosine이 0.95 이하로 떨어진다: 분포 중심 자체가 이동(도메인/전처리/모델 변경)
  • per-item cosine p05가 낮다: 일부 구간에서 임베딩이 크게 바뀜(특정 언어/길이/문자셋)

3) Pinecone·Milvus에서 드리프트가 실제로 품질을 무너뜨리는 메커니즘

3.1 “쿼리만 새 모델” 또는 “문서만 새 모델”인 경우

가장 치명적인 케이스입니다.

  • 문서 벡터는 예전 모델로 색인되어 있는데
  • 쿼리 임베딩만 새 모델로 바뀜

이러면 같은 의미라도 벡터 공간 자체가 다르기 때문에 근접 이웃이 무너집니다. 인덱스가 아무리 좋아도 답이 없습니다.

체크리스트:

  • 임베딩 생성 서비스의 모델 버전이 언제 바뀌었는지
  • 배치 리임베딩 잡이 실제로 완료됐는지
  • 실시간 인입 문서만 새 임베딩으로 들어가 혼재 상태가 됐는지

3.2 차원(dimension) 불일치 또는 silent truncation

Milvus는 컬렉션 스키마에 차원이 고정이고, Pinecone도 인덱스 차원이 고정입니다. 보통은 차원이 다르면 에러가 나지만, 중간 레이어에서 벡터를 잘라 넣거나 패딩하는 버그가 있으면 조용히 품질이 붕괴합니다.

  • 예: 1536차원 모델을 768차원으로 잘라 업서트
  • 예: float16 변환 과정에서 NaN/Inf가 섞임

3.3 정규화 방식 변경

cosine 유사도는 흔히 L2 normalize를 전제로 합니다. 다음 중 하나라도 바뀌면 결과가 달라집니다.

  • 과거: 임베딩 생성 시점에 정규화
  • 현재: 정규화 없이 저장, 검색 시점에도 정규화 없음
  • 또는 dot-product 인덱스인데 cosine처럼 해석

3.4 전처리 드리프트(토크나이저/클리닝/분절)

모델은 같아도 텍스트가 달라지면 벡터가 달라집니다.

  • 줄바꿈 제거, 마크다운 제거 정책 변경
  • chunking 크기/오버랩 변경
  • 언어 감지 후 번역 파이프라인 추가

특히 chunking 변경은 "문서가 같은데 왜 못 찾지"라는 착시를 만듭니다. 실제로는 검색 대상 단위가 바뀐 것입니다.

4) 실전 진단 절차: 30분 안에 범인 좁히기

여기서는 "Pinecone/Milvus 검색 품질 하락"을 받았을 때, 인프라/인덱스/임베딩 중 어디가 문제인지 빠르게 분리하는 순서를 제시합니다.

4.1 Step 1. 동일 쿼리 재현 로그 확보

  • 문제 제보 쿼리 10개만 확보해도 시작 가능
  • 쿼리 문자열, 필터, top_k, 네임스페이스/컬렉션, 시간대

4.2 Step 2. 쿼리 임베딩을 “그대로” 저장하고 재검색

  • 같은 쿼리 문자열이라도 임베딩이 바뀌면 재현이 안 됩니다.
  • 쿼리 임베딩 벡터를 로깅해 두고, 그 벡터로만 검색을 재실행하세요.

Milvus에서 벡터로 검색(개념 예시):

from pymilvus import Collection

col = Collection("docs")

query_vec = [0.01] * 1536  # 실제로는 로깅된 벡터 사용

res = col.search(
    data=[query_vec],
    anns_field="embedding",
    param={"metric_type": "COSINE", "params": {"ef": 128}},
    limit=10,
    output_fields=["doc_id", "title"],
)

for hit in res[0]:
    print(hit.entity.get("doc_id"), hit.distance)

이 단계에서 결론:

  • 같은 벡터로 검색하면 결과가 안정적이다: 임베딩 생성(쿼리 측) 드리프트 가능성
  • 같은 벡터로 검색해도 결과가 흔들린다: 인덱스 재빌드/세그먼트/필터 조건/데이터 혼재 이슈 가능성

4.3 Step 3. 문서 샘플 1천~1만개를 리임베딩해서 분포 비교

  • 동일 텍스트 샘플을 과거 버전 임베딩과 현재 버전 임베딩으로 생성
  • 위 2.3의 per-item cosine과 norm을 비교

과거 임베딩을 바로 생성할 수 없다면:

  • 과거에 저장해 둔 벡터를 샘플링해서 가져오고
  • 동일 문서를 현재 파이프라인으로 다시 임베딩해 비교합니다.

4.4 Step 4. 혼재 상태 확인(문서 벡터 버전 태깅)

가장 추천하는 운영 패턴은 벡터 메타데이터에 임베딩 버전을 넣는 것입니다.

  • embedding_model: 예 text-embedding-3-large
  • embedding_version: 예 2026-02-15
  • preprocess_version: 예 chunk_v4

이렇게 해두면 필터로 혼재 여부를 바로 확인할 수 있습니다.

5) 복구 전략: “리임베딩”만이 답이 아닌 경우

5.1 가장 안전한 복구: 듀얼 인덱스 + 점진적 전환

운영 중인 인덱스를 갈아엎는 대신, 새 인덱스를 병렬로 구축하고 트래픽을 나눠 검증합니다.

  • index_v1: 기존 임베딩 공간
  • index_v2: 새 임베딩 공간
  • 라우팅: 사용자/세션 단위로 5%부터 시작

장점:

  • 롤백이 즉시 가능
  • 품질 지표를 A/B로 비교 가능

5.2 혼재 상태라면: “전량 리임베딩” 전에 우선순위 리임베딩

전체 문서가 1억 chunk면 리임베딩이 부담입니다. 이때는 다음 순서가 효과적입니다.

  1. 상위 트래픽 쿼리에서 자주 노출되는 문서 집합
  2. 최근 업데이트 문서
  3. 핵심 카테고리

즉, 품질 체감에 영향을 주는 영역부터 벡터 공간을 정렬합니다.

5.3 인덱스 파라미터는 마지막에

드리프트가 해결되지 않은 상태에서 efSearchnprobe를 올리면, 비용만 늘고 품질은 제한적으로만 회복됩니다. 인덱스 튜닝은 임베딩 공간이 일관적일 때 효과가 큽니다.

6) 재발 방지: 임베딩 변경을 “스키마 변경”처럼 다루기

임베딩은 모델 출력일 뿐이라 쉽게 바꾸기 쉽지만, 벡터DB 관점에서는 사실상 스키마 변경입니다. 아래 4가지만 도입해도 드리프트 장애가 확 줄어듭니다.

6.1 임베딩/전처리 버전 고정 및 메타데이터 저장

  • 문서마다 embedding_version, preprocess_version 저장
  • 쿼리 로그에도 동일 버전 기록

6.2 골든 쿼리 회귀 테스트를 CI에 넣기

배포 전에 최소한 다음을 자동 체크하세요.

  • Recall@10이 기준치 아래로 떨어지면 배포 중단
  • 점수 분포(상위 cosine 평균)가 급변하면 경고

간단한 회귀 테스트 예시(의사코드):

def recall_at_k(results, relevant_ids, k=10):
    topk = [r.doc_id for r in results[:k]]
    return 1.0 if any(x in relevant_ids for x in topk) else 0.0

# golden_set: (query, relevant_doc_ids)
recalls = []
for q, rel in golden_set:
    vec = embed(q)  # 현재 배포 후보 파이프라인
    results = vector_search(vec, top_k=10)
    recalls.append(recall_at_k(results, rel, k=10))

score = sum(recalls) / len(recalls)
assert score >= 0.85

6.3 백필(리임베딩) 잡의 완료를 “데이터 품질”로 모니터링

  • 총 문서 수 대비 embedding_version == 최신 비율
  • 최신 버전 비율이 임계치 아래면 검색 라우팅을 제한

6.4 장애 분석 관점은 임베딩도 “모델 운영”으로

임베딩 드리프트는 본질적으로 모델/데이터 분포 문제입니다. 정량 진단과 단계적 복구가 중요하다는 점에서, 아래 글의 "급락 원인 분해" 방식이 참고가 됩니다.

7) 체크리스트 요약

  • 쿼리 임베딩을 로깅하고, 동일 벡터로 재검색해 변동성을 분리했는가
  • 문서/쿼리 임베딩 모델 버전이 일치하는가
  • 정규화 여부가 바뀌지 않았는가 (L2 norm 분포 확인)
  • chunking/클리닝 정책이 바뀌지 않았는가
  • 동일 텍스트 샘플의 per-item cosine이 급락하지 않았는가
  • 혼재 상태를 메타데이터로 식별 가능한가
  • 듀얼 인덱스와 골든 쿼리 회귀 테스트로 안전하게 전환하는가

검색 품질 이슈는 종종 "인덱스가 느려졌다"나 "파라미터가 문제"처럼 보이지만, 실제로는 임베딩 공간의 일관성이 깨진 경우가 많습니다. 먼저 드리프트를 수치로 확인하고, 혼재를 제거한 뒤에야 HNSW 튜닝이 의미 있게 먹힙니다.