diff --git a/docs/design/558-vector-tag-vpd-search/README.md b/docs/design/558-vector-tag-vpd-search/README.md index bd9dddd..061d993 100644 --- a/docs/design/558-vector-tag-vpd-search/README.md +++ b/docs/design/558-vector-tag-vpd-search/README.md @@ -1,6 +1,6 @@ # 설계서: 기술 태그 기반 벡터 지식자료 검색과 VPD 연결 -> **상태**: 구현 초안 +> **상태**: 데모 흐름 구현 > **추적성**: Redmine #565 · 기준 구현: `28_agent_ords_vector_tag_vpd_setup.sql`, `29_agent_ords_vector_search_ords.sql` ## 1. 한 문장으로 이해하기 @@ -147,6 +147,17 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지 → 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. 운영 가이드라인 - 태그는 자유 문장보다 대문자·언더스코어 형태의 안정적인 ID로 관리한다. 예: `SPRING_BOOT`, `ORACLE_VPD`, `INTERNAL_ONLY`. @@ -166,7 +177,8 @@ backoffice의 `/objects` 화면은 이 객체에 일반 Handler를 생성하지 - 검색 결과에 임베딩 원문이 포함되지 않는다. - `/permissions` 화면에서 `TAG` 규칙이 기본 `TECH_TAG`와 OR 의미를 일반 문장으로 설명한다. -## 9. 범위 밖 +## 9. 아직 별도 운영 설계가 필요한 부분 -- 임베딩 모델 호출·문서 업로드 UI·태그 사전 승인 워크플로는 이번 시나리오에서 구현하지 않는다. +- 대용량 파일 업로드, 비동기 작업 큐, 재시도·실패 격리, 임베딩 모델 버전별 재색인은 운영 파이프라인에서 별도로 설계한다. +- 태그 사전 승인 워크플로와 문서별 소유자/보존기간 정책은 현재 데모 화면의 범위를 넘어선다. - 운영 DB에 대한 DDL, 기존 정책 교체, 방화벽/NSG 변경은 별도 승인을 받아 실행한다. diff --git a/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql b/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql index 192922b..09a709e 100644 --- a/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql +++ b/sql/adb/28_agent_ords_vector_tag_vpd_setup.sql @@ -18,6 +18,16 @@ SET FEEDBACK ON SET DEFINE OFF 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 EXECUTE IMMEDIATE q'! CREATE TABLE cb_vector_document_chunk ( @@ -254,4 +264,5 @@ COMMIT; PROMPT === Vector/tag VPD setup complete === 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; diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index bcf74fc..442aca2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -20,6 +20,17 @@ public record BackofficeProperties( 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, ""); + } } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java new file mode 100644 index 0000000..c487365 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorChunk.java @@ -0,0 +1,4 @@ +package com.cloudhandson.vpdbackoffice.domain.vector; + +public record VectorChunk(int chunkNo, String text) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java new file mode 100644 index 0000000..d15fee9 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestCommand.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java new file mode 100644 index 0000000..ddac542 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorIngestResult.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.domain.vector; + +public record VectorIngestResult( + String documentId, + int chunkCount, + int tagCount, + String embeddingMode, + String embeddingModel +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java new file mode 100644 index 0000000..ac34bd4 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorKnowledgeSummary.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.domain.vector; + +public record VectorKnowledgeSummary( + int documentCount, + int chunkCount, + int tagCount, + boolean vectorObjectRegistered, + String embeddingModel +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java new file mode 100644 index 0000000..6287bf7 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/vector/VectorSearchResult.java @@ -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 +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java index 6bbc545..210f05e 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OpenAiCompatibleClient.java @@ -7,6 +7,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -46,6 +48,58 @@ public class OpenAiCompatibleClient { 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 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 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 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) { if (!configured()) { throw new AppException("AI 호출 설정이 없습니다."); @@ -100,6 +154,18 @@ public class OpenAiCompatibleClient { 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) { return model != null && model.startsWith("openai.gpt-5.4"); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java new file mode 100644 index 0000000..51169da --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorChunker.java @@ -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 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 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 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 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; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java new file mode 100644 index 0000000..24ceac7 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/VectorKnowledgeService.java @@ -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 tags = normalizeTags(command.techTags()); + String mode = normalizeMode(command.embeddingMode()); + List chunks = VectorChunker.chunk(content, command.chunkSize()); + + replaceDocument(documentId); + long nextChunkId = nextChunkId(chunks.size()); + int tagCount = 0; + for (VectorChunk chunk : chunks) { + List 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 embed(String input, String mode) { + if (AI_MODE.equals(mode)) { + return embeddingClient.embedding(input); + } + return demoEmbedding(input); + } + + private List 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 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 normalizeTags(String value) { + if (value == null || value.isBlank()) { + throw new AppException("기술 태그를 하나 이상 입력하세요."); + } + Set 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 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; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java new file mode 100644 index 0000000..6141500 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/VectorKnowledgeController.java @@ -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"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fcab828..9c085a9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,5 +50,6 @@ backoffice: enabled: ${BACKOFFICE_AI_ENABLED:false} base-url: ${BACKOFFICE_AI_BASE_URL:} model: ${BACKOFFICE_AI_MODEL:} + embedding-model: ${BACKOFFICE_AI_EMBEDDING_MODEL:} api-key: ${BACKOFFICE_AI_API_KEY:} timeout: ${BACKOFFICE_AI_TIMEOUT_SECONDS:30}s diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index d3a9b5d..77a4663 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -38,6 +38,7 @@
ORDS 조회 대상 ORDS 핸들러 + 벡터 지식자료 데모 Chatbot Reasoning SSE 서비스 diff --git a/src/main/resources/templates/fragments/vector-search-result.html b/src/main/resources/templates/fragments/vector-search-result.html new file mode 100644 index 0000000..9ab5a35 --- /dev/null +++ b/src/main/resources/templates/fragments/vector-search-result.html @@ -0,0 +1,42 @@ +
+
+
+
+
임베딩DEMO-4D
+
검색 결과0건
+
+
+

