diff --git a/docs/design/569-vector-query-topk/README.md b/docs/design/569-vector-query-topk/README.md new file mode 100644 index 0000000..13420ad --- /dev/null +++ b/docs/design/569-vector-query-topk/README.md @@ -0,0 +1,90 @@ +# 설계서: 평문 검색어 기반 벡터 Top-K 검증 (#569) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-30 +> **추적성** — Redmine: #569 · 선행 기능: #558 · 관련 검증: #567 +> · 구현 파일: `VectorKnowledgeService`, `ProbeController`, `probe.html`, `probe-result.html`, `app.js` +> · 테스트: `GuidedFlowTemplateTest`, `VectorKnowledgeServiceTest`, `mvn test` + +## 1. 목적 + +`/probe`의 벡터 검색 검증에서 운영자가 임베딩 배열을 직접 만들지 않도록 한다. 사용자는 평문 검색어와 임베딩 방식을 입력하고, 백오피스가 지식자료 등록과 동일한 임베딩 경로로 검색어를 벡터화해 전용 ORDS Handler에 전달한다. 화면에는 VPD를 통과한 검색 단위를 `VECTOR_DISTANCE(..., COSINE)` 오름차순의 Top-K와 거리(`SCORE`)로 표시한다. + +## 2. 범위 + +- **포함** + - `/probe` 벡터 객체 입력을 JSON textarea에서 평문 검색어 textarea로 변경. + - 로컬 DEMO-4D 또는 설정된 AI 임베딩 방식 선택. + - `VectorKnowledgeService`의 단일 평문→임베딩→JSON 변환 경로를 `/vector-knowledge/search`와 `/probe`가 공유. + - 벡터 결과 화면에 반환 K, cosine distance, 문서/청크/태그/본문을 명시적으로 표시. + - 기존 `requestPayload` 기술 상세에는 실제 ORDS에 전달된 생성 JSON을 남겨 디버깅 가능하게 함. +- **제외** + - 임베딩 모델 자체 개발, 문서 저장 스키마 변경, 벡터 차원 자동 변환. + - 사용자 입력을 서버에서 임의의 키워드 검색으로 대체하는 fallback. + - ORDS Handler의 거리 계산·VPD 정책 변경. + +## 3. 인수조건 + +- [ ] `/probe`에서 `CB_VECTOR_SEARCH_DOCUMENTS`를 선택하면 JSON 배열이 아니라 평문 검색어를 입력할 수 있다. +- [ ] 검색 실행 시 평문은 선택한 DEMO/AI 임베딩으로 한 번만 변환되고, ORDS 요청 body의 `embedding` 배열로 전달된다. +- [ ] 벡터가 아닌 객체에서는 벡터 입력과 임베딩 선택이 비활성화되고 기존 probe 요청 계약이 유지된다. +- [ ] 결과에 반환된 Top-K(`rowCount`)와 각 행의 `SCORE`(cosine distance)가 표시되며, 거리가 낮은 순서라는 설명이 있다. +- [ ] 검증 세션 토큰은 기존처럼 실행 후 즉시 회수되고, 평문·임베딩·Bearer 원문은 감사 이벤트에 저장하지 않는다. +- [ ] 빈 검색어, AI 미설정, 미등록 벡터 객체는 기존 오류 화면 계약으로 안내한다. +- [ ] 템플릿/서비스 테스트와 전체 Maven 테스트가 통과한다. + +## 4. 아키텍처 및 흐름 + +```text +[probe: 평문 검색어 + embeddingMode + temp user] + │ + ▼ + [VectorKnowledgeService.vectorizeQuery] + DEMO/AI embedding → {"embedding":[...]} + │ + ▼ + [OrdsProbeService.runProbe] + Bearer + limit → 전용 vector ORDS Handler + │ + ▼ + set_vpd_context → VPD tag predicate → VECTOR_DISTANCE + │ + ▼ + [ProbeResult rows ordered by SCORE] + │ + ▼ + [Top-K + cosine distance 화면 표시] +``` + +`VectorKnowledgeService`가 임베딩 생성 책임을 소유한다. `/vector-knowledge/search`는 검색 결과 도메인(`VectorSearchResult`)을 사용하고, `/probe`는 동일한 body를 `ProbeCommand`에 넣어 권한 결과·SQL trace·감사 흐름을 유지한다. `/probe`의 벡터 입력은 서버에서 생성된 request payload와 분리해 사용자에게 임베딩 배열을 요구하지 않는다. + +## 5. 함수 명세 + +| 함수 | 책임 | 시그니처 | 실패 계약 | +|---|---|---|---| +| `vectorizeQuery` | 평문 검색어를 정규화하고 선택한 방식으로 임베딩해 ORDS body를 만든다 | `VectorQueryEmbedding vectorizeQuery(String query, String embeddingMode)` | 빈 검색어, 잘못된 방식, 미설정 AI는 `AppException` | +| `search` | vectorizeQuery 결과를 전용 객체와 임시 토큰으로 probe한다 | `VectorSearchResult search(long userId, String query, int limit, String embeddingMode)` | 기존 search 오류/토큰 회수 계약 유지 | +| `ProbeController.run` | vector object일 때만 평문을 vectorizeQuery에 전달한다 | `POST /probe` + `requestBody`, `embeddingMode` | 임시 토큰은 `finally`에서 회수 | +| `initProbeVectorInput` | 선택 객체에 따라 평문/임베딩 입력 블록과 컨트롤을 활성화한다 | 브라우저 JS 함수 | 비벡터 선택 시 값 초기화 및 disabled | + +## 6. 데이터/보안 계약 + +- `VectorQueryEmbedding`은 `query`, `embeddingMode`, `embeddingModel`, `requestBody`를 갖는다. `requestBody`는 외부 ORDS 호출 경계에서만 사용한다. +- DEMO 임베딩은 기존 4차원 로컬 모델을 유지한다. AI 임베딩은 `OpenAiCompatibleClient` 설정을 사용하며 저장된 청크와 같은 차원을 사용해야 한다. +- Top-K는 `/probe`의 `limit`을 URI와 Handler에 전달하고, Handler의 `ROWNUM` 제한 및 `ORDER BY score`가 최종 순서를 결정한다. +- 화면의 기술 상세에서만 생성된 request body를 확인할 수 있다. Bearer 원문은 기존 마스킹 계약을 유지하며 검색어와 벡터를 audit event에 추가하지 않는다. + +## 7. 엣지케이스 + +- 벡터 객체가 아닌 대상을 선택하면 `requestBody`와 `embeddingMode`를 전송하지 않고 기존 기본 body `{}`를 사용한다. +- AI 옵션은 설정이 없으면 선택할 수 없고, 직접 요청으로 들어와도 서비스가 거부한다. +- 검색어가 공백이면 ORDS를 호출하지 않고 오류를 반환한다. +- 결과가 0건이면 `VPD_DENY_EMPTY_RESULT`를 유지하며 Top-K 0건과 권한 차단 가능성을 표시한다. +- `SCORE`가 대문자/소문자 JSON key로 반환되는 양쪽 경우를 화면에서 처리한다. + +## 8. 테스트 및 운영 검증 + +- 서비스 단위 테스트: DEMO 평문이 embedding JSON으로 변환되고, AI 미설정/빈 입력이 거부되는지 확인. +- 템플릿 테스트: `/probe`가 평문 입력을 사용하고 JSON 입력 안내를 포함하지 않는지, 결과가 Top-K/거리 표현을 포함하는지 확인. +- 전체 테스트: `mvn test`. +- 운영 smoke: `http://130.162.134.59:8082/ords-handlers`에서 기존 Handler/벡터 객체 등록 상태를 확인한 뒤 `/probe`에서 `Oracle VPD 권한` 같은 평문과 `limit=5`를 실행한다. response의 `SCORE`가 존재하고 기술 상세 request payload에는 서버 생성 embedding 배열이 보이는지 확인한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java new file mode 100644 index 0000000..d75df0b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorQueryEmbedding.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.domain.vector; + +public record VectorQueryEmbedding( + String query, + String embeddingMode, + String embeddingModel, + String requestBody +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java index 0646d20..240bb03 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java @@ -8,6 +8,7 @@ import com.cloudhandson.vpdbackoffice.domain.vector.VectorChunk; import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand; import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult; import com.cloudhandson.vpdbackoffice.domain.vector.VectorKnowledgeSummary; +import com.cloudhandson.vpdbackoffice.domain.vector.VectorQueryEmbedding; import com.cloudhandson.vpdbackoffice.domain.vector.VectorSearchResult; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; @@ -73,6 +74,16 @@ public class VectorKnowledgeService { return embeddingClient.embeddingConfigured(); } + public VectorQueryEmbedding vectorizeQuery(String query, String embeddingMode) { + String normalizedQuery = required(query, "검색 질문"); + String mode = normalizeMode(embeddingMode); + return new VectorQueryEmbedding( + normalizedQuery, + mode, + embeddingModel(mode), + "{\"embedding\":" + json(embed(normalizedQuery, mode)) + "}"); + } + @Transactional public VectorIngestResult ingest(VectorIngestCommand command) { String documentId = requiredDocumentId(command.documentId()); @@ -112,19 +123,19 @@ public class VectorKnowledgeService { } public VectorSearchResult search(long userId, String query, int limit, String embeddingMode) { - String normalizedQuery = required(query, "검색 질문"); ProtectedObject vectorObject = protectedObjectService.findEnabled().stream() .filter(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName())) .findFirst() .orElseThrow(() -> new AppException( "CB_VECTOR_SEARCH_DOCUMENTS 보호 객체가 없습니다. 28_agent_ords_vector_tag_vpd_setup.sql을 먼저 실행하세요.")); - String mode = normalizeMode(embeddingMode); - String requestBody = "{\"embedding\":" + json(embed(normalizedQuery, mode)) + "}"; + VectorQueryEmbedding vectorQuery = vectorizeQuery(query, embeddingMode); IssuedToken temporary = tokenService.issueTemporaryToken(userId, "지식 검색 검증 세션"); try { ProbeResult probe = ordsProbeService.runProbe(new ProbeCommand( - temporary.keyId(), vectorObject.objectId(), temporary.plainToken(), normalizeLimit(limit), requestBody)); - return new VectorSearchResult(normalizedQuery, mode, embeddingModel(mode), probe); + temporary.keyId(), vectorObject.objectId(), temporary.plainToken(), normalizeLimit(limit), + vectorQuery.requestBody())); + return new VectorSearchResult( + vectorQuery.query(), vectorQuery.embeddingMode(), vectorQuery.embeddingModel(), probe); } finally { tokenService.revokeToken(temporary.keyId(), "temporary vector search completed"); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java index c84668b..c3cc28c 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/ProbeController.java @@ -2,9 +2,12 @@ package com.cloudhandson.vpdbackoffice.web; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; +import com.cloudhandson.vpdbackoffice.domain.vector.VectorQueryEmbedding; +import com.cloudhandson.vpdbackoffice.service.AppException; import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import com.cloudhandson.vpdbackoffice.service.OrdsProbeService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; +import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService; import com.cloudhandson.vpdbackoffice.service.VpdPolicyService; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.util.Comparator; @@ -25,19 +28,22 @@ public class ProbeController { private final BearerTokenService tokenService; private final VpdPolicyService vpdPolicyService; private final UserMapper userMapper; + private final VectorKnowledgeService vectorKnowledgeService; public ProbeController( OrdsProbeService probeService, ProtectedObjectService protectedObjectService, BearerTokenService tokenService, VpdPolicyService vpdPolicyService, - UserMapper userMapper + UserMapper userMapper, + VectorKnowledgeService vectorKnowledgeService ) { this.probeService = probeService; this.protectedObjectService = protectedObjectService; this.tokenService = tokenService; this.vpdPolicyService = vpdPolicyService; this.userMapper = userMapper; + this.vectorKnowledgeService = vectorKnowledgeService; } @GetMapping("/probe") @@ -53,6 +59,7 @@ public class ProbeController { model.addAttribute("objects", objects); model.addAttribute("defaultObjectKeys", defaultObjectKeys); model.addAttribute("users", userMapper.findAll()); + model.addAttribute("aiEmbeddingConfigured", vectorKnowledgeService.aiEmbeddingConfigured()); return "probe"; } @@ -63,9 +70,17 @@ public class ProbeController { @RequestParam(required = false) Long tempUserId, @RequestParam(defaultValue = "50") int limit, @RequestParam(required = false) String requestBody, + @RequestParam(defaultValue = "DEMO") String embeddingMode, Model model ) { String normalizedToken = bearerToken == null ? "" : bearerToken.trim(); + ProtectedObject selectedObject = protectedObjectService.findEnabled().stream() + .filter(object -> object.objectId() == objectId) + .findFirst() + .orElse(null); + boolean vectorSearch = selectedObject != null + && VectorKnowledgeService.VECTOR_OBJECT.equalsIgnoreCase(selectedObject.objectName()); + model.addAttribute("vectorSearch", vectorSearch); Long temporaryKeyId = null; if (tempUserId != null) { var issued = tokenService.issueTemporaryToken(tempUserId, "ORDS 검증 임시 실행"); @@ -73,18 +88,24 @@ public class ProbeController { temporaryKeyId = issued.keyId(); } try { + if (vectorSearch) { + VectorQueryEmbedding vectorQuery = vectorKnowledgeService.vectorizeQuery(requestBody, embeddingMode); + requestBody = vectorQuery.requestBody(); + model.addAttribute("vectorQuery", vectorQuery.query()); + model.addAttribute("vectorEmbeddingMode", vectorQuery.embeddingMode()); + model.addAttribute("vectorEmbeddingModel", vectorQuery.embeddingModel()); + } model.addAttribute("result", probeService.runProbe( new ProbeCommand(temporaryKeyId, objectId, normalizedToken, limit, requestBody))); model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken)); + } catch (AppException exception) { + model.addAttribute("errorMessage", exception.getMessage()); } finally { if (temporaryKeyId != null) { tokenService.revokeToken(temporaryKeyId, "temporary probe completed"); } } - model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream() - .filter(object -> object.objectId() == objectId) - .findFirst() - .orElse(null)); + model.addAttribute("selectedObject", selectedObject); return "fragments/probe-result :: result"; } diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 8079afe..14003ab 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -503,17 +503,25 @@ function initProbeObjectDescription() { function initProbeVectorInput() { const select = document.querySelector('select[name="objectId"]'); - const block = document.querySelector('[data-vector-probe-input]'); - const input = block?.querySelector('textarea[name="requestBody"]'); - if (!select || !block || !input) { + const blocks = document.querySelectorAll('[data-vector-probe-input]'); + const input = document.querySelector('[data-vector-probe-input] textarea[name="requestBody"]'); + const controls = document.querySelectorAll('[data-vector-probe-input] textarea, [data-vector-probe-input] select, [data-vector-probe-input] input'); + if (!select || !blocks.length || !input) { return; } const update = () => { const option = selectedOption(select); const isVectorSearch = option?.dataset.vectorSearch === 'true'; - block.hidden = !isVectorSearch; - input.disabled = !isVectorSearch; + blocks.forEach(block => { + block.hidden = !isVectorSearch; + }); + controls.forEach(control => { + control.disabled = !isVectorSearch; + }); input.required = isVectorSearch; + if (!isVectorSearch) { + input.value = ''; + } }; select.addEventListener('change', update); update(); diff --git a/src/main/resources/templates/fragments/probe-result.html b/src/main/resources/templates/fragments/probe-result.html index a5fe9bb..64ebc35 100644 --- a/src/main/resources/templates/fragments/probe-result.html +++ b/src/main/resources/templates/fragments/probe-result.html @@ -2,6 +2,8 @@
+
+
검증 결론 @@ -58,7 +60,43 @@
-
+
+
+
+

