Published on

Rust+Qdrant RAG에서 벡터 드리프트 잡는 법

Authors

서빙 중인 RAG(Retrieval-Augmented Generation) 시스템이 어느 날부터 “비슷한 문서를 못 찾는다”, “근거가 엉뚱하다” 같은 증상을 보이면, 원인은 대개 벡터 드리프트(vector drift) 입니다. 드리프트는 단순히 데이터가 늘어난다는 의미가 아니라, 임베딩 공간 자체의 의미가 시간에 따라 변하면서 기존 벡터와 신규 벡터가 같은 좌표계에 있지 않게 되는 현상을 포함합니다.

이 글은 Rust + Qdrant 조합으로 RAG를 운영할 때, 드리프트를 측정하고(Detect), 영향을 줄이며(Mitigate), 안전하게 재색인(Reindex) 하는 방법을 실무 관점에서 정리합니다. (예시는 Qdrant의 HTTP API를 Rust에서 호출하는 방식으로 설명합니다.)

관련해서 Qdrant 기반 장기메모리/RAG 설계 자체가 필요하다면 먼저 AutoGPT 장기메모리 - Qdrant+RAG로 환각 줄이기도 같이 보면 맥락 잡기에 좋습니다.

벡터 드리프트의 대표 원인 5가지

1) 임베딩 모델 버전 변경

가장 흔합니다. text-embedding-3-large에서 text-embedding-3-small로 바꾸거나, 같은 모델이라도 파라미터/전처리가 달라지면 벡터 공간이 달라집니다. 기존 벡터와 신규 벡터를 같은 컬렉션에 섞으면 검색이 급격히 흔들립니다.

2) 전처리/청킹 정책 변경

토크나이저가 바뀌거나, 청크 길이(chunk_size), 오버랩(overlap)이 바뀌면 문서 의미 분포가 달라집니다. 특히 “제목+본문 결합” 여부, 코드 블록 제거 여부 같은 전처리 차이가 드리프트를 키웁니다.

3) 코퍼스 자체의 주제 분포 변화

제품이 확장되면서 새로운 도메인 문서가 들어오면, 기존에 잘 동작하던 쿼리들이 다른 주제에 “끌려가” 상위 랭크가 바뀔 수 있습니다.

4) 인덱스/거리 함수/정규화 변경

Qdrant에서 코사인 유사도를 쓰면서 정규화 정책이 바뀌거나, HNSW 파라미터(m, ef_construct, ef) 변경으로 근사탐색 특성이 달라지면 품질이 달라집니다.

5) 서빙 계층의 캐시/병렬 처리 버그

임베딩 결과 캐시 키가 불완전해서 다른 텍스트의 벡터를 재사용하는 경우도 있습니다. 이건 “드리프트처럼 보이는 데이터 오염”입니다.

드리프트를 “정의”부터 고정하기: 임베딩 스키마 버전

운영에서 중요한 건 “드리프트를 없애는 것”이 아니라, 드리프트를 통제 가능한 이벤트로 만드는 것입니다. 가장 먼저 할 일은 문서 포인트(payload)에 다음 메타데이터를 강제하는 것입니다.

  • embed_model: 모델 식별자
  • embed_dim: 차원
  • embed_norm: 정규화 여부
  • chunker_version: 청킹 정책 버전
  • corpus_version: 코퍼스 버전(또는 ingest batch)
  • created_at: 생성 시각

이렇게 해두면 드리프트가 발생해도 “어떤 축이 바뀌었는지”를 추적할 수 있고, Qdrant에서 필터로 격리할 수 있습니다.

Qdrant 컬렉션 설계: 버전별 컬렉션 vs 단일 컬렉션

  • 권장(대부분): “버전별 컬렉션”

    • 예: docs_v1, docs_v2
    • 장점: 서로 다른 임베딩 공간을 섞지 않음
    • 단점: 쿼리 시 멀티 컬렉션 병합 로직 필요
  • 대안: “단일 컬렉션 + payload 필터”

    • 예: docsembed_model=...로 구분
    • 장점: 운영 단순
    • 단점: 실수로 필터 누락 시 품질/안전성 리스크

실무에선 버전별 컬렉션이 더 안전합니다. 특히 모델 변경이 잦거나, A/B 테스트를 자주 하면 컬렉션 분리가 사고를 줄입니다.

드리프트 감지: 오프라인 평가 + 온라인 센티널 쿼리

드리프트는 “느낌”으로 잡으면 늦습니다. 최소한 아래 두 가지는 자동화하세요.

  1. 오프라인 리트리벌 평가: 고정된 쿼리-정답(또는 기대 문서 ID) 세트로 Recall@k, MRR, nDCG를 측정
  2. 온라인 센티널 쿼리: 서비스 대표 질문 20~100개를 주기적으로 검색해 상위 결과가 급변하는지 감시

