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 @@ -53,7 +53,7 @@ dependencies {

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

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

tasks.register('documentIndexingE2ELoadTest', Test) {
group = 'verification'
description = '실제 PDF·DOCX 50·100문서의 전체 인덱싱 처리량과 데이터 완전성을 측정합니다.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'document-indexing-e2e-load'
}
maxParallelForks = 1
systemProperties System.properties.findAll { key, value ->
key.toString().startsWith('document.indexing.e2e.load.')
}
if (System.getProperty('document.indexing.e2e.load.output') == null) {
systemProperty(
'document.indexing.e2e.load.output',
layout.buildDirectory.file('reports/document-indexing-e2e-load/document-indexing-e2e-load.json').get().asFile.absolutePath
)
}
// 실제 외부 Service를 점유하고 큰 PDF·DOCX Queue를 만드는 장시간 Benchmark를 일반 Test와 분리한다.
outputs.upToDateWhen { false }
}

def configureOpenSqlDatabase = { Test task ->
task.maxParallelForks = 1
task.outputs.upToDateWhen { false }
Expand Down
178 changes: 178 additions & 0 deletions docs/design/gimin-#143-pdf-docx-indexing-e2e-load-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# PDF·DOCX 전체 인덱싱 E2E 부하 Benchmark 설계

## 1. 배경

자동 Worker 전체 인덱싱 처리량 Benchmark는 실제 PostgreSQL 17, MinIO와 `BAAI/bge-m3`에서
TXT 16·32문서의 기준선을 제공한다. 실제 PDF·DOCX는 소수 문서의 기능 E2E로 Parser와 Metadata를
검증했지만, 여러 문서를 동시에 접수했을 때의 처리량과 전체 Pipeline 데이터 완전성은 측정하지 않았다.

이번 작업은 실제 Text Layer PDF와 OOXML DOCX를 50·100문서 규모로 섞어 다음 질문에 답한다.

1. Parser가 다른 PDF·DOCX 혼합 부하에서 전체 인덱싱 처리량과 지연은 어느 수준인가?
2. 형식별 Upload·Queue·처리·전체 지연에 의미 있는 차이가 있는가?
3. 큰 Queue를 모두 소진한 뒤 페이지·Section Metadata와 Vector 저장 불변식이 유지되는가?

## 2. 범위

### 2.1 포함

- Memory에서 생성하는 실제 Text Layer PDF와 OOXML DOCX Binary
- 실제 인증과 Multipart HTTP 업로드
- 실제 MinIO Object 저장과 Content-Type·크기 검증
- 자동 Polling, Claim, Attempt, Parsing, Chunk 저장과 Lease 갱신
- 실제 BGE-M3 Batch Embedding과 PostgreSQL `vector(1024)` 저장
- 전체·PDF·DOCX별 처리량과 Upload·Queue·처리·E2E 지연
- Document·Version·Job·Attempt·Event·현재 검색 Version 정합성 검증
- PDF 페이지와 DOCX Section Metadata 보존 검증
- Git 제외 JSON 원본 결과와 실행 결과 Markdown 기록
- 일반 테스트와 분리한 전용 Gradle Task

### 2.2 제외

- OCR과 스캔 PDF
- 구형 `.doc`와 HWP
- 운영 Admission Control과 Rate Limit
- 공식 OpenSQL 원격 장비의 절대 성능 판정
- 제품 API·Entity·Migration 변경

## 3. Workload 계약

### 3.1 문서 Fixture

- PDF는 두 페이지 Text Layer를 포함하고 페이지별 고유 주제 문구를 가진다.
- DOCX는 제목과 두 개 이상의 Heading·본문 Section을 포함한다.
- 문서별 고유 식별 문구 외에 형식별 본문 길이와 구조를 동일하게 유지한다.
- 파일 이름과 제목은 Profile, 반복, 형식과 순번을 포함해 충돌을 차단한다.
- PDF와 DOCX는 각 Profile에서 같은 개수로 섞는다.

PDF는 PDFBox, DOCX는 Apache POI로 생성한다. 저장소 Fixture Binary를 추가하지 않고도 실제 Parser
경계를 통과하며, PDF는 OCR이 필요 없는 Text Layer만 검증한다.

### 3.2 Profile

| 구분 | 전체 문서 | PDF | DOCX | 반복 | 통계 포함 |
|---|---:|---:|---:|---:|---|
| Warm-up | 4 | 2 | 2 | 1 | 제외 |
| 중간 부하 | 50 | 25 | 25 | 2 | 포함 |
| 큰 부하 | 100 | 50 | 50 | 2 | 포함 |

본 측정 대상은 총 300문서다. 문서 수와 반복은 `document.indexing.e2e.load.*` System Property로
줄여 Smoke Test를 실행할 수 있다. Worker 최대 동시성은 같은 장비에서 문서 수 변화만 비교하도록
의도적으로 `2`에 고정한다. Embedding Batch Size는 제품 설정을 사용하며 두 값 모두 결과 환경 지문에
기록한다.

### 3.3 측정 순서

1. Warm-up PDF·DOCX를 업로드하고 모두 `INDEXED`가 될 때까지 기다린다.
2. Profile 시작 시각부터 여러 Uploader Thread로 PDF·DOCX를 교대로 접수한다.
3. 각 Upload 응답 시각과 마지막 Upload 완료 시각을 기록한다.
4. 자동 Worker가 Profile의 모든 Job을 `INDEXED`로 전환할 때까지 기다린다.
5. 형식별 Metadata와 전체 DB 불변식을 검증한 뒤 통계를 계산한다.
6. Profile 데이터를 정리하고 다음 반복을 시작한다.

Upload와 Worker 실행이 겹치는 실제 흐름을 유지한다. 모든 Uploader는 하나의 Profile 마감 시각을 공유해
개별 Future마다 Timeout이 누적되지 않게 하고, 실패 시 완료된 Run 결과를 JSON에 보존한다.

## 4. 지표 계약

전체와 PDF·DOCX별로 다음 지표를 기록한다.

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

지연 분포는 선형 보간 p50·p95·p99와 max를 밀리초로 기록한다. Profile별 원본 Run과 Profile별
중앙값을 JSON에 함께 기록한다.

## 5. 정합성 계약

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

- Upload 실패 0건, 대상 Job 전부 `INDEXED`
- 전체 Schema에 `PENDING`·`PROCESSING` 잔여 Job 없음
- Job별 `SUCCESS` Attempt 정확히 1개, 실패 Attempt와 Retry 0건
- Job별 `LOCKED`, `PARSE_STARTED`, `CHUNKED`, `EMBEDDING_STARTED`, `INDEXED` 순서 유지
- Document와 Version 모두 `INDEXED`
- `documents.current_version_id`가 측정 Version을 가리킴
- 문서별 Chunk 수가 1 이상이고 Embedding 수와 일치
- 같은 `(chunk_id, embedding_model_id)` 중복 없음
- 모든 Vector 차원 1024, Embedding 상태 `ACTIVE`
- PDF의 모든 Chunk가 유효한 페이지 번호를 보존
- DOCX의 모든 Chunk가 비어 있지 않은 Section 제목을 보존
- MinIO Object 수, Content-Type과 크기가 Upload 결과와 일치

하나라도 실패하면 성능 숫자를 유효한 결과로 취급하지 않고 Benchmark를 실패시킨다.

## 6. 환경과 결과 보존

시작 전에 다음 계약을 확인한다.

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

Secret, JWT와 Object Storage Credential은 결과에 기록하지 않는다. 원본 JSON은 Git 제외 경로에 둔다.

```text
build/reports/document-indexing-e2e-load/document-indexing-e2e-load.json
```

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

## 7. 실행 경계

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

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

작은 Smoke 실행은 다음과 같다.

```bash
./gradlew documentIndexingE2ELoadTest \
-Ddocument.indexing.e2e.load.document-counts=4 \
-Ddocument.indexing.e2e.load.repetitions=1
```

## 8. 실패 정책

- Infrastructure Health, DB·pgvector Version 또는 BGE Model 계약이 다르면 즉시 실패한다.
- Upload와 Worker 처리는 Profile 공통 제한 시간을 넘으면 상태 Snapshot과 함께 실패한다.
- 완료된 Run은 후속 진단을 위해 JSON에 보존하되 실패 Run은 중앙값에 포함하지 않는다.
- Profile별 식별자를 사용해 데이터를 분리하고, Class 종료 때 Schema와 Bucket을 정리한다.

## 9. 검증

- 형식 혼합과 형식별 통계 계약 단위 테스트
- 실제 Infrastructure를 사용하는 4문서 Smoke Benchmark
- 기본 50·100문서 Profile 각 2회 전체 Benchmark
- 기존 PDF·DOCX 로컬 E2E 회귀
- 전체 일반 Java 테스트

## 10. 커밋 분할

1. `docs: #143 PDF DOCX E2E 부하 Benchmark 설계`
2. `test: #143 문서 형식별 부하 통계 계약 추가`
3. `perf: #143 PDF DOCX 전체 인덱싱 부하 Benchmark 추가`
4. `build: #143 E2E 부하 전용 테스트 작업 추가`
5. `perf: #143 PDF DOCX 50 100문서 실측 결과 기록`

## 11. 완료 조건

- 실제 PDF·DOCX 혼합 Pipeline을 한 명령으로 50·100문서 규모에서 반복 측정할 수 있다.
- 본 측정 300문서가 모두 `INDEXED`로 수렴하고 실패·미완료·중복 Vector가 없다.
- 전체와 형식별 처리량 및 지연 분포가 구조화돼 기록된다.
- 페이지·Section Metadata와 Vector 1024차원 불변식이 모든 Profile에서 유지된다.
- 일반 테스트는 외부 Infrastructure 없이 계속 실행된다.
168 changes: 168 additions & 0 deletions docs/test-results/gimin-#143-pdf-docx-indexing-e2e-load-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Issue #143 PDF·DOCX 전체 인덱싱 E2E 부하 Benchmark 결과

## 1. 결과 요약

PostgreSQL 17.8, pgvector 0.8.1, MinIO와 실제 `BAAI/bge-m3`를 연결하고 Text Layer PDF와
OOXML DOCX의 업로드부터 `INDEXED` 전환까지 전체 경로를 측정했다. PDF·DOCX 2개씩 4문서를 예열한
뒤 50문서와 100문서 Profile을 각각 2회 실행했다.

| 문서 수 | 구성 | 반복 | 중앙 총 시간 | 문서/분 | Chunk·Embedding/초 | 전체 P95 |
|---:|---|---:|---:|---:|---:|---:|
| 50 | PDF 25 + DOCX 25 | 2회 | 96.633초 | 31.046 | 2.070 | 92.178초 |
| 100 | PDF 50 + DOCX 50 | 2회 | 187.138초 | 32.063 | 2.138 | 178.533초 |

본 측정 300문서에서 1,200개 Chunk와 1,200개 1024차원 Embedding이 저장됐다. 업로드 실패,
실패·재시도 Attempt, 미완료 Job과 중복 Vector는 모두 0건이었다.

문서 수를 50개에서 100개로 두 배 늘려도 분당 처리량은 약 3.3% 증가한 범위에서 유지됐다. 처리
P95는 4.093초에서 4.026초로 비슷했고, 전체 P95 증가는 Queue 대기 P95가 88.355초에서
174.684초로 늘어난 영향이다.

## 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 | 8 |
| PDF | 2페이지 Text Layer, 페이지당 1,600자 |
| DOCX | Heading·본문 2개 Section, Section당 1,600자 |
| 문서당 Chunk·Embedding | 각각 4개 |
| Warm-up | PDF 2 + DOCX 2 |
| 본 측정 | 50 / 100문서, Profile당 2회 |
| Application | Spring Boot 3.5.16, Java 17 |
| 실행 장비 | macOS `aarch64`, 가용 Processor 10개 |
| 실행 일자 | 2026-08-11 KST |

DB·MinIO·JWT Credential은 결과에 기록하지 않았다. 이 수치는 단일 Apple Silicon 로컬 장비의
개발 기준선이며, 공식 OpenSQL 원격 Server 성능이나 운영 SLO가 아니다.

## 3. 측정 경로

각 문서는 다음 실제 경로를 통과했다.

```text
PDF·DOCX Binary 생성
→ 인증 Multipart HTTP 업로드
→ MinIO 원본 저장
→ Embedding Job PENDING
→ 자동 Worker Polling·Claim·Attempt
→ PDF·DOCX Parsing
→ Chunk·페이지·Section Metadata 저장
→ 실제 BGE-M3 Batch 호출
→ pgvector vector(1024) 저장
→ Version·Document·Job INDEXED
→ current_version 전환
→ Worker 실행 슬롯 반환
```

Profile마다 전용 Schema의 Job·Document·Chunk·Embedding과 전용 MinIO Bucket의 Object를 초기화해
이전 실행이 다음 수치에 포함되지 않게 했다. PDF와 DOCX는 업로드 순서에서 교대로 배치했다.

## 4. 실행 방법

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

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

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

```text
build/reports/document-indexing-e2e-load/document-indexing-e2e-load.json
```

작은 실제 환경 Smoke는 다음 설정으로 실행했다.

```bash
DB_SSLMODE=disable ./gradlew documentIndexingE2ELoadTest \
-Ddocument.indexing.e2e.load.warm-up-documents=2 \
-Ddocument.indexing.e2e.load.document-counts=4 \
-Ddocument.indexing.e2e.load.repetitions=1 \
-Ddocument.indexing.e2e.load.output=build/reports/document-indexing-e2e-load/smoke.json
```

Smoke는 PDF 2 + DOCX 2, Chunk·Embedding 각 16개를 7.192초에 처리했고 33.370문서/분을
기록했다. 전체 Gradle 실행은 19초였다.

## 5. 반복별 결과

| 문서 수 | 회차 | 총 시간 | 문서/분 | Chunk·Embedding/초 | Upload P95 | Queue P95 | 처리 P95 | 전체 P95 |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 50 | 1 | 96.045초 | 31.235 | 2.082 | 165.7ms | 87.903초 | 3.961초 | 91.590초 |
| 50 | 2 | 97.222초 | 30.857 | 2.057 | 84.9ms | 88.808초 | 4.226초 | 92.767초 |
| 100 | 1 | 188.285초 | 31.867 | 2.124 | 106.3ms | 175.755초 | 4.189초 | 179.814초 |
| 100 | 2 | 185.991초 | 32.260 | 2.151 | 71.4ms | 173.612초 | 3.863초 | 177.252초 |

50문서 처리량 범위는 30.857~31.235문서/분, 100문서는 31.867~32.260문서/분이었다. 단일
최고값 대신 두 반복의 중앙값을 비교 기준으로 사용했다.

## 6. 형식별 결과

각 Profile 전체 시간을 분모로 사용해 형식별 문서·Chunk 처리 기여도를 계산했다.

| 문서 수 | 형식 | 문서/분 중앙값 | Chunk/초 중앙값 | Upload P95 | Queue P95 | 처리 P95 | 전체 P95 |
|---:|---|---:|---:|---:|---:|---:|---:|
| 50 | PDF | 15.523 | 1.035 | 124.1ms | 85.947초 | 4.098초 | 89.800초 |
| 50 | DOCX | 15.523 | 1.035 | 121.6ms | 87.994초 | 3.981초 | 91.820초 |
| 100 | PDF | 16.032 | 1.069 | 88.1ms | 173.244초 | 4.065초 | 177.075초 |
| 100 | DOCX | 16.032 | 1.069 | 88.3ms | 173.388초 | 4.021초 | 177.139초 |

동일 길이의 결정적 Fixture에서는 PDF와 DOCX 처리 P95 차이가 50문서에서 약 0.12초,
100문서에서 약 0.04초였다. 이 결과는 Parser 비용이 현재 전체 처리량 병목이 아니며 Queue 대기가
전체 꼬리 지연을 지배함을 보여준다.

## 7. 데이터 완전성

| 문서 수 | 회차 | PDF·DOCX | Chunk | Embedding | 결과 |
|---:|---:|---:|---:|---:|---|
| 50 | 1 | 25 + 25 | 200 | 200 | PASS |
| 50 | 2 | 25 + 25 | 200 | 200 | PASS |
| 100 | 1 | 50 + 50 | 400 | 400 | PASS |
| 100 | 2 | 50 + 50 | 400 | 400 | PASS |

각 Profile 완료 시 다음 불변식을 함께 검증했다.

- 모든 HTTP 업로드가 성공하고 MinIO Object 수가 문서 수와 일치한다.
- MinIO Object와 DB FileObject의 크기·Content-Type이 원본과 일치한다.
- 모든 Job·Version·Document가 `INDEXED`이고 `current_version_id`가 측정 Version을 가리킨다.
- Job별 성공 Attempt가 정확히 하나이며 Retry Count는 0이다.
- `LOCKED → PARSE_STARTED → CHUNKED → EMBEDDING_STARTED → INDEXED` 순서를 유지한다.
- Chunk와 Embedding이 일대일이고 중복 Chunk Embedding이 없다.
- 모든 활성 Vector의 차원이 1024다.
- 모든 PDF Chunk가 페이지 번호를 가지며 문서마다 페이지 1·2가 보존된다.
- 모든 DOCX Chunk가 Section 제목을 가지며 문서마다 두 Section이 보존된다.
- Profile 종료 때 `PENDING`·`PROCESSING` Job과 점유된 Worker 슬롯이 없다.

## 8. 검증 결과

| 검증 | 결과 |
|---|---|
| 형식별 통계 계약 단위 테스트 | PASS |
| 전용 Gradle Task 노출 | PASS |
| PDF 2 + DOCX 2 실제 Smoke | PASS, Gradle 19초 |
| 50·100문서 각 2회 본 측정 | PASS, Gradle 9분 44초 |
| 본 측정 300문서·1,200 Vector 완전성 | PASS |
| 기존 실제 PDF·DOCX 로컬 E2E | PASS, 2 tests |
| 전체 일반 Java 회귀 | PASS, 746 tests |
| `git diff --check` | PASS |

## 9. 결론과 한계

- 구현됨: 실제 PDF·DOCX 혼합 전체 Pipeline을 50·100문서 규모로 반복 측정할 수 있다.
- 검증됨: 300문서가 실패·재시도 없이 모두 `INDEXED`로 수렴했다.
- 검증됨: 문서 수를 두 배로 늘려도 분당 처리량은 약 31~32문서로 유지됐다.
- 관찰됨: PDF와 DOCX의 처리 P95는 비슷하고 Queue 대기가 전체 P95 증가를 지배했다.
- 검증됨: 페이지·Section Metadata, Chunk·Embedding 일대일과 1024차원 Vector가 모두 유지됐다.
- 한계: Text Layer PDF만 포함하며 스캔 PDF와 OCR은 범위 밖이다.
- 한계: 동일 길이의 결정적 문서라 실제 사용자 파일의 크기·표·이미지 분포를 대표하지 않는다.
- 한계: 단일 Worker Node, 실행 슬롯 2개와 Local CPU BGE-M3 결과다.
- 후속: 실제 사용자 Corpus, 장애 주입과 공식 OpenSQL 원격 환경에서 같은 Harness를 재검증할 수 있다.
Loading