Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
406 changes: 406 additions & 0 deletions docs/design/Gimini-3-#82-chunk-embedding-vector-storage.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ public void markChunked() {
}

public void markEmbedding() {
// Chunk Set이 확정된 Version만 Embedding 생성 단계에 진입할 수 있다.
if (status != DocumentVersionStatus.CHUNKED) {
throw new IllegalStateException("CHUNKED 상태의 문서 버전만 EMBEDDING으로 전환할 수 있습니다.");
}
this.status = DocumentVersionStatus.EMBEDDING;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
package com.opensource.docgrid.domain.document.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import com.opensource.docgrid.domain.document.entity.DocumentChunk;

/**
* 문서 Version별 Chunk 존재 여부와 개수 조회 및 Chunk Set 전체 저장을 담당한다.
* 문서 Version별 Chunk 존재 여부·개수·정렬 조회와 Chunk Set 전체 저장을 담당한다.
*
* <p>Chunk 생성 Transaction은 기존 결과 확인에 존재·개수 조회를 사용하고, 신규 결과는
* {@link JpaRepository#saveAllAndFlush(Iterable)}로 같은 Transaction 안에서 즉시 검증한다.
* Embedding 생성은 Chunk 순서를 저장 결과와 일치시키기 위해 chunkIndex 오름차순 조회를 사용한다.
*/
public interface DocumentChunkRepository extends JpaRepository<DocumentChunk, Long> {

boolean existsByDocumentVersionId(Long documentVersionId);

long countByDocumentVersionId(Long documentVersionId);

List<DocumentChunk> findAllByDocumentVersionIdOrderByChunkIndexAsc(Long documentVersionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.opensource.docgrid.domain.embedding.client;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import com.opensource.docgrid.domain.embedding.dto.request.EmbedRequest;
import com.opensource.docgrid.domain.embedding.dto.response.EmbedServerResponse;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

import lombok.extern.slf4j.Slf4j;

/**
* 외부 Embedding Server의 단건 Vector 생성 HTTP 계약을 담당한다.
*
* <p>이 Client는 모델 선택과 Vector 차원 검증을 수행하지 않는다. 호출 Service가 실행 Context에 맞는
* 모델을 선택하고 반환 Vector를 검증하며, 이 클래스는 전송 오류를 공통 서비스 장애로 변환하는 경계만
* 책임진다.
*/
@Slf4j
@Component
public class EmbeddingClient {

private final RestClient restClient;

public EmbeddingClient(@Qualifier("embeddingRestClient") RestClient restClient) {
this.restClient = restClient;
}

/**
* 입력 Text를 외부 서버에 전달하고 Dense Vector를 반환한다.
*/
public float[] embed(String text) {
EmbedServerResponse response;
try {
response = restClient.post()
.uri("/embed")
.body(new EmbedRequest(text))
.retrieve()
.body(EmbedServerResponse.class);
} catch (RestClientException exception) {
log.error("임베딩 서버 호출에 실패했습니다. cause={}", exception.getClass().getSimpleName());
throw new DocGridException(ErrorCode.EMBEDDING_SERVER_UNAVAILABLE);
}

return response == null ? null : response.vector();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@

import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest;
import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentChunksRequest;
import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentEmbeddingsRequest;
import com.opensource.docgrid.domain.embedding.dto.response.ClaimedEmbeddingJobResponse;
import com.opensource.docgrid.domain.embedding.dto.response.DocumentChunksResponse;
import com.opensource.docgrid.domain.embedding.dto.response.DocumentEmbeddingsResponse;
import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse;
import com.opensource.docgrid.domain.document.service.DocumentParsingService;
import com.opensource.docgrid.domain.document.service.command.DocumentChunkTransactionService.ChunkResult;
import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService;
import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService.EmbeddingResult;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService.StartResult;
Expand All @@ -36,10 +40,10 @@
import lombok.RequiredArgsConstructor;

/**
* 관리자용 Embedding Job Claim과 Attempt 시작 요청을 HTTP API로 제공하는 Controller.
* 관리자용 Embedding Job Claim, Attempt 시작과 문서 Chunk·Embedding 실행을 HTTP API로 제공한다.
*
* <p>HTTP 입력 검증과 성공 상태 변환만 담당한다. Job Claim 및 현재 소유권 기반 Attempt 시작의
* Transaction·동시성 규칙은 각 Command Service에 위임한다.
* <p>HTTP 입력 검증과 성공 상태 변환만 담당한다. Job Claim 및 현재 소유권 기반 파이프라인 단계의
* Transaction·외부 호출·동시성 규칙은 각 Service에 위임한다.
*/
@Tag(name = "Admin - Indexing Job", description = "관리자 전용 인덱싱 Job 제어 API")
@Validated
Expand All @@ -51,6 +55,7 @@ public class IndexingJobAdminController {
private final EmbeddingJobClaimService embeddingJobClaimService;
private final EmbeddingJobAttemptService embeddingJobAttemptService;
private final DocumentParsingService documentParsingService;
private final DocumentEmbeddingService documentEmbeddingService;

@Operation(
summary = "PENDING Job Claim",
Expand Down Expand Up @@ -227,4 +232,70 @@ public ResponseEntity<ApiResponse<DocumentChunksResponse>> createChunks(
}
return ResponseUtils.ok(result.response());
}

@Operation(
summary = "Document Chunk Embedding 생성",
description = "현재 PROCESSING Job의 유효한 Attempt 소유권과 Job 고정 Model을 검증하고 "
+ "Chunk를 순서대로 외부 Embedding 서버에 전달한 뒤 Vector Set을 원자 저장합니다. "
+ "최초 저장은 201, 기존 완료 결과의 멱등 재생은 200을 반환합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "201",
description = "Document Embedding 최초 저장"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "기존 Embedding 결과 재생"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Job ID, Attempt ID, Worker ID 또는 Claim Token 형식 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "인증되지 않았거나 ADMIN 권한 없음",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "Embedding Job 없음",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "현재 소유권, Attempt, Lease 또는 Version 상태 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "Model, Chunk, Vector 또는 Embedding 저장 상태 불일치",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "503",
description = "외부 Embedding 서버 장애",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
})
@PostMapping(
value = "/{jobId}/attempts/{attemptId}/embeddings",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<ApiResponse<DocumentEmbeddingsResponse>> createEmbeddings(
@PathVariable @Positive Long jobId,
@PathVariable @Positive Long attemptId,
@Valid @RequestBody CreateDocumentEmbeddingsRequest request
) {
// 1. 비 Transaction Service가 준비·외부 호출·완료 Transaction의 순서를 조정한다.
EmbeddingResult result = documentEmbeddingService.createEmbeddings(jobId, attemptId, request);

// 2. 같은 응답 Body를 사용하고 실제 최초 저장 여부로 HTTP 상태만 구분한다.
if (result.created()) {
return ResponseUtils.created(result.response());
}
return ResponseUtils.ok(result.response());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.opensource.docgrid.domain.embedding.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

/**
* 현재 Embedding Job Attempt 소유권으로 문서 Chunk Embedding 생성을 요청하는 DTO.
*
* <p>Worker ID와 canonical UUID Claim Token은 준비와 완료 단계의 소유권 검증에만 사용하며,
* 응답, 이벤트와 로그에는 노출하지 않는다.
*/
public record CreateDocumentEmbeddingsRequest(
@Schema(description = "현재 Job을 소유한 Worker 식별자", example = "1")
@NotNull
@Positive
Long workerId,

@Schema(description = "현재 Claim의 canonical UUID Token",
example = "34c19d16-6ae1-4f6a-a35d-0123456789ab")
@NotBlank
@Size(max = 36)
@Pattern(
regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
message = "canonical UUID 형식이어야 합니다."
)
String claimToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.opensource.docgrid.domain.embedding.dto.response;

import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 생성됐거나 멱등 재생된 Document Embedding Set의 식별자와 집계 상태를 전달한다.
*
* <p>Claim Token, Chunk Text와 Vector는 제외하고 현재 실행 Context, 고정 Model과 Version 결과만
* 노출한다. Embedding 저장 완료 시에도 Version은 후속 색인 완료 전까지 EMBEDDING을 유지한다.
*/
public record DocumentEmbeddingsResponse(
@Schema(description = "처리한 Embedding Job 식별자", example = "10")
Long jobId,

@Schema(description = "현재 실행 Attempt 식별자", example = "100")
Long attemptId,

@Schema(description = "Embedding이 저장된 Document Version 식별자", example = "5")
Long documentVersionId,

@Schema(description = "Job에 고정된 Embedding Model 식별자", example = "7")
Long embeddingModelId,

@Schema(description = "Version의 전체 Chunk 수", example = "3")
int chunkCount,

@Schema(description = "현재 Model로 저장된 Embedding 수", example = "3")
int embeddingCount,

@Schema(description = "Embedding 저장 후 Version 상태", example = "EMBEDDING")
DocumentVersionStatus versionStatus
) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.opensource.docgrid.domain.embedding.entity;

import java.util.Arrays;

import com.opensource.docgrid.domain.document.entity.Document;
import com.opensource.docgrid.domain.document.entity.DocumentChunk;
import com.opensource.docgrid.domain.document.entity.DocumentVersion;
Expand Down Expand Up @@ -108,9 +110,21 @@ public Embedding(DocumentChunk chunk, Document document, DocumentVersion documen
this.document = document;
this.documentVersion = documentVersion;
this.embeddingModel = embeddingModel;
this.vector = vector;
// 호출자가 보관한 배열 변경이 영속화 값에 전파되지 않도록 생성 시점에 복사한다.
this.vector = copyVector(vector);
this.dimension = dimension;
this.vectorHash = vectorHash;
this.status = status != null ? status : EmbeddingStatus.ACTIVE;
}

/**
* 영속 Entity 내부 Vector가 호출자에 의해 변경되지 않도록 복사본을 반환한다.
*/
public float[] getVector() {
return copyVector(vector);
}

private static float[] copyVector(float[] source) {
return source == null ? null : Arrays.copyOf(source, source.length);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.opensource.docgrid.domain.embedding.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.opensource.docgrid.domain.embedding.entity.Embedding;

/**
* 문서 Chunk Embedding Set의 영속화와 Version·Model 단위 저장 개수 조회를 담당한다.
*
* <p>Embedding 생성 Transaction은 Job에 고정된 Model 범위의 저장 개수로 최초 실행, 재개,
* 완료 재생과 부분 저장 모순을 구분하고 신규 Set은 한 Transaction에서 전체 저장한다.
*/
public interface EmbeddingRepository extends JpaRepository<Embedding, Long> {

long countByDocumentVersionIdAndEmbeddingModelId(
Long documentVersionId,
Long embeddingModelId
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.opensource.docgrid.domain.embedding.service;

import java.util.Arrays;

/**
* 외부 호출로 생성돼 아직 영속화되지 않은 단일 Chunk Embedding의 불변 값을 전달한다.
*
* <p>완료 Transaction은 Chunk ID·순서·내용 Hash를 준비 Snapshot과 다시 비교하고 Vector 차원,
* 유한 값과 Hash를 검증한 뒤에만 이 값을 Entity로 변환한다.
*/
public record DocumentEmbeddingDraft(
Long chunkId,
int chunkIndex,
String contentHash,
float[] vector,
String vectorHash
) {

public DocumentEmbeddingDraft {
vector = copyVector(vector);
}

@Override
public float[] vector() {
return copyVector(vector);
}

private static float[] copyVector(float[] source) {
return source == null ? null : Arrays.copyOf(source, source.length);
}
}
Loading