권한 결과

+

결과 요약

+ 검색 정보 +
+
+ + + + + + + + + + + + + + + + + + + + + +
ChunkDocumentTitleTECH_TAGScore본문
28001knowledge-001제목ORDS0.01본문
+
+
+ 검색 기술 상세 보기 +
response
+
+
+
diff --git a/src/main/resources/templates/vector-knowledge.html b/src/main/resources/templates/vector-knowledge.html new file mode 100644 index 0000000..0deb38c --- /dev/null +++ b/src/main/resources/templates/vector-knowledge.html @@ -0,0 +1,176 @@ + + + + + +
+
+

벡터 지식자료 데모

+

문서를 청크로 나누고 임베딩·기술 태그를 저장한 뒤, 사용자 권한에 맞는 청크만 검색합니다.

+
+ 전체 시나리오 설명 보기 +

문서 본문을 청크로 나누고 각 청크를 벡터로 변환합니다. 청크마다 기술 태그를 붙이면 권한 규칙의 TAG 조건이 같은 태그가 붙은 행만 허용합니다. 마지막 검색은 Bearer Token으로 VPD를 통과한 청크만 벡터 거리순으로 반환합니다.

+
+
+ +
+ +
+
+
+ DB 준비가 필요합니다. + 벡터 테이블을 확인하세요. +
+ +
+
+
+ 1 · INGEST +

문서 등록 → 청킹 → 임베딩 → 태그 저장

+

같은 문서 ID로 다시 저장하면 기존 청크를 교체합니다.

+
+ 0 chunks +
+
+ 청킹과 임베딩 방식 보기 +
    +
  • 문단을 우선 묶고 길이가 길면 문장·공백 기준으로 나눕니다.
  • +
  • DEMO-4D는 외부 API 없이 동일한 4차원 예제를 재현하는 모드입니다. 의미 검색 품질을 제공하는 실제 임베딩은 아닙니다.
  • +
  • AIBACKOFFICE_AI_EMBEDDING_MODEL과 API key가 설정된 OpenAI 호환 /v1/embeddings를 사용합니다.
  • +
  • 실제 운영에서는 문서 내용과 같은 임베딩 모델로 검색어도 임베딩해야 합니다.
  • +
+
+
+ + + + + + + + + +
+
+ document + 저장 완료 +
+
+ +
+
+
+ 2 · AUTHORIZE +

태그 권한 설정

+

태그 권한은 기존 권한 관리에서 역할별 행 규칙으로 저장합니다.

+
+ 권한 관리 열기 +
+
+
+

예: 백엔드 역할

+

ALLOW TAG SPRING_BOOT
ALLOW TAG ORACLE_VPD

+ 두 태그 중 하나가 붙은 청크를 허용합니다. +
+
+

예: ORDS 제외 역할

+

ALLOW TAG SPRING_BOOT
DENY TAG ORDS

+ 허용 후보 중 ORDS 태그 청크를 다시 제외합니다. +
+
+
+ VPD predicate가 계산하는 식 보기 +
(ALLOW TAG A OR ALLOW TAG B)
+AND NOT (DENY TAG C OR DENY TAG D)
+

허용 태그가 없거나 토큰 컨텍스트가 없으면 1=0으로 닫힙니다. 태그 권한은 행 접근 기준이고, 본문·임베딩 표시 보호는 컬럼 정책으로 별도 관리합니다.

+
+
+ +
+
+
+ 3 · SEARCH +

권한 적용 벡터 검색

+

선택한 사용자에게 임시 토큰을 발급하고 검색 완료 즉시 회수합니다.

+
+ Vector ORDS +
+
+ 검색 실행 순서 보기 +
    +
  1. 검색어를 같은 임베딩 방식으로 벡터화합니다.
  2. +
  3. 임시 Bearer Token으로 전용 ORDS Handler를 호출합니다.
  4. +
  5. VPD가 TECH_TAG 권한에 맞지 않는 청크를 먼저 제거합니다.
  6. +
  7. 남은 청크를 벡터 거리순으로 반환합니다.
  8. +
+
+
+ + + + + + +
+
+
검색하면 선택한 사용자에게 허용된 청크만 여기에 표시됩니다.
+
+
+ +
+
+ 현재 저장 현황 보기 +
+
문서0
+
청크0
+
태그 연결0
+
+
+
+
+ + diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java new file mode 100644 index 0000000..309a230 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/VectorChunkerTest.java @@ -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("청킹할 문서 본문"); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index 39ba257..e743cd6 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -109,6 +109,23 @@ class GuidedFlowTemplateTest { 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 { return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath)); }