From 188b398dcbc45d32991074778932e3a76ca0b05b Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 30 Jun 2026 14:16:42 +0900 Subject: [PATCH] [Developer] #570 show vector VPD effective SQL trace --- docs/design/570-vector-sql-trace/README.md | 83 +++++++++++++++++++ sql/adb/29_agent_ords_vector_search_ords.sql | 6 +- .../service/OrdsProbeService.java | 22 ++++- .../service/OrdsProbeServiceTest.java | 26 ++++++ .../web/GuidedFlowTemplateTest.java | 14 ++++ 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 docs/design/570-vector-sql-trace/README.md create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java diff --git a/docs/design/570-vector-sql-trace/README.md b/docs/design/570-vector-sql-trace/README.md new file mode 100644 index 0000000..cff6992 --- /dev/null +++ b/docs/design/570-vector-sql-trace/README.md @@ -0,0 +1,83 @@ +# 설계서: 벡터 검색 VPD effective SQL trace (#570) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-30 +> **추적성** — Redmine: #570 · 선행 기능: #567, #569 +> · 구현 파일: `OrdsProbeService`, `probe-result.html`, `29_agent_ords_vector_search_ords.sql` +> · 테스트: `GuidedFlowTemplateTest`, `OrdsProbeServiceTest`, `mvn test` + +## 1. 목적 + +벡터 검색 권한 검증에서도 `set_vpd_context` 이후 Oracle VPD가 추가한 행 조건과 벡터 검색 기본 SQL을 한 화면에서 확인한다. 검색 결과의 `SCORE`만 보여주는 것이 아니라 다음과 같은 재현용 effective SQL을 제공한다. + +```sql +SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score +FROM ( + SELECT d.chunk_id, ..., VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score + FROM ADMIN.CB_VECTOR_SEARCH_DOCUMENTS d + WHERE d.embedding IS NOT NULL + AND () + ORDER BY score +) ranked_chunks +WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100) +``` + +실제 Oracle 실행은 보호 뷰에 부착된 DBMS_RLS가 predicate를 자동 주입한다. 화면의 SQL은 Bearer/임베딩 원문을 치환하지 않고 bind placeholder를 유지하는 진단용 표현이다. + +## 2. 범위 + +- **포함** + - 벡터 ORDS Handler trace에 `AND ()`가 들어간 effective SQL 생성. + - ORDS가 trace를 반환하지 않는 구버전 Handler를 위한 백오피스 same-context fallback. + - `/probe`의 기존 `토큰 적용 후 SQL` 영역 재사용. + - 벡터 결과에 VPD predicate와 effective SQL이 함께 표시되는 템플릿/운영 smoke 테스트. +- **제외** + - Oracle optimizer 실행계획, `V$SQL`, `DBMS_XPLAN` 조회. + - 실제 embedding 숫자 배열이나 Bearer 원문의 SQL 문자열 치환. + - 벡터 거리 계산, VPD 정책, 권한 우선순위 자체의 변경. + +## 3. 인수조건 + +- [ ] 벡터 검색 결과에 `토큰 적용 후 SQL`이 표시된다. +- [ ] SQL에 `FROM ADMIN.CB_VECTOR_SEARCH_DOCUMENTS`, `VECTOR_DISTANCE`, `WHERE d.embedding IS NOT NULL`, `AND ()`, `ORDER BY score`, row limit이 포함된다. +- [ ] ORDS trace가 있으면 그 값을 사용하고, 없으면 같은 Bearer context의 predicate fallback으로 결과를 보강한다. +- [ ] fallback 실패는 검색 결과를 실패로 바꾸지 않으며, 기존 trace 미지원 안내를 유지한다. +- [ ] SQL에는 Bearer 원문·임베딩 숫자·context 값이 포함되지 않는다. +- [ ] 기존 비벡터 객체의 SQL trace와 권한 결과 흐름은 회귀하지 않는다. +- [ ] 전체 Maven 테스트와 실제 vector `/probe` smoke가 통과한다. + +## 4. 처리 흐름 + +```text +[ORDS vector response] + ├─ effective_sql 있음 ───────▶ ProbeResult SQL trace 표시 + └─ trace 없음 + └─ same DB connection: set_user_by_bearer + → cb_agent_doc_vpd_filter + → vector effective SQL 조합 + → clear_user (finally) +``` + +`OrdsProbeService`는 객체명이 `CB_VECTOR_SEARCH_DOCUMENTS`인지 whitelist 상수로 판정한다. 일반 객체에는 기존 컬럼 기반 SQL 조합을 적용하고, 벡터 객체에는 `VECTOR_DISTANCE`와 bind placeholder를 포함한 전용 SQL builder를 적용한다. + +## 5. 함수 명세 + +| 함수 | 책임 | 시그니처 | 실패 계약 | +|---|---|---|---| +| `addVectorSqlTrace` | predicate를 벡터 검색 effective SQL에 결합 | `ProbeResult addVectorSqlTrace(ProbeResult, String, ProtectedObject)` | predicate/컬럼 조회 실패 시 원 결과 | +| `vectorEffectiveSql` | 벡터 Handler의 SQL 구조와 VPD 조건을 조합 | `String vectorEffectiveSql(String)` | 입력 predicate는 DB 함수 반환값만 사용 | +| `findVpdPredicate` | 동일 토큰 context에서 기본 VPD predicate 조회 | 기존 private 함수 | 예외 시 null, 반드시 context cleanup | +| vector Handler trace block | ORDS 응답에 predicate와 effective SQL 출력 | SQL PL/SQL source | trace 실패가 검색 결과를 실패로 바꾸지 않음 | + +## 6. 보안 및 표현 규칙 + +- VPD predicate는 `ADMIN.CB_AGENT_DOC_VPD_FILTER`가 현재 토큰 context에서 반환한 문자열만 사용한다. 사용자 입력을 SQL 조각으로 받지 않는다. +- `:embedding`, `:row_limit` placeholder를 사용한다. 실제 embedding 배열은 SQL trace에 포함하지 않는다. +- 응답의 technical detail request payload에는 기존처럼 서버 생성 embedding이 표시될 수 있지만 effective SQL에는 표시하지 않는다. +- `set_user_by_bearer`와 `clear_user`는 같은 pooled connection에서 실행하고 cleanup은 `finally`로 고정한다. + +## 7. 검증 + +- 단위/템플릿: 벡터 SQL에 `VECTOR_DISTANCE`, `AND (predicate)`, row limit이 포함되는지 확인. +- 전체 테스트: `mvn test`. +- 운영: 평문 `Oracle VPD에서 ORDS 권한을 적용하는 방법`, `limit=5`, `agent_hr` 임시 세션으로 `/probe` 실행 후 결과에서 `SCORE`와 SQL trace를 함께 확인한다. diff --git a/sql/adb/29_agent_ords_vector_search_ords.sql b/sql/adb/29_agent_ords_vector_search_ords.sql index 2d6ece0..9ff5208 100644 --- a/sql/adb/29_agent_ords_vector_search_ords.sql +++ b/sql/adb/29_agent_ords_vector_search_ords.sql @@ -58,8 +58,10 @@ BEGIN BEGIN v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS'); v_effective_sql := 'SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score ' - || 'FROM (SELECT ... FROM admin.cb_vector_search_documents d ' - || 'WHERE d.embedding IS NOT NULL /* VPD: ' || v_vpd_predicate || ' */ ' + || 'FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, ' + || 'd.source_uri, d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score ' + || 'FROM admin.cb_vector_search_documents d ' + || 'WHERE d.embedding IS NOT NULL AND (' || v_vpd_predicate || ') ' || 'ORDER BY score) ranked_chunks ' || 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)'; EXCEPTION diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index 46f7914..653d1b8 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -142,7 +142,9 @@ public class OrdsProbeService { prettyHeaders(response.getHeaders()), prettyJson(response.getBody()) ); - if (!result.hasSqlTrace() && !isVectorSearchObject(object)) { + if (isVectorSearchObject(object)) { + result = addVectorSqlTrace(result, command.bearerToken(), object); + } else if (!result.hasSqlTrace()) { result = addLocalSqlTrace(result, command.bearerToken(), object); } return auditAndReturn(command, result); @@ -257,6 +259,24 @@ public class OrdsProbeService { return result.withSqlTrace(predicate, effectiveSql); } + private ProbeResult addVectorSqlTrace(ProbeResult result, String bearerToken, ProtectedObject object) { + String predicate = findVpdPredicate(bearerToken, object); + if (predicate == null || predicate.isBlank()) { + return result; + } + return result.withSqlTrace(predicate, vectorEffectiveSql(object, predicate)); + } + + static String vectorEffectiveSql(ProtectedObject object, String predicate) { + return "SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, tech_tag, score" + + " FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text," + + " d.source_uri, d.tech_tag, VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE) AS score" + + " FROM " + object.owner() + "." + object.objectName() + " d" + + " WHERE d.embedding IS NOT NULL AND (" + predicate + ")" + + " ORDER BY score) ranked_chunks" + + " WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)"; + } + private String findVpdPredicate(String bearerToken, ProtectedObject object) { try { return jdbcTemplate.execute((ConnectionCallback) connection -> { diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java new file mode 100644 index 0000000..0bf290c --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeServiceTest.java @@ -0,0 +1,26 @@ +package com.cloudhandson.vpdbackoffice.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; +import org.junit.jupiter.api.Test; + +class OrdsProbeServiceTest { + + @Test + void buildsVectorEffectiveSqlWithVpdPredicateAndDistancePlaceholder() { + ProtectedObject object = new ProtectedObject( + 5L, "ADMIN", "CB_VECTOR_SEARCH_DOCUMENTS", "cb-agent-vector/search", "Y"); + + String sql = OrdsProbeService.vectorEffectiveSql( + object, "INSTR(',' || UPPER(d.TECH_TAG) || ',', ',ORACLE_VPD,') > 0"); + + assertThat(sql) + .contains("FROM ADMIN.CB_VECTOR_SEARCH_DOCUMENTS d") + .contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)") + .contains("WHERE d.embedding IS NOT NULL AND (INSTR") + .contains("ORDER BY score") + .contains("ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)") + .doesNotContain("Bearer", "0.10", "0.20"); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index d8b3037..6bce594 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -134,6 +134,20 @@ class GuidedFlowTemplateTest { .contains("검색 기술 상세 보기"); } + @Test + void probeResultExplainsVectorEffectiveSqlWhenTheHandlerReturnsTrace() throws IOException { + String result = template("fragments/probe-result.html"); + String vectorSql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql")); + + assertThat(result) + .contains("토큰 적용 후 SQL") + .contains("권한 적용 SQL") + .contains("VPD가 추가한 WHERE 조건"); + assertThat(vectorSql) + .contains("VECTOR_DISTANCE(d.embedding, TO_VECTOR(:embedding), COSINE)") + .contains("WHERE d.embedding IS NOT NULL AND (' || v_vpd_predicate || ')"); + } + @Test void vectorOrdsHandlerReadsBodyStreamOnlyOnce() throws IOException { String sql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));