pgvector 대규모 임베딩 적재 튜닝
상위: Database MOC
개요
수십만~수백만 건의 문서를 임베딩해 document_chunks에 적재하는 배치 작업은 한 번에 끝나지 않는다. 임베딩 API 호출은 느리고, 중간에 프로세스가 죽거나 재시작되며, 이어서 “이미 임베딩된 것은 건너뛰고 남은 것부터” 재개해야 한다. 이 재개 로직을 순진하게 NOT EXISTS + ORDER BY loaded_at으로 짜고 커버링 인덱스를 두지 않으면, 초반엔 멀쩡하다가 진행할수록 배치가 느려지고 결국 배치 하나에 수십 분이 걸리는 지점까지 붕괴한다. 실제로 38만 행 규모에서 배치당 처리 시간이 ~78분까지 치솟았던 사례가 있다. 이 문서는 그 원인과, 초 단위로 되돌리는 인덱스 설계를 정리한 war-story다.
원리
재개 anti-join의 비용
재개 배치의 전형적 쿼리는 “임베딩이 아직 없는 원본을, 적재 순서대로 N건” 이다.
SELECT s.id, s.body
FROM source_docs s
WHERE NOT EXISTS (
SELECT 1 FROM document_chunks c WHERE c.doc_id = s.id
)
ORDER BY s.loaded_at ASC
LIMIT 500;이 쿼리에는 두 개의 무거운 연산이 숨어 있다.
- anti-join —
document_chunks에 대한 존재 검사.doc_id인덱스가 없으면 배치마다 대상 테이블을 훑는다. - 정렬 —
ORDER BY loaded_at. 인덱스가 없으면 매 배치마다 후보 전체를 정렬한다.
왜 “갈수록” 느려지는가
핵심은 비용이 누적적으로 악화된다는 점이다. 진행할수록 document_chunks는 커지고(anti-join 검사 비용↑), 이미 임베딩된 앞부분 행을 매번 스캔·정렬해 버린 뒤 뒤쪽 미처리 구간에 도달해야 하므로(스캔 낭비↑) 배치가 뒤로 갈수록 느려진다. 인덱스가 없으면 이 두 곡선이 곱해져 선형이 아니라 급격히 나빠진다.
| 진행도 | 인덱스 없음 (배치당) | 커버링/정렬 인덱스 |
|---|---|---|
| 초반 | 수 초 | 수 초 |
| 중반 | 수 분 | 수 초 |
| 후반(예: 38만 행) | ~78분 | 수 초 |
실전
커버링 부분 인덱스 + 정렬 인덱스
핵심은 “아직 임베딩되지 않은 행”을 상태 컬럼으로 표현하고, 그 조건을 부분 인덱스(partial index)로 만드는 것이다. 여기서는 원본 테이블에 embedded_at TIMESTAMPTZ NULL 플래그를 두고 임베딩 완료 시 채운다고 가정한다.
-- 아직 임베딩 안 된 행만 담는 부분 인덱스 (인덱스 자체가 작아 스캔이 싸다)
CREATE INDEX CONCURRENTLY idx_src_unembedded
ON source_docs (loaded_at)
WHERE embedded_at IS NULL;WHERE embedded_at IS NULL로 인덱스 크기를 “남은 일감”만큼으로 유지하고, 인덱스 키를 loaded_at으로 두어 정렬까지 이 인덱스 하나로 해결한다. 임베딩이 진행될수록 인덱스는 오히려 작아진다.
-- 재개 쿼리도 상태 플래그 기반으로 단순화
SELECT id, body
FROM source_docs
WHERE embedded_at IS NULL
ORDER BY loaded_at ASC
LIMIT 500;이 쿼리는 부분 인덱스를 range scan으로 타고 정렬을 인덱스로 흡수하므로, 후반 배치도 초 단위로 복귀한다.
anti-join 방식을 유지해야 한다면
플래그 컬럼을 못 쓰고 NOT EXISTS를 유지한다면 조인 대상에 인덱스를 준다.
CREATE INDEX CONCURRENTLY ON document_chunks (doc_id);
CREATE INDEX CONCURRENTLY ON source_docs (loaded_at);EXPLAIN (ANALYZE, BUFFERS)로 anti-join이 Hash Anti Join + Index Scan으로 풀리는지, Seq Scan으로 떨어지는지 확인한다.
per-row INSERT를 버리고 COPY / executemany
적재 자체의 처리량도 재구축 시간을 지배한다. 행마다 INSERT를 날리면 네트워크 왕복 1~2회 × 수십만 번이 그대로 벽시계 시간이 된다.
# 나쁨: 행마다 왕복
for row in rows:
await conn.execute("INSERT INTO document_chunks(doc_id, content, embedding) VALUES($1,$2,$3)",
row.doc_id, row.content, row.embedding)
# 나음: 한 번에 다중 행
await conn.executemany(
"INSERT INTO document_chunks(doc_id, content, embedding) VALUES($1,$2,$3)",
[(r.doc_id, r.content, r.embedding) for r in rows],
)
# 가장 빠름: COPY (asyncpg copy_records_to_table)
await conn.copy_records_to_table(
"document_chunks",
records=[(r.doc_id, r.content, r.embedding) for r in rows],
columns=["doc_id", "content", "embedding"],
)| 방식 | 왕복 | 대량 적재 적합성 |
|---|---|---|
| per-row INSERT | 행당 1~2 | 나쁨 |
executemany | 배치당 1 | 좋음 |
COPY | 배치당 1 (스트림) | 가장 좋음 |
함정·트러블슈팅
- 재개 멱등성 — 재개는 자연키/상태 플래그 기반으로 멱등이어야 한다. 실패한 배치를 재실행해도 중복 청크가 생기지 않도록
ON CONFLICT DO NOTHING또는 upsert를 건다. 자연키·멱등 설계는 안전한 스키마 마이그레이션과 함께 본다. - 인덱스는 CONCURRENTLY로 — 적재가 도는 중 인덱스를 만들면 테이블을 잠글 수 있다. 운영 중이면
CREATE INDEX CONCURRENTLY(트랜잭션 밖). 자세한 락 이슈는 안전한 스키마 마이그레이션 참조. - 적재 후 대량 UPDATE 주의 — 임베딩을 나중에 채우는 UPDATE가 잦으면 dead tuple이 쌓인다. churn이 큰 테이블은 autovacuum 스케일 팩터를 낮춘다 → autovacuum와 bloat.
- 부분 인덱스 조건 불일치 — 부분 인덱스의
WHERE와 쿼리의WHERE가 정확히 일치해야 플래너가 인덱스를 쓴다.embedded_at IS NULL을embedded_at ISNULL이나 다른 표현으로 바꾸면 인덱스를 못 탄다.
정리
- 재개 배치가 “갈수록 느려진다”면 anti-join + 정렬에 인덱스가 없는지 의심한다. 후반부에서 배치당 수십 분(예: 38만 행 ~78분)까지 붕괴할 수 있다.
- 상태 플래그 +
WHERE embedded_at IS NULL부분 인덱스(키 = 정렬 컬럼)가 가장 깔끔한 해법이다. 남은 일감만큼만 인덱스가 커지고, 조회가 초 단위로 복귀한다. - 적재 처리량은
COPY/executemany로 왕복을 줄여 확보한다. EXPLAIN (ANALYZE, BUFFERS)로 인덱스 사용 여부를 눈으로 확인하는 습관이 최선의 방어다.
관련: pgvector - 셋업과 HNSW 인덱스 · autovacuum와 bloat · 커넥션 관리와 asyncpg 함정