Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies {

tasks.named('test') {
useJUnitPlatform {
excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'vector-search-performance'
excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'vector-search-performance', 'worker-indexing-throughput'
}
}

Expand Down Expand Up @@ -152,6 +152,28 @@ tasks.register('vectorSearchPerformanceTest', Test) {
outputs.upToDateWhen { false }
}

tasks.register('workerIndexingThroughputTest', Test) {
group = 'verification'
description = '실제 PostgreSQL, MinIO와 BGE-M3에서 자동 Worker 전체 문서 인덱싱 처리량을 측정합니다.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'worker-indexing-throughput'
}
maxParallelForks = 1
systemProperties System.properties.findAll { key, value ->
key.toString().startsWith('worker.indexing.throughput.')
}
if (System.getProperty('worker.indexing.throughput.output') == null) {
systemProperty(
'worker.indexing.throughput.output',
layout.buildDirectory.file('reports/worker-indexing-throughput/worker-indexing-throughput.json').get().asFile.absolutePath
)
}
// 실제 외부 Service를 점유하고 테스트 데이터를 초기화하는 Benchmark이므로 일반 Test와 Build Cache에서 분리한다.
outputs.upToDateWhen { false }
}

def configureOpenSqlDatabase = { Test task ->
task.maxParallelForks = 1
task.outputs.upToDateWhen { false }
Expand Down
176 changes: 176 additions & 0 deletions docs/design/gimin-#133-worker-indexing-throughput-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 자동 Worker 전체 문서 인덱싱 처리량 Benchmark 설계

## 1. 배경

자동 Worker는 Job 실행 슬롯을 먼저 확보한 뒤 `PENDING` Job을 Claim하고, Parsing, Chunk 저장,
Batch Embedding, Vector 저장과 검색 Version 전환까지 수행한다. 기존 통합·E2E 테스트는 상태 전이와
소유권 정합성을 검증하지만 여러 문서를 연속 처리할 때의 처리량과 지연 기준선은 제공하지 않는다.

이번 작업은 실제 PostgreSQL 17, pgvector, MinIO와 `BAAI/bge-m3`를 연결한 자동 Worker 전체
Pipeline을 반복 측정해 다음 질문에 답한다.

1. 기본 Worker 동시성에서 문서와 Chunk를 초당 얼마나 인덱싱하는가?
2. 업로드 접수, Queue 대기와 실제 처리 시간 중 어느 구간이 전체 지연을 지배하는가?
3. Queue를 모두 소진할 때 처리 누락, 중복 Attempt 또는 중복 Vector가 발생하지 않는가?

## 2. 범위

### 2.1 포함

- 실제 인증과 Multipart HTTP 문서 업로드
- 실제 MinIO Object 저장과 읽기
- 실제 자동 Polling, Claim, Attempt, Lease 갱신과 전체 인덱싱 Pipeline
- 실제 BGE-M3 Batch Embedding과 PostgreSQL `vector(1024)` 저장
- 결정적 TXT Corpus와 같은 Chunk 분포를 사용한 반복 측정
- Warm-up과 본 측정 분리
- 문서·Chunk·Embedding 처리량과 Queue·처리·전체 지연 수집
- Profile별 상태, Attempt, Event, Chunk와 Vector 정합성 검증
- PostgreSQL, pgvector, BGE, Batch Size와 Worker 설정 환경 지문 수집
- Git 제외 JSON 원본 결과와 실행 결과 Markdown 기록
- 일반 테스트와 분리된 전용 Gradle Task

### 2.2 제외

- Worker 프로세스 수에 따른 수평 확장 비교
- Queue 허용 한계와 Backpressure 붕괴 지점 측정
- Lease 갱신 주기별 DB 부하 비교
- BGE-M3 Batch Size 재선정
- PDF·DOCX Parser 형식별 성능 비교
- 공식 OpenSQL 공급사 환경의 최종 성능 수치
- 운영 SLO 확정

## 3. Workload 계약

### 3.1 결정적 문서

- 모든 Profile은 같은 UTF-8 TXT Corpus Template을 사용한다.
- 문서마다 고유한 식별 문구만 바꾸고 본문 길이와 문단 구조는 동일하게 유지한다.
- 본문은 기본 Chunk Size와 Overlap에서 같은 수의 Chunk가 생성되도록 고정한다.
- 파일 이름과 Document 제목은 Profile, 반복과 문서 순번을 포함해 충돌을 방지한다.
- 전체 Profile의 실제 Chunk 수가 같지 않으면 처리량 비교를 실패로 처리한다.

### 3.2 Profile

기본값은 짧은 로컬 실행과 Queue가 유지되는 본 측정을 함께 제공한다.

| 구분 | 문서 수 | 반복 | 통계 포함 |
|---|---:|---:|---|
| Warm-up | 4 | 1 | 제외 |
| 작은 Queue | 16 | 3 | 포함 |
| 지속 Queue | 32 | 3 | 포함 |

문서 수와 반복은 `worker.indexing.throughput.*` System Property로 변경할 수 있다. Worker
`max-concurrency`는 제품 기본값인 `2`로 고정하고 결과 환경 지문에 기록한다. Worker 수평 확장은
별도 Benchmark에서 다룬다.

### 3.3 측정 구간

1. Warm-up 문서를 업로드하고 모두 `INDEXED`가 될 때까지 기다린다.
2. Profile 시작 시각을 기록하고 여러 Uploader Thread로 측정 문서를 접수한다.
3. 마지막 업로드 완료 시각을 기록한다.
4. 자동 Worker가 Profile의 모든 Job을 `INDEXED`로 전환할 때까지 기다린다.
5. 상태와 결과 정합성을 검증한 뒤 통계를 계산한다.

Upload와 Worker 실행이 겹치는 실제 동작을 유지한다. 대신 업로드 시간과 마지막 업로드 뒤 Queue
소진 시간을 분리해 HTTP·MinIO 접수가 Worker 처리량을 가리는지 확인한다.

## 4. 지표 계약

| 지표 | 계산 |
|---|---|
| documents/s | 완료 문서 수 / Profile 전체 경과 시간 |
| documents/min | documents/s × 60 |
| chunks/s | 저장 Chunk 수 / Profile 전체 경과 시간 |
| embeddings/s | 저장 Embedding 수 / Profile 전체 경과 시간 |
| Upload 경과 | 첫 Upload 시작부터 마지막 Upload 응답까지 |
| Queue 소진 | 마지막 Upload 응답부터 마지막 `INDEXED`까지 |
| Queue 대기 | Job `created_at`부터 `LOCKED` Event까지 |
| 실제 처리 | `LOCKED`부터 `INDEXED` Event까지 |
| 전체 Job 지연 | Job `created_at`부터 `INDEXED` Event까지 |

지연 분포는 선형 보간 p50·p95·p99와 max를 밀리초로 기록한다. 모든 Profile 원본과 중앙값 요약은
JSON으로 기록한다.

## 5. 정합성 계약

각 Profile은 측정 뒤 다음 조건을 모두 검증한다.

- 대상 Job 전부 `INDEXED`
- 전체 Schema에 `PENDING` 또는 `PROCESSING` 잔여 Job 없음
- Job별 `SUCCESS` Attempt 정확히 1개
- Job별 `LOCKED`, `PARSE_STARTED`, `CHUNKED`, `EMBEDDING_STARTED`, `INDEXED` 순서 유지
- Document와 Document Version 모두 `INDEXED`
- `documents.current_version_id`가 측정 Version을 가리킴
- 문서별 Chunk 수와 Embedding 수 일치
- 같은 `(chunk_id, embedding_model_id)` 중복 없음
- 모든 Vector 차원 1024
- 실패 Attempt, Retry와 최종 실패 Event 없음

정합성 실패가 하나라도 발생하면 성능 숫자를 유효한 결과로 취급하지 않고 Test를 실패시킨다.

## 6. 환경 검증과 결과 보존

Benchmark 시작 전에 다음을 확인한다.

- PostgreSQL Server Version `17.x`
- pgvector Extension `0.8.1`
- 전용 Test Schema와 MinIO Bucket
- BGE Health와 Model명 `BAAI/bge-m3`
- Document Batch Size와 Worker 최대 동시성

접속 Secret, JWT와 Object Storage Credential은 결과에 기록하지 않는다. 원본 JSON은 다음 Git 제외
경로에 생성한다.

```text
build/reports/worker-indexing-throughput/worker-indexing-throughput.json
```

실행 환경, 중앙값, 해석과 한계만 `docs/test-results/`에 기록한다.

## 7. 실행 경계

일반 `./gradlew test`는 Docker와 실제 BGE-M3에 의존하지 않는다. 전용 Task만 실제 Infrastructure를
요구한다.

```bash
docker compose up -d postgres minio embedding-server
./gradlew workerIndexingThroughputTest
```

확장 측정 예시는 다음과 같다.

```bash
./gradlew workerIndexingThroughputTest \
-Dworker.indexing.throughput.document-counts=32,64 \
-Dworker.indexing.throughput.repetitions=5
```

## 8. 실패 정책

- Infrastructure Health, DB Version, pgvector Version 또는 BGE Model 계약이 다르면 즉시 실패한다.
- Upload, Worker 처리, Embedding 또는 상태 Polling이 제한 시간을 넘으면 Job Snapshot을 포함해 실패한다.
- 완료된 Profile 결과는 후속 진단을 위해 JSON에 보존하되 실패 Profile은 중앙값 판단에서 제외한다.
- Profile 간 데이터는 같은 전용 Schema에서 고유 식별자로 격리하고 마지막에 Schema와 Bucket을 정리한다.

## 9. 검증

- Percentile, 중앙값과 처리량 계산 단위 테스트
- 실제 Infrastructure를 사용하는 작은 Smoke Benchmark
- 기본 Profile 전체 Benchmark
- 기존 로컬 문서 인덱싱 E2E 회귀
- 전체 일반 Java 테스트

## 10. 커밋 분할

1. `docs: #133 자동 Worker 처리량 Benchmark 설계 추가`
2. `test: #133 자동 Worker 전체 인덱싱 처리량 Benchmark 추가`
3. `build: #133 Worker 처리량 전용 실행 경계 추가`
4. `perf: #133 자동 Worker 인덱싱 처리량 실측 결과 기록`

## 11. 완료 조건

- 실제 자동 Worker 전체 Pipeline을 한 명령으로 반복 측정할 수 있다.
- 문서·Chunk·Embedding 처리량과 Queue·처리·전체 지연이 구조화돼 기록된다.
- 성능 Profile마다 완료·소유권·Attempt·Event·Vector 불변식을 검증한다.
- 일반 테스트는 실제 Infrastructure 없이 계속 실행된다.
- 측정 환경의 한계와 운영 수치로 해석하면 안 되는 범위를 결과 문서에 명시한다.
164 changes: 164 additions & 0 deletions docs/test-results/gimin-#133-worker-indexing-throughput-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Issue #133 자동 Worker 전체 문서 인덱싱 처리량 Benchmark 결과

## 1. 결과 요약

PostgreSQL 17.8, pgvector 0.8.1, MinIO와 실제 `BAAI/bge-m3`를 연결하고 자동 Polling Worker가
문서 업로드부터 `INDEXED` 전환까지 처리하는 전체 경로를 측정했다. 4개 문서 예열 후 16개와 32개
문서 Profile을 각각 3회 실행했다.

| 문서 수 | 반복 | 중앙 총 시간 | 문서/초 | 문서/분 | Chunk·Embedding/초 | 전체 P95 |
|---:|---:|---:|---:|---:|---:|---:|
| 16 | 3회 | 50.255초 | 0.318 | 19.102 | 2.547 | 50.127초 |
| 32 | 3회 | 104.335초 | 0.307 | 18.402 | 2.454 | 100.665초 |

Queue를 16개에서 32개로 두 배 늘렸을 때 분당 처리량은 약 3.7% 감소했다. Worker 실행 슬롯을
2개로 고정했기 때문에 처리 시간 P95는 6.768초에서 7.285초로 비슷하게 유지됐고, 전체 지연 증가는
주로 Queue 대기 P95가 43.794초에서 93.554초로 늘어난 데서 발생했다.

## 2. 공개 가능한 실행 환경

| 항목 | 값 |
|---|---|
| Database | PostgreSQL 17.8, Local Docker |
| pgvector | 0.8.1 |
| Object Storage | MinIO, Local Docker |
| Embedding Provider | `BAAI/bge-m3`, Local Docker CPU 추론 |
| Vector 차원 | 1024 |
| Embedding Batch Size | 32 |
| Worker 동시 실행 슬롯 | 2 |
| Worker Polling 주기 | 50 ms |
| 업로더 Thread | 4 |
| 문서 크기 | TXT 6,400자 |
| 문서당 Chunk·Embedding | 각각 8개 |
| Warm-up | 4개 문서 |
| 본 측정 | 16 / 32개 문서, Profile당 3회 |
| Application | Spring Boot 3.5.16, Java 17 |
| 실행 장비 | macOS `aarch64`, 가용 Processor 10개 |
| 실행 일자 | 2026-08-10 KST |

DB Host·Database 이름·Username·Password, MinIO Credential과 JWT 값은 결과에 기록하지 않았다.
이번 결과는 로컬 개발 장비의 기준선이며 공식 OpenSQL 원격 Server 성능이나 운영 SLO가 아니다.

## 3. 측정 범위

각 문서는 다음 전체 경로를 통과했다.

```text
TXT 업로드
→ MinIO 저장
→ Embedding Job PENDING
→ 자동 Worker Polling·Claim
→ Attempt 시작
→ 텍스트 Parsing·Chunk 저장
→ 실제 BGE-M3 Batch 호출
→ vector(1024) 저장
→ Version INDEXED
→ Document current_version 전환
→ Worker 실행 슬롯 반환
```

Profile마다 Benchmark 전용 Schema와 Bucket의 데이터를 초기화해 이전 실행의 Job·Chunk·Embedding이
다음 실행의 수치에 포함되지 않게 했다. Worker Node는 Application 수명주기를 유지하기 위해 Profile
사이에서 재사용했다.

## 4. 실행 방법

PostgreSQL, MinIO와 Embedding Server가 모두 건강 상태인 로컬 환경에서 실행했다.

```bash
docker compose up -d postgres minio embedding-server
DB_SSLMODE=disable ./gradlew workerIndexingThroughputTest
```

구조화 원시 결과는 Git에 포함하지 않는 다음 경로에 생성된다.

```text
build/reports/worker-indexing-throughput/worker-indexing-throughput.json
```

실환경 연결과 결과 계약을 빠르게 확인할 때는 다음 Smoke Profile을 사용했다.

```bash
DB_SSLMODE=disable ./gradlew workerIndexingThroughputTest \
-Dworker.indexing.throughput.warm-up-documents=2 \
-Dworker.indexing.throughput.document-counts=4 \
-Dworker.indexing.throughput.repetitions=1 \
-Dworker.indexing.throughput.output=build/reports/worker-indexing-throughput/smoke.json
```

Smoke 결과는 4개 문서, 32개 Chunk, 32개 Embedding을 12.461초에 처리했고 분당 19.260문서를
기록했다.

## 5. 반복별 원시 결과

| 문서 수 | 회차 | 총 시간 | 문서/분 | Chunk·Embedding/초 | Queue P95 | 처리 P95 | 전체 P95 |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 16 | 1 | 49.379초 | 19.442 | 2.592 | 42.914초 | 7.121초 | 49.134초 |
| 16 | 2 | 51.898초 | 18.498 | 2.466 | 44.887초 | 6.768초 | 51.742초 |
| 16 | 3 | 50.255초 | 19.102 | 2.547 | 43.794초 | 6.468초 | 50.127초 |
| 32 | 1 | 102.558초 | 18.721 | 2.496 | 92.526초 | 6.974초 | 98.769초 |
| 32 | 2 | 104.335초 | 18.402 | 2.454 | 93.554초 | 7.285초 | 100.665초 |
| 32 | 3 | 105.928초 | 18.125 | 2.417 | 95.968초 | 7.431초 | 102.253초 |

같은 Profile 세 번의 분당 처리량 범위는 16문서에서 18.498~19.442, 32문서에서
18.125~18.721이었다. 한 번의 최고값이 아니라 Profile별 중앙값을 비교 기준으로 사용했다.

## 6. 데이터 완전성 검증

| 문서 수 | 회차 | Chunk 수 | Embedding 수 | 결과 |
|---:|---:|---:|---:|---|
| 16 | 1~3 | 매회 128 | 매회 128 | PASS |
| 32 | 1~3 | 매회 256 | 매회 256 | PASS |

각 Profile 완료 시 다음 불변식을 함께 확인했다.

- 모든 Embedding Job이 `INDEXED`다.
- 각 Job에 성공한 Attempt가 정확히 하나 존재한다.
- 모든 Document와 Version이 `INDEXED`이고 `current_version_id`가 측정 Version을 가리킨다.
- Chunk 수와 Embedding 수가 일치하고 중복 Chunk Embedding이 없다.
- 저장된 모든 Vector의 차원은 1024이며 NaN·Infinity가 없다.
- 자동 Worker 실행 슬롯이 0으로 반환되고 Worker가 살아 있다.

따라서 이 결과는 HTTP 접수 시간만 측정한 값이 아니라 Vector 저장과 검색 Version 전환이 완료된 시점까지의
전체 처리량이다.

## 7. 실행 중 발견하고 해결한 환경 문제

### 7.1 로컬 PostgreSQL SSL 설정

첫 Smoke 시도는 외부 Shell의 SSL 설정이 비-SSL 로컬 PostgreSQL에 적용돼 Flyway 연결 전에 실패했다.
`DB_SSLMODE=disable`을 명시한 뒤 Migration과 전체 Pipeline이 정상 실행됐다.

### 7.2 CPU BGE-M3 응답 제한

기존 Embedding HTTP 응답 제한은 5초로 고정돼 있었다. 로컬 CPU BGE-M3는 정상적인 Batch 응답에도
5초를 넘겨 Worker가 `EMBEDDING_PROVIDER_UNAVAILABLE`로 재시도했다. 연결·응답 제한을 환경 설정으로
분리하고 Benchmark에만 2분 응답 제한을 적용했다. 일반 실행의 기본값은 기존과 같은 5초다.

### 7.3 전체 회귀의 JWT 환경 값

전체 회귀 첫 시도는 `JWT_SECRET` 미설정으로 Spring Context 12건이 연쇄 실패했다. Test 전용 JWT와
`DB_SSLMODE=disable`을 명시한 재실행에서 720개 Test가 모두 통과했다. 첫 실패는 Source 결함이나
Benchmark 실패가 아니며 통과 결과로 계산하지 않았다.

## 8. 검증 결과

| 검증 | 결과 |
|---|---|
| 통계 계약 단위 테스트 | PASS |
| 전용 Gradle Task 노출 | PASS |
| 4문서 실제 BGE-M3 Smoke | PASS, 23초 |
| 16·32문서 각 3회 본 측정 | PASS, 8분 1초 |
| 전체 Java 회귀 | PASS, 720 tests, failure/error/skipped 0 |
| `git diff --check` | PASS |

## 9. 결론과 남은 한계

- 구현됨: 자동 Worker의 실제 문서 인덱싱 처리량과 Queue·처리·전체 지연 분포를 반복 측정할 수 있다.
- 검증됨: 16문서에서 32문서로 Queue가 두 배가 되어도 분당 처리량 감소는 약 3.7%였다.
- 검증됨: 여섯 실행 모두 Chunk·Embedding·1024차원 Vector 완전성을 만족했다.
- 관찰됨: 동시 실행 슬롯 2개에서는 Queue 증가가 처리 시간보다 전체 P95를 지배했다.
- 한계: 단일 Local Apple Silicon CPU 장비의 결과로, 공식 OpenSQL Server나 GPU BGE-M3 결과가 아니다.
- 한계: TXT 6,400자 고정 입력이라 PDF·DOCX Parser 비용이나 다양한 문서 길이 분포를 대표하지 않는다.
- 한계: Worker 수·동시 실행 슬롯·Embedding Batch Size를 바꾼 수평 확장 비교는 이번 범위에 포함하지 않았다.
- 후속: 같은 Harness로 Worker 수·동시성 변화, Queue 적체와 Backpressure, 장애 주입 Profile을 비교해야 한다.
Loading