Published on

Pinecone 임베딩 차원 변경 무중단 마이그레이션

Authors

임베딩 모델을 바꾸다 보면(예: text-embedding-3-small에서 text-embedding-3-large로, 또는 자체 모델로 전환) 필연적으로 벡터 차원(dimension) 이 달라집니다. Pinecone은 인덱스 생성 시 차원이 고정되므로, 운영 중인 인덱스의 차원을 “수정”하는 방식은 불가능하고 새 인덱스를 만들고 데이터를 옮기는 마이그레이션이 필요합니다.

문제는 검색 API가 24/7로 호출되는 환경에서 “인덱스 재생성 + 재적재”를 하려면, 잘못하면 검색 품질이 흔들리거나(일부 문서 누락), 쓰기 이벤트가 유실되거나, 트래픽 급증 시 지연이 폭발합니다. 이 글은 무중단(Zero-downtime) 을 목표로 Pinecone 임베딩 차원 변경을 안전하게 수행하는 설계를 다룹니다.


왜 차원 변경이 까다로운가

Pinecone에서 차원 변경이 곧 “새 인덱스”인 이유는 간단합니다.

  • 인덱스는 dimension을 기준으로 내부 자료구조와 HNSW/IVF 계열 구성(서비스 옵션에 따라 다름)을 최적화합니다.
  • 기존 벡터(예: 1536차원)와 신규 벡터(예: 3072차원)는 동일 공간에서 비교 자체가 불가능합니다.
  • 따라서 운영 중인 인덱스에 새 차원 벡터를 섞어 넣을 수 없고, 인덱스 단위로 분리해야 합니다.

무중단 관점에서 핵심 리스크는 다음 3가지입니다.

  1. Write 유실: 마이그레이션 중 들어온 신규/수정 이벤트가 새 인덱스에 반영되지 않음
  2. Read 불일치: 어떤 요청은 구 인덱스, 어떤 요청은 신 인덱스를 보며 결과 품질이 달라짐
  3. 재처리 비용 폭증: 전체 재임베딩/업서트가 길어지며 백필(backfill) 작업이 운영 트래픽과 경합

이 리스크를 줄이려면 전형적으로 듀얼 인덱스 + 듀얼 라이트 + 점진적 리드 전환 패턴을 씁니다. 분산 트랜잭션이 어려운 구간이므로, 보상/재처리 관점은 Saga 사고방식이 유용합니다. 관련해서는 MSA Saga 보상 트랜잭션 설계 실전 가이드도 함께 참고하면 좋습니다.


목표 아키텍처: 듀얼 인덱스, 듀얼 라이트, 스위치

구성 요소를 역할 중심으로 나누면 다음과 같습니다.

  • Index V1: 기존 차원(예: 1536) 인덱스
  • Index V2: 신규 차원(예: 3072) 인덱스
  • Embedding Service: 입력 텍스트를 임베딩으로 변환(모델 버전별 분기)
  • Write Path: 문서 생성/수정/삭제 이벤트를 Pinecone에 반영
  • Read Path: 검색 요청을 받아 쿼리 임베딩 생성 후 Pinecone query 수행
  • Migration Controller: 백필 진행률, 듀얼라이트 상태, 리드 전환 비율을 제어

무중단의 핵심은 이 순서입니다.

  1. V2 인덱스 생성
  2. 백필 시작: 기존 문서를 V2 차원으로 재임베딩하여 V2에 업서트
  3. 듀얼 라이트 활성화: 이후 들어오는 변경 이벤트는 V1과 V2에 모두 반영
  4. 점진적 리드 전환: 일부 트래픽부터 V2로 query를 보내며 품질/지연 관찰
  5. 완전 전환: 리드를 V2로 100% 전환
  6. V1 정리: 듀얼 라이트 종료 후 V1 삭제(또는 롤백 대비 일정 기간 유지)

1) 새 Pinecone 인덱스(V2) 생성

인덱스 생성 시 반드시 다음을 명확히 결정해야 합니다.

  • dimension: 새 임베딩 차원
  • metric: cosine 또는 dotproduct 등(모델 권장에 맞춤)
  • pods/replicas 또는 서버리스 설정: 백필 동안의 쓰기량을 감당할 수 있는지
  • namespace 전략: 테넌트/환경 분리 방식

예시(개념 코드, SDK 버전별 API는 다를 수 있음):

from pinecone import Pinecone

pc = Pinecone(api_key="PINECONE_API_KEY")

index_name_v2 = "docs-v2"

# dimension은 새 모델에 맞춰 고정
pc.create_index(
    name=index_name_v2,
    dimension=3072,
    metric="cosine",
    spec={"serverless": {"cloud": "aws", "region": "us-east-1"}},
)

운영 팁:

  • 백필은 대량 upsert를 유발하므로, 인덱스 생성 직후부터 모니터링(지연, 에러율, 제한)을 붙이세요.
  • 쿼리 트래픽과 백필 트래픽을 분리할 수 있으면(별도 인덱스/별도 프로젝트/별도 서비스) 안정성이 크게 올라갑니다.

