Published on

Pinecone·Milvus 임베딩 교체, 재빌드 없이 하는 법

Authors

서빙 중인 RAG 시스템에서 임베딩 모델을 교체하고 싶을 때, 가장 먼저 떠오르는 두려움은 “인덱스를 다시 만들어야 하나?”입니다. 인덱스 재빌드는 보통 다음을 동반합니다.

  • 대량 재임베딩 비용(시간·GPU/CPU)
  • 재수집 파이프라인 재가동
  • 검색 품질 급락(전환 구간)과 다운타임
  • 운영 복잡도 증가(롤백 불가/어려움)

하지만 Pinecone·Milvus 같은 벡터 DB에서는 “인덱스 재빌드”를 꼭 ‘서비스 중단’과 동일시할 필요가 없습니다. 핵심은 “기존 인덱스를 유지한 채로” 새 임베딩을 공존시키고, 트래픽을 점진적으로 전환하는 것입니다.

이 글에서는 임베딩 교체를 재빌드 없는 운영 관점에서 정의하고, Pinecone과 Milvus에서 각각 어떤 제약이 있고 어떤 패턴이 안전한지, 그리고 코드 수준에서 어떻게 구현하는지 정리합니다.

RAG 검색 품질 이슈가 이미 발생 중이라면, 임베딩 교체 전에 원인부터 분리하는 게 좋습니다. 관련 체크리스트는 LangChain RAG에서 No relevant docs 7가지 원인도 함께 참고하세요.

“재빌드 없이 교체”가 의미하는 것

엄밀히 말해, 임베딩 벡터가 바뀌면 벡터 공간이 달라지므로 기존 벡터로 만든 ANN 인덱스(HNSW/IVF 등) 는 새 벡터에 대해 유효하지 않습니다. 따라서 “같은 인덱스 구조에 새 벡터를 그대로 덮어쓰기”는 대부분의 시스템에서 권장되지 않거나 불가능합니다.

여기서 말하는 “재빌드 없이 교체”는 보통 아래 중 하나를 의미합니다.

  1. 서비스 중단 없이 새 인덱스를 병행 구축(백그라운드 빌드)
  2. 기존 인덱스를 유지하면서 새 임베딩 버전을 공존(듀얼 인덱스)
  3. 점진적 마이그레이션으로 품질·비용·리스크를 분산

즉, 인프라적으로는 새 인덱스가 생기지만, 운영적으로는 “다운타임 없는 교체”에 가깝습니다.

교체 시 반드시 확인할 3가지 제약

1) 차원(dimension) 변경 여부

  • 차원이 바뀌면 동일 컬렉션/인덱스에 업데이트가 불가한 경우가 많습니다.
  • Pinecone은 인덱스 생성 시 차원이 고정입니다.
  • Milvus도 컬렉션 스키마에서 벡터 필드 차원이 고정입니다.

차원이 바뀐다면 새 인덱스(또는 새 컬렉션)가 필수입니다.

2) 거리 메트릭(metric) 변경 여부

cosine에서 dotproduct로 바꾸거나, L2로 바꾸는 경우도 흔합니다. 메트릭이 바뀌면 점수 해석이 달라지고, 인덱스 파라미터 최적점도 달라집니다.

Milvus에서 HNSW·IVF 계열 튜닝이 필요한 경우는 Milvus IVF_FLAT·HNSW 튜닝으로 Recall 0.95 달성을 같이 보면 전환 이후 품질 안정화에 도움이 됩니다.

3) 문서 단위/청크 정책 변경 여부

임베딩 모델을 바꾸는 김에 청크 크기나 오버랩을 바꾸면, 동일 문서라도 벡터 개수와 분포가 바뀌어 단순 교체가 아니라 “재색인”에 가깝습니다.

이 경우에도 전략은 동일합니다. 다만 듀얼라이트 시 “문서 ID 체계”를 더 엄격히 설계해야 합니다.

핵심 전략 4가지 (Pinecone·Milvus 공통)

전략 A: 임베딩 버전 공존(권장)

가장 안전한 방법은 임베딩 버전별로 별도 인덱스/컬렉션을 두는 것입니다.

  • v1 인덱스: 기존 모델
  • v2 인덱스: 신규 모델

쿼리 시점에 둘 다 조회하거나(블렌딩), 일부 트래픽만 v2로 보내며 점진 전환합니다.

장점

  • 롤백이 매우 쉬움(라우팅만 되돌리면 됨)
  • 품질 비교(A/B) 가능

단점

  • 저장 공간 2배(전환 기간 동안)

전략 B: 듀얼라이트(쓰기 이중화)

전환 기간 동안 신규로 들어오는 문서/변경분은 v1v2동시에 upsert 합니다.

  • 과거 데이터는 백필(backfill) 배치로 천천히 재임베딩
  • 실시간 데이터는 듀얼라이트로 동기화

장점

  • “현재 시점 이후” 데이터는 즉시 v2에서 검색 가능
  • 백필 완료 전에도 부분 전환 가능

