Published on

Rust+Qdrant RAG 인덱스 드리프트 잡는 법

Authors

RAG를 운영하다 보면 “어제까지 잘 맞던 검색이 오늘은 엉뚱한 문서를 끌고 온다” 같은 현상을 자주 겪습니다. 모델이 바뀌거나 데이터가 조금만 변해도 검색 결과 분포가 서서히 틀어지는데, 이를 보통 인덱스 드리프트(index drift) 라고 부릅니다. 특히 Rust로 수집·전처리·임베딩·업서트를 자동화하고, 벡터 DB로 Qdrant를 쓰는 구성에서는 드리프트가 조용히 누적되다가 어느 날 품질이 급락합니다.

이 글에서는 Rust+Qdrant 기반 RAG에서 드리프트를 정의하고, 원인을 분해하고, 탐지 지표를 만들고, 격리 및 재색인 전략으로 품질을 안정화하는 방법을 정리합니다.

관련해서 RAG 디버깅 체크리스트(청킹/리랭커/토큰 예산)도 함께 보면 원인 추적이 빨라집니다: LangChain LlamaIndex RAG에서 답변이 반복되고 환각될 때...

인덱스 드리프트를 “정의”부터 명확히 하자

운영에서 말하는 드리프트는 단순히 “데이터가 늘었다”가 아닙니다. 아래 중 하나라도 발생하면 검색 품질이 바뀝니다.

1) 임베딩 드리프트

  • 임베딩 모델 교체(예: text-embedding-3-small에서 다른 모델로)
  • 동일 모델이라도 차원 수, 정규화 방식 변경
  • 전처리(소문자화, 특수문자 제거, 언어 감지) 변경

결과: 같은 문장이라도 벡터 공간이 달라져서 과거 벡터들과 비교 자체가 의미가 약해짐

2) 스키마/페이로드 드리프트

  • Qdrant payload 필드명 변경
  • 필터 조건이 바뀌었는데 기존 포인트는 업데이트되지 않음
  • 멀티테넌시 키(tenant_id) 누락/오염

결과: 검색은 되지만 필터가 기대대로 동작하지 않아 다른 테넌트 문서가 섞이거나, 필터로 문서가 과도하게 탈락

3) 청킹 드리프트

  • 청킹 크기/오버랩 변경
  • 문서 파서 변경(HTML, PDF, Markdown)
  • 문장 경계 기준 변경

결과: 벡터가 대표하는 의미 단위가 달라져서 recall/precision 균형이 깨짐

4) 인덱싱 파이프라인 드리프트

  • 업서트가 부분 실패했는데 재시도 로직이 약함
  • 삭제/갱신이 누락되어 “유령 청크”가 남음
  • 동일 문서가 다른 doc_id로 중복 유입

결과: 중복 문서가 상위에 반복 노출되거나, 최신 문서가 검색되지 않음

Qdrant에서 드리프트가 특히 위험한 이유

Qdrant는 빠르고 운영 친화적이지만, 다음 특성 때문에 드리프트가 “조용히” 쌓이기 쉽습니다.

  • 컬렉션이 살아있는 상태에서 계속 업서트되며, 서로 다른 버전의 포인트가 공존하기 쉽습니다.
  • payload는 스키마 강제가 약하므로, 필드 누락/타입 불일치가 런타임까지 숨어있습니다.
  • 검색은 항상 결과를 내므로(에러 대신 품질 저하), 모니터링 없이는 감지하기 어렵습니다.

따라서 핵심은 버전 태깅 + 탐지 지표 + 격리(dual-write/dual-read) + 재색인 자동화입니다.

설계 원칙: “버전이 다르면 같은 컬렉션에 섞지 않는다”

가장 강력한 예방책은 단순합니다.

  • 임베딩 모델/차원/전처리/청킹 규칙이 바뀌면 새 컬렉션을 만듭니다.
  • 혹은 같은 컬렉션을 쓰더라도 embedding_version을 payload에 넣고, 검색 시 반드시 필터링합니다.

운영 난이도는 전자가 더 낮습니다. 컬렉션을 rag_docs_v2026_02처럼 버전으로 나누면, 롤백도 쉽고 A/B 테스트도 쉬워집니다.

필수 메타데이터: payload에 “계약(Contract)”을 박아라