2) 백필 설계: 재임베딩 + 업서트 파이프라인

백필은 “기존 문서 전체를 새 차원으로 다시 임베딩해서 V2에 넣는 작업”입니다. 여기서 중요한 건 속도가 아니라 정확성과 재시도 가능성입니다.

백필 단위와 멱등성

  • 기본 단위는 doc_id(또는 chunk_id)입니다.
  • 업서트는 같은 id로 반복해도 최종 상태가 같도록 설계합니다(멱등).
  • 메타데이터에 embedding_version 또는 model_id를 넣어, 디버깅과 검증을 쉽게 합니다.

백필 워커 예시(Python)

아래 코드는 개념적으로 “DB에서 문서 배치를 읽고, 새 모델로 임베딩한 뒤, V2에 upsert”를 수행합니다.

import time
from typing import Iterable

BATCH = 100


def embed_v2(texts: list[str]) -> list[list[float]]:
    # 새 모델 호출(예: OpenAI, 자체 모델, Bedrock 등)
    # 반환 차원은 3072라고 가정
    raise NotImplementedError


def fetch_docs(offset: int, limit: int) -> list[dict]:
    # DB에서 문서/청크를 가져오는 부분
    # 예: [{"id": "c_123", "text": "...", "meta": {...}}, ...]
    raise NotImplementedError


def upsert_v2(index, vectors: list[dict], namespace: str):
    index.upsert(vectors=vectors, namespace=namespace)


def backfill(index_v2, namespace: str):
    offset = 0
    while True:
        docs = fetch_docs(offset, BATCH)
        if not docs:
            break

        texts = [d["text"] for d in docs]
        embs = embed_v2(texts)

        vectors = []
        for d, e in zip(docs, embs):
            vectors.append({
                "id": d["id"],
                "values": e,
                "metadata": {
                    **d.get("meta", {}),
                    "embedding_version": "v2",
                },
            })

        # 재시도/백오프는 반드시 넣는 것을 권장
        upsert_v2(index_v2, vectors, namespace)

        offset += BATCH
        time.sleep(0.05)

운영 팁:

  • 백필은 작업 중단/재시작이 흔합니다. offset 기반보다 키셋 페이징 또는 “처리 완료 체크포인트 테이블” 방식이 안전합니다.
  • 임베딩 호출이 병목이면, 워커를 늘리기 전에 레이트리밋과 비용을 먼저 계산하세요.

3) 듀얼 라이트: 변경 이벤트를 V1과 V2에 동시에 반영

백필만으로는 무중단이 아닙니다. 백필이 도는 동안에도 문서는 계속 수정/삭제됩니다. 따라서 변경 이벤트를 동시에 두 인덱스에 반영해야 합니다.

이벤트 기반이 가장 안전

  • DB 트리거보다는 애플리케이션 레벨에서 “문서 변경 이벤트”를 발행하고, 컨슈머가 V1/V2에 각각 반영하는 방식이 일반적입니다.
  • 이때 분산 트랜잭션을 강제하려고 하기보다, 실패 시 재처리/보상으로 수렴시키는 모델이 현실적입니다. Saga 관점의 설계는 MSA Saga 보상 트랜잭션 설계 실전 가이드와 결이 같습니다.

듀얼 라이트의 일관성 전략

  • 최소 보장: “둘 중 하나만 성공해도 이벤트는 ack”는 위험합니다(영구 불일치 가능).
  • 권장: “둘 다 성공해야 ack, 실패 시 재시도”
  • 재시도 시 멱등 키(예: event_id)를 로그/테이블로 관리하면 중복 처리를 줄일 수 있습니다.

개념 코드:

def handle_doc_upsert(event, index_v1, index_v2):
    doc_id = event["doc_id"]
    text = event["text"]
    meta = event.get("meta", {})

    emb_v1 = embed_v1([text])[0]
    emb_v2 = embed_v2([text])[0]

    vec_v1 = {"id": doc_id, "values": emb_v1, "metadata": {**meta, "embedding_version": "v1"}}
    vec_v2 = {"id": doc_id, "values": emb_v2, "metadata": {**meta, "embedding_version": "v2"}}

    # 둘 다 성공해야 커밋
    index_v1.upsert(vectors=[vec_v1], namespace=event["ns"])
    index_v2.upsert(vectors=[vec_v2], namespace=event["ns"])


def handle_doc_delete(event, index_v1, index_v2):
    doc_id = event["doc_id"]
    ns = event["ns"]

    index_v1.delete(ids=[doc_id], namespace=ns)
    index_v2.delete(ids=[doc_id], namespace=ns)

운영 팁:

  • 듀얼 라이트는 쓰기량이 2배가 됩니다. 백필과 겹치면 순간 쓰기폭증이 생기니, 워커 동시성/큐 컨슈머 수를 조절하세요.
  • 데이터베이스도 함께 압박받을 수 있습니다. 쓰기 폭증 튜닝 관점은 MySQL 8.0 쓰기폭증 - REDO 로그·버퍼풀 튜닝에서 아이디어를 얻을 수 있습니다.

4) 점진적 리드 전환: V2로 천천히 옮기기