전략 C: 점진 백필 + 트래픽 점진 전환

백필 진행률에 따라 라우팅을 바꿉니다.

  • v2 커버리지 20%: 내부 사용자/일부 쿼리만
  • 60%: 전체 트래픽의 30%
  • 95%: 기본을 v2로, v1은 폴백

전략 D: 폴백(혼합 검색)으로 공백 줄이기

전환 초기에 v2에 없는 문서가 많을 수 있습니다. 이때는 폴백을 둡니다.

  • 1차: v2 topK 검색
  • 결과가 부족하거나 점수가 낮으면 v1을 추가 조회
  • 결과를 합쳐서 rerank

이 패턴은 “전환 중 검색 품질 급락”을 막는 데 매우 효과적입니다.

Pinecone에서의 구현 패턴

Pinecone은 일반적으로 “인덱스 단위”로 차원·메트릭이 고정입니다. 따라서 임베딩 교체는 실무적으로 새 인덱스를 만들고 병행 운영하는 형태가 됩니다.

1) 인덱스 네이밍과 메타데이터 설계

  • 인덱스 이름에 버전 포함: docs-v1, docs-v2
  • 또는 네임스페이스로 분리(단, 차원이 같아야 유리)
  • 메타데이터에 embedding_version도 저장(디버깅/감사)

문서 ID는 버전과 분리하는 편이 운영에 좋습니다.

  • 권장 ID: docId#chunkId
  • 버전은 인덱스로 분리하거나 메타데이터로 분리

2) 듀얼라이트 예시 (Node.js)

import { Pinecone } from '@pinecone-database/pinecone'

type Chunk = {
  id: string
  valuesV1: number[]
  valuesV2: number[]
  metadata: Record<string, any>
}

const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! })

const indexV1 = pc.index('docs-v1')
const indexV2 = pc.index('docs-v2')

export async function dualUpsert(chunks: Chunk[]) {
  const vectorsV1 = chunks.map(c => ({
    id: c.id,
    values: c.valuesV1,
    metadata: { ...c.metadata, embedding_version: 'v1' },
  }))

  const vectorsV2 = chunks.map(c => ({
    id: c.id,
    values: c.valuesV2,
    metadata: { ...c.metadata, embedding_version: 'v2' },
  }))

  await Promise.all([
    indexV1.upsert(vectorsV1),
    indexV2.upsert(vectorsV2),
  ])
}

포인트

  • 쓰기 경로를 한 번 바꿔두면, 이후 백필은 “과거 데이터”만 처리하면 됩니다.
  • Promise.all은 간단하지만, 운영에서는 재시도·부분 실패 처리(멱등성)까지 넣는 걸 권장합니다.

3) 폴백 검색 + 결과 병합 예시

type Match = { id: string; score: number; metadata?: any }

function mergeById(primary: Match[], secondary: Match[], limit: number): Match[] {
  const seen = new Set<string>()
  const out: Match[] = []

  for (const m of [...primary, ...secondary]) {
    if (seen.has(m.id)) continue
    seen.add(m.id)
    out.push(m)
    if (out.length >= limit) break
  }
  return out
}

export async function queryWithFallback({
  vectorV2,
  vectorV1,
  topK = 10,
  minV2Score = 0.2,
}: {
  vectorV2: number[]
  vectorV1: number[]
  topK?: number
  minV2Score?: number
}) {
  const v2 = await indexV2.query({ vector: vectorV2, topK, includeMetadata: true })

  const v2Matches = (v2.matches ?? []) as Match[]
  const needFallback = v2Matches.length < topK || (v2Matches[0]?.score ?? 0) < minV2Score

  if (!needFallback) return v2Matches

  const v1 = await indexV1.query({ vector: vectorV1, topK, includeMetadata: true })
  const v1Matches = (v1.matches ?? []) as Match[]

  return mergeById(v2Matches, v1Matches, topK)
}

운영 팁

  • 폴백 조건은 “결과 수 부족”뿐 아니라 “최상위 점수”나 “점수 분산(엔트로피)” 기반으로도 잡을 수 있습니다.
  • 최종 품질을 올리려면 병합 후 rerank(예: cross-encoder, LLM rerank)를 붙이되, 비용을 측정하면서 단계적으로 적용하세요.

Milvus에서의 구현 패턴

Milvus는 컬렉션·파티션·필드 스키마가 명확해서, 임베딩 교체는 보통 다음 둘 중 하나로 갑니다.

  • 새 컬렉션 생성: docs_v1, docs_v2 (가장 명확)
  • 같은 컬렉션에 벡터 필드 2개: embedding_v1, embedding_v2 (차원이 같고 스키마 확장을 감당할 때)

실무에서는 새 컬렉션이 롤백·권한·리소스 격리 측면에서 단순합니다.

1) 컬렉션 버전 전략

  • docs_v1: 기존
  • docs_v2: 신규

전환이 끝나면

  • 라우팅 기본값을 v2
  • v1은 일정 기간 보관 후 삭제(감사/회귀 테스트용으로 더 오래 둘 수도 있음)