각 포인트(청크)에 최소 아래 필드를 넣는 것을 권장합니다.

  • tenant_id: 멀티테넌시 분리
  • doc_id: 원문 문서 ID
  • chunk_id: 문서 내 청크 순번
  • source_uri: 원문 위치
  • content_hash: 청크 텍스트 해시(중복/갱신 탐지)
  • ingested_at: 수집 시각
  • pipeline_version: 파이프라인 버전(파서/청킹 규칙)
  • embedding_model: 모델명
  • embedding_dim: 차원
  • embedding_version: 내부 버전(예: 2026-02-emb-v3)

이 메타데이터가 있어야 “지금 검색 결과가 이상한데, 어떤 버전의 벡터가 섞였지?”를 역추적할 수 있습니다.

Rust로 구현하는 드리프트 방지 업서트 패턴

아래 예시는 Rust에서 Qdrant에 업서트할 때, payload에 버전 계약을 넣고 content_hash로 멱등성을 확보하는 패턴입니다.

Cargo 의존성 예시

[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hex = "0.4"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

content hash 계산

use sha2::{Digest, Sha256};

fn content_hash(text: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(text.as_bytes());
    hex::encode(hasher.finalize())
}

Qdrant 업서트(REST) 예시

Qdrant는 공식 Rust 클라이언트도 있지만, 운영에서 디버깅이 쉬운 REST 호출 예시를 보여드립니다.

use anyhow::Result;
use serde_json::json;

pub async fn upsert_points(
    qdrant_url: &str,
    collection: &str,
    points: Vec<(String, Vec<f32>, serde_json::Value)>,
) -> Result<()> {
    let client = reqwest::Client::new();

    let body = json!({
        "points": points.into_iter().map(|(id, vector, payload)| {
            json!({
                "id": id,
                "vector": vector,
                "payload": payload
            })
        }).collect::<Vec<_>>()
    });

    let url = format!("{}/collections/{}/points?wait=true", qdrant_url, collection);
    let resp = client.put(url).json(&body).send().await?;

    if !resp.status().is_success() {
        let text = resp.text().await.unwrap_or_default();
        anyhow::bail!("qdrant upsert failed: {}", text);
    }
    Ok(())
}

payload 구성 예시(중요: 버전 필드)

fn make_payload(
    tenant_id: &str,
    doc_id: &str,
    chunk_id: i32,
    source_uri: &str,
    text: &str,
) -> serde_json::Value {
    let hash = content_hash(text);

    serde_json::json!({
        "tenant_id": tenant_id,
        "doc_id": doc_id,
        "chunk_id": chunk_id,
        "source_uri": source_uri,
        "content_hash": hash,
        "pipeline_version": "2026-02-chunk-v2",
        "embedding_model": "text-embedding-3-large",
        "embedding_dim": 3072,
        "embedding_version": "2026-02-emb-v1"
    })
}

이렇게 해두면 드리프트가 발생해도, 검색 결과의 payload를 보고 “섞였는지/누락됐는지”를 즉시 판단할 수 있습니다.

드리프트 탐지: 품질을 수치로 고정하라

드리프트를 “느낌”으로 잡으면 항상 늦습니다. 아래 3종 지표를 추천합니다.

1) 골든 쿼리 회귀 테스트(offline)

  • 대표 질문 N개와 기대 문서 K개를 고정
  • 매일(또는 배포마다) Recall@K, MRR@K, nDCG@K를 계산
  • 기준 대비 하락 시 알람

핵심은 “검색 단계만” 고정해서 측정하는 것입니다. 생성 모델의 변동을 섞으면 원인 분리가 어려워집니다.

2) 온라인 분포 지표(online)

  • Top K 결과의 점수 분포(평균/표준편차)
  • tenant_id 필터 누락 비율
  • 결과의 embedding_version 혼재율
  • 중복 content_hash 비율

혼재율은 특히 강력합니다. 예를 들어 Top 10 결과 중 embedding_version이 2종 이상이면, 이미 “섞인 인덱스”일 가능성이 큽니다.

3) 데이터 무결성 지표(data)

  • 문서 수 대비 포인트 수 비율(청킹 규칙 변경 감지)
  • doc_id당 청크 수 분포(상자그림 통계)
  • 최근 24h 업서트 실패/재시도 횟수

이런 운영 지표 설계는 분산 추적과 함께 보면 효율이 크게 올라갑니다: OpenTelemetry로 MSA 분산 트랜잭션 추적 실전

Qdrant에서 “버전 혼재”를 막는 검색 필터

컬렉션을 분리하지 못하는 상황이라면, 검색 시점에 강제 필터링을 넣어야 합니다.

