diff --git a/build.gradle b/build.gradle index f6ca27b..ae2903a 100644 --- a/build.gradle +++ b/build.gradle @@ -54,7 +54,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', 'document-indexing-e2e-load', 'chunk-quality-performance' + excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance' } } @@ -176,6 +176,28 @@ tasks.register('vectorSearchPerformanceTest', Test) { outputs.upToDateWhen { false } } +tasks.register('vectorStoragePerformanceTest', Test) { + group = 'verification' + description = 'PostgreSQL pgvector의 저장 규모·JDBC Batch Size별 TPS와 저장 비용을 비교합니다.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'vector-storage-performance' + } + maxParallelForks = 1 + systemProperties System.properties.findAll { key, value -> + key.toString().startsWith('vector.storage.performance.') + } + if (System.getProperty('vector.storage.performance.output') == null) { + systemProperty( + 'vector.storage.performance.output', + layout.buildDirectory.file('reports/vector-storage/vector-storage-latest.json').get().asFile.absolutePath + ) + } + // 실제 PostgreSQL에 최대 10만 개의 1024차원 Vector를 반복 적재하므로 일반 Test와 Cache에서 분리한다. + outputs.upToDateWhen { false } +} + tasks.register('workerIndexingThroughputTest', Test) { group = 'verification' description = '실제 PostgreSQL, MinIO와 BGE-M3에서 자동 Worker 전체 문서 인덱싱 처리량을 측정합니다.' diff --git a/docs/design/gimin-#155-vector-storage-batch-benchmark.md b/docs/design/gimin-#155-vector-storage-batch-benchmark.md new file mode 100644 index 0000000..7c24ab5 --- /dev/null +++ b/docs/design/gimin-#155-vector-storage-batch-benchmark.md @@ -0,0 +1,124 @@ +# Vector 저장 TPS·Batch Size 비교 Benchmark 설계 + +- 작성일: 2026-08-11 +- 상태: 구현 및 로컬 실측 완료 + +## 1. 배경 + +DocGrid는 `vector(1024)`와 HNSW 검색 구조, Exact·HNSW 검색 지연과 Recall을 검증했다. 그러나 실제 +Vector 저장 경로는 기능·원자성만 확인했고, 데이터 규모와 JDBC Batch Size가 저장 처리량에 미치는 +영향은 측정하지 않았다. 검색이 빨라도 Embedding 결과를 DB에 넣는 단계가 느리면 전체 인덱싱 처리량의 +병목이 될 수 있다. + +이번 Benchmark는 실제 PostgreSQL 17 + pgvector에서 제품과 같은 1024차원 Vector·HNSW 구조를 +사용해 1천·1만·10만 건 저장 TPS와 Batch 호출 수, 저장 공간을 비교한다. + +## 2. 목표와 성공 기준 + +- 1,000·10,000·100,000건을 같은 Vector Pool과 Schema에서 비교한다. +- JDBC Batch Size 1·100·500·1,000을 같은 Transaction 경계로 비교한다. +- Vector 생성 시간은 측정에서 제외하고 Bind·Batch 전송·DB 반영·Commit을 포함한다. +- 모든 Profile에서 Row 수, `vector_dims=1024`, HNSW Index와 Batch 실행 횟수를 검증한다. +- Profile별 Warm-up 뒤 본 측정 2회를 실행해 Duration·TPS 분포를 기록한다. +- 결과를 재현 가능한 JSON과 `docs/test-results/` 문서로 남긴다. +- 제품 Table, Migration, Repository와 운영 설정은 변경하지 않는다. + +## 3. 비교 Profile + +| 축 | 값 | +|---|---| +| 저장 건수 | `1,000`, `10,000`, `100,000` | +| JDBC Batch Size | `1`, `100`, `500`, `1,000` | +| Vector | Dense 1024차원, Seed 고정 Normalized Vector Pool | +| Index | `vector_cosine_ops`, HNSW `m=16`, `ef_construction=64` | +| Transaction | Profile Round당 한 Transaction, 마지막 Commit 포함 | +| Warm-up | Profile별 최대 1,000건 1회 | +| 본 측정 | Profile별 2회 | + +총 12개 Profile을 측정한다. Batch Size가 Row 수보다 큰 경우 한 번의 `executeBatch()`로 처리한다. + +## 4. 측정 경계 + +### 4.1 포함 + +1. PreparedStatement Parameter Bind +2. `addBatch()` +3. 설정한 크기마다 `executeBatch()` +4. PostgreSQL `vector(1024)` 저장과 HNSW Online Index 갱신 +5. Transaction Commit + +### 4.2 제외 + +- BGE-M3 추론과 HTTP 전송 +- Chunk 생성과 JPA Entity 변환 +- Connection 획득과 Table·Index 생성 +- Vector 값 생성과 문자열 직렬화 +- HNSW 신규 Index 일괄 구축 시간 +- 검색 지연과 Recall + +Vector 문자열은 측정 전에 1,024개를 결정적으로 만들어 재사용한다. 저장 자체가 아닌 Random Vector +생성·정규화·문자열 변환 비용이 TPS에 섞이지 않게 하기 위해서다. + +## 5. 격리와 실행 순서 + +- Test마다 고유 Schema를 사용하고 종료 시 Schema 전체를 삭제한다. +- 전용 Probe Table만 생성하며 제품 `embeddings` Table은 사용하지 않는다. +- HNSW Index는 빈 Table에 먼저 생성해 Online Insert 비용을 모든 Profile에 동일하게 포함한다. +- Warm-up과 각 측정 Round 앞에서 `TRUNCATE`해 같은 빈 Table 상태에서 시작한다. +- 데이터 규모마다 Batch Size 시작 순서를 회전해 실행 순서 편향을 줄인다. +- 전용 Gradle Task는 직렬로 실행하고 일반 `test`에서는 제외한다. + +## 6. 지표와 불변식 + +| 지표 | 계산·검증 | +|---|---| +| Duration | 첫 Bind 직전부터 Commit 완료까지 | +| TPS | `rowCount / durationSeconds` | +| Batch 실행 수 | 실제 `executeBatch()` 호출 횟수 | +| 기대 Batch 수 | `ceil(rowCount / batchSize)` | +| Row 수 | Profile Round 종료 뒤 정확히 입력 건수와 일치 | +| Vector 차원 | `min(vector_dims)=max(vector_dims)=1024` | +| Table·Index 크기 | `pg_table_size`, `pg_relation_size` | +| HNSW 계약 | `pg_indexes.indexdef`에 전용 Index와 `USING hnsw` 존재 | + +`executeBatch()` 결과에 `EXECUTE_FAILED`가 있거나 기대 행 수와 결과 수가 다르면 즉시 실패한다. 측정 +오류를 낮은 TPS처럼 기록하지 않는다. + +## 7. 결과 해석 원칙 + +1. 같은 데이터 규모 안에서 Batch Size별 TPS와 Batch 호출 감소를 비교한다. +2. 1천→1만→10만 건에서 같은 Batch Size의 TPS가 어떻게 변하는지 확인한다. +3. 가장 높은 TPS 하나만 고르지 않고 100·500·1,000의 개선 폭이 작아지는 지점을 찾는다. +4. 로컬 Docker 절대 TPS는 운영 SLO가 아니라 상대 비교 기준선으로 사용한다. +5. 결과만으로 제품 Repository 저장 방식을 변경하지 않고 Pipeline E2E에서 다시 검증한다. + +## 8. 실행 방법 + +```bash +docker compose up -d postgres +DB_SSLMODE=disable ./gradlew vectorStoragePerformanceTest +``` + +Smoke 실행은 다음처럼 규모와 Round를 줄인다. + +```bash +DB_SSLMODE=disable ./gradlew vectorStoragePerformanceTest \ + -Dvector.storage.performance.sizes=1000 \ + -Dvector.storage.performance.batch-sizes=1,100 \ + -Dvector.storage.performance.measured-runs=1 +``` + +기본 결과는 `build/reports/vector-storage/vector-storage-latest.json`에 생성한다. + +실측 결과와 원본 데이터는 다음 파일에 보존한다. + +- `docs/test-results/gimin-#155-vector-storage-batch-benchmark.md` +- `docs/test-results/gimin-vector-storage-batch-benchmark-data.json` + +## 9. 완료 조건 + +- 전용 Task가 12개 Profile을 실제 PostgreSQL에서 완료한다. +- 1천·1만·10만 건과 Batch 1·100·500·1,000 결과가 모두 기록된다. +- Row·차원·HNSW·Batch 호출 불변식이 자동 검증된다. +- 일반 회귀 테스트가 실제 대량 적재 없이 성공한다. +- JSON과 최종 결과 문서에 환경·수치·결론·한계가 기록된다. diff --git a/docs/test-results/gimin-#155-vector-storage-batch-benchmark.md b/docs/test-results/gimin-#155-vector-storage-batch-benchmark.md new file mode 100644 index 0000000..7392cab --- /dev/null +++ b/docs/test-results/gimin-#155-vector-storage-batch-benchmark.md @@ -0,0 +1,111 @@ +# Vector 저장 TPS·Batch Size 비교 결과 + +- 측정일: 2026-08-11 +- 결과: 성공 +- 원본 데이터: [gimin-vector-storage-batch-benchmark-data.json](gimin-vector-storage-batch-benchmark-data.json) +- 설계: [gimin-#155-vector-storage-batch-benchmark.md](../design/gimin-%23155-vector-storage-batch-benchmark.md) + +## 1. 결론 + +실제 PostgreSQL 17.8과 pgvector 0.8.1에서 1,000·10,000·100,000개의 1024차원 Vector를 +HNSW Index가 이미 존재하는 Table에 저장했다. JDBC Batch는 데이터가 커질수록 효과가 뚜렷했다. + +- 100,000건 기준 Batch 1의 p50은 `1,130.06 rows/s`, Batch 1,000은 `1,631.31 rows/s`였다. +- Batch 1,000은 Batch 1보다 처리량이 `44.36%` 높고, p50 저장 시간은 약 `22.63초` 짧았다. +- 같은 조건에서 `executeBatch()` 호출은 `100,000회 → 100회`로 `99.9%` 감소했다. +- Batch 100도 `1,559.21 rows/s`로 Batch 1,000 성능의 약 `95.58%`를 확보했다. +- 1,000건에서는 Batch 100이 가장 빨랐다. 작은 입력에 과도한 Batch를 적용할 이점은 확인되지 않았다. + +따라서 대량 Vector 적재의 로컬 기준선은 Batch 100~1,000이 타당하다. 다만 이번 결과만으로 제품의 +기본 Batch Size를 확정하지 않고, 실제 Chunk·Embedding Pipeline의 메모리와 Transaction 시간을 함께 +측정한 뒤 결정한다. + +## 2. 측정 환경 + +| 항목 | 값 | +|---|---| +| Database | PostgreSQL 17.8 (Debian 17.8-1.pgdg12+1) | +| pgvector | 0.8.1 | +| JDBC Driver | PostgreSQL JDBC Driver 42.7.11 | +| Architecture | aarch64 | +| Java | 17.0.18 | +| Vector | 1024차원, Seed 고정 Normalized Vector Pool 1,024개 | +| HNSW | cosine, `m=16`, `ef_construction=64` | +| Warm-up | Profile별 최대 1,000건 | +| 본 측정 | Profile별 2회 | +| 측정 경계 | 첫 Bind 직전부터 HNSW Online 갱신과 Commit 완료까지 | + +## 3. 전체 결과 + +TPS와 시간은 각 지표의 2회 표본을 독립적으로 정렬해 계산한 nearest-rank p50이다. 따라서 같은 +Profile의 TPS p50과 시간 p50도 서로 다른 Round에서 나올 수 있으며, 두 값을 서로 환산하면 안 된다. +표본 수가 작으므로 절대 성능 SLO가 아니라 같은 로컬 환경의 상대 기준선으로 해석한다. + +| 저장 건수 | Batch Size | TPS p50 | 시간 p50 | Batch 호출 | Batch 1 대비 TPS | 총 저장 크기 | +|---:|---:|---:|---:|---:|---:|---:| +| 1,000 | 1 | 414.81 | 2.389초 | 1,000 | 기준 | 13.23 MiB | +| 1,000 | 100 | **472.13** | **2.116초** | 10 | **+13.82%** | 13.23 MiB | +| 1,000 | 500 | 467.97 | 2.119초 | 2 | +12.82% | 13.23 MiB | +| 1,000 | 1,000 | 464.08 | 2.133초 | 1 | +11.88% | 13.23 MiB | +| 10,000 | 1 | 1,143.18 | 8.708초 | 10,000 | 기준 | 61.38 MiB | +| 10,000 | 100 | 1,524.13 | 6.467초 | 100 | +33.32% | 61.38 MiB | +| 10,000 | 500 | 1,397.54 | 6.922초 | 20 | +22.25% | 61.40 MiB | +| 10,000 | 1,000 | **1,533.76** | **6.495초** | 10 | **+34.17%** | 61.38 MiB | +| 100,000 | 1 | 1,130.06 | 83.220초 | 100,000 | 기준 | 732.80 MiB | +| 100,000 | 100 | 1,559.21 | 61.827초 | 1,000 | +37.98% | 733.44 MiB | +| 100,000 | 500 | 1,580.80 | 62.624초 | 200 | +39.89% | 732.71 MiB | +| 100,000 | 1,000 | **1,631.31** | **60.590초** | 100 | **+44.36%** | 734.47 MiB | + +## 4. 해석 + +### 4.1 Batch가 줄인 비용 + +Batch Size가 커지면 JDBC 왕복과 `executeBatch()` 호출 수가 감소했다. 특히 100,000건에서 Batch 100은 +호출 수를 99%, Batch 1,000은 99.9% 줄였다. 실제 TPS도 함께 개선됐으므로 호출 감소가 단순한 코드상 +차이가 아니라 저장 처리량에 영향을 줬다. + +### 4.2 처리량이 무한히 증가하지 않는 이유 + +모든 Profile은 HNSW Index를 먼저 만든 뒤 Vector를 Online Insert했다. Batch 100~1,000에서도 처리량이 +약 1.6천 rows/s 부근에 머문 것은 JDBC 호출 외에 1024차원 Vector 저장과 HNSW 갱신 비용이 남아 있기 +때문이다. 이 Benchmark는 빠른 Bulk Import가 아니라 실제 온라인 인덱싱 경로에 가까운 기준선이다. + +### 4.3 단조 증가를 주장하지 않는 이유 + +10,000건의 Batch 500은 Batch 100과 1,000보다 낮았다. Profile별 본 측정이 2회이고 로컬 Docker의 +I/O·CPU 변동이 있으므로 모든 규모에서 Batch가 커질수록 TPS가 반드시 증가한다고 결론 내릴 수 없다. +운영 기본값을 고르기 전 5회 이상 반복하고 실제 Chunk 크기·Worker 동시성을 포함해 재검증해야 한다. + +### 4.4 저장 공간 + +100,000건의 Table은 약 533.2 MiB, HNSW Index는 약 199.5~201.2 MiB였다. Batch Size는 전송 방식만 +바꾸므로 관계 크기는 실질적으로 같았고, 작은 차이는 HNSW 구성과 Page 배치 변동 범위였다. + +## 5. 자동 검증 + +각 Round에서 다음 조건을 통과해야 결과에 포함되도록 구현했다. + +- 저장 Row 수가 요청 건수와 정확히 일치 +- `min(vector_dims)=max(vector_dims)=1024` +- 실제 Batch 실행 수가 `ceil(rowCount / batchSize)`와 일치 +- JDBC 결과에 `EXECUTE_FAILED`가 없음 +- `vector_cosine_ops` HNSW Index가 존재 +- 저장 시간과 TPS가 0보다 큰 유한값 + +## 6. 재현 방법 + +```bash +docker compose up -d postgres +DB_SSLMODE=disable ./gradlew vectorStoragePerformanceTest +``` + +기본 실행은 12개 Profile을 순차 측정하고 +`build/reports/vector-storage/vector-storage-latest.json`을 생성한다. + +## 7. 한계와 다음 검증 + +- 로컬 Docker 단일 환경 결과이므로 공식 OpenSQL 수치와 동일하다고 볼 수 없다. +- 본 측정 2회는 회귀 기준선에는 쓸 수 있지만 안정적인 백분위 산출에는 부족하다. +- BGE-M3, Chunk 생성, HTTP, JPA와 Worker 동시 실행 비용은 제외했다. +- 제품 Batch Size 변경 전 공식 OpenSQL과 실제 Pipeline E2E에서 메모리·Connection Pool·Transaction + 시간을 함께 검증한다. diff --git a/docs/test-results/gimin-vector-storage-batch-benchmark-data.json b/docs/test-results/gimin-vector-storage-batch-benchmark-data.json new file mode 100644 index 0000000..cd4ea79 --- /dev/null +++ b/docs/test-results/gimin-vector-storage-batch-benchmark-data.json @@ -0,0 +1,413 @@ +{ + "generatedAt" : "2026-08-11T07:53:59.298903Z", + "databaseProduct" : "PostgreSQL", + "databaseVersion" : "17.8 (Debian 17.8-1.pgdg12+1)", + "jdbcDriver" : "PostgreSQL JDBC Driver", + "jdbcDriverVersion" : "42.7.11", + "pgvectorVersion" : "0.8.1", + "architecture" : "aarch64", + "javaVersion" : "17.0.18", + "vectorDimension" : 1024, + "hnswM" : 16, + "hnswEfConstruction" : 64, + "warmUpRows" : 1000, + "measuredRuns" : 2, + "vectorPoolSize" : 1024, + "profiles" : [ { + "rowCount" : 1000, + "batchSize" : 1, + "executionOrder" : 0, + "expectedBatchExecutions" : 1000, + "rounds" : [ { + "durationMillis" : 2389.147166, + "rowsPerSecond" : 418.5593981948954, + "batchExecutions" : 1000, + "acknowledgedRows" : 1000 + }, { + "durationMillis" : 2410.766209, + "rowsPerSecond" : 414.8058804983856, + "batchExecutions" : 1000, + "acknowledgedRows" : 1000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 2389.147166, + "p50" : 2389.147166, + "p95" : 2410.766209, + "maximum" : 2410.766209 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 414.8058804983856, + "p50" : 414.8058804983856, + "p95" : 418.5593981948954, + "maximum" : 418.5593981948954 + }, + "tableBytes" : 5668864, + "indexBytes" : 8200192, + "totalBytes" : 13869056 + }, { + "rowCount" : 1000, + "batchSize" : 100, + "executionOrder" : 1, + "expectedBatchExecutions" : 10, + "rounds" : [ { + "durationMillis" : 2115.742083, + "rowsPerSecond" : 472.64740255204345, + "batchExecutions" : 10, + "acknowledgedRows" : 1000 + }, { + "durationMillis" : 2118.055625, + "rowsPerSecond" : 472.1311320612744, + "batchExecutions" : 10, + "acknowledgedRows" : 1000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 2115.742083, + "p50" : 2115.742083, + "p95" : 2118.055625, + "maximum" : 2118.055625 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 472.1311320612744, + "p50" : 472.1311320612744, + "p95" : 472.64740255204345, + "maximum" : 472.64740255204345 + }, + "tableBytes" : 5668864, + "indexBytes" : 8200192, + "totalBytes" : 13869056 + }, { + "rowCount" : 1000, + "batchSize" : 500, + "executionOrder" : 2, + "expectedBatchExecutions" : 2, + "rounds" : [ { + "durationMillis" : 2136.909917, + "rowsPerSecond" : 467.9654448905812, + "batchExecutions" : 2, + "acknowledgedRows" : 1000 + }, { + "durationMillis" : 2119.149417, + "rowsPerSecond" : 471.88744313067946, + "batchExecutions" : 2, + "acknowledgedRows" : 1000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 2119.149417, + "p50" : 2119.149417, + "p95" : 2136.909917, + "maximum" : 2136.909917 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 467.9654448905812, + "p50" : 467.9654448905812, + "p95" : 471.88744313067946, + "maximum" : 471.88744313067946 + }, + "tableBytes" : 5668864, + "indexBytes" : 8200192, + "totalBytes" : 13869056 + }, { + "rowCount" : 1000, + "batchSize" : 1000, + "executionOrder" : 3, + "expectedBatchExecutions" : 1, + "rounds" : [ { + "durationMillis" : 2133.162667, + "rowsPerSecond" : 468.78750292698606, + "batchExecutions" : 1, + "acknowledgedRows" : 1000 + }, { + "durationMillis" : 2154.789833, + "rowsPerSecond" : 464.0823827388089, + "batchExecutions" : 1, + "acknowledgedRows" : 1000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 2133.162667, + "p50" : 2133.162667, + "p95" : 2154.789833, + "maximum" : 2154.789833 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 464.0823827388089, + "p50" : 464.0823827388089, + "p95" : 468.78750292698606, + "maximum" : 468.78750292698606 + }, + "tableBytes" : 5668864, + "indexBytes" : 8200192, + "totalBytes" : 13869056 + }, { + "rowCount" : 10000, + "batchSize" : 100, + "executionOrder" : 4, + "expectedBatchExecutions" : 100, + "rounds" : [ { + "durationMillis" : 6561.1185, + "rowsPerSecond" : 1524.1303750267582, + "batchExecutions" : 100, + "acknowledgedRows" : 10000 + }, { + "durationMillis" : 6467.21275, + "rowsPerSecond" : 1546.2611771972402, + "batchExecutions" : 100, + "acknowledgedRows" : 10000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 6467.21275, + "p50" : 6467.21275, + "p95" : 6561.1185, + "maximum" : 6561.1185 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1524.1303750267582, + "p50" : 1524.1303750267582, + "p95" : 1546.2611771972402, + "maximum" : 1546.2611771972402 + }, + "tableBytes" : 55967744, + "indexBytes" : 8396800, + "totalBytes" : 64364544 + }, { + "rowCount" : 10000, + "batchSize" : 500, + "executionOrder" : 5, + "expectedBatchExecutions" : 20, + "rounds" : [ { + "durationMillis" : 6921.522417, + "rowsPerSecond" : 1444.7688525054732, + "batchExecutions" : 20, + "acknowledgedRows" : 10000 + }, { + "durationMillis" : 7155.43175, + "rowsPerSecond" : 1397.539708208383, + "batchExecutions" : 20, + "acknowledgedRows" : 10000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 6921.522417, + "p50" : 6921.522417, + "p95" : 7155.43175, + "maximum" : 7155.43175 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1397.539708208383, + "p50" : 1397.539708208383, + "p95" : 1444.7688525054732, + "maximum" : 1444.7688525054732 + }, + "tableBytes" : 55984128, + "indexBytes" : 8396800, + "totalBytes" : 64380928 + }, { + "rowCount" : 10000, + "batchSize" : 1000, + "executionOrder" : 6, + "expectedBatchExecutions" : 10, + "rounds" : [ { + "durationMillis" : 6519.939291, + "rowsPerSecond" : 1533.7566123972672, + "batchExecutions" : 10, + "acknowledgedRows" : 10000 + }, { + "durationMillis" : 6494.799417, + "rowsPerSecond" : 1539.6934313052395, + "batchExecutions" : 10, + "acknowledgedRows" : 10000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 6494.799417, + "p50" : 6494.799417, + "p95" : 6519.939291, + "maximum" : 6519.939291 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1533.7566123972672, + "p50" : 1533.7566123972672, + "p95" : 1539.6934313052395, + "maximum" : 1539.6934313052395 + }, + "tableBytes" : 55967744, + "indexBytes" : 8396800, + "totalBytes" : 64364544 + }, { + "rowCount" : 10000, + "batchSize" : 1, + "executionOrder" : 7, + "expectedBatchExecutions" : 10000, + "rounds" : [ { + "durationMillis" : 8747.500417, + "rowsPerSecond" : 1143.1837122940717, + "batchExecutions" : 10000, + "acknowledgedRows" : 10000 + }, { + "durationMillis" : 8707.683875, + "rowsPerSecond" : 1148.4110061356585, + "batchExecutions" : 10000, + "acknowledgedRows" : 10000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 8707.683875, + "p50" : 8707.683875, + "p95" : 8747.500417, + "maximum" : 8747.500417 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1143.1837122940717, + "p50" : 1143.1837122940717, + "p95" : 1148.4110061356585, + "maximum" : 1148.4110061356585 + }, + "tableBytes" : 55967744, + "indexBytes" : 8396800, + "totalBytes" : 64364544 + }, { + "rowCount" : 100000, + "batchSize" : 500, + "executionOrder" : 8, + "expectedBatchExecutions" : 200, + "rounds" : [ { + "durationMillis" : 62623.56975, + "rowsPerSecond" : 1596.8428564390485, + "batchExecutions" : 200, + "acknowledgedRows" : 100000 + }, { + "durationMillis" : 63259.189042, + "rowsPerSecond" : 1580.7980076002316, + "batchExecutions" : 200, + "acknowledgedRows" : 100000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 62623.56975, + "p50" : 62623.56975, + "p95" : 63259.189042, + "maximum" : 63259.189042 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1580.7980076002316, + "p50" : 1580.7980076002316, + "p95" : 1596.8428564390485, + "maximum" : 1596.8428564390485 + }, + "tableBytes" : 559120384, + "indexBytes" : 209182720, + "totalBytes" : 768303104 + }, { + "rowCount" : 100000, + "batchSize" : 1000, + "executionOrder" : 9, + "expectedBatchExecutions" : 100, + "rounds" : [ { + "durationMillis" : 61300.273, + "rowsPerSecond" : 1631.314105240608, + "batchExecutions" : 100, + "acknowledgedRows" : 100000 + }, { + "durationMillis" : 60589.525583, + "rowsPerSecond" : 1650.4502888541786, + "batchExecutions" : 100, + "acknowledgedRows" : 100000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 60589.525583, + "p50" : 60589.525583, + "p95" : 61300.273, + "maximum" : 61300.273 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1631.314105240608, + "p50" : 1631.314105240608, + "p95" : 1650.4502888541786, + "maximum" : 1650.4502888541786 + }, + "tableBytes" : 559120384, + "indexBytes" : 211025920, + "totalBytes" : 770146304 + }, { + "rowCount" : 100000, + "batchSize" : 1, + "executionOrder" : 10, + "expectedBatchExecutions" : 100000, + "rounds" : [ { + "durationMillis" : 88490.733125, + "rowsPerSecond" : 1130.0618321100612, + "batchExecutions" : 100000, + "acknowledgedRows" : 100000 + }, { + "durationMillis" : 83220.220584, + "rowsPerSecond" : 1201.631037484009, + "batchExecutions" : 100000, + "acknowledgedRows" : 100000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 83220.220584, + "p50" : 83220.220584, + "p95" : 88490.733125, + "maximum" : 88490.733125 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1130.0618321100612, + "p50" : 1130.0618321100612, + "p95" : 1201.631037484009, + "maximum" : 1201.631037484009 + }, + "tableBytes" : 559120384, + "indexBytes" : 209272832, + "totalBytes" : 768393216 + }, { + "rowCount" : 100000, + "batchSize" : 100, + "executionOrder" : 11, + "expectedBatchExecutions" : 1000, + "rounds" : [ { + "durationMillis" : 64134.855333, + "rowsPerSecond" : 1559.2145562780418, + "batchExecutions" : 1000, + "acknowledgedRows" : 100000 + }, { + "durationMillis" : 61826.909542, + "rowsPerSecond" : 1617.418705556816, + "batchExecutions" : 1000, + "acknowledgedRows" : 100000 + } ], + "durationMillis" : { + "sampleCount" : 2, + "minimum" : 61826.909542, + "p50" : 61826.909542, + "p95" : 64134.855333, + "maximum" : 64134.855333 + }, + "rowsPerSecond" : { + "sampleCount" : 2, + "minimum" : 1559.2145562780418, + "p50" : 1559.2145562780418, + "p95" : 1617.418705556816, + "maximum" : 1617.418705556816 + }, + "tableBytes" : 559128576, + "indexBytes" : 209936384, + "totalBytes" : 769064960 + } ] +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmark.java b/src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmark.java new file mode 100644 index 0000000..4b99c52 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmark.java @@ -0,0 +1,533 @@ +package com.opensource.docgrid.domain.embedding.benchmark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +/** + * 실제 PostgreSQL pgvector에서 저장 규모와 JDBC Batch Size별 Vector 저장 TPS를 비교한다. + * + *
제품 Table을 변경하지 않고 고유 Test Schema의 Probe Table만 사용한다. Vector 생성 비용은
+ * 측정 전에 분리하며 Bind부터 HNSW Online 갱신과 Commit까지를 저장 경계로 측정한다.
+ */
+@Slf4j
+@Tag("integration")
+@Tag("vector-storage-performance")
+@ActiveProfiles("test")
+@SpringBootTest
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@DisplayName("Vector 저장 TPS·Batch Size Benchmark")
+class VectorStoragePerformanceBenchmark {
+
+ static final int VECTOR_DIMENSION = 1024;
+ static final int HNSW_M = 16;
+ static final int HNSW_EF_CONSTRUCTION = 64;
+ private static final long VECTOR_SEED = 151L;
+ private static final String TEST_SCHEMA = "docgrid_vector_storage_performance_test_"
+ + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
+ private static final String PROBE_TABLE = "vector_storage_performance_probe";
+ private static final String HNSW_INDEX = "vector_storage_performance_hnsw";
+
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DataSource dataSource;
+ @Autowired private ObjectMapper objectMapper;
+
+ @DynamicPropertySource
+ static void configureEnvironment(DynamicPropertyRegistry registry) {
+ registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA);
+ registry.add("jwt.secret", () -> "docgrid-vector-storage-performance-test-secret-key-2026");
+ registry.add(
+ "spring.datasource.hikari.data-source-properties.ApplicationName",
+ () -> "docgrid-vector-storage-performance-test"
+ );
+ }
+
+ @AfterAll
+ void dropIsolatedSchema() {
+ jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + TEST_SCHEMA + " CASCADE");
+ }
+
+ @Test
+ @Timeout(3_600)
+ @DisplayName("1천·1만·10만 건의 Vector 저장 TPS를 Batch Size별로 비교한다")
+ void compareVectorStorageThroughputByScaleAndBatchSize() throws Exception {
+ BenchmarkConfiguration configuration = BenchmarkConfiguration.fromSystemProperties();
+ List