feat #565: add vector knowledge ingestion demo
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결
|
# 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결
|
||||||
|
|
||||||
> **상태**: 구현 초안
|
> **상태**: 데모 흐름 구현
|
||||||
> **추적성**: Redmine #565 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql`
|
> **추적성**: Redmine #565 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql`
|
||||||
|
|
||||||
## 1. 한 문장으로 이해하기
|
## 1. 한 문장으로 이해하기
|
||||||
@@ -147,6 +147,17 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지
|
|||||||
→ CB_VECTOR_DOCUMENT_TAG에 태그 저장
|
→ CB_VECTOR_DOCUMENT_TAG에 태그 저장
|
||||||
```
|
```
|
||||||
|
|
||||||
|
백오피스의 `/vector-knowledge` 화면에서 이 흐름을 데모로 실행할 수 있다.
|
||||||
|
|
||||||
|
- 문서 ID·제목·본문·기술 태그를 입력하면 문단/길이 기준으로 청크를 만든다.
|
||||||
|
- `DEMO-4D`는 외부 API 없이 샘플 데이터와 같은 4차원 벡터를 만드는 재현 모드다.
|
||||||
|
- `AI`를 선택하면 `BACKOFFICE_AI_BASE_URL/v1/embeddings`와
|
||||||
|
`BACKOFFICE_AI_EMBEDDING_MODEL`을 사용해 청크마다 실제 임베딩을 생성한다.
|
||||||
|
- 같은 방식으로 검색어를 임베딩하고, 임시 토큰으로 전용 ORDS Handler를 호출한다.
|
||||||
|
- VPD가 `TECH_TAG`를 먼저 필터링한 뒤 남은 청크만 벡터 거리순으로 반환한다.
|
||||||
|
|
||||||
|
`DEMO-4D`는 의미 기반 품질을 보장하는 모델이 아니라 권한 흐름을 재현하기 위한 고정 차원 예제다. 운영 검색은 ingestion과 검색에 같은 임베딩 모델·차원을 사용해야 한다.
|
||||||
|
|
||||||
## 7. 운영 가이드라인
|
## 7. 운영 가이드라인
|
||||||
|
|
||||||
- 태그는 자유 문장보다 대문자·언더스코어 형태의 안정적인 ID로 관리한다. 예: `SPRING_BOOT`, `ORACLE_VPD`, `INTERNAL_ONLY`.
|
- 태그는 자유 문장보다 대문자·언더스코어 형태의 안정적인 ID로 관리한다. 예: `SPRING_BOOT`, `ORACLE_VPD`, `INTERNAL_ONLY`.
|
||||||
@@ -166,7 +177,8 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지
|
|||||||
- 검색 결과에 임베딩 원문이 포함되지 않는다.
|
- 검색 결과에 임베딩 원문이 포함되지 않는다.
|
||||||
- `/permissions` 화면에서 `TAG` 규칙이 기본 `TECH_TAG`와 OR 의미를 일반 문장으로 설명한다.
|
- `/permissions` 화면에서 `TAG` 규칙이 기본 `TECH_TAG`와 OR 의미를 일반 문장으로 설명한다.
|
||||||
|
|
||||||
## 9. 범위 밖
|
## 9. 아직 별도 운영 설계가 필요한 부분
|
||||||
|
|
||||||
- 임베딩 모델 호출·문서 업로드 UI·태그 사전 승인 워크플로는 이번 시나리오에서 구현하지 않는다.
|
- 대용량 파일 업로드, 비동기 작업 큐, 재시도·실패 격리, 임베딩 모델 버전별 재색인은 운영 파이프라인에서 별도로 설계한다.
|
||||||
|
- 태그 사전 승인 워크플로와 문서별 소유자/보존기간 정책은 현재 데모 화면의 범위를 넘어선다.
|
||||||
- 운영 DB에 대한 DDL, 기존 정책 교체, 방화벽/NSG 변경은 별도 승인을 받아 실행한다.
|
- 운영 DB에 대한 DDL, 기존 정책 교체, 방화벽/NSG 변경은 별도 승인을 받아 실행한다.
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ SET FEEDBACK ON
|
|||||||
SET DEFINE OFF
|
SET DEFINE OFF
|
||||||
|
|
||||||
PROMPT === 1. Creating vector chunk and tag tables ===
|
PROMPT === 1. Creating vector chunk and tag tables ===
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE SEQUENCE cb_vector_chunk_seq START WITH 30000 INCREMENT BY 1 NOCACHE';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE != -955 THEN
|
||||||
|
RAISE;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
EXECUTE IMMEDIATE q'!
|
EXECUTE IMMEDIATE q'!
|
||||||
CREATE TABLE cb_vector_document_chunk (
|
CREATE TABLE cb_vector_document_chunk (
|
||||||
@@ -254,4 +264,5 @@ COMMIT;
|
|||||||
|
|
||||||
PROMPT === Vector/tag VPD setup complete ===
|
PROMPT === Vector/tag VPD setup complete ===
|
||||||
PROMPT Next: run 29_agent_ords_vector_search_ords.sql as CB_ORDS.
|
PROMPT Next: run 29_agent_ords_vector_search_ords.sql as CB_ORDS.
|
||||||
|
PROMPT Backoffice ingestion: /vector-knowledge (DEMO-4D or configured AI embeddings).
|
||||||
EXIT;
|
EXIT;
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ public record BackofficeProperties(
|
|||||||
public record Ords(String baseUrl, Duration timeout) {
|
public record Ords(String baseUrl, Duration timeout) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
|
public record Ai(
|
||||||
|
boolean enabled,
|
||||||
|
String baseUrl,
|
||||||
|
String model,
|
||||||
|
String apiKey,
|
||||||
|
Duration timeout,
|
||||||
|
String embeddingModel
|
||||||
|
) {
|
||||||
|
|
||||||
|
public Ai(boolean enabled, String baseUrl, String model, String apiKey, Duration timeout) {
|
||||||
|
this(enabled, baseUrl, model, apiKey, timeout, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||||
|
|
||||||
|
public record VectorChunk(int chunkNo, String text) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||||
|
|
||||||
|
public record VectorIngestCommand(
|
||||||
|
String documentId,
|
||||||
|
String title,
|
||||||
|
String sourceUri,
|
||||||
|
String content,
|
||||||
|
String techTags,
|
||||||
|
int chunkSize,
|
||||||
|
String embeddingMode
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||||
|
|
||||||
|
public record VectorIngestResult(
|
||||||
|
String documentId,
|
||||||
|
int chunkCount,
|
||||||
|
int tagCount,
|
||||||
|
String embeddingMode,
|
||||||
|
String embeddingModel
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||||
|
|
||||||
|
public record VectorKnowledgeSummary(
|
||||||
|
int documentCount,
|
||||||
|
int chunkCount,
|
||||||
|
int tagCount,
|
||||||
|
boolean vectorObjectRegistered,
|
||||||
|
String embeddingModel
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.domain.vector;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||||
|
|
||||||
|
public record VectorSearchResult(
|
||||||
|
String query,
|
||||||
|
String embeddingMode,
|
||||||
|
String embeddingModel,
|
||||||
|
ProbeResult probe
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
|||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -46,6 +48,58 @@ public class OpenAiCompatibleClient {
|
|||||||
return ai == null ? "" : ai.model();
|
return ai == null ? "" : ai.model();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean embeddingConfigured() {
|
||||||
|
BackofficeProperties.Ai ai = properties.ai();
|
||||||
|
return ai != null
|
||||||
|
&& ai.enabled()
|
||||||
|
&& hasText(ai.baseUrl())
|
||||||
|
&& hasText(ai.embeddingModel())
|
||||||
|
&& hasText(ai.apiKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String embeddingModelName() {
|
||||||
|
BackofficeProperties.Ai ai = properties.ai();
|
||||||
|
return ai == null || ai.embeddingModel() == null ? "" : ai.embeddingModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Double> embedding(String input) {
|
||||||
|
if (!embeddingConfigured()) {
|
||||||
|
throw new AppException("AI 임베딩 설정이 없습니다. BACKOFFICE_AI_EMBEDDING_MODEL을 설정하거나 데모 임베딩을 선택하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
BackofficeProperties.Ai ai = properties.ai();
|
||||||
|
ObjectNode request = objectMapper.createObjectNode();
|
||||||
|
request.put("model", ai.embeddingModel());
|
||||||
|
request.put("input", input == null ? "" : input);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.setBearerAuth(ai.apiKey());
|
||||||
|
Duration timeout = ai.timeout() == null ? Duration.ofSeconds(30) : ai.timeout();
|
||||||
|
RestTemplate restTemplate = restTemplateBuilder
|
||||||
|
.setConnectTimeout(timeout)
|
||||||
|
.setReadTimeout(timeout)
|
||||||
|
.build();
|
||||||
|
ResponseEntity<String> response = restTemplate.postForEntity(
|
||||||
|
embeddingEndpoint(ai.baseUrl()),
|
||||||
|
new HttpEntity<>(request.toString(), headers),
|
||||||
|
String.class
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
JsonNode values = objectMapper.readTree(response.getBody()).path("data").path(0).path("embedding");
|
||||||
|
if (!values.isArray() || values.isEmpty()) {
|
||||||
|
throw new AppException("AI 임베딩 응답에 embedding 배열이 없습니다.");
|
||||||
|
}
|
||||||
|
List<Double> result = new ArrayList<>();
|
||||||
|
values.forEach(value -> result.add(value.asDouble()));
|
||||||
|
return List.copyOf(result);
|
||||||
|
} catch (AppException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("AI 임베딩 응답을 해석할 수 없습니다: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public String chat(String systemPrompt, String userPrompt) {
|
public String chat(String systemPrompt, String userPrompt) {
|
||||||
if (!configured()) {
|
if (!configured()) {
|
||||||
throw new AppException("AI 호출 설정이 없습니다.");
|
throw new AppException("AI 호출 설정이 없습니다.");
|
||||||
@@ -100,6 +154,18 @@ public class OpenAiCompatibleClient {
|
|||||||
return URI.create(withoutSlash + "/v1/chat/completions");
|
return URI.create(withoutSlash + "/v1/chat/completions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private URI embeddingEndpoint(String baseUrl) {
|
||||||
|
String trimmed = baseUrl.trim();
|
||||||
|
if (trimmed.endsWith("/embeddings")) {
|
||||||
|
return URI.create(trimmed);
|
||||||
|
}
|
||||||
|
String withoutSlash = trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||||
|
if (withoutSlash.endsWith("/v1")) {
|
||||||
|
return URI.create(withoutSlash + "/embeddings");
|
||||||
|
}
|
||||||
|
return URI.create(withoutSlash + "/v1/embeddings");
|
||||||
|
}
|
||||||
|
|
||||||
private boolean usesCompletionTokenLimit(String model) {
|
private boolean usesCompletionTokenLimit(String model) {
|
||||||
return model != null && model.startsWith("openai.gpt-5.4");
|
return model != null && model.startsWith("openai.gpt-5.4");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorChunk;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small, deterministic paragraph/sentence chunker for the demonstration flow.
|
||||||
|
* Production ingestion can replace this with a tokenizer-aware chunker without
|
||||||
|
* changing the tag/VPD data contract.
|
||||||
|
*/
|
||||||
|
public final class VectorChunker {
|
||||||
|
|
||||||
|
private static final int MIN_CHUNK_SIZE = 80;
|
||||||
|
private static final int MAX_CHUNK_SIZE = 2000;
|
||||||
|
|
||||||
|
private VectorChunker() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<VectorChunk> chunk(String content, int requestedSize) {
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
throw new AppException("청킹할 문서 본문을 입력하세요.");
|
||||||
|
}
|
||||||
|
int chunkSize = Math.max(MIN_CHUNK_SIZE, Math.min(requestedSize <= 0 ? 600 : requestedSize, MAX_CHUNK_SIZE));
|
||||||
|
List<VectorChunk> chunks = new ArrayList<>();
|
||||||
|
String normalized = content.replace("\r\n", "\n").replace('\r', '\n').trim();
|
||||||
|
String[] paragraphs = normalized.split("\\n\\s*\\n+");
|
||||||
|
StringBuilder current = new StringBuilder();
|
||||||
|
for (String paragraph : paragraphs) {
|
||||||
|
String trimmed = paragraph.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current.length() > 0 && current.length() + trimmed.length() + 1 > chunkSize) {
|
||||||
|
appendChunks(chunks, current.toString(), chunkSize);
|
||||||
|
current.setLength(0);
|
||||||
|
}
|
||||||
|
if (current.length() > 0) {
|
||||||
|
current.append('\n');
|
||||||
|
}
|
||||||
|
current.append(trimmed);
|
||||||
|
}
|
||||||
|
if (current.length() > 0) {
|
||||||
|
appendChunks(chunks, current.toString(), chunkSize);
|
||||||
|
}
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
throw new AppException("청킹할 문서 본문을 입력하세요.");
|
||||||
|
}
|
||||||
|
List<VectorChunk> numbered = new ArrayList<>();
|
||||||
|
for (int index = 0; index < chunks.size(); index++) {
|
||||||
|
numbered.add(new VectorChunk(index + 1, chunks.get(index).text()));
|
||||||
|
}
|
||||||
|
return List.copyOf(numbered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendChunks(List<VectorChunk> chunks, String text, int chunkSize) {
|
||||||
|
String remaining = text.trim();
|
||||||
|
while (remaining.length() > chunkSize) {
|
||||||
|
int cut = lastBreak(remaining, chunkSize);
|
||||||
|
chunks.add(new VectorChunk(0, remaining.substring(0, cut).trim()));
|
||||||
|
remaining = remaining.substring(cut).trim();
|
||||||
|
}
|
||||||
|
if (!remaining.isEmpty()) {
|
||||||
|
chunks.add(new VectorChunk(0, remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lastBreak(String text, int chunkSize) {
|
||||||
|
int sentence = Math.max(text.lastIndexOf('.', chunkSize), text.lastIndexOf('。', chunkSize));
|
||||||
|
if (sentence >= chunkSize / 2) {
|
||||||
|
return sentence + 1;
|
||||||
|
}
|
||||||
|
int whitespace = text.lastIndexOf(' ', chunkSize);
|
||||||
|
return whitespace >= chunkSize / 2 ? whitespace : chunkSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
|
||||||
|
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.VectorSearchResult;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class VectorKnowledgeService {
|
||||||
|
|
||||||
|
public static final String VECTOR_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
|
||||||
|
public static final String DEMO_MODE = "DEMO";
|
||||||
|
public static final String AI_MODE = "AI";
|
||||||
|
|
||||||
|
private static final Pattern TAG_PATTERN = Pattern.compile("[A-Z0-9_-]+");
|
||||||
|
private static final int MAX_DOCUMENT_LENGTH = 500_000;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final OpenAiCompatibleClient embeddingClient;
|
||||||
|
private final ProtectedObjectService protectedObjectService;
|
||||||
|
private final BearerTokenService tokenService;
|
||||||
|
private final OrdsProbeService ordsProbeService;
|
||||||
|
|
||||||
|
public VectorKnowledgeService(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
OpenAiCompatibleClient embeddingClient,
|
||||||
|
ProtectedObjectService protectedObjectService,
|
||||||
|
BearerTokenService tokenService,
|
||||||
|
OrdsProbeService ordsProbeService
|
||||||
|
) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.embeddingClient = embeddingClient;
|
||||||
|
this.protectedObjectService = protectedObjectService;
|
||||||
|
this.tokenService = tokenService;
|
||||||
|
this.ordsProbeService = ordsProbeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VectorKnowledgeSummary summary() {
|
||||||
|
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
|
||||||
|
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
|
||||||
|
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
|
||||||
|
boolean registered = protectedObjectService.findEnabled().stream()
|
||||||
|
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
|
||||||
|
return new VectorKnowledgeSummary(
|
||||||
|
documents,
|
||||||
|
chunks,
|
||||||
|
tags,
|
||||||
|
registered,
|
||||||
|
embeddingClient.embeddingConfigured() ? embeddingClient.embeddingModelName() : "DEMO-4D"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean aiEmbeddingConfigured() {
|
||||||
|
return embeddingClient.embeddingConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VectorIngestResult ingest(VectorIngestCommand command) {
|
||||||
|
String documentId = requiredDocumentId(command.documentId());
|
||||||
|
String title = required(command.title(), "문서 제목");
|
||||||
|
String content = required(command.content(), "문서 본문");
|
||||||
|
if (content.length() > MAX_DOCUMENT_LENGTH) {
|
||||||
|
throw new AppException("문서 본문은 " + MAX_DOCUMENT_LENGTH + "자 이내로 입력하세요.");
|
||||||
|
}
|
||||||
|
String sourceUri = command.sourceUri() == null || command.sourceUri().isBlank()
|
||||||
|
? "kb://backoffice/" + documentId
|
||||||
|
: command.sourceUri().trim();
|
||||||
|
Set<String> tags = normalizeTags(command.techTags());
|
||||||
|
String mode = normalizeMode(command.embeddingMode());
|
||||||
|
List<VectorChunk> chunks = VectorChunker.chunk(content, command.chunkSize());
|
||||||
|
|
||||||
|
replaceDocument(documentId);
|
||||||
|
long nextChunkId = nextChunkId(chunks.size());
|
||||||
|
int tagCount = 0;
|
||||||
|
for (VectorChunk chunk : chunks) {
|
||||||
|
List<Double> embedding = embed(chunk.text(), mode);
|
||||||
|
String embeddingJson = json(embedding);
|
||||||
|
long chunkId = nextChunkId++;
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO cb_vector_document_chunk
|
||||||
|
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
|
||||||
|
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
|
||||||
|
for (String tag : tags) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""", chunkId, tag);
|
||||||
|
tagCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new VectorIngestResult(documentId, chunks.size(), tagCount, mode, embeddingModel(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) + "}";
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
tokenService.revokeToken(temporary.keyId(), "temporary vector search completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replaceDocument(String documentId) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
DELETE FROM cb_vector_document_tag
|
||||||
|
WHERE chunk_id IN (
|
||||||
|
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ?
|
||||||
|
)
|
||||||
|
""", documentId);
|
||||||
|
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long nextChunkId(int chunkCount) {
|
||||||
|
try {
|
||||||
|
Long sequenceValue = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
|
||||||
|
if (sequenceValue != null) {
|
||||||
|
return sequenceValue;
|
||||||
|
}
|
||||||
|
} catch (DataAccessException ignored) {
|
||||||
|
// Older installations may not have the optional sequence yet. The
|
||||||
|
// fallback keeps the demonstration usable; the setup SQL creates it.
|
||||||
|
}
|
||||||
|
Long max = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class);
|
||||||
|
return (max == null ? 28000 : max) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Double> embed(String input, String mode) {
|
||||||
|
if (AI_MODE.equals(mode)) {
|
||||||
|
return embeddingClient.embedding(input);
|
||||||
|
}
|
||||||
|
return demoEmbedding(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Double> demoEmbedding(String input) {
|
||||||
|
double[] vector = new double[4];
|
||||||
|
String normalized = input.toLowerCase(Locale.ROOT);
|
||||||
|
addIfContains(vector, normalized, 0, "spring", "boot", "java", "security");
|
||||||
|
addIfContains(vector, normalized, 1, "oracle", "vpd", "policy", "database", "db");
|
||||||
|
addIfContains(vector, normalized, 2, "ords", "rest", "handler", "http", "api");
|
||||||
|
addIfContains(vector, normalized, 3, "mcp", "tool", "권한", "tag", "태그");
|
||||||
|
for (String token : normalized.split("[^a-z0-9가-힣]+")) {
|
||||||
|
if (token.length() >= 3) {
|
||||||
|
vector[Math.floorMod(token.hashCode(), vector.length)] += 0.03;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double length = 0;
|
||||||
|
for (double value : vector) {
|
||||||
|
length += value * value;
|
||||||
|
}
|
||||||
|
if (length == 0) {
|
||||||
|
vector[0] = 1;
|
||||||
|
length = 1;
|
||||||
|
}
|
||||||
|
double scale = Math.sqrt(length);
|
||||||
|
List<Double> result = new ArrayList<>(vector.length);
|
||||||
|
for (double value : vector) {
|
||||||
|
result.add(value / scale);
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addIfContains(double[] vector, String input, int index, String... terms) {
|
||||||
|
for (String term : terms) {
|
||||||
|
if (input.contains(term)) {
|
||||||
|
vector[index] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> normalizeTags(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new AppException("기술 태그를 하나 이상 입력하세요.");
|
||||||
|
}
|
||||||
|
Set<String> tags = new LinkedHashSet<>();
|
||||||
|
for (String raw : value.split("[,\\s]+")) {
|
||||||
|
String tag = raw.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (tag.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TAG_PATTERN.matcher(tag).matches()) {
|
||||||
|
throw new AppException("기술 태그는 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다: " + tag);
|
||||||
|
}
|
||||||
|
tags.add(tag);
|
||||||
|
}
|
||||||
|
if (tags.isEmpty()) {
|
||||||
|
throw new AppException("기술 태그를 하나 이상 입력하세요.");
|
||||||
|
}
|
||||||
|
return Set.copyOf(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeMode(String mode) {
|
||||||
|
String normalized = mode == null ? DEMO_MODE : mode.trim().toUpperCase(Locale.ROOT);
|
||||||
|
if (!DEMO_MODE.equals(normalized) && !AI_MODE.equals(normalized)) {
|
||||||
|
throw new AppException("임베딩 방식은 DEMO 또는 AI만 사용할 수 있습니다.");
|
||||||
|
}
|
||||||
|
if (AI_MODE.equals(normalized) && !embeddingClient.embeddingConfigured()) {
|
||||||
|
throw new AppException("AI 임베딩이 설정되지 않았습니다. 설정에서 embedding model/API key를 추가하거나 DEMO 임베딩을 선택하세요.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String embeddingModel(String mode) {
|
||||||
|
return AI_MODE.equals(mode) ? embeddingClient.embeddingModelName() : "DEMO-4D";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String json(List<Double> values) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(values);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AppException("임베딩 JSON 생성에 실패했습니다: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(String sql) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizeLimit(int limit) {
|
||||||
|
return Math.min(Math.max(limit, 1), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String required(String value, String label) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new AppException(label + "을 입력하세요.");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredDocumentId(String value) {
|
||||||
|
String documentId = required(value, "문서 ID");
|
||||||
|
if (documentId.length() > 200 || !documentId.matches("[A-Za-z0-9_.:-]+")) {
|
||||||
|
throw new AppException("문서 ID는 영문·숫자와 '.', '_', ':', '-'만 사용할 수 있습니다.");
|
||||||
|
}
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestCommand;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.vector.VectorIngestResult;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.VectorKnowledgeService;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class VectorKnowledgeController {
|
||||||
|
|
||||||
|
private final VectorKnowledgeService vectorKnowledgeService;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
|
public VectorKnowledgeController(VectorKnowledgeService vectorKnowledgeService, UserMapper userMapper) {
|
||||||
|
this.vectorKnowledgeService = vectorKnowledgeService;
|
||||||
|
this.userMapper = userMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/vector-knowledge")
|
||||||
|
public String page(Model model) {
|
||||||
|
model.addAttribute("users", userMapper.findAll());
|
||||||
|
model.addAttribute("aiEmbeddingConfigured", vectorKnowledgeService.aiEmbeddingConfigured());
|
||||||
|
try {
|
||||||
|
model.addAttribute("summary", vectorKnowledgeService.summary());
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
|
||||||
|
model.addAttribute("summary", null);
|
||||||
|
model.addAttribute("runtimeError", error);
|
||||||
|
}
|
||||||
|
return "vector-knowledge";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/vector-knowledge/ingest")
|
||||||
|
public String ingest(
|
||||||
|
@RequestParam String documentId,
|
||||||
|
@RequestParam String title,
|
||||||
|
@RequestParam(required = false, defaultValue = "") String sourceUri,
|
||||||
|
@RequestParam String content,
|
||||||
|
@RequestParam String techTags,
|
||||||
|
@RequestParam(defaultValue = "600") int chunkSize,
|
||||||
|
@RequestParam(defaultValue = "DEMO") String embeddingMode,
|
||||||
|
RedirectAttributes redirectAttributes
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
VectorIngestResult result = vectorKnowledgeService.ingest(new VectorIngestCommand(
|
||||||
|
documentId, title, sourceUri, content, techTags, chunkSize, embeddingMode));
|
||||||
|
redirectAttributes.addFlashAttribute("ingestResult", result);
|
||||||
|
redirectAttributes.addFlashAttribute("message",
|
||||||
|
result.chunkCount() + "개 청크를 저장했습니다. 태그 " + result.tagCount() + "개가 각 청크에 연결되었습니다.");
|
||||||
|
} catch (Exception exception) {
|
||||||
|
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/vector-knowledge";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/vector-knowledge/search")
|
||||||
|
public String search(
|
||||||
|
@RequestParam long userId,
|
||||||
|
@RequestParam String query,
|
||||||
|
@RequestParam(defaultValue = "10") int limit,
|
||||||
|
@RequestParam(defaultValue = "DEMO") String embeddingMode,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
model.addAttribute("searchResult", vectorKnowledgeService.search(userId, query, limit, embeddingMode));
|
||||||
|
} catch (Exception exception) {
|
||||||
|
model.addAttribute("errorMessage", exception.getMessage());
|
||||||
|
}
|
||||||
|
return "fragments/vector-search-result :: result";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,5 +50,6 @@ backoffice:
|
|||||||
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||||
base-url: ${BACKOFFICE_AI_BASE_URL:}
|
base-url: ${BACKOFFICE_AI_BASE_URL:}
|
||||||
model: ${BACKOFFICE_AI_MODEL:}
|
model: ${BACKOFFICE_AI_MODEL:}
|
||||||
|
embedding-model: ${BACKOFFICE_AI_EMBEDDING_MODEL:}
|
||||||
api-key: ${BACKOFFICE_AI_API_KEY:}
|
api-key: ${BACKOFFICE_AI_API_KEY:}
|
||||||
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
|
timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<div class="rw-menu-panel">
|
<div class="rw-menu-panel">
|
||||||
<a class="nav-link" href="/objects">ORDS 조회 대상</a>
|
<a class="nav-link" href="/objects">ORDS 조회 대상</a>
|
||||||
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
|
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
|
||||||
|
<a class="nav-link" href="/vector-knowledge">벡터 지식자료 데모</a>
|
||||||
<a class="nav-link" href="/mcp-chatbot">Chatbot</a>
|
<a class="nav-link" href="/mcp-chatbot">Chatbot</a>
|
||||||
<a class="nav-link" href="/mcp-reasoning">Reasoning</a>
|
<a class="nav-link" href="/mcp-reasoning">Reasoning</a>
|
||||||
<a class="nav-link" href="/mcp-sse">SSE 서비스</a>
|
<a class="nav-link" href="/mcp-sse">SSE 서비스</a>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<div th:fragment="result">
|
||||||
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
<div th:if="${searchResult}">
|
||||||
|
<div class="result-metrics">
|
||||||
|
<div><span>임베딩</span><strong th:text="${searchResult.embeddingModel()}">DEMO-4D</strong></div>
|
||||||
|
<div><span>검색 결과</span><strong th:text="${searchResult.probe().rowCount() + '건 · ' + searchResult.probe().status()}">0건</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="next-action-card mt-3">
|
||||||
|
<h3 th:text="${searchResult.probe().title()}">권한 결과</h3>
|
||||||
|
<p th:text="${searchResult.probe().plainSummary()}">결과 요약</p>
|
||||||
|
<small th:text="${'질문: ' + searchResult.query() + ' · 방식: ' + searchResult.embeddingMode()}">검색 정보</small>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive mt-3" th:if="${!#lists.isEmpty(searchResult.probe().rows())}">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Chunk</th>
|
||||||
|
<th>Document</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>TECH_TAG</th>
|
||||||
|
<th>Score</th>
|
||||||
|
<th>본문</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="row : ${searchResult.probe().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>
|
||||||
|
<details class="technical-details mt-3">
|
||||||
|
<summary>검색 기술 상세 보기</summary>
|
||||||
|
<pre class="table-pre" th:text="${searchResult.probe().responseBody()}">response</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
176
src/main/resources/templates/vector-knowledge.html
Normal file
176
src/main/resources/templates/vector-knowledge.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:replace="~{fragments/layout :: head('벡터 지식자료 데모')}"></head>
|
||||||
|
<body>
|
||||||
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
|
<main class="container py-4">
|
||||||
|
<div class="page-title">
|
||||||
|
<h1>벡터 지식자료 데모</h1>
|
||||||
|
<p class="context-summary">문서를 청크로 나누고 임베딩·기술 태그를 저장한 뒤, 사용자 권한에 맞는 청크만 검색합니다.</p>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>전체 시나리오 설명 보기</summary>
|
||||||
|
<p>문서 본문을 청크로 나누고 각 청크를 벡터로 변환합니다. 청크마다 기술 태그를 붙이면 권한 규칙의 <code>TAG</code> 조건이 같은 태그가 붙은 행만 허용합니다. 마지막 검색은 Bearer Token으로 VPD를 통과한 청크만 벡터 거리순으로 반환합니다.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
|
||||||
|
|
||||||
|
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||||
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||||
|
<strong th:text="${runtimeError.title()}">DB 준비가 필요합니다.</strong>
|
||||||
|
<span th:text="${runtimeError.message()}">벡터 테이블을 확인하세요.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">1 · INGEST</span>
|
||||||
|
<h2>문서 등록 → 청킹 → 임베딩 → 태그 저장</h2>
|
||||||
|
<p class="section-subtitle">같은 문서 ID로 다시 저장하면 기존 청크를 교체합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary" th:text="${summary == null ? 'DB 확인 필요' : summary.chunkCount() + ' chunks'}">0 chunks</span>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>청킹과 임베딩 방식 보기</summary>
|
||||||
|
<ul>
|
||||||
|
<li>문단을 우선 묶고 길이가 길면 문장·공백 기준으로 나눕니다.</li>
|
||||||
|
<li><strong>DEMO-4D</strong>는 외부 API 없이 동일한 4차원 예제를 재현하는 모드입니다. 의미 검색 품질을 제공하는 실제 임베딩은 아닙니다.</li>
|
||||||
|
<li><strong>AI</strong>는 <code>BACKOFFICE_AI_EMBEDDING_MODEL</code>과 API key가 설정된 OpenAI 호환 <code>/v1/embeddings</code>를 사용합니다.</li>
|
||||||
|
<li>실제 운영에서는 문서 내용과 같은 임베딩 모델로 검색어도 임베딩해야 합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<form method="post" action="/vector-knowledge/ingest" class="form-grid mt-3">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>
|
||||||
|
문서 ID
|
||||||
|
<input class="form-control" name="documentId" placeholder="knowledge-security-001" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
문서 제목
|
||||||
|
<input class="form-control" name="title" placeholder="VPD와 ORDS 권한 설계" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
원문 주소 (선택)
|
||||||
|
<input class="form-control" name="sourceUri" placeholder="kb://security/vpd-ords">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
기술 태그 (쉼표 또는 공백)
|
||||||
|
<input class="form-control" name="techTags" placeholder="SPRING_BOOT ORACLE_VPD" required>
|
||||||
|
<span class="form-hint">태그는 대문자로 정규화됩니다. 예: <code>SPRING_BOOT</code>, <code>ORACLE_VPD</code></span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
청크 길이
|
||||||
|
<input class="form-control" name="chunkSize" type="number" min="80" max="2000" value="600">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
임베딩 방식
|
||||||
|
<select class="form-select" name="embeddingMode">
|
||||||
|
<option value="DEMO">DEMO-4D (로컬 재현)</option>
|
||||||
|
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩 (설정 필요)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="span-2">
|
||||||
|
문서 본문
|
||||||
|
<textarea class="form-control" name="content" rows="8"
|
||||||
|
placeholder="문서 내용을 붙여 넣으세요. 빈 줄은 문단 경계로 사용됩니다." required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn rw-btn-primary" type="submit">청크·임베딩·태그 저장</button>
|
||||||
|
</form>
|
||||||
|
<div class="alert alert-info mt-3 mb-0" th:if="${ingestResult}">
|
||||||
|
<strong th:text="${ingestResult.documentId()}">document</strong>
|
||||||
|
<span th:text="${ingestResult.chunkCount() + '개 청크 / ' + ingestResult.tagCount() + '개 태그 연결 / ' + ingestResult.embeddingModel()}">저장 완료</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">2 · AUTHORIZE</span>
|
||||||
|
<h2>태그 권한 설정</h2>
|
||||||
|
<p class="section-subtitle">태그 권한은 기존 권한 관리에서 역할별 행 규칙으로 저장합니다.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 관리 열기</a>
|
||||||
|
</div>
|
||||||
|
<div class="macro-micro-grid">
|
||||||
|
<div>
|
||||||
|
<h3>예: 백엔드 역할</h3>
|
||||||
|
<p><code>ALLOW TAG SPRING_BOOT</code><br><code>ALLOW TAG ORACLE_VPD</code></p>
|
||||||
|
<small class="text-muted">두 태그 중 하나가 붙은 청크를 허용합니다.</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>예: ORDS 제외 역할</h3>
|
||||||
|
<p><code>ALLOW TAG SPRING_BOOT</code><br><code>DENY TAG ORDS</code></p>
|
||||||
|
<small class="text-muted">허용 후보 중 ORDS 태그 청크를 다시 제외합니다.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details mt-3">
|
||||||
|
<summary>VPD predicate가 계산하는 식 보기</summary>
|
||||||
|
<pre class="code-block">(ALLOW TAG A OR ALLOW TAG B)
|
||||||
|
AND NOT (DENY TAG C OR DENY TAG D)</pre>
|
||||||
|
<p>허용 태그가 없거나 토큰 컨텍스트가 없으면 <code>1=0</code>으로 닫힙니다. 태그 권한은 행 접근 기준이고, 본문·임베딩 표시 보호는 컬럼 정책으로 별도 관리합니다.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">3 · SEARCH</span>
|
||||||
|
<h2>권한 적용 벡터 검색</h2>
|
||||||
|
<p class="section-subtitle">선택한 사용자에게 임시 토큰을 발급하고 검색 완료 즉시 회수합니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge" th:classappend="${summary != null && summary.vectorObjectRegistered()} ? ' text-bg-success' : ' text-bg-warning'"
|
||||||
|
th:text="${summary != null && summary.vectorObjectRegistered()} ? 'Vector ORDS 연결됨' : 'Vector ORDS 설치 필요'">Vector ORDS</span>
|
||||||
|
</div>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>검색 실행 순서 보기</summary>
|
||||||
|
<ol>
|
||||||
|
<li>검색어를 같은 임베딩 방식으로 벡터화합니다.</li>
|
||||||
|
<li>임시 Bearer Token으로 전용 ORDS Handler를 호출합니다.</li>
|
||||||
|
<li>VPD가 TECH_TAG 권한에 맞지 않는 청크를 먼저 제거합니다.</li>
|
||||||
|
<li>남은 청크를 벡터 거리순으로 반환합니다.</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
<form hx-post="/vector-knowledge/search" hx-target="#vector-search-result" hx-swap="innerHTML" class="form-grid mt-3">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<label>
|
||||||
|
테스트 사용자
|
||||||
|
<select class="form-select" name="userId" required>
|
||||||
|
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username() + ' / ' + user.deptCode()}"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
결과 수
|
||||||
|
<input class="form-control" name="limit" type="number" min="1" max="100" value="10">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
임베딩 방식
|
||||||
|
<select class="form-select" name="embeddingMode">
|
||||||
|
<option value="DEMO">DEMO-4D (샘플과 동일)</option>
|
||||||
|
<option value="AI" th:disabled="${!aiEmbeddingConfigured}">AI 임베딩</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="span-2">
|
||||||
|
검색 질문
|
||||||
|
<textarea class="form-control" name="query" rows="3" placeholder="예: Oracle VPD에서 ORDS 권한을 적용하는 방법" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn rw-btn-primary" type="submit">권한 적용 검색</button>
|
||||||
|
</form>
|
||||||
|
<section id="vector-search-result" class="mt-3" aria-live="polite">
|
||||||
|
<div class="empty-result-guide">검색하면 선택한 사용자에게 허용된 청크만 여기에 표시됩니다.</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>현재 저장 현황 보기</summary>
|
||||||
|
<div class="summary-grid mt-3">
|
||||||
|
<div class="summary-tile"><span class="label">문서</span><strong th:text="${summary == null ? '-' : summary.documentCount()}">0</strong></div>
|
||||||
|
<div class="summary-tile"><span class="label">청크</span><strong th:text="${summary == null ? '-' : summary.chunkCount()}">0</strong></div>
|
||||||
|
<div class="summary-tile"><span class="label">태그 연결</span><strong th:text="${summary == null ? '-' : summary.tagCount()}">0</strong></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class VectorChunkerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsParagraphsTogetherUntilTheConfiguredLimit() {
|
||||||
|
var chunks = VectorChunker.chunk(
|
||||||
|
"첫 번째 문단입니다.\n\n두 번째 문단은 같은 지식자료입니다.", 80);
|
||||||
|
|
||||||
|
assertThat(chunks).hasSize(1);
|
||||||
|
assertThat(chunks.get(0).chunkNo()).isEqualTo(1);
|
||||||
|
assertThat(chunks.get(0).text()).contains("첫 번째", "두 번째");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitsLongTextAndNumbersChunksFromOne() {
|
||||||
|
var chunks = VectorChunker.chunk("문장 하나입니다. ".repeat(30), 100);
|
||||||
|
|
||||||
|
assertThat(chunks).hasSizeGreaterThan(1);
|
||||||
|
assertThat(chunks).extracting("chunkNo").containsExactlyElementsOf(
|
||||||
|
java.util.stream.IntStream.rangeClosed(1, chunks.size()).boxed().toList());
|
||||||
|
assertThat(chunks).allSatisfy(chunk -> assertThat(chunk.text()).isNotBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsBlankContent() {
|
||||||
|
assertThatThrownBy(() -> VectorChunker.chunk(" ", 600))
|
||||||
|
.isInstanceOf(AppException.class)
|
||||||
|
.hasMessageContaining("청킹할 문서 본문");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,23 @@ class GuidedFlowTemplateTest {
|
|||||||
assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken");
|
assertThat(sse).contains("Instruction / parameter mapping").contains("bearerToken");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void vectorKnowledgePageShowsIngestPermissionAndSearchFlow() throws IOException {
|
||||||
|
String vector = template("vector-knowledge.html");
|
||||||
|
String result = template("fragments/vector-search-result.html");
|
||||||
|
|
||||||
|
assertThat(vector)
|
||||||
|
.contains("문서 등록 → 청킹 → 임베딩 → 태그 저장")
|
||||||
|
.contains("ALLOW TAG SPRING_BOOT")
|
||||||
|
.contains("DENY TAG ORDS")
|
||||||
|
.contains("권한 적용 벡터 검색")
|
||||||
|
.contains("/vector-knowledge/ingest")
|
||||||
|
.contains("/vector-knowledge/search");
|
||||||
|
assertThat(result)
|
||||||
|
.contains("TECH_TAG")
|
||||||
|
.contains("검색 기술 상세 보기");
|
||||||
|
}
|
||||||
|
|
||||||
private String template(String relativePath) throws IOException {
|
private String template(String relativePath) throws IOException {
|
||||||
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
|
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user