Skip to content

Commit eecf255

Browse files
authored
기본 임베딩 모델 등록 및 조회
기본 임베딩 모델 등록 및 조회
2 parents 458aba6 + a73a92c commit eecf255

20 files changed

Lines changed: 786 additions & 9 deletions

File tree

.codex/hooks.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "Bash",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "bash '/Users/giminkim/IdeaProjects/backend/.codex/hooks/pre-bash.sh'"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

.codex/hooks/pre-bash.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
INPUT=$(cat)
3+
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command',''))" 2>/dev/null || echo "")
4+
5+
# git push --force 차단
6+
if echo "$COMMAND" | grep -qE "git push.*(--force|-f\b)"; then
7+
echo "🚫 git push --force 는 금지되어 있습니다." >&2
8+
exit 2
9+
fi
10+
11+
# main 브랜치 직접 push 차단
12+
if echo "$COMMAND" | grep -qE "git push (origin )?main"; then
13+
echo "🚫 main 브랜치 직접 push 는 금지되어 있습니다. PR을 통해 merge하세요." >&2
14+
exit 2
15+
fi
16+
17+
exit 0

AGENTS.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# AGENTS.md — DocGrid
2+
3+
## Engineering Guidelines
4+
5+
Don't assume. Don't hide confusion. Surface tradeoffs.
6+
7+
**Think Before Coding**
8+
- State assumptions explicitly. If uncertain, ask.
9+
- If multiple interpretations exist, present them — don't pick silently.
10+
- For non-trivial tasks, start in Plan Mode and don't implement until approved.
11+
12+
**Simplicity First**
13+
- Minimum code that solves the problem. Nothing speculative.
14+
- No features beyond what was asked. No abstractions for single-use code.
15+
16+
**Surgical Changes**
17+
- Touch only what you must. Don't "improve" adjacent code or formatting.
18+
- Match existing style, even if you'd do it differently.
19+
- Every changed line should trace directly to the user's request.
20+
21+
**Goal-Driven Execution**
22+
- Define success criteria before starting.
23+
- For multi-step tasks, state a brief plan and verify each step.
24+
25+
---
26+
27+
## 어디서 무엇을 읽을지
28+
29+
### 🔵 작업 직전 항상
30+
- 프로젝트 구조 → 이 파일 (AGENTS.md)
31+
- 도메인 목록 → `src/main/java/com/opensource/docgrid/domain/`
32+
33+
### 🟢 상황별 룰 (`.Codex/rules/`) — 자동 로드됨
34+
- Java 코드 작성 시 → `code_style.md`
35+
- 테스트 작성/수정 시 → `testing_guide.md`
36+
- Security/Config 만질 때 → `security.md`
37+
- 배포/Docker/GitHub Actions 관련 → `deploy.md`
38+
39+
### 🟣 AI 작업 흔적 (`.dev/`)
40+
- 새로 알게 된 패턴·주의점·오류 기록 → `learnings/`
41+
- 작업 중 임시 메모 (작업 종료 후 삭제 — 비어있는 게 정상) → `scratchpad/`
42+
43+
---
44+
45+
## 프로젝트 개요
46+
47+
- **Framework**: Spring Boot 3.5.16
48+
- **Language**: Java 17
49+
- **Build**: Gradle
50+
- **DB**: PostgreSQL + Flyway
51+
- **Package**: `com.opensource.docgrid`
52+
53+
## 프로젝트 구조
54+
55+
```
56+
src/main/java/com/opensource/docgrid/
57+
├── global/
58+
│ ├── common/ # 공통 응답 (ApiResponse, ErrorResponse, BaseEntity)
59+
│ ├── config/ # 설정 (SecurityConfig, CorsConfig, SwaggerConfig)
60+
│ └── exception/ # 전역 예외 (DocGridException, ErrorCode, GlobalExceptionHandler)
61+
└── domain/
62+
└── {도메인}/
63+
├── entity/
64+
├── repository/
65+
├── service/
66+
│ ├── command/ # 상태 변경
67+
│ └── query/ # 조회 전용
68+
├── controller/
69+
├── dto/
70+
│ ├── request/
71+
│ └── response/
72+
├── converter/ # Entity ↔ DTO 변환
73+
└── enums/
74+
```
75+
76+
## 주요 명령어
77+
78+
```bash
79+
./gradlew build
80+
./gradlew clean build
81+
./gradlew build -x test
82+
./gradlew test
83+
```
84+
85+
---
86+
87+
## 영구 금지
88+
89+
- `git add -A` / `git add .` (민감 파일 우회 위험)
90+
- `git push --force` / `--no-verify` / `--amend` (안전장치 우회)
91+
- `main` 브랜치 직접 push — PR + 리뷰 후 merge만 허용
92+
- 시크릿을 `application.yml`에 하드코딩
93+
- Entity를 Controller 계층에 직접 노출
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.opensource.docgrid.domain.embedding.controller;
2+
3+
import org.springframework.http.MediaType;
4+
import org.springframework.http.ResponseEntity;
5+
import org.springframework.web.bind.annotation.GetMapping;
6+
import org.springframework.web.bind.annotation.RequestMapping;
7+
import org.springframework.web.bind.annotation.RestController;
8+
9+
import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse;
10+
import com.opensource.docgrid.domain.embedding.service.query.EmbeddingModelQueryService;
11+
import com.opensource.docgrid.global.common.response.ApiResponse;
12+
import com.opensource.docgrid.global.common.response.ErrorResponse;
13+
import com.opensource.docgrid.global.common.response.ResponseUtils;
14+
15+
import io.swagger.v3.oas.annotations.Operation;
16+
import io.swagger.v3.oas.annotations.media.Content;
17+
import io.swagger.v3.oas.annotations.media.ExampleObject;
18+
import io.swagger.v3.oas.annotations.media.Schema;
19+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
20+
import io.swagger.v3.oas.annotations.tags.Tag;
21+
import lombok.RequiredArgsConstructor;
22+
23+
@Tag(
24+
name = "Embedding Model",
25+
description = "문서 인덱싱과 검색에서 사용할 임베딩 모델 설정 조회 API"
26+
)
27+
@RestController
28+
@RequestMapping("/api/embedding-models")
29+
@RequiredArgsConstructor
30+
public class EmbeddingModelController {
31+
32+
private final EmbeddingModelQueryService embeddingModelQueryService;
33+
34+
@Operation(
35+
summary = "기본 임베딩 모델 조회",
36+
description = """
37+
is_active=true, is_searchable=true인 기본 임베딩 모델을 조회합니다.
38+
신규 embedding_job 생성 시 사용할 모델 설정이며, 정상적으로 하나만 존재해야 합니다.
39+
모델이 없거나 여러 개 존재하면 서버 설정 오류가 발생합니다.
40+
실제 Vector를 생성하거나 임베딩 모델을 실행하지 않습니다.
41+
"""
42+
)
43+
@ApiResponses({
44+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
45+
responseCode = "200",
46+
description = "기본 임베딩 모델 조회 성공"
47+
),
48+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
49+
responseCode = "500",
50+
description = "기본 임베딩 모델 설정 오류",
51+
content = @Content(
52+
schema = @Schema(implementation = ErrorResponse.class),
53+
examples = {
54+
@ExampleObject(
55+
name = "모델 미설정",
56+
value = """
57+
{"success":false,"status":500,"code":"EMBEDDING-MODEL-001","message":"사용 가능한 임베딩 모델이 설정되지 않았습니다.","method":"GET","path":"/api/embedding-models/active","timestamp":"2026-07-14 12:00:00"}
58+
"""
59+
),
60+
@ExampleObject(
61+
name = "모델 중복 설정",
62+
value = """
63+
{"success":false,"status":500,"code":"EMBEDDING-MODEL-002","message":"사용 가능한 임베딩 모델이 여러 개 설정되어 있습니다.","method":"GET","path":"/api/embedding-models/active","timestamp":"2026-07-14 12:00:00"}
64+
"""
65+
)
66+
}
67+
)
68+
)
69+
})
70+
@GetMapping(value = "/active", produces = MediaType.APPLICATION_JSON_VALUE)
71+
public ResponseEntity<ApiResponse<EmbeddingModelResponse>> getActiveModel() {
72+
return ResponseUtils.ok(embeddingModelQueryService.getActiveModelResponse());
73+
}
74+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.opensource.docgrid.domain.embedding.converter;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse;
6+
import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel;
7+
8+
@Component
9+
public class EmbeddingModelConverter {
10+
11+
public EmbeddingModelResponse toResponse(EmbeddingModel embeddingModel) {
12+
return new EmbeddingModelResponse(
13+
embeddingModel.getId(),
14+
embeddingModel.getProvider(),
15+
embeddingModel.getModelName(),
16+
embeddingModel.getModelVersion(),
17+
embeddingModel.getDimension(),
18+
embeddingModel.getDistanceMetric()
19+
);
20+
}
21+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.opensource.docgrid.domain.embedding.dto.response;
2+
3+
import com.opensource.docgrid.domain.embedding.enums.DistanceMetric;
4+
import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider;
5+
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
8+
public record EmbeddingModelResponse(
9+
@Schema(description = "임베딩 모델 식별자", example = "1")
10+
Long id,
11+
12+
@Schema(description = "모델 제공 또는 실행 방식", example = "MOCK")
13+
EmbeddingProvider provider,
14+
15+
@Schema(description = "모델 이름", example = "mock-bge-m3")
16+
String modelName,
17+
18+
@Schema(description = "모델 버전 또는 Revision", example = "v1")
19+
String modelVersion,
20+
21+
@Schema(description = "모델이 생성하는 Vector 차원", example = "1024")
22+
int dimension,
23+
24+
@Schema(description = "Vector 유사도 계산 방식", example = "COSINE")
25+
DistanceMetric distanceMetric
26+
) {
27+
}

src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingModel.java

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.opensource.docgrid.domain.embedding.entity;
22

3+
import java.util.Objects;
4+
35
import com.opensource.docgrid.domain.embedding.enums.DistanceMetric;
46
import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider;
57
import com.opensource.docgrid.domain.embedding.enums.VectorStorageStrategy;
@@ -31,8 +33,8 @@
3133
* index: is_active, is_searchable.
3234
*
3335
* <p>주의사항: 1단계 MVP는 active이면서 searchable인 모델을 단 1개만 사용하는 것을 전제로 한다.
34-
* TODO: 이를 애플리케이션 레벨 검증 또는 DB partial unique index로 보강해 "active+searchable 모델은 항상 1개"임을
35-
* 강제할 필요가 있다. dimension은 모델별로 고정된 값이며 embeddings.dimension과 반드시 일치해야 한다.
36+
* active이면서 searchable인 모델은 DB partial unique index와 조회 서비스의 개수 검증으로 중복을 방지한다.
37+
* dimension은 모델별로 고정된 값이며 embeddings.dimension과 반드시 일치해야 한다.
3638
* configJson은 Hibernate JSON 타입 매핑이 없어 TEXT로 임시 매핑했으며, 추후 OpenSQL JSON / Hibernate JSON
3739
* 매핑으로 교체가 필요하다.
3840
*/
@@ -93,14 +95,28 @@ public class EmbeddingModel extends BaseEntity {
9395
public EmbeddingModel(EmbeddingProvider provider, String modelName, String modelVersion, int dimension,
9496
DistanceMetric distanceMetric, boolean isActive, boolean isSearchable,
9597
VectorStorageStrategy vectorStorageStrategy, String configJson) {
96-
this.provider = provider;
97-
this.modelName = modelName;
98-
this.modelVersion = modelVersion;
98+
if (dimension <= 0) {
99+
throw new IllegalArgumentException("dimension은 0보다 커야 합니다.");
100+
}
101+
102+
this.provider = Objects.requireNonNull(provider, "provider는 필수입니다.");
103+
this.modelName = requireText(modelName, "modelName");
104+
this.modelVersion = requireText(modelVersion, "modelVersion");
99105
this.dimension = dimension;
100-
this.distanceMetric = distanceMetric;
106+
this.distanceMetric = Objects.requireNonNull(distanceMetric, "distanceMetric은 필수입니다.");
101107
this.isActive = isActive;
102108
this.isSearchable = isSearchable;
103-
this.vectorStorageStrategy = vectorStorageStrategy;
109+
this.vectorStorageStrategy = Objects.requireNonNull(
110+
vectorStorageStrategy,
111+
"vectorStorageStrategy는 필수입니다."
112+
);
104113
this.configJson = configJson;
105114
}
115+
116+
private static String requireText(String value, String fieldName) {
117+
if (value == null || value.isBlank()) {
118+
throw new IllegalArgumentException(fieldName + "은(는) 공백일 수 없습니다.");
119+
}
120+
return value;
121+
}
106122
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.opensource.docgrid.domain.embedding.repository;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel;
8+
import com.opensource.docgrid.domain.embedding.enums.EmbeddingProvider;
9+
10+
public interface EmbeddingModelRepository extends JpaRepository<EmbeddingModel, Long> {
11+
12+
// 다중 기본 모델 설정을 숨기지 않고 서비스에서 개수를 검증할 수 있도록 List로 반환한다.
13+
List<EmbeddingModel> findAllByIsActiveTrueAndIsSearchableTrue();
14+
15+
boolean existsByProviderAndModelNameAndModelVersion(
16+
EmbeddingProvider provider,
17+
String modelName,
18+
String modelVersion
19+
);
20+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.opensource.docgrid.domain.embedding.service.query;
2+
3+
import java.util.List;
4+
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
8+
import com.opensource.docgrid.domain.embedding.converter.EmbeddingModelConverter;
9+
import com.opensource.docgrid.domain.embedding.dto.response.EmbeddingModelResponse;
10+
import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel;
11+
import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository;
12+
import com.opensource.docgrid.global.exception.DocGridException;
13+
import com.opensource.docgrid.global.exception.ErrorCode;
14+
15+
import lombok.RequiredArgsConstructor;
16+
import lombok.extern.slf4j.Slf4j;
17+
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
@Transactional(readOnly = true)
22+
public class EmbeddingModelQueryService {
23+
24+
private final EmbeddingModelRepository embeddingModelRepository;
25+
private final EmbeddingModelConverter embeddingModelConverter;
26+
27+
// 후속 embedding_jobs 생성 시 모델 Entity를 연관관계에 고정하기 위한 내부 조회 메서드다.
28+
public EmbeddingModel getActiveModel() {
29+
List<EmbeddingModel> activeModels =
30+
embeddingModelRepository.findAllByIsActiveTrueAndIsSearchableTrue();
31+
32+
if (activeModels.isEmpty()) {
33+
log.error("사용 가능한 임베딩 모델이 설정되지 않았습니다.");
34+
throw new DocGridException(ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED);
35+
}
36+
37+
if (activeModels.size() > 1) {
38+
log.error("사용 가능한 임베딩 모델이 여러 개 설정되어 있습니다. count={}", activeModels.size());
39+
throw new DocGridException(ErrorCode.MULTIPLE_ACTIVE_EMBEDDING_MODELS);
40+
}
41+
42+
return activeModels.get(0);
43+
}
44+
45+
public EmbeddingModelResponse getActiveModelResponse() {
46+
return embeddingModelConverter.toResponse(getActiveModel());
47+
}
48+
}

src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,19 @@ public enum ErrorCode {
2020
DATA_CONFLICT(HttpStatus.CONFLICT, "COMMON-008", "데이터 충돌이 발생했습니다."),
2121

2222
// USER
23-
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-001", "사용자를 찾을 수 없습니다.");
23+
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-001", "사용자를 찾을 수 없습니다."),
24+
25+
// EMBEDDING MODEL
26+
EMBEDDING_MODEL_NOT_CONFIGURED(
27+
HttpStatus.INTERNAL_SERVER_ERROR,
28+
"EMBEDDING-MODEL-001",
29+
"사용 가능한 임베딩 모델이 설정되지 않았습니다."
30+
),
31+
MULTIPLE_ACTIVE_EMBEDDING_MODELS(
32+
HttpStatus.INTERNAL_SERVER_ERROR,
33+
"EMBEDDING-MODEL-002",
34+
"사용 가능한 임베딩 모델이 여러 개 설정되어 있습니다."
35+
);
2436

2537
private final HttpStatus httpStatus;
2638
private final String code;

0 commit comments

Comments
 (0)