Published on

RAG 품질 급락? 임베딩 드리프트 탐지·재임베딩

Authors

서빙 중인 RAG가 어느 날부터 “문서가 있는데도 못 찾는” 느낌으로 망가지기 시작했다면, 단순히 청크 전략이나 프롬프트 문제가 아니라 임베딩 드리프트(embedding drift) 가능성을 먼저 점검해야 합니다. 특히 임베딩 모델 교체, 벡터DB 설정 변경, 문서 전처리 파이프라인 수정, 데이터 분포 변화가 겹치면 검색 품질이 급락하는 일이 흔합니다.

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

  • 임베딩 드리프트가 무엇이고, RAG 품질에 어떤 형태로 나타나는지
  • 드리프트를 “감”이 아니라 지표로 탐지하는 방법
  • 재임베딩과 재인덱싱을 무중단에 가깝게 운영하는 절차
  • 실제 운영에서 자주 터지는 함정(차원 불일치, 정규화, 거리 메트릭, 청크 버전 불일치)

RAG 자체를 꼭 써야 하는지 고민 중이라면, 검색 없이도 챗봇 품질을 끌어올리는 접근도 함께 참고할 만합니다: Assistants·LangChain로 RAG 없이 챗봇 고도화

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

임베딩 드리프트는 “같은 의미 공간”이라고 믿고 운영하던 벡터 표현이, 시간에 따라 또는 구성 변경에 따라 서로 다른 공간이 되어버리는 현상입니다. 드리프트가 발생하면 다음 문제가 생깁니다.

  • 과거에 색인한 문서 벡터와, 현재 쿼리 임베딩이 동일한 좌표계에 있지 않음
  • Top-k가 엉뚱한 문서를 반환하거나, 유사도 점수가 전반적으로 낮아짐
  • 리랭커가 있어도 후보군 자체가 틀려서 회복이 안 됨

드리프트는 크게 두 종류로 나눌 수 있습니다.

1) 구성 드리프트(인위적 드리프트)

운영자가 바꾼 설정 때문에 생깁니다.

  • 임베딩 모델 버전 변경(예: text-embedding-3-small에서 다른 모델로)
  • 차원 수 변경(모델마다 embedding dimension이 다름)
  • 정규화 방식 변경(예: L2 normalize 적용 여부)
  • 거리 메트릭 변경(코사인 기반으로 운영하다가 dot product로)
  • 청크 분할 규칙 변경(문서 벡터가 표현하는 의미 단위 자체가 달라짐)

2) 데이터 드리프트(자연 드리프트)

데이터 분포가 바뀌면서 생깁니다.

  • 신규 문서가 기존과 다른 도메인/용어를 포함
  • 문서 템플릿 변경(표, 코드 블록, 로그가 급증)
  • 언어 비율 변화(한글 위주에서 영문 문서가 증가)

데이터 드리프트 자체는 “정상”일 수 있지만, 검색 품질 저하로 이어지면 관측하고 대응해야 합니다.

드리프트 증상: RAG 품질 급락이 실제로 보이는 방식

현장에서 자주 보는 증상은 다음과 같습니다.

  • 동일한 질문을 해도 과거 대비 “근거 문서”가 전혀 다른 문서로 바뀜
  • Top-k 유사도 점수(코사인 등)가 전반적으로 낮아짐
  • 사용자 피드백에서 “문서에 있는데 못 찾는다”가 급증
  • 특정 기간 이후로만 품질이 나빠짐(배포 시점과 일치)

여기서 중요한 포인트는, 드리프트는 종종 LLM 응답 품질 문제처럼 보이지만 실제 원인은 “검색 후보군 붕괴”인 경우가 많다는 점입니다.

탐지 전략: 임베딩 드리프트를 지표로 잡아내기

드리프트 탐지는 “정답 데이터가 없으면 못 한다”라고 생각하기 쉽지만, 운영 환경에서는 다음과 같은 약지도/무지도 지표로 충분히 이상을 잡아낼 수 있습니다.

1) 쿼리-문서 유사도 분포 모니터링

가장 간단하면서도 효과적인 방법입니다.

  • 매 요청마다 Top-1 또는 Top-k의 유사도 점수를 로깅
  • 배포 전후로 점수 분포가 유의미하게 이동했는지 확인

