Published on

RAG 벡터DB 드리프트 잡는 재색인·하이브리드 검색

Authors

RAG 시스템을 운영하다 보면 어느 순간부터 답변이 “그럴듯하지만 근거가 약한” 방향으로 미끄러지는 경험을 하게 됩니다. 로그를 보면 검색 결과가 예전보다 덜 관련 있고, 상위 컨텍스트가 바뀌거나, 아예 엉뚱한 문서가 끼어드는 현상이 반복됩니다. 많은 경우 원인은 모델이 아니라 벡터DB 쪽의 드리프트(drift) 입니다.

이 글에서는 RAG에서 발생하는 벡터DB 드리프트를 어떻게 관측하고, 언제/어떻게 재색인(리임베딩)해야 하며, 하이브리드 검색으로 어떻게 완충할지 운영 관점에서 정리합니다. 또한 대규모 재색인 시 자주 터지는 타임아웃, 레이트리밋, 배치 실패를 줄이기 위한 구현 패턴도 함께 다룹니다.

벡터DB 드리프트란 무엇인가

벡터DB 드리프트는 한 문장으로 말하면 “현재 쿼리 임베딩과 과거에 저장된 문서 임베딩이 같은 공간에서 의미적으로 잘 맞지 않게 되는 상태” 입니다. 드리프트는 크게 세 가지로 나뉩니다.

1) 임베딩 모델 드리프트

  • 임베딩 모델을 교체하거나 버전을 올렸는데, 기존 문서 벡터는 그대로인 경우
  • 동일 모델이라도 토크나이저/정규화/풀링 방식이 바뀌면 공간이 달라질 수 있음

증상

  • 검색 상위 결과의 평균 코사인 유사도가 떨어짐
  • 특정 도메인(예: 최신 정책, 최신 제품명)에서만 급격히 recall이 감소

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

  • 문서가 대량 추가되면서 기존 문서의 상대적 중요도가 변함
  • chunking 규칙 변경(문단 길이, overlap)로 “비슷한 내용이 더 잘게/더 크게” 들어옴

증상

  • 중복 chunk가 상위 랭킹을 점유
  • 최신 문서가 항상 상위에 뜨거나 반대로 아예 안 뜸

3) 인덱스/검색 파라미터 드리프트

  • HNSW, IVF 등 ANN 파라미터 변경 또는 리빌드
  • efSearch, nprobe, topK 조정으로 품질-지연 트레이드오프가 바뀜

증상

  • 트래픽 증가와 함께 latency는 안정적이지만 정답률이 서서히 하락
  • 재현이 어려운 “가끔” 엉뚱한 결과가 상위에 등장

드리프트를 관측하는 운영 지표

드리프트는 “느낌”으로만 판단하면 재색인을 과하게 하거나, 반대로 늦게 대응하게 됩니다. 최소한 아래 지표를 배치로 수집해 시계열로 봐야 합니다.

1) 검색 점수 분포

  • 상위 1개, 상위 5개, 상위 10개의 평균 유사도
  • 유사도 분산(variance)

의미

  • 평균이 내려가면 전반적 정합성이 무너진 것
  • 분산이 커지면 특정 유형 쿼리에서만 드리프트가 발생한 것

2) 컨텍스트 안정성 지표

동일/유사 쿼리에 대해 상위 문서가 얼마나 유지되는지 봅니다.

  • Jaccard(topK_t, topK_t-7days) 같은 형태

의미

  • 코퍼스가 크게 변하지 않았는데도 topK가 자주 바뀌면 임베딩/인덱스 드리프트 가능

3) 다운스트림 품질 지표

  • 정답률(골든셋), human eval, 또는 간단한 proxy 지표
  • “근거 문서 포함률”(정답 문서가 topK에 포함되는 비율)

4) 실패율과 재시도율