2) pymilvus로 듀얼라이트 예시

from pymilvus import MilvusClient

client = MilvusClient(uri="http://localhost:19530")

COL_V1 = "docs_v1"
COL_V2 = "docs_v2"


def dual_insert(rows_v1, rows_v2):
    # rows_*: list[dict], dict는 스키마에 맞게 구성
    # 예: {"id": "doc1#0", "vector": [...], "text": "...", "doc_id": "doc1"}

    res1 = client.insert(collection_name=COL_V1, data=rows_v1)
    res2 = client.insert(collection_name=COL_V2, data=rows_v2)
    return res1, res2

주의할 점

  • Milvus는 일관성 레벨과 flush 타이밍에 따라 “방금 넣은 데이터가 검색에 안 보이는” 구간이 생길 수 있습니다. 전환 테스트에서는 flush 또는 적절한 일관성 설정을 고려하세요.

3) 검색 폴백 예시

def search_with_fallback(vec_v2, vec_v1, topk=10, min_score=0.2):
    v2 = client.search(
        collection_name=COL_V2,
        data=[vec_v2],
        limit=topk,
        output_fields=["doc_id", "text"],
    )

    hits_v2 = v2[0]
    need_fallback = (len(hits_v2) < topk) or ((hits_v2[0]["score"] if hits_v2 else 0) < min_score)

    if not need_fallback:
        return hits_v2

    v1 = client.search(
        collection_name=COL_V1,
        data=[vec_v1],
        limit=topk,
        output_fields=["doc_id", "text"],
    )

    # 단순 병합(중복 제거는 id 기준으로 별도 구현 권장)
    return hits_v2 + v1[0]

Milvus에서 성능/리콜이 흔들리면, 임베딩 교체 이슈인지 인덱스 파라미터 이슈인지 분리해야 합니다. 특히 HNSW의 efSearch나 IVF의 nprobe가 전환 후 품질을 크게 좌우합니다. 튜닝 관점은 Milvus IVF_FLAT·HNSW 튜닝으로 Recall 0.95 달성을 참고하면 좋습니다.

“업데이트로 덮어쓰기”가 위험한 이유

일부 팀은 동일 ID로 벡터를 upsert 해서 “교체”하려고 합니다. 이 접근은 다음 조건을 모두 만족할 때만 제한적으로 고려할 만합니다.

  • 차원 동일
  • 메트릭 동일
  • 인덱스가 동적 업데이트에 충분히 강함
  • 업데이트 도중 검색 품질 저하를 감수할 수 있음

문제는, ANN 인덱스는 내부 그래프/클러스터 구조가 데이터 분포에 민감합니다. 대량 업데이트는 사실상 “점진적 재구성”을 유발하고, 그 과정에서 리콜이 흔들릴 수 있습니다. 또한 롤백이 어렵습니다(이전 벡터를 별도로 저장하지 않으면 복구 불가).

그래서 운영 관점에서는 “덮어쓰기 교체”보다 버전 공존 + 점진 전환이 실패 확률이 훨씬 낮습니다.

전환 체크리스트: 품질·비용·운영

품질 검증

  • 오프라인: 대표 쿼리셋으로 Recall@K, MRR, nDCG 비교
  • 온라인: A/B로 클릭·정답률·후속 질문률 측정
  • 폴백 비율: 폴백 호출이 얼마나 발생하는지(= v2 커버리지/품질 지표)

비용/성능

  • 듀얼 인덱스 기간의 저장비용
  • 쿼리 2회 호출로 인한 latency 증가
  • rerank 도입 시 토큰/모델 비용

운영 안정성

  • 멱등 upsert: 동일 청크 재처리에도 결과가 안정적인지
  • 재시도/부분 실패 처리: v1 성공, v2 실패 시 어떻게 할지
  • 관측성: embedding_version, index_version, model_name 로그/메트릭 태깅

추천 아키텍처(현실적인 결론)

  • 임베딩 모델 교체는 “인덱스 재빌드 여부”가 아니라 전환 전략의 문제입니다.
  • Pinecone은 대개 인덱스 2개 병행이 정석입니다.
  • Milvus도 컬렉션 버전 분리가 가장 단순하고 안전합니다.

현실적인 권장 플로우는 다음입니다.

  1. v2 인덱스/컬렉션 생성
  2. 쓰기 경로 듀얼라이트 적용(신규 데이터부터 동기화)
  3. 과거 데이터 백필 배치 실행(진행률 추적)
  4. 쿼리 라우팅을 점진 전환(A/B)
  5. 폴백을 유지하며 공백 최소화
  6. 안정화 후 v1 단계적 폐기

이렇게 하면 “재빌드”는 존재하더라도, 서비스는 멈추지 않고, 품질을 측정하며, 필요하면 즉시 롤백할 수 있습니다. 임베딩 교체를 한 번이라도 운영에서 겪어보면, 결국 이 방식이 가장 싸고(실패 비용 포함) 안전하다는 결론에 도달하는 경우가 많습니다.