예를 들어 코사인 유사도 기준으로, 과거에는 Top-1이 평균 0.78이었는데 배포 후 0.62로 떨어졌다면 강한 신호입니다.

아래는 Python으로 일 단위 Top-1 점수 분포를 비교하는 예시입니다.

import pandas as pd
from scipy.stats import ks_2samp

# logs.csv 예시 컬럼: ts, query, top1_score, embedding_model, index_version
logs = pd.read_csv("logs.csv", parse_dates=["ts"])

before = logs[(logs.ts < "2026-02-01") & (logs.embedding_model == "text-embedding-3-small")]["top1_score"].dropna()
after = logs[(logs.ts >= "2026-02-01") & (logs.embedding_model == "text-embedding-3-small")]["top1_score"].dropna()

stat, p = ks_2samp(before, after)
print({"ks_stat": float(stat), "p_value": float(p), "before_mean": float(before.mean()), "after_mean": float(after.mean())})

# p_value가 매우 작고(mean 하락)라면 분포 변화(드리프트) 가능성이 큼
  • KS test는 분포 변화 탐지에 자주 쓰입니다.
  • 점수 스케일은 벡터DB/거리 메트릭에 따라 다르니 “절대값”보다 “변화”에 집중하세요.

2) 앵커 쿼리(고정 질의) 기반 회귀 테스트

운영에서 가장 추천하는 방식입니다.

  • 도메인 대표 질문 50개에서 500개 정도를 “앵커 쿼리”로 고정
  • 각 쿼리에 대해 기대하는 문서 doc_id 또는 키워드를 약하게라도 저장
  • 배포 전후로 Top-k hit rate, MRR 등을 비교

정답 라벨을 완벽히 만들 필요는 없습니다.

  • “이 질문은 최소한 A 문서가 Top-10에는 있어야 한다” 수준이면 충분
  • 라벨이 없더라도 “이전 인덱스가 반환하던 Top-3와의 겹침 비율” 같은 지표도 유용

3) 인덱스 내부 자기일관성 체크(문서-문서)

문서 임베딩은 보통 시간에 따라 누적됩니다. 이때 “새로 들어온 문서들이 기존 문서들과 너무 멀리 떨어지는지”를 볼 수 있습니다.

  • 신규 문서 집합의 최근접 이웃 거리 분포
  • 클러스터링 후 군집 수/실루엣 변화

이는 데이터 드리프트(문서 분포 변화) 탐지에도 도움이 됩니다.

4) 운영 메타데이터 불일치 탐지(가장 많이 터지는 원인)

드리프트처럼 보이지만 사실은 “불일치”인 경우가 많습니다.

  • 임베딩 차원 불일치(예: 1536 차원 인덱스에 3072 차원 쿼리)
  • 정규화 불일치(문서는 normalize, 쿼리는 raw)
  • 거리 메트릭 불일치(코사인용 인덱스에 dot product로 쿼리)
  • 청크 버전 불일치(문서는 v1 청크, 쿼리는 v2 전처리)

따라서 모든 벡터에 다음 메타를 “필수”로 붙이는 것을 권장합니다.

  • embedding_model
  • embedding_dim
  • normalize 여부
  • chunker_version
  • index_version

재임베딩이 필요한 시점: 언제까지 버틸 수 있나

재임베딩은 비용이 큰 작업이라 “무조건 최신으로”가 답은 아닙니다. 다음 조건 중 하나라도 해당하면 재임베딩을 강하게 고려하세요.

  • 임베딩 모델을 변경했고, 과거 문서 벡터를 그대로 쓰고 있다
  • 전처리/청크 전략을 바꿨고, 검색 결과가 체감될 정도로 흔들린다
  • 앵커 쿼리 회귀에서 Top-k hit rate가 의미 있게 하락했다
  • 유사도 점수 분포가 갑자기 이동했고, 배포 시점과 일치한다

반대로, 데이터만 조금씩 늘어나는 상황이라면 “증분 임베딩”만으로도 충분할 수 있습니다.

운영 설계: 인덱스 버저닝과 무중단 재임베딩

