From 6c023041675a813df278ffa48f610f38e9f7f68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 13 Aug 2026 02:23:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20#155=20Vector=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20Batch=20benchmark=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gimin-vector-storage-batch-benchmark.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/design/gimin-vector-storage-batch-benchmark.md diff --git a/docs/design/gimin-vector-storage-batch-benchmark.md b/docs/design/gimin-vector-storage-batch-benchmark.md new file mode 100644 index 0000000..d05f5f6 --- /dev/null +++ b/docs/design/gimin-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-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과 최종 결과 문서에 환경·수치·결론·한계가 기록된다. From d1dbb34954cd1f173e78d347d7ac30db2f138d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 13 Aug 2026 02:24:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20#155=20Vector=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20TPS=20Batch=20benchmark=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 24 +- .../VectorStoragePerformanceBenchmark.java | 533 ++++++++++++++++++ ...VectorStoragePerformanceBenchmarkTest.java | 121 ++++ 3 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmark.java create mode 100644 src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmarkTest.java diff --git a/build.gradle b/build.gradle index a2880cb..19c7ca9 100644 --- a/build.gradle +++ b/build.gradle @@ -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', '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' } } @@ -175,6 +175,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/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 vectorPool = createVectorPool(configuration.vectorPoolSize()); + List profiles = new ArrayList<>(); + + createProbeTable(); + assertHnswIndexContract(); + try { + int executionOrder = 0; + for (int scaleIndex = 0; scaleIndex < configuration.rowCounts().size(); scaleIndex++) { + int rowCount = configuration.rowCounts().get(scaleIndex); + for (int batchSize : rotate(configuration.batchSizes(), scaleIndex)) { + profiles.add(runProfile( + rowCount, + batchSize, + executionOrder++, + configuration, + vectorPool + )); + } + } + } finally { + jdbcTemplate.execute("DROP TABLE IF EXISTS " + PROBE_TABLE); + } + + BenchmarkReport report = createReport(configuration, profiles); + writeReport(configuration.outputPath(), report); + assertThat(report.profiles()).hasSize( + configuration.rowCounts().size() * configuration.batchSizes().size() + ); + } + + private void createProbeTable() { + jdbcTemplate.execute("DROP TABLE IF EXISTS " + PROBE_TABLE); + jdbcTemplate.execute( + "CREATE TABLE " + PROBE_TABLE + + " (id BIGINT PRIMARY KEY, vector vector(1024) NOT NULL)" + ); + jdbcTemplate.execute( + "CREATE INDEX " + HNSW_INDEX + " ON " + PROBE_TABLE + + " USING hnsw (vector vector_cosine_ops) WITH (m = " + HNSW_M + + ", ef_construction = " + HNSW_EF_CONSTRUCTION + ")" + ); + } + + private ProfileResult runProfile( + int rowCount, + int batchSize, + int executionOrder, + BenchmarkConfiguration configuration, + List vectorPool + ) throws SQLException { + // 1. 측정 경로와 같은 Batch Size로 작은 입력을 먼저 적재해 Driver와 DB 경로를 준비한다. + truncateProbeTable(); + insertVectors( + Math.min(rowCount, configuration.warmUpRows()), + batchSize, + vectorPool + ); + + // 2. 각 Round는 빈 Table에서 시작하고 Bind부터 Commit까지 같은 경계로 측정한다. + List rounds = new ArrayList<>(); + long tableBytes = 0L; + long indexBytes = 0L; + for (int round = 1; round <= configuration.measuredRuns(); round++) { + truncateProbeTable(); + RoundMeasurement measurement = insertVectors(rowCount, batchSize, vectorPool); + assertStoredVectors(rowCount); + rounds.add(measurement); + tableBytes = tableSize(PROBE_TABLE); + indexBytes = relationSize(HNSW_INDEX); + } + + // 3. Raw Round와 요약 TPS를 함께 남겨 작은 표본을 숨기지 않는다. + ProfileResult result = new ProfileResult( + rowCount, + batchSize, + executionOrder, + expectedBatchExecutions(rowCount, batchSize), + List.copyOf(rounds), + TimingSummary.from(rounds.stream().map(RoundMeasurement::durationMillis).toList()), + TimingSummary.from(rounds.stream().map(RoundMeasurement::rowsPerSecond).toList()), + tableBytes, + indexBytes, + tableBytes + indexBytes + ); + logProfile(result); + return result; + } + + private RoundMeasurement insertVectors(int rowCount, int batchSize, List vectorPool) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO " + PROBE_TABLE + " (id, vector) VALUES (?, CAST(? AS vector))" + )) { + long startedAt = System.nanoTime(); + int pendingRows = 0; + int batchExecutions = 0; + int acknowledgedRows = 0; + for (int row = 0; row < rowCount; row++) { + statement.setLong(1, row + 1L); + statement.setString(2, vectorPool.get(row % vectorPool.size())); + statement.addBatch(); + pendingRows++; + if (pendingRows == batchSize) { + acknowledgedRows += executeBatch(statement, pendingRows); + batchExecutions++; + pendingRows = 0; + } + } + if (pendingRows > 0) { + acknowledgedRows += executeBatch(statement, pendingRows); + batchExecutions++; + } + connection.commit(); + double durationMillis = nanosToMillis(System.nanoTime() - startedAt); + + assertThat(acknowledgedRows).isEqualTo(rowCount); + assertThat(batchExecutions).isEqualTo(expectedBatchExecutions(rowCount, batchSize)); + return new RoundMeasurement( + durationMillis, + rowsPerSecond(rowCount, durationMillis), + batchExecutions, + acknowledgedRows + ); + } catch (SQLException | RuntimeException exception) { + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(originalAutoCommit); + } + } + } + + private int executeBatch(PreparedStatement statement, int expectedRows) throws SQLException { + int[] updateCounts = statement.executeBatch(); + assertThat(updateCounts).hasSize(expectedRows); + assertThat(updateCounts).doesNotContain(java.sql.Statement.EXECUTE_FAILED); + return updateCounts.length; + } + + private void assertStoredVectors(int expectedRows) { + StoredVectorSummary summary = jdbcTemplate.queryForObject( + "SELECT COUNT(*) AS row_count, MIN(vector_dims(vector)) AS min_dims, " + + "MAX(vector_dims(vector)) AS max_dims FROM " + PROBE_TABLE, + (resultSet, rowNumber) -> new StoredVectorSummary( + resultSet.getLong("row_count"), + resultSet.getInt("min_dims"), + resultSet.getInt("max_dims") + ) + ); + assertThat(summary).isNotNull(); + assertThat(summary.rowCount()).isEqualTo(expectedRows); + assertThat(summary.minDimensions()).isEqualTo(VECTOR_DIMENSION); + assertThat(summary.maxDimensions()).isEqualTo(VECTOR_DIMENSION); + } + + private void assertHnswIndexContract() { + String indexDefinition = jdbcTemplate.queryForObject( + "SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() " + + "AND tablename = ? AND indexname = ?", + String.class, + PROBE_TABLE, + HNSW_INDEX + ); + assertThat(indexDefinition) + .contains("USING hnsw") + .contains("vector_cosine_ops"); + } + + private void truncateProbeTable() { + jdbcTemplate.execute("TRUNCATE TABLE " + PROBE_TABLE); + } + + private List createVectorPool(int poolSize) { + Random random = new Random(VECTOR_SEED); + List vectors = new ArrayList<>(poolSize); + for (int index = 0; index < poolSize; index++) { + vectors.add(normalizedVector(random)); + } + return List.copyOf(vectors); + } + + private String normalizedVector(Random random) { + float[] values = new float[VECTOR_DIMENSION]; + double squaredNorm = 0.0; + for (int index = 0; index < VECTOR_DIMENSION; index++) { + values[index] = random.nextFloat() - 0.5F; + squaredNorm += values[index] * values[index]; + } + + double norm = Math.sqrt(squaredNorm); + StringBuilder vector = new StringBuilder(VECTOR_DIMENSION * 13).append('['); + for (int index = 0; index < VECTOR_DIMENSION; index++) { + if (index > 0) { + vector.append(','); + } + vector.append(values[index] / norm); + } + return vector.append(']').toString(); + } + + private BenchmarkReport createReport(BenchmarkConfiguration configuration, List profiles) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + return new BenchmarkReport( + Instant.now().toString(), + metadata.getDatabaseProductName(), + metadata.getDatabaseProductVersion(), + metadata.getDriverName(), + metadata.getDriverVersion(), + jdbcTemplate.queryForObject( + "SELECT extversion FROM pg_extension WHERE extname = 'vector'", + String.class + ), + System.getProperty("os.arch"), + System.getProperty("java.version"), + VECTOR_DIMENSION, + HNSW_M, + HNSW_EF_CONSTRUCTION, + configuration.warmUpRows(), + configuration.measuredRuns(), + configuration.vectorPoolSize(), + List.copyOf(profiles) + ); + } + } + + private void writeReport(Path outputPath, BenchmarkReport report) throws IOException { + Path parent = outputPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + objectMapper.writerWithDefaultPrettyPrinter().writeValue(outputPath.toFile(), report); + log.info("VECTOR_STORAGE_PERFORMANCE_REPORT path={}", outputPath.toAbsolutePath().normalize()); + } + + private long relationSize(String relationName) { + return jdbcTemplate.queryForObject("SELECT pg_relation_size(?::regclass)", Long.class, relationName); + } + + private long tableSize(String relationName) { + return jdbcTemplate.queryForObject("SELECT pg_table_size(?::regclass)", Long.class, relationName); + } + + private void logProfile(ProfileResult result) { + log.info( + "VECTOR_STORAGE_PERFORMANCE_RESULT rows={}, batchSize={}, durationP50Ms={}, " + + "tpsP50={}, batchExecutions={}, tableBytes={}, indexBytes={}", + result.rowCount(), + result.batchSize(), + result.durationMillis().p50(), + result.rowsPerSecond().p50(), + result.expectedBatchExecutions(), + result.tableBytes(), + result.indexBytes() + ); + } + + static List rotate(List values, int offset) { + List rotated = new ArrayList<>(values); + Collections.rotate(rotated, -(offset % values.size())); + return List.copyOf(rotated); + } + + static int expectedBatchExecutions(int rowCount, int batchSize) { + if (rowCount <= 0 || batchSize <= 0) { + throw new IllegalArgumentException("Row 수와 Batch Size는 1 이상이어야 합니다."); + } + return (int) (((long) rowCount + batchSize - 1L) / batchSize); + } + + static double rowsPerSecond(int rowCount, double durationMillis) { + if (rowCount <= 0 || !Double.isFinite(durationMillis) || durationMillis <= 0.0) { + throw new IllegalArgumentException("Row 수와 저장 시간은 0보다 큰 유한값이어야 합니다."); + } + return rowCount / (durationMillis / 1_000.0); + } + + private static double nanosToMillis(long nanos) { + return nanos / (double) TimeUnit.MILLISECONDS.toNanos(1); + } + + /** Benchmark 입력값을 검증하고 실행 중 불변으로 보존한다. */ + record BenchmarkConfiguration( + List rowCounts, + List batchSizes, + int warmUpRows, + int measuredRuns, + int vectorPoolSize, + Path outputPath + ) { + private static final String DEFAULT_SIZES = "1000,10000,100000"; + private static final String DEFAULT_BATCH_SIZES = "1,100,500,1000"; + private static final String DEFAULT_OUTPUT = "build/reports/vector-storage/vector-storage-latest.json"; + + BenchmarkConfiguration { + rowCounts = validatedPositiveValues(rowCounts, "저장 규모"); + batchSizes = validatedPositiveValues(batchSizes, "Batch Size"); + if (warmUpRows <= 0) { + throw new IllegalArgumentException("Warm-up Row 수는 1 이상이어야 합니다."); + } + if (measuredRuns <= 0) { + throw new IllegalArgumentException("본 측정 횟수는 1 이상이어야 합니다."); + } + if (vectorPoolSize <= 0) { + throw new IllegalArgumentException("Vector Pool 크기는 1 이상이어야 합니다."); + } + if (outputPath == null || outputPath.toString().isBlank()) { + throw new IllegalArgumentException("결과 경로는 비어 있을 수 없습니다."); + } + } + + static BenchmarkConfiguration fromSystemProperties() { + return new BenchmarkConfiguration( + parsePositiveValues( + System.getProperty("vector.storage.performance.sizes", DEFAULT_SIZES), + "저장 규모" + ), + parsePositiveValues( + System.getProperty("vector.storage.performance.batch-sizes", DEFAULT_BATCH_SIZES), + "Batch Size" + ), + positiveIntProperty("vector.storage.performance.warm-up-rows", 1_000), + positiveIntProperty("vector.storage.performance.measured-runs", 2), + positiveIntProperty("vector.storage.performance.vector-pool-size", 1_024), + Path.of(System.getProperty("vector.storage.performance.output", DEFAULT_OUTPUT)) + ); + } + + static List parsePositiveValues(String rawValue, String label) { + try { + return List.of(rawValue.split(",")).stream() + .map(String::trim) + .map(Integer::parseInt) + .toList(); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(label + " 값은 쉼표로 구분한 정수여야 합니다.", exception); + } + } + + private static List validatedPositiveValues(List values, String label) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException(label + " 값은 한 개 이상이어야 합니다."); + } + if (values.stream().anyMatch(value -> value == null || value <= 0)) { + throw new IllegalArgumentException(label + " 값은 1 이상의 정수여야 합니다."); + } + List copied = List.copyOf(values); + if (copied.stream().distinct().count() != copied.size()) { + throw new IllegalArgumentException(label + " 값은 중복될 수 없습니다."); + } + return copied; + } + + private static int positiveIntProperty(String name, int defaultValue) { + try { + int value = Integer.parseInt(System.getProperty(name, Integer.toString(defaultValue))); + if (value <= 0) { + throw new IllegalArgumentException(name + "은 1 이상이어야 합니다."); + } + return value; + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(name + "은 정수여야 합니다.", exception); + } + } + } + + /** 한 저장 Round의 시간, 처리량과 실제 Batch 실행 근거를 보존한다. */ + record RoundMeasurement( + double durationMillis, + double rowsPerSecond, + int batchExecutions, + int acknowledgedRows + ) { + } + + /** 반복 표본의 최솟값, 중앙값, p95와 최댓값을 보존한다. */ + record TimingSummary(int sampleCount, double minimum, double p50, double p95, double maximum) { + static TimingSummary from(List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("측정 표본은 한 개 이상이어야 합니다."); + } + if (values.stream().anyMatch(value -> value == null || !Double.isFinite(value) || value <= 0.0)) { + throw new IllegalArgumentException("측정 표본은 0보다 큰 유한값이어야 합니다."); + } + List sorted = values.stream().sorted().toList(); + return new TimingSummary( + sorted.size(), + sorted.get(0), + percentile(sorted, 0.50), + percentile(sorted, 0.95), + sorted.get(sorted.size() - 1) + ); + } + + private static double percentile(List sorted, double percentile) { + int rank = Math.max(1, (int) Math.ceil(percentile * sorted.size())); + return sorted.get(rank - 1); + } + } + + /** 저장 완료 뒤 Row 수와 Vector 차원 범위를 검증한다. */ + record StoredVectorSummary(long rowCount, int minDimensions, int maxDimensions) { + } + + /** 한 저장 규모·Batch Size의 반복 결과와 저장 공간을 묶는다. */ + record ProfileResult( + int rowCount, + int batchSize, + int executionOrder, + int expectedBatchExecutions, + List rounds, + TimingSummary durationMillis, + TimingSummary rowsPerSecond, + long tableBytes, + long indexBytes, + long totalBytes + ) { + } + + /** 공개 가능한 실행 환경과 모든 저장 Profile을 JSON으로 표현한다. */ + record BenchmarkReport( + String generatedAt, + String databaseProduct, + String databaseVersion, + String jdbcDriver, + String jdbcDriverVersion, + String pgvectorVersion, + String architecture, + String javaVersion, + int vectorDimension, + int hnswM, + int hnswEfConstruction, + int warmUpRows, + int measuredRuns, + int vectorPoolSize, + List profiles + ) { + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmarkTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmarkTest.java new file mode 100644 index 0000000..402bbeb --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/benchmark/VectorStoragePerformanceBenchmarkTest.java @@ -0,0 +1,121 @@ +package com.opensource.docgrid.domain.embedding.benchmark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.opensource.docgrid.domain.embedding.benchmark.VectorStoragePerformanceBenchmark.BenchmarkConfiguration; +import com.opensource.docgrid.domain.embedding.benchmark.VectorStoragePerformanceBenchmark.TimingSummary; + +/** + * Vector 저장 Benchmark의 Profile 순환, Batch 호출 수, TPS와 설정 검증 계약을 보호한다. + */ +@DisplayName("Vector 저장 Benchmark 지원 계약") +class VectorStoragePerformanceBenchmarkTest { + + @Test + @DisplayName("저장 규모별 시작 Batch Size를 결정적으로 회전한다") + void rotate_changesStartingBatchSizeByScale() { + List batchSizes = List.of(1, 100, 500, 1_000); + + assertThat(VectorStoragePerformanceBenchmark.rotate(batchSizes, 0)) + .containsExactly(1, 100, 500, 1_000); + assertThat(VectorStoragePerformanceBenchmark.rotate(batchSizes, 1)) + .containsExactly(100, 500, 1_000, 1); + assertThat(VectorStoragePerformanceBenchmark.rotate(batchSizes, 2)) + .containsExactly(500, 1_000, 1, 100); + } + + @Test + @DisplayName("마지막 부분 Batch를 포함한 실행 횟수를 계산한다") + void expectedBatchExecutions_includesPartialFinalBatch() { + assertThat(VectorStoragePerformanceBenchmark.expectedBatchExecutions(1_000, 1)).isEqualTo(1_000); + assertThat(VectorStoragePerformanceBenchmark.expectedBatchExecutions(1_000, 100)).isEqualTo(10); + assertThat(VectorStoragePerformanceBenchmark.expectedBatchExecutions(1_001, 1_000)).isEqualTo(2); + assertThat(VectorStoragePerformanceBenchmark.expectedBatchExecutions(Integer.MAX_VALUE, 2)) + .isEqualTo(1_073_741_824); + + assertThatThrownBy(() -> VectorStoragePerformanceBenchmark.expectedBatchExecutions(1_000, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Row 수와 Batch Size는 1 이상이어야 합니다."); + } + + @Test + @DisplayName("저장 시간에서 초당 Row 처리량을 계산한다") + void rowsPerSecond_usesCommitInclusiveDuration() { + assertThat(VectorStoragePerformanceBenchmark.rowsPerSecond(10_000, 2_000.0)).isEqualTo(5_000.0); + + assertThatThrownBy(() -> VectorStoragePerformanceBenchmark.rowsPerSecond(10_000, 0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Row 수와 저장 시간은 0보다 큰 유한값이어야 합니다."); + } + + @Test + @DisplayName("반복 표본의 Nearest-rank 중앙값과 p95를 계산한다") + void timingSummary_usesNearestRankPercentiles() { + TimingSummary summary = TimingSummary.from(List.of(8.0, 1.0, 4.0, 2.0, 7.0, 3.0, 6.0, 5.0)); + + assertThat(summary.sampleCount()).isEqualTo(8); + assertThat(summary.minimum()).isEqualTo(1.0); + assertThat(summary.p50()).isEqualTo(4.0); + assertThat(summary.p95()).isEqualTo(8.0); + assertThat(summary.maximum()).isEqualTo(8.0); + } + + @Test + @DisplayName("설정은 빈 값, 중복과 0 이하 입력을 거부한다") + void benchmarkConfiguration_rejectsInvalidProfiles() { + assertThatThrownBy(() -> new BenchmarkConfiguration( + List.of(1_000, 1_000), + List.of(1, 100), + 1_000, + 2, + 1_024, + Path.of("result.json") + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("저장 규모 값은 중복될 수 없습니다."); + + assertThatThrownBy(() -> new BenchmarkConfiguration( + List.of(1_000), + List.of(0), + 1_000, + 2, + 1_024, + Path.of("result.json") + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Batch Size 값은 1 이상의 정수여야 합니다."); + + List valuesWithNull = new ArrayList<>(); + valuesWithNull.add(1); + valuesWithNull.add(null); + assertThatThrownBy(() -> new BenchmarkConfiguration( + List.of(1_000), + valuesWithNull, + 1_000, + 2, + 1_024, + Path.of("result.json") + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Batch Size 값은 1 이상의 정수여야 합니다."); + } + + @Test + @DisplayName("쉼표 구분 Profile 값을 정수 목록으로 해석한다") + void parsePositiveValues_parsesCommaSeparatedIntegers() { + assertThat(BenchmarkConfiguration.parsePositiveValues("1000, 10000,100000", "저장 규모")) + .containsExactly(1_000, 10_000, 100_000); + + assertThatThrownBy(() -> BenchmarkConfiguration.parsePositiveValues("1000,invalid", "저장 규모")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("저장 규모 값은 쉼표로 구분한 정수여야 합니다."); + } +} From e83ce8b0a73d63b1b6ba1dea07081987ffa85331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 13 Aug 2026 02:25:46 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20#155=20Vector=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20Batch=20=EC=8B=A4=EC=B8=A1=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...n-vector-storage-batch-benchmark-data.json | 413 ++++++++++++++++++ .../gimin-vector-storage-batch-benchmark.md | 110 +++++ 2 files changed, 523 insertions(+) create mode 100644 docs/test-results/gimin-vector-storage-batch-benchmark-data.json create mode 100644 docs/test-results/gimin-vector-storage-batch-benchmark.md 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/docs/test-results/gimin-vector-storage-batch-benchmark.md b/docs/test-results/gimin-vector-storage-batch-benchmark.md new file mode 100644 index 0000000..d594b46 --- /dev/null +++ b/docs/test-results/gimin-vector-storage-batch-benchmark.md @@ -0,0 +1,110 @@ +# Vector 저장 TPS·Batch Size 비교 결과 + +- 측정일: 2026-08-11 +- 결과: 성공 +- 원본 데이터: [gimin-vector-storage-batch-benchmark-data.json](gimin-vector-storage-batch-benchmark-data.json) +- 설계: [gimin-vector-storage-batch-benchmark.md](../design/gimin-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이다. 표본 수가 작으므로 절대 성능 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 + 시간을 함께 검증한다. From 7dc31e72270f1e1a109154447977255c11756710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 13 Aug 2026 02:35:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20#155=20=EB=B2=A4=EC=B9=98=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EB=AC=B8=EC=84=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ark.md => gimin-#155-vector-storage-batch-benchmark.md} | 2 +- ...ark.md => gimin-#155-vector-storage-batch-benchmark.md} | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) rename docs/design/{gimin-vector-storage-batch-benchmark.md => gimin-#155-vector-storage-batch-benchmark.md} (98%) rename docs/test-results/{gimin-vector-storage-batch-benchmark.md => gimin-#155-vector-storage-batch-benchmark.md} (91%) diff --git a/docs/design/gimin-vector-storage-batch-benchmark.md b/docs/design/gimin-#155-vector-storage-batch-benchmark.md similarity index 98% rename from docs/design/gimin-vector-storage-batch-benchmark.md rename to docs/design/gimin-#155-vector-storage-batch-benchmark.md index d05f5f6..7c24ab5 100644 --- a/docs/design/gimin-vector-storage-batch-benchmark.md +++ b/docs/design/gimin-#155-vector-storage-batch-benchmark.md @@ -112,7 +112,7 @@ DB_SSLMODE=disable ./gradlew vectorStoragePerformanceTest \ 실측 결과와 원본 데이터는 다음 파일에 보존한다. -- `docs/test-results/gimin-vector-storage-batch-benchmark.md` +- `docs/test-results/gimin-#155-vector-storage-batch-benchmark.md` - `docs/test-results/gimin-vector-storage-batch-benchmark-data.json` ## 9. 완료 조건 diff --git a/docs/test-results/gimin-vector-storage-batch-benchmark.md b/docs/test-results/gimin-#155-vector-storage-batch-benchmark.md similarity index 91% rename from docs/test-results/gimin-vector-storage-batch-benchmark.md rename to docs/test-results/gimin-#155-vector-storage-batch-benchmark.md index d594b46..7392cab 100644 --- a/docs/test-results/gimin-vector-storage-batch-benchmark.md +++ b/docs/test-results/gimin-#155-vector-storage-batch-benchmark.md @@ -3,7 +3,7 @@ - 측정일: 2026-08-11 - 결과: 성공 - 원본 데이터: [gimin-vector-storage-batch-benchmark-data.json](gimin-vector-storage-batch-benchmark-data.json) -- 설계: [gimin-vector-storage-batch-benchmark.md](../design/gimin-vector-storage-batch-benchmark.md) +- 설계: [gimin-#155-vector-storage-batch-benchmark.md](../design/gimin-%23155-vector-storage-batch-benchmark.md) ## 1. 결론 @@ -37,8 +37,9 @@ HNSW Index가 이미 존재하는 Table에 저장했다. JDBC Batch는 데이터 ## 3. 전체 결과 -TPS와 시간은 2회 표본의 nearest-rank p50이다. 표본 수가 작으므로 절대 성능 SLO가 아니라 같은 -로컬 환경의 상대 기준선으로 해석한다. +TPS와 시간은 각 지표의 2회 표본을 독립적으로 정렬해 계산한 nearest-rank p50이다. 따라서 같은 +Profile의 TPS p50과 시간 p50도 서로 다른 Round에서 나올 수 있으며, 두 값을 서로 환산하면 안 된다. +표본 수가 작으므로 절대 성능 SLO가 아니라 같은 로컬 환경의 상대 기준선으로 해석한다. | 저장 건수 | Batch Size | TPS p50 | 시간 p50 | Batch 호출 | Batch 1 대비 TPS | 총 저장 크기 | |---:|---:|---:|---:|---:|---:|---:|