벡터 검색 Top-K

+

거리(SCORE)가 낮은 순서로 VPD를 통과한 검색 단위를 반환했습니다.

+
+ K=0 +
+

검색 정보

+
+ + + + + + + + + + + + + + + + + + + + + +
검색 단위 ID자료 ID제목기술 태그벡터 거리 (SCORE)본문
28001knowledge-001제목ORDS0.01본문
+
+
+ +
@@ -120,6 +158,7 @@

Response Body

+ diff --git a/src/main/resources/templates/probe.html b/src/main/resources/templates/probe.html index 76219e0..e4064d9 100644 --- a/src/main/resources/templates/probe.html +++ b/src/main/resources/templates/probe.html @@ -67,10 +67,18 @@ 권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다. + diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java new file mode 100644 index 0000000..10882f2 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeServiceTest.java @@ -0,0 +1,55 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import com.cloudhandson.vpdbackoffice.domain.vector.VectorQueryEmbedding; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +class VectorKnowledgeServiceTest { + + @Test + void vectorizesPlainTextWithTheLocalEmbeddingAndBuildsOrdsBody() { + OpenAiCompatibleClient embeddingClient = mock(OpenAiCompatibleClient.class); + VectorKnowledgeService service = service(embeddingClient); + + VectorQueryEmbedding result = service.vectorizeQuery(" Oracle VPD에서 ORDS 권한 확인 ", "demo"); + + assertThat(result.query()).isEqualTo("Oracle VPD에서 ORDS 권한 확인"); + assertThat(result.embeddingMode()).isEqualTo("DEMO"); + assertThat(result.embeddingModel()).isEqualTo("로컬 임베딩(개발용)"); + assertThat(result.requestBody()).startsWith("{\"embedding\":[").contains("]}"); + } + + @Test + void rejectsAiModeWhenEmbeddingConfigurationIsMissing() { + OpenAiCompatibleClient embeddingClient = mock(OpenAiCompatibleClient.class); + VectorKnowledgeService service = service(embeddingClient); + + assertThatThrownBy(() -> service.vectorizeQuery("검색어", "AI")) + .isInstanceOf(AppException.class) + .hasMessageContaining("AI 임베딩이 설정되지 않았습니다"); + } + + @Test + void rejectsBlankPlainTextBeforeCallingOrds() { + VectorKnowledgeService service = service(mock(OpenAiCompatibleClient.class)); + + assertThatThrownBy(() -> service.vectorizeQuery(" ", "DEMO")) + .isInstanceOf(AppException.class) + .hasMessageContaining("검색 질문"); + } + + private VectorKnowledgeService service(OpenAiCompatibleClient embeddingClient) { + return new VectorKnowledgeService( + mock(JdbcTemplate.class), + new ObjectMapper(), + embeddingClient, + mock(ProtectedObjectService.class), + mock(BearerTokenService.class), + mock(OrdsProbeService.class)); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index ae1564c..d8b3037 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -28,10 +28,15 @@ class GuidedFlowTemplateTest { assertThat(probe) .contains("name=\"bearerToken\"") .contains("검증 세션 사용자") + .contains("벡터 검색어 (평문)") + .contains("임베딩 방식") + .doesNotContain("벡터 검색 요청 본문 (JSON)") .doesNotContain("name=\"tokenKeyId\""); assertThat(result) .contains("적용된 사용자와 권한") .contains("토큰 적용 후 SQL") + .contains("벡터 검색 Top-K") + .contains("벡터 거리 (SCORE)") .contains("vpd_predicate") .contains("다음에 할 일") .contains("
column