feat #565: add vector knowledge ingestion demo

This commit is contained in:
devmrko
2026-06-29 18:11:16 +09:00
parent c7b11ab669
commit 4ba6a57835
18 changed files with 847 additions and 4 deletions

View File

@@ -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

View File

@@ -38,6 +38,7 @@
<div class="rw-menu-panel">
<a class="nav-link" href="/objects">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-reasoning">Reasoning</a>
<a class="nav-link" href="/mcp-sse">SSE 서비스</a>

View File

@@ -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>

View 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>