이제 V2에 데이터가 충분히 차고, 듀얼 라이트로 최신성도 유지된다면 리드를 전환합니다.

전환 방식 3가지

  1. 퍼센트 라우팅: 1% → 10% → 50% → 100%로 점진 전환
  2. 테넌트/사용자 기반 라우팅: 특정 고객군부터 V2로 전환
  3. 기능 플래그 기반: 내부 운영자/스태프 트래픽부터 전환

리드 전환 시 가장 흔한 함정

  • 동일 질의라도 임베딩 모델이 바뀌면 이웃 관계가 달라져 결과가 변합니다. 이는 “오류”가 아니라 “모델 변경 효과”입니다.
  • 따라서 전환 전에 오프라인 평가(골든 쿼리셋에 대한 nDCG/Recall 등)와 온라인 A/B를 같이 보세요.

쿼리 경로 예시

def search(query: str, ns: str, use_v2: bool, index_v1, index_v2):
    if use_v2:
        q = embed_v2([query])[0]
        res = index_v2.query(vector=q, top_k=10, namespace=ns, include_metadata=True)
        return res

    q = embed_v1([query])[0]
    res = index_v1.query(vector=q, top_k=10, namespace=ns, include_metadata=True)
    return res

운영 팁:

  • 전환 중에는 “V1 결과와 V2 결과를 동시에 계산해 로그로만 남기는” 섀도우 쿼리를 일부 트래픽에 적용하면, 품질 차이와 회귀를 빨리 잡을 수 있습니다.
  • 다만 섀도우 쿼리는 비용이 2배이므로, 샘플링(예: 0.1%)을 권장합니다.

5) 데이터 검증: 누락과 불일치 잡는 체크리스트

무중단 마이그레이션에서 가장 무서운 건 “조용한 누락”입니다. 다음을 최소로 점검하세요.

카운트/커버리지 검증

  • 문서 총량이 V1과 V2에서 동일한지(네임스페이스별)
  • 최근 N시간 변경분이 V2에 반영되었는지(이벤트 로그 기반)

샘플링 기반 내용 검증

  • 임의의 doc_id를 뽑아 V1/V2 모두 존재하는지 확인
  • 메타데이터 필드(권한, 테넌트, 카테고리)가 동일하게 들어갔는지 확인

권한/필터 검증

Pinecone 쿼리에서 메타데이터 필터를 사용한다면, V2에서도 필터가 동일하게 작동하는지 반드시 확인해야 합니다. 필터 누락은 검색 품질 문제가 아니라 보안 사고로 이어질 수 있습니다.


6) 컷오버(완전 전환)와 롤백 전략

컷오버 절차

  1. 리드 라우팅을 V2로 100% 전환
  2. 일정 기간(예: 24~72시간) 듀얼 라이트 유지
  3. 안정화되면 듀얼 라이트 종료(쓰기 경로에서 V1 제거)
  4. V1 인덱스는 즉시 삭제하지 말고, 롤백 윈도우 동안 보관

롤백

  • 리드 라우팅을 다시 V1로 되돌리는 것은 보통 쉽습니다(플래그만 되돌리면 됨).
  • 문제는 “V2에만 반영된 쓰기”가 생기지 않도록 듀얼 라이트를 일정 기간 유지하는 것입니다.
  • 듀얼 라이트를 종료한 뒤에는 롤백이 사실상 “또 다른 마이그레이션”이 됩니다.

7) 운영에서 자주 터지는 이슈와 대응

백필로 인한 시스템 과부하

  • 임베딩 API 레이트리밋
  • Pinecone upsert 제한
  • 원천 DB 조회 부하

대응:

  • 백필 워커 동시성 제한
  • 배치 크기 조절
  • 지수 백오프 재시도
  • 야간/저트래픽 시간대 집중

이벤트 순서 뒤바뀜

예: update 다음에 늦게 도착한 update가 덮어써서 최신이 깨짐

대응:

  • 이벤트에 updated_at 또는 단조 증가 버전(version)을 포함
  • upsert 시 메타데이터에 버전을 넣고, 애플리케이션 레벨에서 최신 버전만 반영

듀얼 라이트 중 일부 실패

대응:

  • “둘 다 성공해야 ack” + 재시도 큐
  • 최종적으로는 “V2 재동기화 잡”을 별도 운영(최근 1일치 변경분을 다시 스캔)

결론: 무중단의 본질은 ‘재처리 가능한 파이프라인’

Pinecone 임베딩 차원 변경은 단순히 인덱스 하나를 바꾸는 작업이 아니라, 데이터 파이프라인을 이중화하고 점진적으로 전환하는 운영 이벤트입니다. 성공하는 팀의 공통점은 다음 3가지를 먼저 설계합니다.

  • 멱등 upsert와 재시도 가능한 백필
  • 듀얼 라이트로 최신성 보장
  • 점진적 리드 전환과 빠른 롤백

이 3가지만 갖추면, 차원 변경뿐 아니라 모델 교체, 메타데이터 스키마 변경, 네임스페이스 재구성 같은 작업도 같은 패턴으로 확장할 수 있습니다.