From 3654e94ad26a855981e95068146a893a2e6c87d3 Mon Sep 17 00:00:00 2001 From: "donghyuck, son" Date: Thu, 30 Apr 2026 14:03:47 +0900 Subject: [PATCH] =?UTF-8?q?[ai-assisted]=20fix(vector):=20projection=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20SQL=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: - #382 Why: - PostgreSQL JSON key literal이 따옴표 없이 생성되어 completed projection의 points/search-visualization 조회 SQL이 500 오류를 유발할 수 있었다. - Vector Map 클라이언트가 필터와 범례를 구성하려면 targetTypes가 UI 문서 분류가 아니라 RAG index objectType 기준임이 명확해야 한다. What: - PostgreSQL JSON literal key는 metadata ->> 'key' 형태로 생성하고, named parameter key는 metadata ->> :param 형태로 유지했다. - PostgreSQL/MySQL JSON path SQL 생성 회귀 테스트를 추가했다. - projection 완료 시 실제 좌표에 포함된 VectorItem targetType 목록을 저장하고, projection 목록 응답에도 targetTypes를 포함했다. - README에 targetTypes가 tb_ai_document_chunk.object_type 기준이라는 API 계약을 문서화했다. Validation: - ./gradlew :starter:studio-platform-starter-ai:test --tests '*JdbcVectorProjectionSqlTest' --tests '*DefaultVectorSearchVisualizationServiceTest' --tests '*DefaultVectorProjectionServiceTest' : PASS - ./gradlew :starter:studio-platform-starter-ai:test :starter:studio-platform-starter-ai-web:test && git diff --check : PASS - ./gradlew :studio-platform-ai:test :starter:studio-platform-starter-ai:test :starter:studio-platform-starter-ai-web:test && git diff --check : PASS - 로컬 PostgreSQL smoke: 최신 COMPLETED projection proj-20260430044829-105b3fa0의 point join 122건 확인 --- .../studio-platform-starter-ai-web/README.md | 11 +++++-- .../VectorVisualizationMgmtController.java | 1 + .../ProjectionSummaryResponse.java | 2 ++ ...VectorVisualizationMgmtControllerTest.java | 14 ++++++++ .../DefaultVectorProjectionJobService.java | 10 +++++- .../JdbcVectorProjectionRepository.java | 4 ++- .../JdbcVectorProjectionSql.java | 5 ++- .../DefaultVectorProjectionServiceTest.java | 5 +-- .../JdbcVectorProjectionSqlTest.java | 32 +++++++++++++++++++ .../VectorProjectionRepository.java | 2 +- 10 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSqlTest.java diff --git a/starter/studio-platform-starter-ai-web/README.md b/starter/studio-platform-starter-ai-web/README.md index 09b670d3..4d5b8c6f 100644 --- a/starter/studio-platform-starter-ai-web/README.md +++ b/starter/studio-platform-starter-ai-web/README.md @@ -114,7 +114,10 @@ studio: projection job 상태와 미리 계산된 좌표만 저장한다. 화면 요청 시마다 고차원 벡터를 다시 projection하지 않는다. 기본 알고리즘은 `PCA`다. v1 구현은 Java 내장 연산으로 PCA 좌표를 계산하고, 후속 UMAP/t-SNE는 -`VectorProjectionGenerator` 구현을 추가해 확장한다. `targetTypes`가 비어 있으면 전체 vector item을 대상으로 한다. +`VectorProjectionGenerator` 구현을 추가해 확장한다. `targetTypes`는 UI 문서 분류가 아니라 +`tb_ai_document_chunk.object_type`에 저장된 RAG index objectType 기준이다. 예를 들어 `attachment`, +`forums-post-attachment`, 정책 object type 값처럼 색인 job이 사용한 objectType을 지정한다. +`targetTypes`가 비어 있으면 전체 vector item을 대상으로 한다. `filters`는 v1에서 metadata equality 조건만 사용하며 null 값은 무시한다. 한 projection job은 최대 1,000개 vector item, 2,048 embedding dimension까지 처리한다. 더 큰 범위는 `targetTypes`나 metadata filter로 나눠 생성한다. projection 생성과 point/item/search visualization 조회는 object별 ACL을 행마다 평가하지 않는 corpus-level 관리 API이므로 @@ -139,7 +142,9 @@ Content-Type: application/json 응답은 즉시 `REQUESTED`를 반환한다. 서버는 비동기 job에서 `PROCESSING`으로 전환한 뒤 기존 `tb_ai_document_chunk`의 embedding을 읽어 좌표를 만들고, 기존 point를 삭제 후 재생성한다. -완료 시 `COMPLETED`, 실패 시 `FAILED`와 `errorMessage`를 저장한다. +완료 시 `COMPLETED`, 실패 시 `FAILED`와 `errorMessage`를 저장한다. 완료된 projection의 목록/상세 응답 +`targetTypes`는 실제 좌표에 포함된 `tb_ai_document_chunk.object_type` 목록을 반환하므로 클라이언트는 이 값을 +필터와 범례 구성에 사용할 수 있다. ```json { @@ -184,7 +189,7 @@ Content-Type: application/json projection point를 매칭하고, query 위치는 매칭된 Top-K point 좌표의 평균으로 계산한다. 매칭 point가 없으면 `query.x`, `query.y`는 `null`, `results`는 빈 배열로 200 응답한다. 검색은 선택된 projection의 `targetTypes`와 `filters` 범위를 기준으로 제한하고, 요청 `targetTypes`가 있으면 -projection 범위와 교집합인 type만 대상으로 한다. `query`는 provider 비용과 지연을 제한하기 위해 최대 +projection 범위와 교집합인 RAG index objectType만 대상으로 한다. `query`는 provider 비용과 지연을 제한하기 위해 최대 2,000자까지 허용한다. ### RAG Index Job Management diff --git a/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtController.java b/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtController.java index 44003139..d4c3c77b 100644 --- a/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtController.java +++ b/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtController.java @@ -155,6 +155,7 @@ private ProjectionSummaryResponse summary(VectorProjection projection) { projection.name(), projection.algorithm().name(), projection.status().name(), + projection.targetTypes(), projection.itemCount(), projection.createdAt(), projection.completedAt()); diff --git a/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/dto/visualization/ProjectionSummaryResponse.java b/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/dto/visualization/ProjectionSummaryResponse.java index b1c379ae..ffd40812 100644 --- a/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/dto/visualization/ProjectionSummaryResponse.java +++ b/starter/studio-platform-starter-ai-web/src/main/java/studio/one/platform/ai/web/dto/visualization/ProjectionSummaryResponse.java @@ -1,12 +1,14 @@ package studio.one.platform.ai.web.dto.visualization; import java.time.Instant; +import java.util.List; public record ProjectionSummaryResponse( String projectionId, String name, String algorithm, String status, + List targetTypes, int itemCount, Instant createdAt, Instant completedAt) { diff --git a/starter/studio-platform-starter-ai-web/src/test/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtControllerTest.java b/starter/studio-platform-starter-ai-web/src/test/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtControllerTest.java index 136c30b8..f43fe9d1 100644 --- a/starter/studio-platform-starter-ai-web/src/test/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtControllerTest.java +++ b/starter/studio-platform-starter-ai-web/src/test/java/studio/one/platform/ai/web/controller/VectorVisualizationMgmtControllerTest.java @@ -77,6 +77,20 @@ void pointsReturnsClientOrientedShape() { }); } + @Test + void listProjectionIncludesActualTargetTypes() { + VectorProjectionService projectionService = mock(VectorProjectionService.class); + when(projectionService.list(50, 0)).thenReturn(List.of(projection(ProjectionStatus.COMPLETED))); + VectorVisualizationMgmtController controller = new VectorVisualizationMgmtController( + projectionService, + mock(VectorSearchVisualizationService.class)); + + var response = controller.listProjections(50, 0); + + assertThat(response.getBody().getData().items()).singleElement() + .satisfies(item -> assertThat(item.targetTypes()).containsExactly("COURSE_CHUNK")); + } + @Test void itemDetailDoesNotReturnEmbeddingMetadata() { VectorProjectionService projectionService = mock(VectorProjectionService.class); diff --git a/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionJobService.java b/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionJobService.java index 6e94c549..154d8f3f 100644 --- a/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionJobService.java +++ b/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionJobService.java @@ -55,7 +55,7 @@ public void run(String projectionId) { } pointRepository.deleteByProjectionId(projectionId); pointRepository.saveAll(points); - projectionRepository.markCompleted(projectionId, points.size(), Instant.now()); + projectionRepository.markCompleted(projectionId, points.size(), actualTargetTypes(items), Instant.now()); } catch (Exception ex) { log.warn("Vector projection job failed. projectionId={}", projectionId, ex); projectionRepository.updateStatus( @@ -72,4 +72,12 @@ private VectorProjectionGenerator generatorFor(VectorProjection projection) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("UNSUPPORTED_PROJECTION_ALGORITHM")); } + + private List actualTargetTypes(List items) { + return items.stream() + .map(VectorItem::targetType) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + } } diff --git a/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionRepository.java b/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionRepository.java index 6dbf463c..a6808ceb 100644 --- a/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionRepository.java +++ b/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionRepository.java @@ -89,10 +89,11 @@ public void updateStatus(String projectionId, ProjectionStatus status, String er } @Override - public void markCompleted(String projectionId, int itemCount, Instant completedAt) { + public void markCompleted(String projectionId, int itemCount, List targetTypes, Instant completedAt) { jdbcTemplate.update(""" UPDATE tb_ai_vector_projection SET status = 'COMPLETED', + target_types = :targetTypes, item_count = :itemCount, error_message = NULL, completed_at = :completedAt @@ -100,6 +101,7 @@ public void markCompleted(String projectionId, int itemCount, Instant completedA """, new MapSqlParameterSource() .addValue("projectionId", projectionId) .addValue("itemCount", itemCount) + .addValue("targetTypes", String.join(",", targetTypes == null ? List.of() : targetTypes)) .addValue("completedAt", timestamp(completedAt))); } diff --git a/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSql.java b/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSql.java index c445db7d..4671d9c7 100644 --- a/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSql.java +++ b/starter/studio-platform-starter-ai/src/main/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSql.java @@ -24,7 +24,10 @@ static boolean isPostgres(NamedParameterJdbcTemplate template) { static String jsonText(String alias, String keyExpression, boolean postgres) { String column = alias == null || alias.isBlank() ? "metadata" : alias + ".metadata"; if (postgres) { - return column + " ->> " + keyExpression; + if (keyExpression.startsWith(":")) { + return column + " ->> " + keyExpression; + } + return column + " ->> '" + keyExpression.replace("'", "''") + "'"; } String path = keyExpression.startsWith(":") ? "CONCAT('$.', " + keyExpression + ")" diff --git a/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionServiceTest.java b/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionServiceTest.java index 31b63e64..1c8acf93 100644 --- a/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionServiceTest.java +++ b/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/DefaultVectorProjectionServiceTest.java @@ -69,6 +69,7 @@ public List generate(String projectionId, List targetTypes, Instant completedAt) { VectorProjection current = projections.get(projectionId); projections.put(projectionId, new VectorProjection( current.projectionId(), current.name(), current.algorithm(), ProjectionStatus.COMPLETED, - current.targetTypes(), + targetTypes, current.filters(), itemCount, null, diff --git a/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSqlTest.java b/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSqlTest.java new file mode 100644 index 00000000..aa5ad10e --- /dev/null +++ b/starter/studio-platform-starter-ai/src/test/java/studio/one/platform/ai/service/visualization/JdbcVectorProjectionSqlTest.java @@ -0,0 +1,32 @@ +package studio.one.platform.ai.service.visualization; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class JdbcVectorProjectionSqlTest { + + @Test + void postgresJsonTextQuotesLiteralKey() { + assertThat(JdbcVectorProjectionSql.jsonText("c", "chunkId", true)) + .isEqualTo("c.metadata ->> 'chunkId'"); + } + + @Test + void postgresJsonTextKeepsNamedParameterKey() { + assertThat(JdbcVectorProjectionSql.jsonText(null, ":filterKey0", true)) + .isEqualTo("metadata ->> :filterKey0"); + } + + @Test + void mysqlJsonTextUsesJsonExtractPath() { + assertThat(JdbcVectorProjectionSql.jsonText("c", "chunkId", false)) + .isEqualTo("JSON_UNQUOTE(JSON_EXTRACT(c.metadata, '$.chunkId'))"); + } + + @Test + void mysqlJsonTextUsesParameterizedPath() { + assertThat(JdbcVectorProjectionSql.jsonText(null, ":filterKey0", false)) + .isEqualTo("JSON_UNQUOTE(JSON_EXTRACT(metadata, CONCAT('$.', :filterKey0)))"); + } +} diff --git a/studio-platform-ai/src/main/java/studio/one/platform/ai/core/vector/visualization/VectorProjectionRepository.java b/studio-platform-ai/src/main/java/studio/one/platform/ai/core/vector/visualization/VectorProjectionRepository.java index 9f08f101..a3bee722 100644 --- a/studio-platform-ai/src/main/java/studio/one/platform/ai/core/vector/visualization/VectorProjectionRepository.java +++ b/studio-platform-ai/src/main/java/studio/one/platform/ai/core/vector/visualization/VectorProjectionRepository.java @@ -14,5 +14,5 @@ public interface VectorProjectionRepository { void updateStatus(String projectionId, ProjectionStatus status, String errorMessage, Instant completedAt); - void markCompleted(String projectionId, int itemCount, Instant completedAt); + void markCompleted(String projectionId, int itemCount, List targetTypes, Instant completedAt); }