[Developer] #569 plain text vector Top-K probe

This commit is contained in:
devmrko
2026-06-30 13:23:49 +09:00
parent 084c5e59cf
commit cde5266a77
9 changed files with 266 additions and 20 deletions

View File

@@ -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 배열이 보이는지 확인한다.

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.vector;
public record VectorQueryEmbedding(
String query,
String embeddingMode,
String embeddingModel,
String requestBody
) {
}

View File

@@ -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");
}

View File

@@ -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";
}

View File

@@ -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';
blocks.forEach(block => {
block.hidden = !isVectorSearch;
input.disabled = !isVectorSearch;
});
controls.forEach(control => {
control.disabled = !isVectorSearch;
});
input.required = isVectorSearch;
if (!isVectorSearch) {
input.value = '';
}
};
select.addEventListener('change', update);
update();

View File

@@ -2,6 +2,8 @@
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="result" class="probe-result-flow">
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
<th:block th:if="${result}">
<div class="section-heading">
<div>
<span class="architecture-kicker">검증 결론</span>
@@ -58,7 +60,43 @@
</div>
</div>
<div class="table-responsive mt-3" th:if="${!#lists.isEmpty(result.rows())}">
<div class="vector-result-panel mt-3"
th:if="${vectorSearch and !#lists.isEmpty(result.rows())}">
<div class="section-heading compact-heading">
<div>
<h3>벡터 검색 Top-K</h3>
<p class="section-subtitle">거리(SCORE)가 낮은 순서로 VPD를 통과한 검색 단위를 반환했습니다.</p>
</div>
<span class="badge text-bg-light" th:text="${'K=' + result.rowCount()}">K=0</span>
</div>
<p class="form-hint" th:text="${'검색어: ' + vectorQuery + ' · 임베딩: ' + vectorEmbeddingMode + ' · 모델: ' + vectorEmbeddingModel}">검색 정보</p>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>검색 단위 ID</th>
<th>자료 ID</th>
<th>제목</th>
<th>기술 태그</th>
<th>벡터 거리 (SCORE)</th>
<th>본문</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${result.rows()}">
<td th:text="${row['CHUNK_ID'] ?: row['chunk_id']}">28001</td>
<td th:text="${row['DOCUMENT_ID'] ?: row['document_id']}">knowledge-001</td>
<td th:text="${row['TITLE'] ?: row['title']}">제목</td>
<td><code th:text="${row['TECH_TAG'] ?: row['tech_tag']}">ORDS</code></td>
<td th:text="${row['SCORE'] ?: row['score']}">0.01</td>
<td class="matrix-list" th:text="${row['CHUNK_TEXT'] ?: row['chunk_text']}">본문</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="table-responsive mt-3" th:if="${!vectorSearch and !#lists.isEmpty(result.rows())}">
<table class="table table-sm table-striped align-middle">
<thead><tr><th th:each="column : ${result.columns()}" th:text="${column}">column</th></tr></thead>
<tbody>
@@ -120,6 +158,7 @@
<section class="probe-exchange"><h3>Response Body</h3><pre th:text="${result.responseBody()} ?: ''"></pre></section>
</div>
</details>
</th:block>
</div>
</body>
</html>

View File

@@ -67,10 +67,18 @@
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
</label>
<label class="span-2 vector-probe-input" data-vector-probe-input hidden>
벡터 검색 요청 본문 (JSON)
<textarea class="form-control" name="requestBody" rows="3" disabled
placeholder='{"embedding":[0.10,0.20,0.30,0.40]}'></textarea>
<span class="form-hint">벡터 검색 객체를 선택했을 때만 필요합니다. 검색어를 외부 임베딩 모델로 바꾼 배열을 넣습니다.</span>
벡터 검색어 (평문)
<textarea class="form-control" name="requestBody" rows="3" required disabled
placeholder="예: Oracle VPD에서 ORDS 권한을 적용하는 방법"></textarea>
<span class="form-hint">검색어를 평문으로 입력하면 백오피스가 같은 임베딩 방식으로 벡터화해 전용 ORDS Handler에 전달합니다.</span>
</label>
<label class="vector-probe-input" data-vector-probe-input hidden>
임베딩 방식
<select class="form-select" name="embeddingMode" disabled>
<option value="DEMO">로컬 임베딩(개발용)</option>
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩</option>
</select>
<span class="form-hint">자료 등록 때 사용한 방식·차원과 맞춰야 합니다. AI 설정이 없으면 로컬 임베딩을 사용하세요.</span>
</label>
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
</form>

View File

@@ -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));
}
}

View File

@@ -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("<details")