센티널 쿼리의 핵심 지표

  • top1_doc_id 변화율
  • topk 결과의 Jaccard 유사도
  • 상위 결과의 평균 유사도(score) 분포 변화
  • “정답 문서가 topk에 존재하는지” (가능하면)

점수 분포가 전체적으로 내려가면 임베딩/정규화/거리함수 이슈를 의심하고, 특정 도메인에서만 내려가면 코퍼스 분포 변화를 의심합니다.

Rust로 Qdrant 검색 + 드리프트 지표 계산 예제

아래는 Rust에서 Qdrant search를 호출하고, 센티널 쿼리의 결과 ID를 저장해 전일 대비 Jaccard를 계산하는 최소 예시입니다.

주의: 본문에서 부등호 문자는 MDX 빌드 이슈가 있을 수 있어, 코드 블록 안에서만 사용합니다.

use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;

#[derive(Serialize)]
struct SearchRequest<'a> {
    vector: Vec<f32>,
    limit: usize,
    with_payload: bool,
    // 필요 시 filter를 추가해 embed_model 등을 강제
    // filter: Option<Filter>,
    #[serde(skip_serializing_if = "Option::is_none")]
    score_threshold: Option<f32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    params: Option<SearchParams>,
    #[serde(skip_serializing_if = "Option::is_none")]
    _comment: Option<&'a str>,
}

#[derive(Serialize)]
struct SearchParams {
    hnsw_ef: Option<usize>,
    exact: Option<bool>,
}

#[derive(Deserialize)]
struct SearchResponse {
    result: Vec<ScoredPoint>,
}

#[derive(Deserialize)]
struct ScoredPoint {
    id: PointId,
    score: f32,
}

#[derive(Deserialize, Eq, PartialEq, Hash)]
#[serde(untagged)]
enum PointId {
    Num(u64),
    Str(String),
}

async fn qdrant_search(
    client: &Client,
    base_url: &str,
    collection: &str,
    vector: Vec<f32>,
    limit: usize,
) -> anyhow::Result<Vec<PointId>> {
    let url = format!("{}/collections/{}/points/search", base_url, collection);

    let req = SearchRequest {
        vector,
        limit,
        with_payload: false,
        score_threshold: None,
        params: Some(SearchParams {
            hnsw_ef: Some(128),
            exact: Some(false),
        }),
        _comment: Some("sentinel-query"),
    };

    let resp = client
        .post(url)
        .json(&req)
        .send()
        .await?
        .error_for_status()?;

    let body: SearchResponse = resp.json().await?;
    Ok(body.result.into_iter().map(|p| p.id).collect())
}

fn jaccard(a: &[PointId], b: &[PointId]) -> f32 {
    let sa: HashSet<&PointId> = a.iter().collect();
    let sb: HashSet<&PointId> = b.iter().collect();
    let inter = sa.intersection(&sb).count() as f32;
    let uni = sa.union(&sb).count() as f32;
    if uni == 0.0 { 1.0 } else { inter / uni }
}

운영에선 위 결과를 Redis나 Postgres에 저장해 “어제의 topk”와 비교하고, jaccard가 임계치(예: 0.3) 아래로 떨어지면 알람을 울립니다.

드리프트 완화 1: 임베딩 버전 격리(필수)

가장 효과적인 처방은 서로 다른 임베딩 공간을 절대 섞지 않는 것입니다.

  • 모델 변경 시: docs_v2 새 컬렉션 생성
  • 인덱싱 파이프라인은 active_collection을 참조
  • 서빙은 active_collection로만 검색
  • 마이그레이션 기간 동안만 docs_v1docs_v2를 병렬 검색 후 결과 병합

병렬 검색 병합 전략

  • 단순 병합: 각 컬렉션 topk를 받아 score를 정규화해서 합치기
  • 안전 병합: “신규 컬렉션 우선 + 부족분만 구 컬렉션에서 채우기”

임베딩 공간이 다르면 score 스케일이 달라질 수 있으므로, 단순 score 비교는 위험합니다. 운영 난이도를 낮추려면 신규 우선 전략이 대체로 안전합니다.

드리프트 완화 2: 재색인(Re-embed) 파이프라인을 ‘정상 작업’으로 만들기

드리프트를 완전히 없애려면 결국 재임베딩이 필요합니다. 문제는 재임베딩이 “장애 작업”이 되면 항상 미뤄지고, 품질은 계속 내려갑니다. 재색인을 정기 작업으로 만들려면 아래 패턴이 좋습니다.