재색인 파이프라인은 대량 API 호출로 인해 실패가 흔합니다. 임베딩 API가 429, 529 같은 과부하/레이트리밋을 내면 재색인 자체가 흔들립니다. 이때는 재시도와 백오프를 표준화하는 것이 중요합니다. 관련 패턴은 Claude API 529·429 재시도 전략과 구현 패턴 글의 접근을 그대로 응용할 수 있습니다.

재색인(리임베딩) 전략: 언제, 무엇을, 어떻게

재색인은 비용이 큽니다. “전체 재색인”을 버튼처럼 누르면 끝나는 문제가 아니라, 스키마/버전/트래픽/일관성을 함께 설계해야 합니다.

재색인이 필요한 대표 시나리오

  • 임베딩 모델 변경(가장 확실)
  • chunking 규칙 변경(문서 벡터가 완전히 달라짐)
  • 검색 품질 지표가 임계치 이하로 하락
  • ANN 인덱스 재빌드 후 품질 변동이 커짐

1) 임베딩 버전 필드로 이중 인덱싱

운영에서 가장 안전한 방식은 새 버전 임베딩을 별도 컬렉션/인덱스에 병행 구축하는 것입니다.

권장 메타데이터 예시

  • doc_id: 원문 문서 ID
  • chunk_id: 청크 ID
  • embedding_version: 예: v1, v2
  • source_updated_at: 원문 갱신 시각
  • chunk_hash: chunk 텍스트의 해시(중복/변경 감지)

이렇게 해두면

  • v2 구축 중에도 v1로 서비스 지속
  • A/B 테스트로 v2 품질 확인 후 스위칭
  • 롤백이 쉬움

2) 전체 재색인 vs 증분 재색인

전체 재색인

장점

  • 일관성이 가장 좋음
  • 파라미터/전처리 변경을 깔끔하게 반영

단점

  • 비용과 시간이 큼
  • 백필(backfill) 중 검색 품질이 흔들릴 수 있음

증분 재색인

원칙

  • chunk_hash가 바뀐 것만 리임베딩
  • 새 문서만 임베딩

주의점

  • 임베딩 모델이 바뀌면 증분으로는 해결이 안 됨(공간 자체가 다름)

3) 스냅샷 기반 스위치오버

대규모 재색인을 할 때는 “부분적으로 섞인” 상태가 가장 위험합니다. 다음 같은 절차를 권장합니다.

  1. 신규 인덱스 index_v2 생성
  2. 전체/증분 백필 수행
  3. 골든셋으로 retrieval 품질 검증
  4. 트래픽 일부만 index_v2로 라우팅(A/B)
  5. 문제 없으면 전체 전환
  6. 일정 기간 후 index_v1 삭제

하이브리드 검색: 드리프트를 완충하는 가장 현실적인 방법

벡터 검색은 의미 기반이 강하지만, 드리프트가 오면 “의미 공간”이 흔들립니다. 반면 키워드 기반(BM25 등)은 모델 드리프트에 덜 민감합니다. 그래서 운영 RAG에서는 하이브리드 검색이 사실상 표준에 가깝습니다.

하이브리드의 핵심은

  • 벡터 검색이 놓치는 키워드 정확도를 BM25가 보완
  • BM25가 놓치는 동의어/표현 다양성을 벡터가 보완
  • 둘 중 하나가 드리프트/품질 저하가 와도 다른 쪽이 안전망 역할

하이브리드 결합 방식 3가지

1) 점수 가중합(Weighted Sum)

  • score = alpha * bm25 + (1 - alpha) * vector
  • 단, 서로 스케일이 다르므로 정규화가 필요

2) Reciprocal Rank Fusion(RRF)

스코어 스케일 문제를 피하기 좋아 운영에서 많이 씁니다.

  • RRF(d) = sum(1 / (k + rank_i(d)))

3) 2단계 리랭킹

1단계에서 넓게 후보를 모으고 2단계에서 cross-encoder 또는 LLM 기반 리랭커로 최종 정렬

비용은 늘지만 “검색 품질”을 가장 확실히 끌어올립니다.

