- Published on
Pinecone 429·타임아웃 대응 - 배치 업서트·재시도
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 중인 RAG/검색 시스템에서 Pinecone에 임베딩을 업서트할 때 가장 흔하게 마주치는 장애가 429 Too Many Requests 와 타임아웃입니다. 특히 대량 백필(backfill)이나 주기 배치(예: 매시간 문서 싱크)에서 동시 업서트가 폭주하면 429가 연쇄적으로 발생하고, 재시도 로직이 없거나 잘못되면 타임아웃까지 겹치면서 작업이 끝나지 않습니다.
이 글은 “Pinecone가 느리다”가 아니라, 클라이언트가 어떤 방식으로 부하를 만들고 있는지를 기준으로 문제를 쪼개고, 실제 운영에서 통하는 배치 업서트/재시도 설계를 제시합니다.
- 429와 타임아웃의 원인 분리
- 배치 크기와 동시성(컨커런시) 튜닝
- 지수 백오프 + 지터(jitter) 재시도
- 아이템포턴시(중복 업서트 안전)와 체크포인팅
- 관측(메트릭/로그)으로 “안정 구간” 찾기
관련해서 네트워크 타임아웃을 다루는 글은 Cloud Run 504 Timeout 원인·해결 9가지, 스트리밍/재시도/체크포인팅 패턴은 OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드도 함께 참고하면 좋습니다.
1) 429와 타임아웃: 같은 증상, 다른 처방
429(레이트리밋/과부하)
- 짧은 시간에 너무 많은 요청을 보냈거나
- 업서트 요청이 너무 무겁거나(배치가 과대, 메타데이터 과대)
- 인덱스/프로젝트 쿼터 또는 순간 처리량 한계를 넘겼을 때
이 경우 핵심 처방은 요청량을 줄이거나(동시성 제한), 요청당 비용을 낮추는 것(배치 크기/데이터 크기 최적화) 입니다.
타임아웃
- 네트워크/프록시/런타임의
read timeout이 짧거나 - 배치가 너무 커서 서버 처리 시간이 길어지거나
- 429 이후 재시도 폭풍(retry storm)으로 큐가 밀려 응답이 늦어질 때
타임아웃은 단순히 타임아웃 값을 키우는 것만으로 해결되지 않습니다. 서버가 처리 가능한 형태로 요청을 만드는 것이 먼저입니다.
2) 배치 업서트의 기본: “크게 한 번”이 아니라 “적당히 여러 번”
대량 업서트에서 흔한 실수는 “배치를 크게 잡으면 HTTP 오버헤드가 줄어 성능이 좋겠지”입니다. 현실은 다릅니다.
- 배치가 커질수록 요청 처리 시간이 늘어 타임아웃 위험이 커짐
- 실패 시 재시도 비용이 커짐(한 번 실패로 수백/수천 벡터 재전송)
- 서버 측에서도 큰 요청은 큐잉/메모리 부담이 커져 429/지연을 유발
권장 접근
- 배치 크기를 보수적으로 시작 (예: 50~200 벡터)
- 동시성은 낮게 시작 (예: 2~8 워커)
- 성공률/지연을 보며 점진적으로 올리기
여기서 “정답 배치 크기”는 벡터 차원, 메타데이터 크기, 네트워크, 인덱스 스펙에 따라 달라집니다. 그래서 아래처럼 자동 튜닝 가능한 구조로 만들어두면 운영이 편합니다.
3) 재시도는 “무조건”이 아니라 “조건부 + 백오프 + 지터”
재시도 대상
- 429
- 5xx
- 타임아웃(클라이언트 측
read timeout,connect timeout)
재시도하면 안 되는 경우
- 4xx 중 429가 아닌 것(예: 인증 실패, 스키마 오류)
- 요청 데이터 자체가 잘못된 경우(예: 벡터 차원 불일치)
백오프와 지터가 필요한 이유
모든 워커가 동시에 실패하고 동시에 재시도하면, 같은 시간에 다시 폭주해서 또 429가 납니다. 이게 retry storm 입니다.
- 지수 백오프:
base * 2^attempt - 지터: 랜덤 요소를 섞어 재시도 타이밍을 분산
4) Python 예제: 배치 업서트 + 동시성 제한 + 재시도
아래 코드는 핵심 패턴만 담은 예시입니다.
batch_size로 벡터를 쪼갬Semaphore로 동시 업서트 수 제한- 429/타임아웃/5xx에 대해 지수 백오프 + 지터
- 실패한 배치는 최대 재시도 후 에러로 올려서 작업을 중단하거나, DLQ로 보낼 수 있게 설계
import asyncio
import random
import time
from typing import Any, Dict, Iterable, List, Tuple
# vectors: List[Tuple[str, List[float], Dict[str, Any]]]
# 예: (id, values, metadata)
class RetryableError(Exception):
pass
def chunked(items: List[Any], size: int) -> Iterable[List[Any]]:
for i in range(0, len(items), size):
yield items[i:i + size]
def backoff_sleep_seconds(attempt: int, base: float = 0.5, cap: float = 20.0) -> float:
exp = min(cap, base * (2 ** attempt))
jitter = random.uniform(0, exp * 0.2)
return exp + jitter
async def upsert_with_retry(
index, # pinecone index client
batch: List[Tuple[str, List[float], Dict[str, Any]]],
namespace: str,
max_attempts: int = 8,
) -> None:
last_err = None
for attempt in range(max_attempts):
try:
# Pinecone SDK에 맞게 호출 형태를 조정하세요.
# 일부 SDK는 sync이므로 asyncio.to_thread로 감싸야 합니다.
await asyncio.to_thread(
index.upsert,
vectors=batch,
namespace=namespace,
)
return
except Exception as e:
msg = str(e).lower()
last_err = e
# 매우 단순화된 분기: 실제로는 SDK 예외 타입/HTTP status를 파싱하세요.
is_429 = "429" in msg or "too many" in msg
is_timeout = "timeout" in msg or "timed out" in msg
is_5xx = any(code in msg for code in ["500", "502", "503", "504"])
if not (is_429 or is_timeout or is_5xx):
raise
sleep_s = backoff_sleep_seconds(attempt)
await asyncio.sleep(sleep_s)
raise RetryableError(f"upsert failed after retries: {last_err}")
async def batch_upsert(
index,
vectors: List[Tuple[str, List[float], Dict[str, Any]]],
namespace: str,
batch_size: int = 100,
concurrency: int = 4,
) -> None:
sem = asyncio.Semaphore(concurrency)
async def worker(one_batch: List[Tuple[str, List[float], Dict[str, Any]]]) -> None:
async with sem:
await upsert_with_retry(index, one_batch, namespace)
tasks = [asyncio.create_task(worker(b)) for b in chunked(vectors, batch_size)]
# 실패를 숨기지 않기 위해 gather에서 예외를 그대로 올립니다.
await asyncio.gather(*tasks)
# 사용 예
# asyncio.run(batch_upsert(index, vectors, namespace="docs", batch_size=100, concurrency=4))
포인트
concurrency를 먼저 낮추고 안정화한 뒤 올리세요.- 429가 자주 보이면
concurrency를 줄이거나batch_size를 줄이는 게 보통 더 효과적입니다. - SDK가 비동기 미지원이면
asyncio.to_thread같은 방식으로 감싸되, 스레드가 과도하게 늘지 않도록 동시성을 반드시 제한해야 합니다.
5) 아이템포턴시: 중복 업서트가 안전해야 재시도가 쉬워진다
재시도는 본질적으로 “같은 요청을 다시 보낸다”입니다. 그러면 중복 데이터가 생기지 않을까요?
Pinecone 업서트는 일반적으로 동일한 id 로 업서트하면 덮어쓰기가 되기 때문에, id 설계를 제대로 하면 아이템포턴시를 확보할 수 있습니다.
좋은 id 전략
- 문서 단위:
docId - 청크 단위:
docId:chunkIndex - 버전 포함:
docId:chunkIndex:contentHash
이 중 운영에서 특히 강력한 패턴은 contentHash 를 섞는 방식입니다.
- 내용이 바뀌면
id가 바뀌어 새 벡터로 들어감 - 내용이 안 바뀌면 재시도/재실행해도 같은
id로 덮어써서 안전
단, 버전이 바뀔 때 기존 벡터를 지우는 정책(예: 이전 버전 id 목록을 별도 저장 후 삭제)이 필요할 수 있습니다.
6) 체크포인팅: “중간에 죽어도 이어서”가 되게 만들기
대량 업서트 작업은 길게는 수십 분~수 시간도 걸립니다. 중간에 컨테이너 재시작, 배포, 네트워크 이슈가 나면 다시 처음부터 하면 비용이 큽니다.
간단한 체크포인팅 방법
- 입력을 정렬 가능한 순서로 만들고(예: 문서 ID 오름차순)
- 마지막 성공 지점을 외부 저장소에 기록
- 예: Redis, DB, S3, GCS, 파일
- 재시작 시 그 지점부터 재개
또는 배치 단위로 “완료된 배치 키”를 저장하는 방식도 가능합니다.
7) 관측(Observability): 튜닝은 감이 아니라 수치로
429/타임아웃을 줄이려면 최소한 아래 지표는 찍어야 합니다.
- 업서트 요청 수(초당)
- 업서트 성공/실패(상태 코드별)
- p50/p95/p99 지연
- 재시도 횟수 분포(평균/최대)
- 배치 크기별 실패율
이 지표로 “안정 구간”을 찾습니다.
- p95 지연이 급증하는 구간에서
batch_size를 줄이기 - 429가 증가하는 구간에서
concurrency를 줄이기 - 재시도 횟수가 늘어나는 구간에서 백오프
base또는cap을 조정하기
8) 운영에서 자주 하는 실수 6가지
- 재시도를 즉시 반복
- 백오프/지터 없이 반복하면 429가 더 늘어납니다.
- 타임아웃만 무작정 증가
- 근본 원인이 과대 배치/과도 동시성이면 타임아웃은 늦게 실패할 뿐입니다.
- 배치 크기와 동시성을 동시에 크게
- 요청당 비용과 요청 수가 동시에 커져 가장 빠르게 무너집니다.
- 실패 배치를 그대로 무한 재시도
- 스키마 오류, 차원 불일치 같은 비재시도성 오류를 분리해야 합니다.
- 메타데이터를 과하게 넣음
- 메타데이터가 커지면 네트워크/서버 처리 시간이 늘고, 결국 타임아웃/429로 되돌아옵니다.
- 작업이 끊겼을 때 재개 불가
- 체크포인팅이 없으면 장애 한 번에 비용이 폭발합니다.
9) 권장 “안전한” 초기값 가이드
환경마다 다르지만, 처음 안정화를 목표로 할 때는 아래처럼 시작하는 게 보통 안전합니다.
batch_size: 50~200concurrency: 2~8- 재시도: 최대 6~10회
- 백오프:
base0.31.0초,30초, 지터 10~30%cap10
그리고 지표를 보며 하나씩만 올리세요.
10) 마무리: 429/타임아웃은 “설계 문제”로 풀린다
Pinecone 429와 타임아웃은 대개 인프라가 아니라 클라이언트 업서트 패턴에서 발생합니다. 핵심은 다음 4가지입니다.
- 배치를 적당히 쪼개기(
batch_size) - 동시성을 제한하기(
concurrency) - 지수 백오프 + 지터로 재시도 폭풍 막기
- 아이템포턴시 + 체크포인팅으로 “재시도/재실행”을 안전하게 만들기
이 구조로 바꾸면 대량 업서트 작업이 “언젠가 끝나겠지”가 아니라, 예측 가능한 처리량과 실패율로 관리 가능한 파이프라인이 됩니다.