(1) 스냅샷 가능한 원문 저장

임베딩만 저장하면 재색인이 불가능합니다. 최소한 청크 텍스트를 저장하거나, 원문을 재구성할 수 있는 참조를 저장해야 합니다.

  • doc_id, chunk_id, chunk_text, chunker_version, source_uri, checksum

(2) 배치 단위 idempotent 업서트

재시도해도 동일 결과가 나오게 설계합니다.

  • Qdrant point iddoc_id:chunk_id 같은 결정적 키로
  • payload에 checksum을 넣고 변경된 청크만 재임베딩

(3) 인덱싱 이벤트의 내구성

인덱싱 작업이 Kafka 같은 스트림을 탄다면, “정확히 한 번”이 깨질 때 중복 업서트가 발생합니다. 이때도 결정적 id를 쓰면 견딜 수 있지만, 파이프라인 자체의 내구성을 높이려면 Outbox 패턴이 도움이 됩니다. 자세한 내용은 Kafka Exactly-Once가 깨질 때 - Outbox+사가 참고.

드리프트 완화 3: Qdrant 필터로 ‘동일 좌표계’만 검색

단일 컬렉션을 유지한다면, 최소한 검색 시 필터로 동일한 임베딩 스키마만 조회해야 합니다.

  • must 조건: embed_model == "..." AND chunker_version == "..."
  • 마이그레이션 기간: shouldv2 우선, v1 보조

필터를 강제하지 않으면 “신규 벡터가 구모델 벡터를 밀어내는” 형태로 품질이 흔들립니다.

드리프트 완화 4: HNSW 파라미터와 검색 파라미터를 분리 운영

드리프트처럼 보이지만 사실은 근사탐색 파라미터 변화 때문에 생기는 경우가 있습니다.

  • 인덱스 구축 시 파라미터: m, ef_construct
  • 쿼리 시 파라미터: hnsw_ef, exact

운영 팁:

  • 센티널 쿼리 감시에서 이상이 감지되면, 우선 exact=true로 재실행해 “인덱스/근사탐색 문제인지”를 분리합니다.
  • exact=true에서만 품질이 정상이라면, 드리프트라기보다 hnsw_ef 부족 또는 인덱스 파편화 가능성이 큽니다.

드리프트 완화 5: 임계치 기반 하이브리드 폴백

벡터 검색이 불확실할 때는 폴백이 필요합니다.

  • score_threshold 이하이면 BM25(키워드) 검색으로 폴백
  • 또는 “상위 score 평균이 낮으면” 재질의(쿼리 확장) 수행

이 전략은 드리프트를 “숨기는” 게 아니라, 드리프트 기간 동안 사용자 피해를 줄이는 안전장치입니다.

운영 체크리스트: ‘드리프트가 나도 망하지 않는’ 구성

데이터/스키마

  • payload에 embed_model, chunker_version, checksum 저장
  • 재임베딩 가능한 원문/청크 저장
  • point id 결정적으로 생성

서빙

  • 검색 시 버전 필터 강제 또는 컬렉션 분리
  • 센티널 쿼리 + 오프라인 평가 자동화
  • 이상 시 exact=true 재검증 루틴

배포/마이그레이션

  • docs_vN 새 컬렉션을 만들고 병렬 인덱싱
  • 트래픽 일부만 vN으로 라우팅(A/B)
  • 품질 지표 통과 시 active_collection 스위치

쿠버네티스 환경에서 인덱싱 워커를 돌리다 메모리 이슈로 재색인이 자꾸 끊기면, 단순히 리밋만 올리기보다 GC/리밋/워킹셋을 같이 보세요. 관련 진단은 K8s OOMKilled 반복? 메모리 리밋·GC 진단법이 도움이 됩니다.

결론: 드리프트는 ‘사고’가 아니라 ‘변경 이벤트’다

Rust+Qdrant로 RAG를 운영할 때 벡터 드리프트를 잡는 핵심은 세 가지입니다.

  1. 버전 격리: 모델/청킹이 바뀌면 컬렉션을 분리하거나 필터로 강제
  2. 측정 자동화: 센티널 쿼리와 오프라인 평가로 품질 하락을 조기 감지
  3. 재색인 상시화: 원문 보관, 결정적 ID, idempotent 업서트로 재임베딩을 일상 작업으로

이렇게 해두면 “어느 날 갑자기 검색이 망가졌다”가 아니라, “이번 주 임베딩 버전 변경으로 품질이 어떻게 변했는지”를 숫자로 보고, 안전하게 롤아웃/롤백할 수 있습니다.