Qdrant 검색 요청에서 payload filter로 embedding_versionpipeline_version을 고정하세요.

{
  "vector": [0.1, 0.2, 0.3],
  "limit": 10,
  "with_payload": true,
  "filter": {
    "must": [
      { "key": "tenant_id", "match": { "value": "t1" } },
      { "key": "embedding_version", "match": { "value": "2026-02-emb-v1" } },
      { "key": "pipeline_version", "match": { "value": "2026-02-chunk-v2" } }
    ]
  }
}

이 필터가 없으면, “새 임베딩으로 만든 일부 포인트”와 “옛 임베딩 포인트”가 같은 후보군에서 경쟁하며 검색 품질을 망칩니다.

재색인 전략 3가지: 상황별로 선택

1) 컬렉션 버전 업(권장)

  • 새 컬렉션 생성
  • 백필(backfill)로 전체 재색인
  • 애플리케이션에서 컬렉션 alias(또는 설정값)만 교체

장점: 가장 안전, 롤백 쉬움 단점: 스토리지/백필 비용

2) 듀얼 라이트 + 듀얼 리드(A/B)

  • 일정 기간 기존 컬렉션과 신규 컬렉션에 동시에 업서트
  • 읽기는 90:10 같은 비율로 분산하거나, 테넌트 단위로 카나리
  • 품질 지표가 통과하면 읽기 전환

장점: 리스크 최소화 단점: 구현 복잡도 상승

3) 동일 컬렉션 내 점진적 교체(비권장)

  • payload에 embedding_version을 추가
  • 신규 업서트는 새 버전으로만
  • 검색은 새 버전만 필터
  • 구버전 포인트는 백그라운드로 삭제

장점: 컬렉션 추가 없이 가능 단점: 혼재/누락/삭제 실패가 발생하면 바로 품질 사고

“유령 청크”와 중복을 잡는 멱등 키 설계

드리프트의 상당수는 사실 임베딩 모델이 아니라 인덱싱 무결성 문제입니다.

  • 동일 문서가 여러 번 들어가 중복 청크가 쌓임
  • 문서 업데이트 시 예전 청크가 삭제되지 않음

이를 막으려면 포인트 id를 랜덤 UUID로 두지 말고, 결정적(deterministic) ID로 만드세요.

예: tenant_id + doc_id + chunk_id + pipeline_version을 합쳐 해시

fn point_id(tenant_id: &str, doc_id: &str, chunk_id: i32, pipeline_version: &str) -> String {
    let raw = format!("{}|{}|{}|{}", tenant_id, doc_id, chunk_id, pipeline_version);
    content_hash(&raw)
}

이렇게 하면 같은 문서를 다시 처리해도 업서트가 덮어쓰기라서 중복이 줄어듭니다. 문서가 변경되어 청크 내용이 바뀌면 content_hash가 달라지므로, “내용 변화 감지”도 가능합니다.

운영 체크리스트: 드리프트가 의심될 때의 순서

  1. 검색 필터 확인: tenant_id, embedding_version, pipeline_version이 강제되고 있는가
  2. 혼재율 확인: Top K 결과 payload에서 버전이 섞이는가
  3. 청킹 분포 확인: doc_id당 청크 수 분포가 최근 급변했는가
  4. 중복률 확인: 동일 content_hash가 상위 결과에 반복되는가
  5. 업서트 실패/지연 확인: 재시도 폭증, 타임아웃이 있었는가

임베딩 API를 호출하는 구조라면, 레이트 리밋으로 일부만 실패하고 조용히 누락되는 경우가 많습니다. 재시도 설계는 별도로 단단히 해두는 것이 좋습니다: OpenAI 429 rate_limit_exceeded 재시도 설계

결론: 드리프트는 “모델 문제”가 아니라 “계약/버전/관측” 문제다

Rust+Qdrant RAG에서 인덱스 드리프트를 잡는 핵심은 다음 4가지입니다.

  • payload에 버전 계약(embedding_version, pipeline_version)을 강제
  • 검색 시 버전 필터를 강제(혼재 차단)
  • 골든 쿼리 회귀 테스트와 온라인 분포 지표로 조기 탐지
  • 컬렉션 버전 업 또는 듀얼 라이트/리드로 안전한 재색인

이 패턴을 적용하면 “왜 갑자기 검색이 망가졌지?”를 추측으로 해결하는 대신, 재현 가능한 지표와 격리 전략으로 운영 품질을 안정적으로 유지할 수 있습니다.