재임베딩의 핵심은 “새 인덱스를 만들고, 트래픽을 점진적으로 전환”하는 것입니다.

권장 아키텍처

  • 인덱스는 index_v1, index_v2처럼 버전으로 분리
  • 애플리케이션은 active_index_version을 설정으로 참조
  • 재임베딩 파이프라인은 새 버전에만 write
  • 검증 통과 후 read 트래픽을 v2로 스위치

메타 스키마 예시

{
  "doc_id": "kb_12345",
  "chunk_id": "kb_12345#0007",
  "text": "...",
  "embedding": [0.0123, -0.0456],
  "embedding_model": "text-embedding-3-small",
  "embedding_dim": 1536,
  "normalize": true,
  "chunker_version": "chunk_v3",
  "index_version": "index_v2",
  "updated_at": "2026-02-26T10:20:30Z"
}

듀얼 리드(dual read)로 안전하게 비교

완전 스위치 전에 일정 비율 트래픽 또는 내부 테스트에서 v1과 v2를 동시에 조회해 비교합니다.

  • v1 Top-k와 v2 Top-k의 겹침 비율
  • v2 결과의 리랭커 점수 상승 여부
  • 앵커 쿼리 hit rate

듀얼 리드 의사코드

def search(query: str, active_version: str, shadow_version: str | None = None):
    primary = vectordb.search(query, index_version=active_version, top_k=10)

    if shadow_version:
        shadow = vectordb.search(query, index_version=shadow_version, top_k=10)
        log_compare(query, primary, shadow)

    return primary

이 방식의 장점은 “실제 트래픽 분포”에서 새 인덱스를 검증할 수 있다는 점입니다.

재임베딩 파이프라인: 실무 체크리스트

재임베딩은 단순히 문서를 다시 임베딩하는 작업이 아니라, 전처리부터 인덱싱까지 전체 파이프라인의 재현성이 중요합니다.

1) 입력 텍스트 정규화 고정

다음이 바뀌면 벡터가 크게 달라집니다.

  • 공백/개행 처리
  • 마크다운 제거 여부
  • 코드 블록 보존 여부
  • 표/리스트 직렬화 방식

“문서 파서 버전”을 명시하고, 동일 버전으로 재임베딩해야 합니다.

2) 청크 전략 버전 고정

청크 전략이 바뀌면 chunk_id 체계도 바뀌고, 검색 후보군의 의미 단위가 바뀝니다.

  • 토큰 기반 청크
  • 문단 기반 청크
  • 슬라이딩 윈도우 오버랩

청크 버전이 바뀌는 재임베딩이라면, 기존 chunk_id에 의존하는 캐시/피드백/라벨 데이터도 함께 마이그레이션 계획을 세우세요.

3) 임베딩 생성의 결정성 확보

가능하면 다음을 로그로 남기세요.

  • 모델명(정확한 문자열)
  • 차원 수
  • normalize 여부
  • 배치 크기, 재시도 정책
  • 실패한 문서 목록

OpenAI 임베딩 생성 예시

아래 예시는 “임베딩 생성 시 normalize를 명시적으로 적용”하는 패턴입니다. 코사인 유사도를 쓰는 벡터DB라면 L2 normalize를 강제해 일관성을 확보하는 것이 좋습니다.

import numpy as np
from openai import OpenAI

client = OpenAI()

def l2_normalize(v: list[float]) -> list[float]:
    x = np.array(v, dtype=np.float32)
    n = np.linalg.norm(x)
    if n == 0:
        return v
    return (x / n).tolist()

def embed_text(text: str, model: str = "text-embedding-3-small") -> list[float]:
    resp = client.embeddings.create(
        model=model,
        input=text
    )
    vec = resp.data[0].embedding
    return l2_normalize(vec)

주의할 점은 “문서 벡터는 normalize인데 쿼리 벡터는 raw” 같은 비대칭이 생기지 않도록, 쿼리 임베딩에도 동일 함수를 적용해야 한다는 것입니다.

4) 벡터DB 인덱스 설정을 재현 가능하게

인덱스 타입(HNSW, IVF 등)과 파라미터가 바뀌면 검색 리콜이 달라집니다.

  • HNSW의 ef_search, M
  • IVF의 nlist, nprobe