구현 예제: 재색인 파이프라인(증분) + 버전 관리

아래는 문서 chunk를 생성하고, 해시 기반으로 변경분만 임베딩한 뒤, embedding_version을 붙여 upsert하는 예시입니다. 벡터DB는 제품마다 API가 달라서, 인터페이스 중심으로 작성합니다.

import crypto from "crypto";

type Chunk = {
  docId: string;
  chunkId: string;
  text: string;
  sourceUpdatedAt: string;
};

type VectorRecord = {
  id: string; // `${docId}:${chunkId}:${embeddingVersion}`
  values: number[];
  metadata: {
    doc_id: string;
    chunk_id: string;
    embedding_version: string;
    source_updated_at: string;
    chunk_hash: string;
  };
};

function sha256(text: string) {
  return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}

async function embed(texts: string[]): Promise<number[][]> {
  // 실제 임베딩 API 호출로 교체
  // 실패/레이트리밋 대비 재시도는 별도 래퍼로 감싸는 것을 권장
  return texts.map(() => Array.from({ length: 1536 }, () => Math.random()));
}

interface VectorDB {
  fetchMetadata(ids: string[]): Promise<Array<{ id: string; chunk_hash?: string }>>;
  upsert(records: VectorRecord[]): Promise<void>;
}

export async function incrementalReindex(
  db: VectorDB,
  chunks: Chunk[],
  embeddingVersion: string,
  batchSize = 64
) {
  // 1) 대상 ID 구성
  const ids = chunks.map(
    (c) => `${c.docId}:${c.chunkId}:${embeddingVersion}`
  );

  // 2) 기존 메타데이터 조회(해시 비교)
  const existing = await db.fetchMetadata(ids);
  const existingMap = new Map(existing.map((e) => [e.id, e.chunk_hash]));

  // 3) 변경분만 선별
  const toIndex: Chunk[] = [];
  for (const c of chunks) {
    const id = `${c.docId}:${c.chunkId}:${embeddingVersion}`;
    const hash = sha256(c.text);
    if (existingMap.get(id) !== hash) toIndex.push(c);
  }

  // 4) 배치 임베딩 + upsert
  for (let i = 0; i < toIndex.length; i += batchSize) {
    const batch = toIndex.slice(i, i + batchSize);
    const vectors = await embed(batch.map((b) => b.text));

    const records: VectorRecord[] = batch.map((b, idx) => {
      const chunkHash = sha256(b.text);
      return {
        id: `${b.docId}:${b.chunkId}:${embeddingVersion}`,
        values: vectors[idx],
        metadata: {
          doc_id: b.docId,
          chunk_id: b.chunkId,
          embedding_version: embeddingVersion,
          source_updated_at: b.sourceUpdatedAt,
          chunk_hash: chunkHash,
        },
      };
    });

    await db.upsert(records);
  }

  return {
    total: chunks.length,
    reindexed: toIndex.length,
    embeddingVersion,
  };
}

포인트

  • embedding_version을 ID와 메타데이터에 모두 포함해 혼합 상태를 피합니다.
  • chunk_hash로 증분 재색인을 안정화합니다.
  • 임베딩 호출은 반드시 재시도/백오프/서킷브레이커를 고려해야 합니다.

구현 예제: 하이브리드 검색 + RRF 결합

BM25와 벡터 topK를 각각 구한 뒤 RRF로 합치는 간단한 예시입니다.

from collections import defaultdict

