- Published on
Pinecone·Milvus 검색품질 급락? 임베딩 드리프트 탐지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙은 멀쩡한데 RAG 검색 품질이 갑자기 무너지는 순간이 있습니다. Pinecone나 Milvus 자체 장애를 의심하기 쉽지만, 실제 현장에서는 임베딩 드리프트(embedding drift) 가 원인인 경우가 훨씬 많습니다. 즉, 인덱스에 들어간 벡터의 분포와, 현재 쿼리에서 생성되는 벡터의 분포가 달라지면서 유사도 점수의 의미가 바뀌고 Top-K가 엉뚱한 문서를 끌어오는 상황입니다.
이 글에서는 Pinecone·Milvus 공통으로 적용 가능한 관점에서
- 임베딩 드리프트가 왜 검색 품질을 급락시키는지
- 드리프트를 어떻게 수치로 탐지 하는지
- 탐지 후 어떤 운영 액션(재색인, 버저닝, 롤백, 가드레일) 을 해야 하는지
를 코드와 함께 정리합니다.
검색 품질 급락의 전형적인 징후
다음 증상이 동시에 보이면 “벡터DB 문제”가 아니라 “임베딩/파이프라인 문제”일 확률이 큽니다.
- 동일 쿼리의 Top-K가 며칠 전과 완전히 달라짐
- 평균 유사도 점수(코사인 기준)가 전반적으로 낮아지거나, 반대로 비정상적으로 높아짐
- 특정 카테고리/언어/길이의 문서만 유난히 검색이 안 됨
- 재시도해도 결과는 안정적이지만 정답이 아닌 방향으로 안정적 임
여기서 핵심은 “검색이 랜덤해졌다”가 아니라, “일관되게 이상해졌다”입니다. 이는 보통 분포가 바뀌었기 때문 입니다.
임베딩 드리프트란 무엇인가
임베딩 드리프트는 크게 두 축으로 나뉩니다.
1) 모델 드리프트
- 임베딩 모델 버전 변경(예:
text-embedding-3-small에서text-embedding-3-large로 변경) - 동일 모델이라도 파라미터 변경(정규화 여부, pooling 방식, truncation 길이)
- ONNX/INT8 양자화 등 최적화 과정에서 수치적 특성이 바뀜
임베딩은 “같은 공간”에서 비교해야 의미가 있습니다. 모델이 바뀌면 공간 자체가 달라져서, 과거에 색인한 벡터와 현재 쿼리 벡터가 서로 다른 좌표계 에 있게 됩니다.
양자화나 변환 파이프라인이 원인인 경우도 많습니다. 임베딩 품질이 미세하게 바뀌어도 검색 품질은 크게 흔들릴 수 있습니다. 관련해서는 PyTorch→ONNX→INT8 양자화 정확도 하락 잡기 같은 최적화 단계에서의 정확도 관리 관점이 그대로 적용됩니다.
2) 데이터/전처리 드리프트
- 문서 텍스트 전처리 변경(HTML 제거 규칙, 이모지/특수문자 처리, 공백 정규화)
- chunking 전략 변경(문장 단위에서 토큰 단위로, chunk size 변경)
- 언어 비율 변화(한글 문서가 늘었는데 영어 중심 임베딩을 그대로 사용)
전처리/청킹은 임베딩 입력을 바꾸기 때문에, 결과적으로 벡터 분포를 바꿉니다. 특히 chunk size가 바뀌면 벡터 노름(norm)이나 유사도 분포가 통째로 달라지는 일이 흔합니다.
Pinecone·Milvus에서 드리프트가 더 치명적인 이유
Pinecone나 Milvus는 인덱싱 구조(HNSW, IVF 등)와 검색 파라미터(efSearch, nprobe)에 따라 근사 탐색의 성질이 달라집니다. 드리프트가 생기면 다음이 겹쳐서 “급락”처럼 보입니다.
- 분포가 바뀌어 이웃 구조가 달라짐
- 근사 탐색이 “원래 분포”에 튜닝되어 있었는데, 새 분포에서는 recall이 떨어짐
- 점수 스케일이 바뀌어 reranker나 threshold 로직이 오작동
즉, 드리프트는 단지 “정답이 달라짐”이 아니라, 인덱스와 탐색 파라미터가 기대하던 가정까지 깨뜨립니다.
드리프트 탐지: 무엇을 측정할 것인가
실무에서 유용한 지표는 크게 3가지입니다.
- 벡터 노름(norm) 분포 변화
- 코사인 유사도를 쓰더라도, 임베딩이 정규화되어 있지 않으면 노름 분포가 의미를 가집니다.
- 쿼리-문서 유사도 분포 변화
- 샘플 쿼리 집합을 고정해두고, Top-
k의 점수 분포(평균/분산/상위 퍼센타일)를 비교합니다.
- 최근접 이웃의 안정성 변화
- 동일 문서 벡터를 다시 임베딩했을 때, 자기 자신이 Top-1로 나오지 않거나, 이웃 집합이 크게 바뀌면 위험 신호입니다.
여기서 중요한 운영 팁은 “정답 라벨이 없어도” 드리프트는 충분히 탐지 가능하다는 점입니다. 분포 기반 감시만으로도 대부분의 사고를 조기에 잡습니다.
코드: 임베딩 분포 드리프트를 빠르게 감지하기
아래 예시는 다음을 수행합니다.
- 과거(베이스라인) 임베딩 샘플과 현재 임베딩 샘플을 비교
- 노름 분포, 코사인 유사도 분포를 계산
- 간단한 PSI(Population Stability Index)로 분포 변화량을 수치화
import numpy as np
def l2_norms(x: np.ndarray) -> np.ndarray:
return np.linalg.norm(x, axis=1)
def cosine_sim(a: np.ndarray, b: np.ndarray, eps: float = 1e-12) -> np.ndarray:
# a, b: (n, d)
a_n = a / (np.linalg.norm(a, axis=1, keepdims=True) + eps)
b_n = b / (np.linalg.norm(b, axis=1, keepdims=True) + eps)
return np.sum(a_n * b_n, axis=1)
def psi(expected: np.ndarray, actual: np.ndarray, bins: int = 20, eps: float = 1e-6) -> float:
# expected, actual: 1D arrays
q = np.quantile(expected, np.linspace(0, 1, bins + 1))
q[0] -= 1e-9
q[-1] += 1e-9
e_hist, _ = np.histogram(expected, bins=q)
a_hist, _ = np.histogram(actual, bins=q)
e = e_hist / (np.sum(e_hist) + eps)
a = a_hist / (np.sum(a_hist) + eps)
e = np.clip(e, eps, 1)
a = np.clip(a, eps, 1)
return float(np.sum((a - e) * np.log(a / e)))
# baseline_embeddings: 과거 문서 임베딩 샘플 (n, d)
# current_embeddings: 현재 문서 임베딩 샘플 (m, d)
# baseline_queries, current_queries: 쿼리 임베딩 샘플
baseline_embeddings = np.load("baseline_doc_emb.npy")
current_embeddings = np.load("current_doc_emb.npy")
baseline_queries = np.load("baseline_query_emb.npy")
current_queries = np.load("current_query_emb.npy")
# 1) norm drift
base_norm = l2_norms(baseline_embeddings)
curr_norm = l2_norms(current_embeddings)
print("norm mean", base_norm.mean(), curr_norm.mean())
print("norm psi", psi(base_norm, curr_norm))
# 2) query-doc similarity drift (샘플링해서 1:1 비교)
# 실제로는 동일 쿼리 세트를 고정해두는 게 좋습니다.
n = min(len(baseline_queries), len(current_queries), len(baseline_embeddings), len(current_embeddings))
base_sim = cosine_sim(baseline_queries[:n], baseline_embeddings[:n])
curr_sim = cosine_sim(current_queries[:n], current_embeddings[:n])
print("sim mean", base_sim.mean(), curr_sim.mean())
print("sim psi", psi(base_sim, curr_sim))
PSI 해석 가이드
PSI는 조직마다 기준이 다르지만, 경험적으로 다음처럼 운영하면 무난합니다.
0.0에서0.1: 정상 범위0.1에서0.25: 주의(배포/데이터 변화 확인)0.25이상: 경보(검색 품질 하락 가능성 큼)
PSI는 단독으로 절대 진실은 아니지만, “배포 직후 PSI가 급증했다” 같은 시그널은 매우 강력합니다.
코드: Pinecone·Milvus 공통 Top-K 점수 분포 모니터링
벡터DB를 무엇을 쓰든, 결국 결과는 Top-K와 점수입니다. 샘플 쿼리 집합을 고정해두고, 점수 분포가 바뀌는지 매일 체크하면 드리프트를 빨리 잡습니다.
아래는 “검색 결과 점수의 상위 퍼센타일”을 로그로 남기는 예시입니다. Pinecone SDK나 Milvus client 호출부는 환경마다 달라서, search_fn만 주입받는 형태로 작성했습니다.
import numpy as np
from typing import Callable, List, Dict, Any
def monitor_score_distribution(
query_vectors: np.ndarray,
search_fn: Callable[[np.ndarray, int], List[Dict[str, Any]]],
top_k: int = 10,
) -> Dict[str, float]:
scores = []
for q in query_vectors:
results = search_fn(q, top_k)
# results: [{"id": "...", "score": 0.123}, ...]
for r in results:
scores.append(float(r["score"]))
arr = np.array(scores, dtype=np.float64)
if len(arr) == 0:
return {"count": 0}
return {
"count": float(len(arr)),
"mean": float(arr.mean()),
"p50": float(np.quantile(arr, 0.50)),
"p90": float(np.quantile(arr, 0.90)),
"p99": float(np.quantile(arr, 0.99)),
"min": float(arr.min()),
"max": float(arr.max()),
}
# 사용 예시
# search_fn은 Pinecone든 Milvus든 결과를 score 포함 형태로 표준화해서 반환
이 지표는 단순하지만 효과가 큽니다. 예를 들어 코사인 유사도 기반 시스템에서 p90이 갑자기 떨어지면 “쿼리-문서가 전반적으로 멀어졌다”는 뜻이고, 이는 임베딩 공간이 바뀌었거나 전처리가 달라졌을 가능성이 큽니다.
원인 추적 체크리스트: 드리프트는 어디서 생겼나
드리프트가 감지되면, 다음 순서로 원인을 좁히는 것이 빠릅니다.
1) 임베딩 모델 버전과 설정 확인
- 모델 이름/버전이 바뀌었는지
normalize_embeddings같은 후처리 여부가 바뀌었는지- 입력 최대 길이(truncation)가 바뀌었는지
여기서 가장 흔한 사고는 “쿼리는 새 모델, 문서는 옛 모델”처럼 부분 교체 가 일어난 경우입니다.
2) 차원(dimension)과 거리 메트릭 확인
- 임베딩 차원이 바뀌었는데 인덱스는 그대로인 경우
- 코사인과 내적(dot), L2를 혼용한 경우
특히 Milvus는 컬렉션 스키마와 인덱스 파라미터가 고정되기 때문에, 차원 변경은 거의 항상 재색인이 필요합니다.
3) 청킹/전처리 변경 확인
- chunk size, overlap 변경
- 문서 텍스트 추출 로직 변경(PDF 파서, HTML stripper)
이 변화는 코드 diff만 보면 작아 보여도, 임베딩 입력이 크게 바뀌어 검색 품질이 급락할 수 있습니다.
4) 근사 탐색 파라미터 재튜닝 필요 여부
분포가 바뀌면 nprobe나 efSearch 같은 파라미터의 최적점도 바뀝니다. “드리프트가 약간”이어도, 근사 탐색이 그 약간을 증폭시켜 recall이 떨어질 수 있습니다.
대응 전략: 재색인만이 답인가
드리프트의 종류에 따라 대응이 달라집니다.
A) 모델이 바뀌었다면: 버저닝과 이중 인덱스가 정석
embedding_version을 메타데이터로 저장- 새 버전 인덱스를 별도로 구축(이중 인덱스)
- 트래픽을 점진적으로 전환(canary)
이 방식은 비용이 들지만, “검색 품질 급락” 같은 대형 사고를 가장 확실히 막습니다.
B) 전처리/청킹이 바뀌었다면: 재색인 범위를 줄여라
모든 문서를 재색인하기 전에, 영향 범위를 먼저 계산할 수 있습니다.
- 변경된 파서/규칙이 적용되는 문서만 재처리
- chunking 규칙이 바뀐 경우, 문서 단위가 아니라 “chunk 단위”로 증분 재생성
C) 점수 스케일이 바뀌었다면: threshold 로직부터 점검
많은 RAG 시스템은
score가 특정 값 이하이면 “검색 실패” 처리- 또는 Top-K 평균 점수가 낮으면 “웹 검색 fallback”
같은 정책을 둡니다. 드리프트로 점수 스케일이 바뀌면 이 정책이 오작동해 체감 품질이 더 급락합니다. 먼저 threshold를 로그 기반으로 재보정하세요.
운영 가드레일: 재발 방지를 위한 설계
1) 임베딩 파이프라인에 “서명(signature)” 남기기
문서/쿼리 임베딩을 만들 때 아래를 함께 저장하면, 사고 원인 파악이 빨라집니다.
- 모델 이름과 버전
- 전처리 버전
- chunking 파라미터
- 차원 수
이 값들을 합쳐 해시를 만들고, embedding_signature로 메타데이터에 넣는 방식이 실전에서 유용합니다.
2) 배포 파이프라인에서 드리프트 테스트를 게이트로
CI에서 샘플 문서/쿼리를 임베딩해 PSI 같은 지표를 계산하고, 기준을 넘으면 배포를 막는 방식입니다. 이때 캐시나 빌드 환경 차이로 테스트가 흔들리면 신뢰도가 떨어지니, CI 캐시/환경을 안정화하는 것도 중요합니다. 관련해서는 Docker BuildKit 캐시 깨짐? GitLab CI 속도 3배 같은 캐시 안정화 글이 참고가 됩니다.
3) 온라인 모니터링: “고정 쿼리 세트”로 매일 리그레션
- 실제 사용자 쿼리에서 대표 샘플을 뽑아 고정
- 매일 Top-K 결과의 변화율, 점수 분포, 중복률 등을 기록
이 방식은 라벨 없이도 품질 변화를 빠르게 감지합니다.
4) 앱 레벨 캐시/ISR가 품질 이슈를 가리는 경우
검색 품질 문제를 디버깅할 때, 프론트/엣지 캐시가 과거 결과를 보여줘서 “문제가 없는 것처럼 보였다가” 캐시 만료 후 갑자기 폭발하는 경우가 있습니다. Next.js를 쓴다면 ISR 캐시 동작도 함께 점검하세요. Next.js ISR 캐시 꼬임으로 404·구버전 뜰 때 해결 관점이 그대로 적용됩니다.
Pinecone와 Milvus에서 특히 자주 하는 실수
Pinecone: 네임스페이스/인덱스 전략 없이 모델만 교체
- 같은 인덱스에 새 임베딩을 섞어 넣으면, 결과가 “부분적으로만” 망가져서 더 찾기 어렵습니다.
- 모델 버전별로 namespace를 분리하거나, 인덱스를 분리하는 편이 안전합니다.
Milvus: 인덱스 파라미터를 고정값으로 박아두기
- 데이터 분포가 바뀌면
nprobe최적값도 바뀝니다. - 드리프트 감지 후에는 재색인뿐 아니라 탐색 파라미터 재튜닝 도 체크리스트에 넣으세요.
결론: “벡터DB가 느려졌다”보다 먼저 드리프트를 의심하라
Pinecone·Milvus에서 검색 품질이 급락할 때, 가장 먼저 확인할 것은 CPU나 인덱스 상태가 아니라 임베딩 공간이 동일한가 입니다.
- 모델/전처리/청킹이 바뀌면 임베딩 드리프트가 생긴다
- 드리프트는 PSI, 노름/점수 분포, 이웃 안정성으로 라벨 없이도 탐지 가능하다
- 대응은 재색인만이 아니라 버저닝, 이중 인덱스, threshold 재보정, 파라미터 재튜닝까지 포함된다
운영 관점에서 가장 강력한 해법은 “임베딩 버전 관리”와 “고정 쿼리 리그레션 모니터링”입니다. 이 두 가지만 갖춰도 검색 품질 급락의 상당수를 배포 전에 막을 수 있습니다.