재임베딩과 함께 인덱스 파라미터를 바꾸는 경우, “모델 변경 효과”와 “인덱스 변경 효과”가 섞여 원인 분석이 어려워집니다. 가능하면 한 번에 하나만 바꾸고, 불가피하면 실험 설계를 분리하세요.

드리프트를 줄이는 설계 패턴

드리프트는 완전히 없앨 수는 없지만, 운영 리스크를 크게 낮출 수 있습니다.

1) 모델 변경은 항상 인덱스 버전 변경과 함께

  • 모델만 바꾸고 기존 인덱스를 유지하면 거의 항상 문제가 납니다.
  • “임베딩 모델 버전”과 “인덱스 버전”을 1대1로 묶는 것이 안전합니다.

2) 쿼리/문서 임베딩 파이프라인을 단일 모듈로

  • 문서 임베딩과 쿼리 임베딩이 서로 다른 코드 경로를 타면 불일치가 생깁니다.
  • 공통 라이브러리로 분리하고, 동일한 normalize/전처리를 강제하세요.

3) 회귀 테스트를 CI에 넣기

앵커 쿼리 기반 평가는 배포 안전장치로 매우 강력합니다.

  • PR 단계에서 “인덱스 빌드까지”는 무겁다면
  • 최소한 “임베딩 생성 및 유사도 분포 sanity check”라도 자동화하세요

배포 자동화 과정에서 인증/권한 이슈가 자주 발목을 잡는다면, OIDC 기반으로 파이프라인을 정리해두는 것도 도움이 됩니다: GitHub Actions OIDC로 AWS 배포 실패 해결 가이드

재임베딩 이후 검증: 무엇을 보고 성공이라 말할까

재임베딩의 성공 기준을 미리 정해두면, “언제 스위치할지”가 명확해집니다.

  • 앵커 쿼리 Top-10 hit rate가 v1 대비 같거나 상승
  • Top-1 유사도 분포 평균/중앙값이 회복
  • 사용자 피드백에서 “못 찾는다” 비율 감소
  • LLM 응답의 인용 문서 일치율 상승(가능하다면)

추가로, 재임베딩은 대규모 배치 작업이라 실패/재시도/재시작 이슈가 많습니다. 파이프라인을 systemd 서비스로 운영한다면 재시작 루프 진단 체크도 함께 준비해두세요: systemd 서비스가 계속 재시작될 때 진단 체크리스트

자주 겪는 함정 7가지

마지막으로, “드리프트 탐지”보다 더 흔한 실수들을 정리합니다.

  1. 차원 불일치: 인덱스는 1536인데 쿼리는 3072로 들어감
  2. normalize 불일치: 문서만 L2 normalize, 쿼리는 raw
  3. 거리 메트릭 불일치: 코사인 인덱스에 dot product 점수로 해석
  4. 청크 버전 혼재: v1 청크와 v2 청크가 한 인덱스에 섞임
  5. 필터 조건 변경: 메타 필터가 바뀌어 검색 대상이 달라짐
  6. 언어/도메인 혼합: 한 인덱스에 서로 다른 도메인을 무분별하게 적재
  7. 평가 부재: 앵커 쿼리나 분포 모니터링 없이 체감으로만 운영

결론

RAG 품질 급락은 “LLM이 멍청해졌다”의 문제가 아니라, 상당수는 임베딩 공간의 불일치 또는 검색 인프라 설정 변화에서 시작됩니다. 따라서 운영 관점에서는

  • 유사도 분포와 앵커 쿼리로 드리프트를 정량 탐지하고
  • 인덱스 버저닝과 듀얼 리드로 재임베딩을 안전하게 검증한 뒤
  • 메타데이터로 불일치를 원천 차단하는 것

이 3가지를 갖추면, 재임베딩은 “대형 장애 이벤트”가 아니라 “정기적인 유지보수 작업”으로 바뀝니다.

다음 단계로는, 현재 운영 중인 벡터DB와 트래픽 패턴(일 요청 수, 문서 수, 증분 업데이트 주기)을 기준으로 재임베딩 비용과 무중단 전환 전략을 더 구체화해보는 것을 권장합니다.