[Developer] #570 show vector VPD effective SQL trace
This commit is contained in:
83
docs/design/570-vector-sql-trace/README.md
Normal file
83
docs/design/570-vector-sql-trace/README.md
Normal file
@@ -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 (<VPD predicate>)
|
||||||
|
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 (<predicate>)`가 들어간 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 (<VPD predicate>)`, `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를 함께 확인한다.
|
||||||
@@ -58,8 +58,10 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
v_vpd_predicate := admin.cb_agent_doc_vpd_filter('ADMIN', 'CB_VECTOR_SEARCH_DOCUMENTS');
|
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 '
|
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 '
|
|| 'FROM (SELECT d.chunk_id, d.document_id, d.chunk_no, d.title, d.chunk_text, '
|
||||||
|| 'WHERE d.embedding IS NOT NULL /* VPD: ' || v_vpd_predicate || ' */ '
|
|| '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 '
|
|| 'ORDER BY score) ranked_chunks '
|
||||||
|| 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)';
|
|| 'WHERE ROWNUM <= LEAST(GREATEST(NVL(:row_limit, 10), 1), 100)';
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ public class OrdsProbeService {
|
|||||||
prettyHeaders(response.getHeaders()),
|
prettyHeaders(response.getHeaders()),
|
||||||
prettyJson(response.getBody())
|
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);
|
result = addLocalSqlTrace(result, command.bearerToken(), object);
|
||||||
}
|
}
|
||||||
return auditAndReturn(command, result);
|
return auditAndReturn(command, result);
|
||||||
@@ -257,6 +259,24 @@ public class OrdsProbeService {
|
|||||||
return result.withSqlTrace(predicate, effectiveSql);
|
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) {
|
private String findVpdPredicate(String bearerToken, ProtectedObject object) {
|
||||||
try {
|
try {
|
||||||
return jdbcTemplate.execute((ConnectionCallback<String>) connection -> {
|
return jdbcTemplate.execute((ConnectionCallback<String>) connection -> {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,20 @@ class GuidedFlowTemplateTest {
|
|||||||
.contains("검색 기술 상세 보기");
|
.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
|
@Test
|
||||||
void vectorOrdsHandlerReadsBodyStreamOnlyOnce() throws IOException {
|
void vectorOrdsHandlerReadsBodyStreamOnlyOnce() throws IOException {
|
||||||
String sql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));
|
String sql = Files.readString(Path.of("sql/adb/29_agent_ords_vector_search_ords.sql"));
|
||||||
|
|||||||
Reference in New Issue
Block a user