def rrf_fusion(rank_lists, k=60):
    scores = defaultdict(float)
    for docs in rank_lists:
        for rank, doc_id in enumerate(docs, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# 예시: 각각 상위 문서 ID 리스트
bm25_topk = ["d7", "d2", "d9", "d1"]
vec_topk  = ["d2", "d3", "d7", "d8"]

fused = rrf_fusion([bm25_topk, vec_topk], k=60)
print(fused[:5])

운영 팁

  • 벡터 검색 품질이 흔들리는 기간에는 BM25 비중을 키우는 식으로 “안전 모드”를 둘 수 있습니다.
  • 반대로 코퍼스가 매우 짧거나 키워드가 불안정한 도메인(구어체, 상담 로그 등)에서는 벡터 비중을 키웁니다.

드리프트를 줄이는 인덱싱 설계 체크리스트

1) chunking을 스펙으로 고정하고 버전 관리

  • chunk 길이, overlap, 구분자 규칙을 코드 상수로만 두지 말고 “스펙”으로 기록
  • 변경 시 chunking_version을 올리고, 재색인 범위를 명시

2) 중복 chunk 방지

  • 동일 chunk가 여러 문서에서 재사용되면 검색 결과가 중복으로 도배될 수 있음
  • chunk_hash 기반 dedup 테이블을 두거나, upsert 키를 설계해 중복을 억제

3) 메타데이터 필터를 적극 활용

  • 테넌트/프로덕트/언어/권한 등 필터가 없으면 “검색은 되는데 답이 틀린” 사고가 잦습니다.
  • RAG의 품질 문제처럼 보여도 실제로는 권한 필터 누락인 경우가 많습니다.

4) 재색인 작업의 동시성 제어

  • 임베딩 API와 벡터DB upsert는 모두 병목이 생깁니다.
  • 무작정 병렬을 올리면 429가 늘고 전체 처리량이 오히려 떨어집니다.

재시도/백오프 패턴은 앞서 언급한 글(Claude API 529·429 재시도 전략과 구현 패턴)의 구조를 참고해, 재색인 워커에 그대로 적용하는 것이 좋습니다.

“재색인만”으로 해결되지 않는 경우: 리랭커와 평가셋

드리프트를 잡기 위해 재색인을 했는데도 품질이 회복되지 않는다면, 다음을 의심해야 합니다.

  • 골든셋이 현재 사용자 질문 분포를 반영하지 못함
  • chunk가 답을 만들기에 충분한 단위가 아님(너무 짧거나 너무 김)
  • topK는 맞지만 최종 생성 단계에서 근거 사용이 약함

이때는

  • 2단계 리랭킹(특히 cross-encoder 계열)
  • “근거 인용 강제” 프롬프트
  • retrieval 평가셋(질문-정답-근거문서) 업데이트

가 같이 필요합니다.

대규모 재색인 운영 팁: CI/CD와 캐시

재색인 자체는 애플리케이션 배포와 별개로 돌아가지만, 실제로는 다음이 자주 엮입니다.

  • 임베딩 모델 버전 변경과 함께 코드 배포
  • 인덱싱 워커 도커 이미지 빌드/배포
  • 배치 잡이 잦아져 CI 시간이 늘어남

이때 도커 빌드 캐시를 제대로 쓰면 재색인 워커의 배포 비용을 크게 줄일 수 있습니다. 관련해서는 Docker BuildKit 캐시로 CI 빌드 70% 줄이기 글의 전략이 그대로 도움이 됩니다.

정리: 드리프트 대응의 우선순위

  1. 드리프트를 “감”이 아니라 지표로 본다(유사도 분포, topK 안정성, 근거 포함률)
  2. 임베딩/청킹 변경은 embedding_version으로 이중 인덱싱하고 스위치오버한다
  3. 증분 재색인은 chunk_hash로 안전하게, 전체 재색인은 스냅샷 방식으로
  4. 하이브리드 검색(RRF 등)으로 드리프트를 완충하고, 필요 시 2단계 리랭킹을 붙인다
  5. 재색인 워커는 레이트리밋/과부하를 전제로 재시도·백오프를 설계한다

RAG 운영에서 벡터DB 드리프트는 “언젠가 반드시” 옵니다. 중요한 건 한 번에 완벽한 인덱스를 만드는 게 아니라, 드리프트가 와도 서비스 품질을 유지하고 빠르게 복구할 수 있는 체계를 갖추는 것입니다.