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.VectorQueryEmbedding; 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 sg_vector_document_chunk"); int chunks = count("SELECT COUNT(*) FROM sg_vector_document_chunk"); int tags = count("SELECT COUNT(*) FROM sg_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() : "로컬 임베딩(개발용)" ); } public boolean aiEmbeddingConfigured() { return embeddingClient.embeddingConfigured(); } public VectorQueryEmbedding vectorizeQuery(String query, String embeddingMode) { String normalizedQuery = required(query, "검색 질문"); String mode = normalizeMode(embeddingMode); return new VectorQueryEmbedding( normalizedQuery, mode, embeddingModel(mode), "{\"embedding\":" + json(embed(normalizedQuery, mode)) + "}"); } @Transactional public VectorIngestResult ingest(VectorIngestCommand command) { String documentId = requiredDocumentId(command.documentId()); 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 sg_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 sg_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) { 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을 먼저 실행하세요.")); VectorQueryEmbedding vectorQuery = vectorizeQuery(query, embeddingMode); IssuedToken temporary = tokenService.issueTemporaryToken(userId, "지식 검색 검증 세션"); try { ProbeResult probe = ordsProbeService.runProbe(new ProbeCommand( temporary.keyId(), vectorObject.objectId(), temporary.plainToken(), normalizeLimit(limit), vectorQuery.requestBody())); return new VectorSearchResult( vectorQuery.query(), vectorQuery.embeddingMode(), vectorQuery.embeddingModel(), probe); } finally { tokenService.revokeToken(temporary.keyId(), "temporary vector search completed"); } } private void replaceDocument(String documentId) { jdbcTemplate.update(""" DELETE FROM sg_vector_document_tag WHERE chunk_id IN ( SELECT chunk_id FROM sg_vector_document_chunk WHERE document_id = ? ) """, documentId); jdbcTemplate.update("DELETE FROM sg_vector_document_chunk WHERE document_id = ?", documentId); } private long nextChunkId(int chunkCount) { try { Long sequenceValue = jdbcTemplate.queryForObject( "SELECT sg_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 sg_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("임베딩 방식은 로컬 임베딩 또는 AI 임베딩만 사용할 수 있습니다."); } if (AI_MODE.equals(normalized) && !embeddingClient.embeddingConfigured()) { throw new AppException("AI 임베딩이 설정되지 않았습니다. 설정에서 embedding model/API key를 추가하거나 로컬 임베딩(개발용)을 선택하세요."); } return normalized; } private String embeddingModel(String mode) { return AI_MODE.equals(mode) ? embeddingClient.embeddingModelName() : "로컬 임베딩(개발용)"; } 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; } }